diff --git a/README.md b/README.md index e941e7b5f..abe55b5a9 100644 --- a/README.md +++ b/README.md @@ -21,30 +21,33 @@ Available addons ---------------- addon | version | maintainers | summary --- | --- | --- | --- -[base_rest](base_rest/) | 16.0.1.0.3 | | Develop your own high level REST APIs for Odoo thanks to this addon. -[base_rest_auth_api_key](base_rest_auth_api_key/) | 16.0.1.0.0 | [![lmignon](https://github.com/lmignon.png?size=30px)](https://github.com/lmignon) | Base Rest: Add support for the auth_api_key security policy into the openapi documentation +[auth_partner](auth_partner/) | 16.0.1.0.0 | | Implements the base features for a authenticable partner +[base_rest](base_rest/) | 16.0.1.0.4 | | Develop your own high level REST APIs for Odoo thanks to this addon. +[base_rest_auth_api_key](base_rest_auth_api_key/) | 16.0.1.0.0 | lmignon | Base Rest: Add support for the auth_api_key security policy into the openapi documentation [base_rest_datamodel](base_rest_datamodel/) | 16.0.1.0.0 | | Datamodel binding for base_rest -[base_rest_demo](base_rest_demo/) | 16.0.2.0.2 | [![lmignon](https://github.com/lmignon.png?size=30px)](https://github.com/lmignon) | Demo addon for Base REST +[base_rest_demo](base_rest_demo/) | 16.0.2.0.4 | lmignon | Demo addon for Base REST [base_rest_pydantic](base_rest_pydantic/) | 16.0.2.0.1 | | Pydantic binding for base_rest -[datamodel](datamodel/) | 16.0.1.0.1 | [![lmignon](https://github.com/lmignon.png?size=30px)](https://github.com/lmignon) | This addon allows you to define simple data models supporting serialization/deserialization -[extendable](extendable/) | 16.0.1.0.2 | [![lmignon](https://github.com/lmignon.png?size=30px)](https://github.com/lmignon) | Extendable classes registry loader for Odoo -[extendable_fastapi](extendable_fastapi/) | 16.0.2.1.1 | [![lmignon](https://github.com/lmignon.png?size=30px)](https://github.com/lmignon) | Allows the use of extendable into fastapi apps -[fastapi](fastapi/) | 16.0.1.4.5 | [![lmignon](https://github.com/lmignon.png?size=30px)](https://github.com/lmignon) | Odoo FastAPI endpoint -[fastapi_auth_jwt](fastapi_auth_jwt/) | 16.0.1.0.4 | [![sbidoul](https://github.com/sbidoul.png?size=30px)](https://github.com/sbidoul) | JWT bearer token authentication for FastAPI. -[fastapi_auth_jwt_demo](fastapi_auth_jwt_demo/) | 16.0.2.0.1 | [![sbidoul](https://github.com/sbidoul.png?size=30px)](https://github.com/sbidoul) | Test/demo module for fastapi_auth_jwt. -[graphql_base](graphql_base/) | 16.0.1.0.1 | [![sbidoul](https://github.com/sbidoul.png?size=30px)](https://github.com/sbidoul) | Base GraphQL/GraphiQL controller -[graphql_demo](graphql_demo/) | 16.0.1.0.1 | [![sbidoul](https://github.com/sbidoul.png?size=30px)](https://github.com/sbidoul) | GraphQL Demo -[pydantic](pydantic/) | 16.0.1.0.0 | [![lmignon](https://github.com/lmignon.png?size=30px)](https://github.com/lmignon) | Utility addon to ease mapping between Pydantic and Odoo models -[rest_log](rest_log/) | 16.0.1.0.2 | [![simahawk](https://github.com/simahawk.png?size=30px)](https://github.com/simahawk) | Track REST API calls into DB +[datamodel](datamodel/) | 16.0.1.0.2 | lmignon | This addon allows you to define simple data models supporting serialization/deserialization +[extendable](extendable/) | 16.0.1.0.2 | lmignon | Extendable classes registry loader for Odoo +[extendable_fastapi](extendable_fastapi/) | 16.0.2.1.2 | lmignon | Allows the use of extendable into fastapi apps +[fastapi](fastapi/) | 16.0.1.7.0 | lmignon | Odoo FastAPI endpoint +[fastapi_auth_jwt](fastapi_auth_jwt/) | 16.0.1.0.4 | sbidoul | JWT bearer token authentication for FastAPI. +[fastapi_auth_jwt_demo](fastapi_auth_jwt_demo/) | 16.0.2.0.1 | sbidoul | Test/demo module for fastapi_auth_jwt. +[fastapi_auth_partner](fastapi_auth_partner/) | 16.0.1.0.0 | | This provides an implementation of auth_partner for FastAPI +[fastapi_encrypted_errors](fastapi_encrypted_errors/) | 16.0.1.0.1 | paradoxxxzero | Adds encrypted error messages to FastAPI error responses. +[graphql_base](graphql_base/) | 16.0.1.0.1 | sbidoul | Base GraphQL/GraphiQL controller +[graphql_demo](graphql_demo/) | 16.0.1.0.1 | sbidoul | GraphQL Demo +[pydantic](pydantic/) | 16.0.1.0.0 | lmignon | Utility addon to ease mapping between Pydantic and Odoo models +[rest_log](rest_log/) | 16.0.1.0.3 | simahawk | Track REST API calls into DB Unported addons --------------- addon | version | maintainers | summary --- | --- | --- | --- -[base_rest_auth_jwt](base_rest_auth_jwt/) | 15.0.1.1.0 (unported) | [![lmignon](https://github.com/lmignon.png?size=30px)](https://github.com/lmignon) | Base Rest: Add support for the auth_jwt security policy into the openapi documentation +[base_rest_auth_jwt](base_rest_auth_jwt/) | 15.0.1.1.0 (unported) | lmignon | Base Rest: Add support for the auth_jwt security policy into the openapi documentation [base_rest_auth_user_service](base_rest_auth_user_service/) | 15.0.1.0.1 (unported) | | Login/logout from session using a REST call -[model_serializer](model_serializer/) | 15.0.1.2.0 (unported) | [![fdegrave](https://github.com/fdegrave.png?size=30px)](https://github.com/fdegrave) | Automatically translate Odoo models into Datamodels for (de)serialization +[model_serializer](model_serializer/) | 15.0.1.2.0 (unported) | fdegrave | Automatically translate Odoo models into Datamodels for (de)serialization [//]: # (end addons) diff --git a/api_log/README.rst b/api_log/README.rst new file mode 100644 index 000000000..1de6387d3 --- /dev/null +++ b/api_log/README.rst @@ -0,0 +1,87 @@ +======= +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 +- `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..2f4388e55 --- /dev/null +++ b/api_log/models/__init__.py @@ -0,0 +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 new file mode 100644 index 000000000..362c49887 --- /dev/null +++ b/api_log/models/api_log.py @@ -0,0 +1,270 @@ +# 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 +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" + + collection_ref = fields.Reference( + selection="_selection_collection_ref", + index=True, + ) + 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() + 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 + ) + + @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. + + 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): + try: + headers_dict = {key: value for key, value in headers.items()} + return self._sanitize_headers_dict(headers_dict) + except AttributeError: + return {} + + 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) + if override_log_values: + log_request_values.update(override_log_values) + 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/models/api_log_collection.py b/api_log/models/api_log_collection.py new file mode 100644 index 000000000..93b0f3ece --- /dev/null +++ b/api_log/models/api_log_collection.py @@ -0,0 +1,46 @@ +# 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 _compute_log_ids(self): + for collection in self: + collection.log_ids = self.env["api.log"].search( + [("collection_ref", "=", "%s,%s" % (collection._name, collection.id))] + ) + + def action_logs(self): + collections_refs = [ + "%s,%s" % (collection._name, collection.id) for collection in self + ] + return { + "type": "ir.actions.act_window", + "res_model": "api.log", + "name": "Logs", + "view_type": "form", + "view_mode": "tree,form", + "target": "current", + "domain": [ + ( + "collection_ref", + "in", + collections_refs, + ), + ], + "context": dict(self.env.context), + } 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/api_log/security/ir_model_access.xml b/api_log/security/ir_model_access.xml new file mode 100644 index 000000000..a092c0d3a --- /dev/null +++ b/api_log/security/ir_model_access.xml @@ -0,0 +1,17 @@ + + + + + API Log: Read access + + + + + + + + diff --git a/api_log/security/res_groups.xml b/api_log/security/res_groups.xml new file mode 100644 index 000000000..8b9ddf38b --- /dev/null +++ b/api_log/security/res_groups.xml @@ -0,0 +1,17 @@ + + + + + + API Log Access + + + + diff --git a/api_log/static/description/index.html b/api_log/static/description/index.html new file mode 100644 index 000000000..e00096eb9 --- /dev/null +++ b/api_log/static/description/index.html @@ -0,0 +1,429 @@ + + + + + +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..e02138286 --- /dev/null +++ b/api_log/tests/common.py @@ -0,0 +1,11 @@ +# Copyright 2025 Simone Rubino - PyTech +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo.tests.common import HttpCase + + +class Common(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..3a3868231 --- /dev/null +++ b/api_log/tests/test_api_log.py @@ -0,0 +1,43 @@ +# 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 Common + + +class TestAPILog(Common): + 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", + ) + request = Request(httprequest) + log = self.log_model.log_request(request) + + 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() + 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..4e8ba689f --- /dev/null +++ b/api_log/views/api_log_views.xml @@ -0,0 +1,118 @@ + + + + + API Log + api.log + tree,form + + + + api.log.form + api.log + +
+ +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + api.log.tree + api.log + + + + + + + + + + + + + + + + api.log.search + api.log + + + + + + + + + + + + + + + + + + +
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..3973453c9 --- /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 FastapiLog(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( + FastapiLog, + 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/auth_partner/README.rst b/auth_partner/README.rst new file mode 100644 index 000000000..6f646f298 --- /dev/null +++ b/auth_partner/README.rst @@ -0,0 +1,106 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +============ +Partner Auth +============ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:33a8bc75dc8127331753aa9a54fe3a5b56f7d51a23cc7e9eb0000cc55f78c689 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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/auth_partner + :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-auth_partner + :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 adds to the partners the ability to authenticate through directories. + +This module does not implement any routing, it only provides the basic mechanisms in a directory for: + + - Registering a partner and sending an welcome email (to validate email address): `_signup` + - Authenticating a partner: `_login` + - Validating a partner email using a token: `_validate_email` + - Impersonating: `_impersonate`, `_impersonating` + - Resetting the password with a unique token sent by mail: `_request_reset_password`, `_set_password` + - Sending an invite mail when registering a partner from odoo interface for the partner to enter a password: `_send_invite`, `_set_password` + +For a routing implementation, see the `fastapi_auth_partner <../fastapi_auth_partner>`_ module. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +This module isn't meant to be used standalone but you can still see the directories and authenticable partners in: + +Settings > Technical > Partner Authentication > Partner + +and + +Settings > Technical > Partner Authentication > Directory + + +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 +~~~~~~~~~~~~ + +* `Akretion `_: + + * Sébastien Beau + * Florian Mounier + +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. + +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/auth_partner/__init__.py b/auth_partner/__init__.py new file mode 100644 index 000000000..aee8895e7 --- /dev/null +++ b/auth_partner/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizards diff --git a/auth_partner/__manifest__.py b/auth_partner/__manifest__.py new file mode 100644 index 000000000..99e8a94fa --- /dev/null +++ b/auth_partner/__manifest__.py @@ -0,0 +1,38 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Partner Auth", + "summary": "Implements the base features for a authenticable partner", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/rest-framework", + "depends": [ + "auth_signup", + "mail", + "queue_job", + "server_environment", + ], + "data": [ + "security/res_group.xml", + "security/ir.model.access.csv", + "security/ir_rule.xml", + "data/email_data.xml", + "wizards/wizard_auth_partner_force_set_password_view.xml", + "wizards/wizard_auth_partner_reset_password_view.xml", + "views/auth_partner_view.xml", + "views/auth_directory_view.xml", + "views/res_partner_view.xml", + ], + "demo": [ + "demo/res_partner_demo.xml", + "demo/auth_directory_demo.xml", + "demo/auth_partner_demo.xml", + ], + "external_dependencies": { + "python": ["itsdangerous", "pyjwt"], + }, +} diff --git a/auth_partner/data/email_data.xml b/auth_partner/data/email_data.xml new file mode 100644 index 000000000..92193d06d --- /dev/null +++ b/auth_partner/data/email_data.xml @@ -0,0 +1,67 @@ + + + + Auth Directory: Reset Password + noreply@example.org + Reset Password + {{object.partner_id.id}} + + + ${object.partner_id.lang} + +
+ Hi + Click on the following link to reset your password + Reset Password +
+
+
+ + + Auth Directory: Set Password + noreply@example.org + Welcome + {{object.partner_id.id}} + + + {{object.partner_id.lang}} + +
+ Hi + Welcome, your account have been created + Click on the following link to set your password + Set Password +
+
+
+ + + Auth Directory: Validate Email + noreply@example.org + Welcome + {{object.partner_id.id}} + + + {{object.partner_id.lang}} + +
+ Hi + Welcome to the site, please click on the following link to verify your email + Validate Email +
+
+
+ +
diff --git a/auth_partner/demo/auth_directory_demo.xml b/auth_partner/demo/auth_directory_demo.xml new file mode 100644 index 000000000..81708b69a --- /dev/null +++ b/auth_partner/demo/auth_directory_demo.xml @@ -0,0 +1,9 @@ + + + + Demo Auth Directory + + + + + diff --git a/auth_partner/demo/auth_partner_demo.xml b/auth_partner/demo/auth_partner_demo.xml new file mode 100644 index 000000000..93dda262c --- /dev/null +++ b/auth_partner/demo/auth_partner_demo.xml @@ -0,0 +1,8 @@ + + + + + + Super-secret$1 + + diff --git a/auth_partner/demo/res_partner_demo.xml b/auth_partner/demo/res_partner_demo.xml new file mode 100644 index 000000000..43a063524 --- /dev/null +++ b/auth_partner/demo/res_partner_demo.xml @@ -0,0 +1,7 @@ + + + + Demo auth partner + partner-auth@example.org + + diff --git a/auth_partner/i18n/auth_partner.pot b/auth_partner/i18n/auth_partner.pot new file mode 100644 index 000000000..b9e8a3e77 --- /dev/null +++ b/auth_partner/i18n/auth_partner.pot @@ -0,0 +1,554 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * auth_partner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: auth_partner +#: model:ir.model.fields.selection,name:auth_partner.selection__wizard_auth_partner_reset_password__delay__14-days +msgid "14 Days" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields.selection,name:auth_partner.selection__wizard_auth_partner_reset_password__delay__2-days +msgid "2-days" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields.selection,name:auth_partner.selection__wizard_auth_partner_reset_password__delay__6-hours +msgid "6 Hours" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields.selection,name:auth_partner.selection__wizard_auth_partner_reset_password__delay__7-days +msgid "7 Days" +msgstr "" + +#. module: auth_partner +#: model:mail.template,body_html:auth_partner.email_reset_password +msgid "" +"
\n" +" Hi \n" +" Click on the following link to reset your password\n" +" Reset Password\n" +"
\n" +" " +msgstr "" + +#. module: auth_partner +#: model:mail.template,body_html:auth_partner.email_validate_email +msgid "" +"
\n" +" Hi \n" +" Welcome to the site, please click on the following link to verify your email\n" +" Validate Email\n" +"
\n" +" " +msgstr "" + +#. module: auth_partner +#: model:mail.template,body_html:auth_partner.email_set_password +msgid "" +"
\n" +" Hi \n" +" Welcome, your account have been created\n" +" Click on the following link to set your password\n" +" Set Password\n" +"
\n" +" " +msgstr "" + +#. module: auth_partner +#: model:res.groups,name:auth_partner.group_auth_partner_api +msgid "API Partner Auth Access" +msgstr "" + +#. module: auth_partner +#: model:res.groups,name:auth_partner.group_auth_partner_manager +msgid "API Partner Auth Manager" +msgstr "" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_directory_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.view_partner_form +msgid "Account" +msgstr "" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_reset_password_view_form +msgid "" +"An email will be send with a token to each customer, you can specify the " +"date until the link is valid" +msgstr "" + +#. module: auth_partner +#: model:ir.model,name:auth_partner.model_auth_directory +msgid "Auth Directory" +msgstr "" + +#. module: auth_partner +#: model:mail.template,name:auth_partner.email_reset_password +msgid "Auth Directory: Reset Password" +msgstr "" + +#. module: auth_partner +#: model:mail.template,name:auth_partner.email_set_password +msgid "Auth Directory: Set Password" +msgstr "" + +#. module: auth_partner +#: model:mail.template,name:auth_partner.email_validate_email +msgid "Auth Directory: Validate Email" +msgstr "" + +#. module: auth_partner +#: model:ir.model,name:auth_partner.model_auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_search +msgid "Auth Partner" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_res_partner__auth_partner_count +#: model:ir.model.fields,field_description:auth_partner.field_res_users__auth_partner_count +msgid "Auth Partner Count" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__auth_partner_ids +msgid "Auth Partners" +msgstr "" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_force_set_password_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_reset_password_view_form +msgid "Cancel" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__password_confirm +msgid "Confirm Password" +msgstr "" + +#. module: auth_partner +#: model:ir.model,name:auth_partner.model_res_partner +msgid "Contact" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__count_partner +msgid "Count Partner" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__create_uid +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__create_uid +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__create_uid +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__create_uid +msgid "Created by" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__create_date +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__create_date +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__create_date +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__create_date +msgid "Created on" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__date_last_impersonation +msgid "Date Last Impersonation" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__date_last_request_reset_pwd +msgid "Date Last Request Reset Pwd" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__date_last_sucessfull_reset_pwd +msgid "Date Last Sucessfull Reset Pwd" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__date_validity +msgid "Date Validity" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__date_last_request_reset_pwd +msgid "Date of the last password reset request" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__date_last_impersonation +msgid "Date of the last sucessfull impersonation" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__date_last_sucessfull_reset_pwd +msgid "Date of the last sucessfull password reset" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__delay +msgid "Delay" +msgstr "" + +#. module: auth_partner +#: model:ir.actions.act_window,name:auth_partner.auth_directory_action +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__directory_id +#: model:ir.ui.menu,name:auth_partner.auth_directory_menu +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_search +msgid "Directory" +msgstr "" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_directory_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_directory_view_search +msgid "Directory Auth" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__display_name +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__display_name +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__display_name +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__display_name +msgid "Display Name" +msgstr "" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_partner.py:0 +#, python-format +msgid "" +"Email address not validated. Validate your email address by clicking on the " +"link in the email sent to you or request a new password. " +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__encrypted_password +msgid "Encrypted Password" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__force_verified_email +msgid "Force Verified Email" +msgstr "" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_search +msgid "Group By" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__id +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__id +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__id +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__id +msgid "ID" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_directory__force_verified_email +msgid "If checked, email must be verified to be able to log in" +msgstr "" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_form +msgid "Impersonate" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__impersonating_token_duration +msgid "Impersonating Token Duration" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__impersonating_user_ids +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__impersonating_user_ids +msgid "Impersonating Users" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_directory__set_password_token_duration +msgid "In minute, default 1440 minutes => 24h" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_directory__impersonating_token_duration +msgid "In seconds, default 60 seconds" +msgstr "" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Invalid Login or Password" +msgstr "" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_directory.py:0 +#: code:addons/auth_partner/models/auth_directory.py:0 +#: code:addons/auth_partner/models/auth_directory.py:0 +#, python-format +msgid "Invalid Token" +msgstr "" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_directory.py:0 +#, python-format +msgid "Invalid token" +msgstr "" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_force_set_password_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_reset_password_view_form +msgid "Label" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory____last_update +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner____last_update +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password____last_update +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password____last_update +msgid "Last Modified on" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__write_uid +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__write_uid +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__write_uid +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__write_date +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__write_date +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__write_date +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__write_date +msgid "Last Updated on" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__login +msgid "Login" +msgstr "" + +#. module: auth_partner +#: model:ir.model.constraint,message:auth_partner.constraint_auth_partner_directory_login_uniq +msgid "Login must be uniq per directory !" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__template_id +msgid "Mail Template" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__reset_password_template_id +msgid "Mail Template Forget Password" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__set_password_template_id +msgid "Mail Template New Password" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__validate_email_template_id +msgid "Mail Template Validate Email" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__mail_verified +msgid "Mail Verified" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields.selection,name:auth_partner.selection__wizard_auth_partner_reset_password__delay__manually +msgid "Manually" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__name +msgid "Name" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__nbr_pending_reset_sent +msgid "Nbr Pending Reset Sent" +msgstr "" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/wizards/wizard_auth_partner_force_set_password.py:0 +#, python-format +msgid "No active_id in context" +msgstr "" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_directory.py:0 +#, python-format +msgid "No email template defined for %(template)s in %(directory)s" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__nbr_pending_reset_sent +msgid "" +"Number of pending reset sent from your customer.This field is usefull when " +"after a migration from an other system you ask all you customer to reset " +"their password and you senddifferent mail depending on the number of " +"reminder" +msgstr "" + +#. module: auth_partner +#: model:ir.actions.act_window,name:auth_partner.auth_partner_action +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__partner_id +#: model:ir.ui.menu,name:auth_partner.auth_partner_menu +msgid "Partner" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_res_partner__auth_partner_ids +#: model:ir.model.fields,field_description:auth_partner.field_res_users__auth_partner_ids +msgid "Partner Auth" +msgstr "" + +#. module: auth_partner +#: model:ir.ui.menu,name:auth_partner.auth +msgid "Partner Authentication" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__password +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__password +msgid "Password" +msgstr "" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/wizards/wizard_auth_partner_force_set_password.py:0 +#, python-format +msgid "Password and Confirm Password must be the same" +msgstr "" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_directory_view_form +msgid "Regenerate secret key" +msgstr "" + +#. module: auth_partner +#: model:mail.template,subject:auth_partner.email_reset_password +msgid "Reset Password" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__secret_key +msgid "Secret Key" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__secret_key_env_default +msgid "Secret Key Env Default" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__secret_key_env_is_editable +msgid "Secret Key Env Is Editable" +msgstr "" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_form +msgid "Send Invite" +msgstr "" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_reset_password_view_form +msgid "Send Reset Password" +msgstr "" + +#. module: auth_partner +#: model:ir.actions.act_window,name:auth_partner.auth_partner_action_reset_password +msgid "Send Reset Password Instruction" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__server_env_defaults +msgid "Server Env Defaults" +msgstr "" + +#. module: auth_partner +#: model:ir.actions.act_window,name:auth_partner.auth_partner_force_set_password_action +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_force_set_password_view_form +msgid "Set Password" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__set_password_token_duration +msgid "Set Password Token Duration" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__user_can_impersonate +msgid "Technical field to check if the user can impersonate" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_directory__impersonating_user_ids +#: model:ir.model.fields,help:auth_partner.field_auth_partner__impersonating_user_ids +msgid "These odoo users can impersonate any partner of this directory" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__mail_verified +msgid "" +"This field is set to True when the user has clicked on the link sent by " +"email" +msgstr "" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/wizards/wizard_auth_partner_force_set_password.py:0 +#, python-format +msgid "This wizard can only be used on auth.partner" +msgstr "" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__user_can_impersonate +msgid "User Can Impersonate" +msgstr "" + +#. module: auth_partner +#: model:mail.template,subject:auth_partner.email_set_password +#: model:mail.template,subject:auth_partner.email_validate_email +msgid "Welcome" +msgstr "" + +#. module: auth_partner +#: model:ir.model,name:auth_partner.model_wizard_auth_partner_force_set_password +#: model:ir.model,name:auth_partner.model_wizard_auth_partner_reset_password +msgid "Wizard Partner Auth Reset Password" +msgstr "" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_partner.py:0 +#, python-format +msgid "You are not allowed to impersonate this user" +msgstr "" diff --git a/auth_partner/i18n/it.po b/auth_partner/i18n/it.po new file mode 100644 index 000000000..a67f0f833 --- /dev/null +++ b/auth_partner/i18n/it.po @@ -0,0 +1,598 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * auth_partner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-06-25 09:25+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.4\n" + +#. module: auth_partner +#: model:ir.model.fields.selection,name:auth_partner.selection__wizard_auth_partner_reset_password__delay__14-days +msgid "14 Days" +msgstr "14 Giorni" + +#. module: auth_partner +#: model:ir.model.fields.selection,name:auth_partner.selection__wizard_auth_partner_reset_password__delay__2-days +msgid "2-days" +msgstr "2 Giorni" + +#. module: auth_partner +#: model:ir.model.fields.selection,name:auth_partner.selection__wizard_auth_partner_reset_password__delay__6-hours +msgid "6 Hours" +msgstr "6 Ore" + +#. module: auth_partner +#: model:ir.model.fields.selection,name:auth_partner.selection__wizard_auth_partner_reset_password__delay__7-days +msgid "7 Days" +msgstr "7 Giorni" + +#. module: auth_partner +#: model:mail.template,body_html:auth_partner.email_reset_password +msgid "" +"
\n" +" Hi \n" +" Click on the following link to reset your password\n" +" Reset Password\n" +"
\n" +" " +msgstr "" +"
\n" +" Salve \n" +" fare clic sul seguente collegamento per resettare la passwrod\n" +" Resetta " +"password\n" +"
\n" +" " + +#. module: auth_partner +#: model:mail.template,body_html:auth_partner.email_validate_email +msgid "" +"
\n" +" Hi \n" +" Welcome to the site, please click on the following link to verify your email\n" +" Validate Email\n" +"
\n" +" " +msgstr "" +"
\n" +" Salve \n" +" Benvenuto sul sito, clicca sul seguente collegamento per " +"verificare la tua e-mail\n" +" Valida " +"e-mail\n" +"
\n" +" " + +#. module: auth_partner +#: model:mail.template,body_html:auth_partner.email_set_password +msgid "" +"
\n" +" Hi \n" +" Welcome, your account have been created\n" +" Click on the following link to set your password\n" +" Set Password\n" +"
\n" +" " +msgstr "" +"
\n" +" Salve \n" +" Benvenuto, il tuo account è stato creato\n" +" Clicca sul collegamento seguente per impostare la tua password\n" +" Imposta " +"password\n" +"
\n" +" " + +#. module: auth_partner +#: model:res.groups,name:auth_partner.group_auth_partner_api +msgid "API Partner Auth Access" +msgstr "Autorizzazione accesso API del partner" + +#. module: auth_partner +#: model:res.groups,name:auth_partner.group_auth_partner_manager +msgid "API Partner Auth Manager" +msgstr "Responsabile autorizzazione API del partner" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_directory_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.view_partner_form +msgid "Account" +msgstr "Conto" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_reset_password_view_form +msgid "" +"An email will be send with a token to each customer, you can specify the " +"date until the link is valid" +msgstr "" +"Verrà inviata una e-mail con un token ad ogni cliente, si può indicare la " +"data entro cui il collegamento è valido" + +#. module: auth_partner +#: model:ir.model,name:auth_partner.model_auth_directory +msgid "Auth Directory" +msgstr "Cartella autorizzazione" + +#. module: auth_partner +#: model:mail.template,name:auth_partner.email_reset_password +msgid "Auth Directory: Reset Password" +msgstr "Cartella autorizzazione: reimposta password" + +#. module: auth_partner +#: model:mail.template,name:auth_partner.email_set_password +msgid "Auth Directory: Set Password" +msgstr "Cartella autorizzazione: imposta password" + +#. module: auth_partner +#: model:mail.template,name:auth_partner.email_validate_email +msgid "Auth Directory: Validate Email" +msgstr "Cartella autorizzazione: valida e-mail" + +#. module: auth_partner +#: model:ir.model,name:auth_partner.model_auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_search +msgid "Auth Partner" +msgstr "Partner autorizzazione" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_res_partner__auth_partner_count +#: model:ir.model.fields,field_description:auth_partner.field_res_users__auth_partner_count +msgid "Auth Partner Count" +msgstr "Conteggio partner autorizzazione" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__auth_partner_ids +msgid "Auth Partners" +msgstr "Partner autorizzazione" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_force_set_password_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_reset_password_view_form +msgid "Cancel" +msgstr "Annulla" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__password_confirm +msgid "Confirm Password" +msgstr "Conferma password" + +#. module: auth_partner +#: model:ir.model,name:auth_partner.model_res_partner +msgid "Contact" +msgstr "Contatto" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__count_partner +msgid "Count Partner" +msgstr "Conteggio partner" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__create_uid +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__create_uid +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__create_uid +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__create_date +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__create_date +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__create_date +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__date_last_impersonation +msgid "Date Last Impersonation" +msgstr "Data ultima imitazione" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__date_last_request_reset_pwd +msgid "Date Last Request Reset Pwd" +msgstr "Data ultima richiesta reimpostazione password" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__date_last_sucessfull_reset_pwd +msgid "Date Last Sucessfull Reset Pwd" +msgstr "Data ultima reimpostazione password riuscita" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__date_validity +msgid "Date Validity" +msgstr "Validità data" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__date_last_request_reset_pwd +msgid "Date of the last password reset request" +msgstr "Data ultima richiesta reimpostazione password" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__date_last_impersonation +msgid "Date of the last sucessfull impersonation" +msgstr "Data ultima imitazione riuscita" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__date_last_sucessfull_reset_pwd +msgid "Date of the last sucessfull password reset" +msgstr "Data ultima reimpostazione password riuscita" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__delay +msgid "Delay" +msgstr "Ritardo" + +#. module: auth_partner +#: model:ir.actions.act_window,name:auth_partner.auth_directory_action +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__directory_id +#: model:ir.ui.menu,name:auth_partner.auth_directory_menu +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_search +msgid "Directory" +msgstr "Cartella" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_directory_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_directory_view_search +msgid "Directory Auth" +msgstr "Autorizzazione cartella" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__display_name +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__display_name +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__display_name +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_partner.py:0 +#, python-format +msgid "" +"Email address not validated. Validate your email address by clicking on the " +"link in the email sent to you or request a new password. " +msgstr "" +"Indirizzo e-mail non validato. Validare il proprio indirizzo e-mail facendo " +"click sul collegamento nella e-mail inviata per la richiesta di una nuova " +"password. " + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__encrypted_password +msgid "Encrypted Password" +msgstr "Password criptata" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__force_verified_email +msgid "Force Verified Email" +msgstr "Forza e-mail verificata" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_search +msgid "Group By" +msgstr "Raggruppa per" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__id +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__id +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__id +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__id +msgid "ID" +msgstr "ID" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_directory__force_verified_email +msgid "If checked, email must be verified to be able to log in" +msgstr "" +"Se selezionata, l'e-mail deve essere verificata per consentire l'accesso" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_form +msgid "Impersonate" +msgstr "Imita" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__impersonating_token_duration +msgid "Impersonating Token Duration" +msgstr "Durata token imitazione" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__impersonating_user_ids +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__impersonating_user_ids +msgid "Impersonating Users" +msgstr "Utenti imitazione" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_directory__set_password_token_duration +msgid "In minute, default 1440 minutes => 24h" +msgstr "In minuti,predefinito 1440 minuti => 24 ore" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_directory__impersonating_token_duration +msgid "In seconds, default 60 seconds" +msgstr "In secondi, predefinito 60 secondi" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Invalid Login or Password" +msgstr "Nome o password errati" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_directory.py:0 +#: code:addons/auth_partner/models/auth_directory.py:0 +#: code:addons/auth_partner/models/auth_directory.py:0 +#, python-format +msgid "Invalid Token" +msgstr "Token non valido" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_directory.py:0 +#, python-format +msgid "Invalid token" +msgstr "Token non valido" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_force_set_password_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_reset_password_view_form +msgid "Label" +msgstr "Etichetta" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory____last_update +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner____last_update +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password____last_update +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__write_uid +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__write_uid +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__write_uid +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__write_date +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__write_date +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__write_date +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__login +msgid "Login" +msgstr "Login" + +#. module: auth_partner +#: model:ir.model.constraint,message:auth_partner.constraint_auth_partner_directory_login_uniq +msgid "Login must be uniq per directory !" +msgstr "La login deve essere univoca per cartella!" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_reset_password__template_id +msgid "Mail Template" +msgstr "Modello e-mail" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__reset_password_template_id +msgid "Mail Template Forget Password" +msgstr "Modello e-mail password dimenticata" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__set_password_template_id +msgid "Mail Template New Password" +msgstr "Modello e-mail nuova password" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__validate_email_template_id +msgid "Mail Template Validate Email" +msgstr "Modello e-mail validazione e-mail" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__mail_verified +msgid "Mail Verified" +msgstr "E-mail verificata" + +#. module: auth_partner +#: model:ir.model.fields.selection,name:auth_partner.selection__wizard_auth_partner_reset_password__delay__manually +msgid "Manually" +msgstr "Manualmente" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__name +msgid "Name" +msgstr "Nome" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__nbr_pending_reset_sent +msgid "Nbr Pending Reset Sent" +msgstr "N° reset inviati in attesa" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/wizards/wizard_auth_partner_force_set_password.py:0 +#, python-format +msgid "No active_id in context" +msgstr "Manca active_id nel context" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_directory.py:0 +#, python-format +msgid "No email template defined for %(template)s in %(directory)s" +msgstr "Modello e-mail non definito per %(template)s in %(directory)s" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__nbr_pending_reset_sent +msgid "" +"Number of pending reset sent from your customer.This field is usefull when " +"after a migration from an other system you ask all you customer to reset " +"their password and you senddifferent mail depending on the number of " +"reminder" +msgstr "" +"Numero di reimpostazioni in sospeso inviate dal cliente. Questo campo è " +"utile quando, dopo una migrazione da un altro sistema, si chiede a tutti i " +"tuoi clienti di reimpostare la propria password e si inviano e-mail diverse " +"a seconda del numero di promemoria" + +#. module: auth_partner +#: model:ir.actions.act_window,name:auth_partner.auth_partner_action +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__partner_id +#: model:ir.ui.menu,name:auth_partner.auth_partner_menu +msgid "Partner" +msgstr "Partner" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_res_partner__auth_partner_ids +#: model:ir.model.fields,field_description:auth_partner.field_res_users__auth_partner_ids +msgid "Partner Auth" +msgstr "Autorizzazione partner" + +#. module: auth_partner +#: model:ir.ui.menu,name:auth_partner.auth +msgid "Partner Authentication" +msgstr "Autenticazione partner" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__password +#: model:ir.model.fields,field_description:auth_partner.field_wizard_auth_partner_force_set_password__password +msgid "Password" +msgstr "Password" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/wizards/wizard_auth_partner_force_set_password.py:0 +#, python-format +msgid "Password and Confirm Password must be the same" +msgstr "La password e la conferma devono essere uguali" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_directory_view_form +msgid "Regenerate secret key" +msgstr "Rigenera chiave segreta" + +#. module: auth_partner +#: model:mail.template,subject:auth_partner.email_reset_password +msgid "Reset Password" +msgstr "Resetta password" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__secret_key +msgid "Secret Key" +msgstr "Chiave segreta" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__secret_key_env_default +msgid "Secret Key Env Default" +msgstr "Chiave segreta ambiente predefinita" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__secret_key_env_is_editable +msgid "Secret Key Env Is Editable" +msgstr "La chiave segreta è modificabile" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_form +msgid "Send Invite" +msgstr "Invia invito" + +#. module: auth_partner +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_reset_password_view_form +msgid "Send Reset Password" +msgstr "Invia reset password" + +#. module: auth_partner +#: model:ir.actions.act_window,name:auth_partner.auth_partner_action_reset_password +msgid "Send Reset Password Instruction" +msgstr "Invia istruzioni reset password" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__server_env_defaults +msgid "Server Env Defaults" +msgstr "Predefiniti ambiente server" + +#. module: auth_partner +#: model:ir.actions.act_window,name:auth_partner.auth_partner_force_set_password_action +#: model_terms:ir.ui.view,arch_db:auth_partner.auth_partner_view_form +#: model_terms:ir.ui.view,arch_db:auth_partner.wizard_auth_partner_force_set_password_view_form +msgid "Set Password" +msgstr "Imposta password" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_directory__set_password_token_duration +msgid "Set Password Token Duration" +msgstr "Durata token impostazione password" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__user_can_impersonate +msgid "Technical field to check if the user can impersonate" +msgstr "Campo tecnico per controllare se l'utente può imitare" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_directory__impersonating_user_ids +#: model:ir.model.fields,help:auth_partner.field_auth_partner__impersonating_user_ids +msgid "These odoo users can impersonate any partner of this directory" +msgstr "Questi utenti Odoo possono imitare qualsiasi partner in questa cartella" + +#. module: auth_partner +#: model:ir.model.fields,help:auth_partner.field_auth_partner__mail_verified +msgid "" +"This field is set to True when the user has clicked on the link sent by " +"email" +msgstr "" +"Questo campo è impostato a true quando l'utente ha cliccato nel collegamento " +"inviato per e-mail" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/wizards/wizard_auth_partner_force_set_password.py:0 +#, python-format +msgid "This wizard can only be used on auth.partner" +msgstr "Questa procedura guidata può essere usata solo su auth.partner" + +#. module: auth_partner +#: model:ir.model.fields,field_description:auth_partner.field_auth_partner__user_can_impersonate +msgid "User Can Impersonate" +msgstr "L'utente può imitare" + +#. module: auth_partner +#: model:mail.template,subject:auth_partner.email_set_password +#: model:mail.template,subject:auth_partner.email_validate_email +msgid "Welcome" +msgstr "Benvenuto" + +#. module: auth_partner +#: model:ir.model,name:auth_partner.model_wizard_auth_partner_force_set_password +#: model:ir.model,name:auth_partner.model_wizard_auth_partner_reset_password +msgid "Wizard Partner Auth Reset Password" +msgstr "Procedura guidata reset password autorizzazione partner" + +#. module: auth_partner +#. odoo-python +#: code:addons/auth_partner/models/auth_partner.py:0 +#, python-format +msgid "You are not allowed to impersonate this user" +msgstr "Non si è autorizzati a imitare questo utente" diff --git a/auth_partner/models/__init__.py b/auth_partner/models/__init__.py new file mode 100644 index 000000000..6259e6d10 --- /dev/null +++ b/auth_partner/models/__init__.py @@ -0,0 +1,3 @@ +from . import auth_directory +from . import auth_partner +from . import res_partner diff --git a/auth_partner/models/auth_directory.py b/auth_partner/models/auth_directory.py new file mode 100644 index 000000000..fe2663640 --- /dev/null +++ b/auth_partner/models/auth_directory.py @@ -0,0 +1,209 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from datetime import datetime, timezone +from secrets import token_urlsafe + +import jwt + +from odoo import _, fields, models +from odoo.exceptions import UserError + +from odoo.addons.queue_job.delay import chain + + +class AuthDirectory(models.Model): + _name = "auth.directory" + _description = "Auth Directory" + _inherit = "server.env.mixin" + + name = fields.Char(required=True) + auth_partner_ids = fields.One2many("auth.partner", "directory_id", "Auth Partners") + set_password_token_duration = fields.Integer( + default=1440, help="In minute, default 1440 minutes => 24h", required=True + ) + impersonating_token_duration = fields.Integer( + default=60, help="In seconds, default 60 seconds", required=True + ) + reset_password_template_id = fields.Many2one( + "mail.template", + "Mail Template Forget Password", + required=True, + default=lambda self: self.env.ref( + "auth_partner.email_reset_password", + raise_if_not_found=False, + ), + ) + set_password_template_id = fields.Many2one( + "mail.template", + "Mail Template New Password", + required=True, + default=lambda self: self.env.ref( + "auth_partner.email_set_password", + raise_if_not_found=False, + ), + ) + validate_email_template_id = fields.Many2one( + "mail.template", + "Mail Template Validate Email", + required=True, + default=lambda self: self.env.ref( + "auth_partner.email_validate_email", + raise_if_not_found=False, + ), + ) + secret_key = fields.Char( + groups="base.group_system", + required=True, + default=lambda self: self._generate_default_secret_key(), + ) + count_partner = fields.Integer(compute="_compute_count_partner") + + impersonating_user_ids = fields.Many2many( + "res.users", + "auth_directory_impersonating_user_rel", + "directory_id", + "user_id", + string="Impersonating Users", + help="These odoo users can impersonate any partner of this directory", + default=lambda self: ( + self.env.ref("base.user_root") | self.env.ref("base.user_admin") + ).ids, + groups="auth_partner.group_auth_partner_manager", + ) + force_verified_email = fields.Boolean( + help="If checked, email must be verified to be able to log in" + ) + + def _generate_default_secret_key(self): + # generate random ~64 chars secret key + return token_urlsafe(64) + + def action_regenerate_secret_key(self): + self.ensure_one() + self.secret_key = self._generate_default_secret_key() + + def _compute_count_partner(self): + data = self.env["auth.partner"].read_group( + [ + ("directory_id", "in", self.ids), + ], + ["directory_id"], + groupby=["directory_id"], + lazy=False, + ) + res = {item["directory_id"][0]: item["__count"] for item in data} + + for record in self: + record.count_partner = res.get(record.id, 0) + + def _get_template(self, type_or_template): + if isinstance(type_or_template, str): + return getattr(self, type_or_template + "_template_id", None) + return type_or_template + + def _prepare_mail_context(self, context): + return context or {} + + def _send_mail_background( + self, type_or_template, auth_partner, callback_job=None, **context + ): + """ + Send an email asynchronously to the auth_partner + using the template defined in the directory + """ + self.ensure_one() + auth_partner.ensure_one() + # Load context synchronously + context = self._prepare_mail_context(context) + + job = self.delayable()._send_mail_impl( + type_or_template, auth_partner, **context + ) + if callback_job: + job = chain(job, callback_job) + return job.delay() + + def _send_mail(self, type_or_template, auth_partner, **context): + """Send an email to the auth_partner using the template defined in the directory""" + self.ensure_one() + auth_partner.ensure_one() + context = self._prepare_mail_context(context) + + self._send_mail_impl(type_or_template, auth_partner, **context) + + def _send_mail_impl(self, type_or_template, auth_partner, **context): + template = self.sudo()._get_template(type_or_template) + if not template: + raise UserError( + _("No email template defined for %(template)s in %(directory)s") + % {"template": type_or_template, "directory": self.name} + ) + template.sudo().with_context(**context).send_mail( + auth_partner.id, force_send=True, raise_exception=True + ) + + return f"Mail {template.name} sent to {auth_partner.login}" + + def _generate_token(self, action, auth_partner, expiration_delta, key_salt=""): + # We need to sudo here as secret_key is a protected field + self = self.sudo() + return jwt.encode( + { + "exp": datetime.now(tz=timezone.utc) + expiration_delta, + "aud": str(self.id), + "action": action, + "ap": auth_partner.id, + }, + self.secret_key + key_salt, + algorithm="HS256", + ) + + def _decode_token( + self, + token, + action, + key_salt=None, + ): + # We need to sudo here as secret_key is a protected field + self = self.sudo() + key = self.secret_key + if key_salt: + try: + obj = jwt.decode( + token, algorithms=["HS256"], options={"verify_signature": False} + ) + except jwt.PyJWTError as e: + raise UserError(_("Invalid Token")) from e + probable_auth_partner = self.env["auth.partner"].browse(obj["ap"]) + if not probable_auth_partner: + raise UserError(_("Invalid Token")) + key += key_salt(probable_auth_partner) + + try: + obj = jwt.decode( + token, + key, + audience=str(self.id), + options={"require": ["exp", "aud", "ap", "action"]}, + algorithms=["HS256"], + ) + except jwt.PyJWTError as e: + raise UserError(_("Invalid Token")) from e + + auth_partner = self.env["auth.partner"].browse(obj["ap"]) + + if ( + obj["action"] != action + or not auth_partner + or auth_partner.directory_id != self + ): + raise UserError(_("Invalid token")) + + return auth_partner + + @property + def _server_env_fields(self): + return {"secret_key": {}} diff --git a/auth_partner/models/auth_partner.py b/auth_partner/models/auth_partner.py new file mode 100644 index 000000000..737e14529 --- /dev/null +++ b/auth_partner/models/auth_partner.py @@ -0,0 +1,310 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging +from datetime import timedelta + +import passlib + +from odoo import _, api, fields, models +from odoo.exceptions import AccessDenied + +# please read passlib great documentation +# https://passlib.readthedocs.io +# https://passlib.readthedocs.io/en/stable/narr/quickstart.html#choosing-a-hash +# be carefull odoo requirements use an old version of passlib +DEFAULT_CRYPT_CONTEXT = passlib.context.CryptContext(["pbkdf2_sha512"]) + +_logger = logging.getLogger(__name__) + + +class AuthPartner(models.Model): + _name = "auth.partner" + _description = "Auth Partner" + _rec_name = "login" + + partner_id = fields.Many2one( + "res.partner", "Partner", required=True, ondelete="cascade", index=True + ) + directory_id = fields.Many2one( + "auth.directory", "Directory", required=True, index=True + ) + user_can_impersonate = fields.Boolean( + compute="_compute_user_can_impersonate", + help="Technical field to check if the user can impersonate", + ) + impersonating_user_ids = fields.Many2many( + related="directory_id.impersonating_user_ids", + ) + login = fields.Char( + compute="_compute_login", + store=True, + required=True, + index=True, + precompute=True, + ) + password = fields.Char(compute="_compute_password", inverse="_inverse_password") + encrypted_password = fields.Char(index=True) + nbr_pending_reset_sent = fields.Integer( + index=True, + help=( + "Number of pending reset sent from your customer." + "This field is usefull when after a migration from an other system " + "you ask all you customer to reset their password and you send" + "different mail depending on the number of reminder" + ), + ) + date_last_request_reset_pwd = fields.Datetime( + help="Date of the last password reset request" + ) + date_last_sucessfull_reset_pwd = fields.Datetime( + help="Date of the last sucessfull password reset" + ) + date_last_impersonation = fields.Datetime( + help="Date of the last sucessfull impersonation" + ) + + mail_verified = fields.Boolean( + help="This field is set to True when the user has clicked on the link sent by email" + ) + + _sql_constraints = [ + ( + "directory_login_uniq", + "unique (directory_id, login)", + "Login must be uniq per directory !", + ), + ] + + @api.depends("partner_id.email") + def _compute_login(self): + for record in self: + record.login = record.partner_id.email + + def _crypt_context(self): + return DEFAULT_CRYPT_CONTEXT + + def _check_no_empty(self, login, password): + # double check by security but calling this through a service should + # already have check this + if not ( + isinstance(password, str) and password and isinstance(login, str) and login + ): + _logger.warning("Invalid login/password for sign in") + raise AccessDenied() + + def _get_hashed_password(self, directory, login): + self.flush() + self.env.cr.execute( + """ + SELECT id, COALESCE(encrypted_password, '') + FROM auth_partner + WHERE login=%s AND directory_id=%s""", + (login, directory.id), + ) + hashed = self.env.cr.fetchone() + if hashed and hashed[1]: + # ensure that we have a auth.partner and this partner have a password set + return hashed + else: + raise AccessDenied() + + def _compute_password(self): + for record in self: + record.password = "" + + def _inverse_password(self): + for record in self: + ctx = record._crypt_context() + hash_ = getattr(ctx, "hash", ctx.encrypt) + record.encrypted_password = hash_(record.password) + record.password = "" + + def _prepare_partner_auth_signup(self, directory, vals): + return { + "login": vals["login"].lower(), + "password": vals["password"], + "directory_id": directory.id, + } + + def _prepare_partner_signup(self, directory, vals): + return { + "name": vals["name"], + "email": vals["login"].lower(), + "auth_partner_ids": [ + (0, 0, self._prepare_partner_auth_signup(directory, vals)) + ], + } + + @api.model + def _signup(self, directory, **kwargs): + partner = self.env["res.partner"].create( + [ + self._prepare_partner_signup(directory, kwargs), + ] + ) + auth_partner = partner.auth_partner_ids + directory._send_mail_background( + "validate_email", + auth_partner, + token=auth_partner._generate_validate_email_token(), + ) + return auth_partner + + @api.model + def _login(self, directory, login, password, **kwargs): + self._check_no_empty(login, password) + login = login.lower() + try: + _id, hashed = self._get_hashed_password(directory, login) + valid, replacement = self._crypt_context().verify_and_update( + password, hashed + ) + + auth_partner = valid and self.browse(_id) + except AccessDenied: + # We do not want to leak information about the login, + # always raise the same exception + auth_partner = None + + if not auth_partner or not auth_partner.partner_id.active: + raise AccessDenied(_("Invalid Login or Password")) + + if directory.sudo().force_verified_email and not auth_partner.mail_verified: + raise AccessDenied( + _( + "Email address not validated. Validate your email address by " + "clicking on the link in the email sent to you or request a new " + "password. " + ) + ) + + if replacement is not None: + auth_partner.encrypted_password = replacement + + return auth_partner + + @api.model + def _validate_email(self, directory, token): + auth_partner = directory._decode_token(token, "validate_email") + auth_partner.write({"mail_verified": True}) + return auth_partner + + def _get_impersonate_url(self, token, **kwargs): + # You should override this method according to the impersonation url + # your framework is using + + base = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + url = f"{base}/auth/impersonate/{token}" + return url + + def _get_impersonate_action(self, token, **kwargs): + return { + "type": "ir.actions.act_url", + "url": self._get_impersonate_url(token, **kwargs), + "target": "new", + } + + def impersonate(self): + self.ensure_one() + if self.env.user not in self.impersonating_user_ids: + raise AccessDenied(_("You are not allowed to impersonate this user")) + + token = self._generate_impersonating_token() + return self._get_impersonate_action(token) + + @api.depends_context("uid") + def _compute_user_can_impersonate(self): + for record in self: + record.user_can_impersonate = self.env.user in record.impersonating_user_ids + + @api.model + def _impersonating(self, directory, token): + partner_auth = directory._decode_token( + token, + "impersonating", + key_salt=lambda auth_partner: ( + auth_partner.date_last_impersonation.isoformat() + if auth_partner.date_last_impersonation + else "never" + ), + ) + partner_auth.date_last_impersonation = fields.Datetime.now() + return partner_auth + + def _on_reset_password_sent(self): + self.ensure_one() + self.date_last_request_reset_pwd = fields.Datetime.now() + self.date_last_sucessfull_reset_pwd = None + self.nbr_pending_reset_sent += 1 + + def _send_invite(self): + self.ensure_one() + self.directory_id._send_mail_background( + "set_password", + self, + callback_job=self.delayable()._on_reset_password_sent(), + token=self._generate_set_password_token(), + ) + + def send_invite(self): + for rec in self: + rec._send_invite() + + def _request_reset_password(self): + return self.directory_id._send_mail_background( + "reset_password", + self, + callback_job=self.delayable()._on_reset_password_sent(), + token=self._generate_set_password_token(), + ) + + def _set_password(self, directory, token, password): + auth_partner = directory._decode_token( + token, + "set_password", + # See `_generate_set_password_token` for the key_salt + key_salt=lambda auth_partner: auth_partner.encrypted_password or "empty", + ) + auth_partner.write( + { + "password": password, + "mail_verified": True, + } + ) + auth_partner.date_last_sucessfull_reset_pwd = fields.Datetime.now() + auth_partner.nbr_pending_reset_sent = 0 + return auth_partner + + def _generate_set_password_token(self, expiration_delta=None): + # Here we use the current encrypted_password as key_salt to ensure that + # the token will be used to reset the password only once. + return self.directory_id._generate_token( + "set_password", + self, + expiration_delta + or timedelta(minutes=self.directory_id.set_password_token_duration), + key_salt=self.encrypted_password or "empty", + ) + + def _generate_validate_email_token(self): + return self.directory_id._generate_token( + # 30 days seem to be a good value, no need for configuration + "validate_email", + self, + timedelta(days=30), + ) + + def _generate_impersonating_token(self): + return self.directory_id._generate_token( + "impersonating", + self, + timedelta(minutes=self.directory_id.impersonating_token_duration), + key_salt=( + self.date_last_impersonation.isoformat() + if self.date_last_impersonation + else "never" + ), + ) diff --git a/auth_partner/models/res_partner.py b/auth_partner/models/res_partner.py new file mode 100644 index 000000000..0a398533d --- /dev/null +++ b/auth_partner/models/res_partner.py @@ -0,0 +1,34 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + auth_partner_ids = fields.One2many("auth.partner", "partner_id", "Partner Auth") + auth_partner_count = fields.Integer( + compute="_compute_auth_partner_count", compute_sudo=True + ) + + def _compute_auth_partner_count(self): + data = self.env["auth.partner"].read_group( + [ + ("partner_id", "in", self.ids), + ], + ["partner_id"], + groupby=["partner_id"], + lazy=False, + ) + res = {item["partner_id"][0]: item["__count"] for item in data} + + for record in self: + record.auth_partner_count = res.get(record.id, 0) + + def _get_auth_partner_for_directory(self, directory): + return self.sudo().auth_partner_ids.filtered( + lambda r: r.directory_id == directory + ) diff --git a/auth_partner/readme/CONTRIBUTORS.rst b/auth_partner/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..bae3cc9a1 --- /dev/null +++ b/auth_partner/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Akretion `_: + + * Sébastien Beau + * Florian Mounier diff --git a/auth_partner/readme/DESCRIPTION.rst b/auth_partner/readme/DESCRIPTION.rst new file mode 100644 index 000000000..2a63b69ea --- /dev/null +++ b/auth_partner/readme/DESCRIPTION.rst @@ -0,0 +1,12 @@ +This module adds to the partners the ability to authenticate through directories. + +This module does not implement any routing, it only provides the basic mechanisms in a directory for: + + - Registering a partner and sending an welcome email (to validate email address): `_signup` + - Authenticating a partner: `_login` + - Validating a partner email using a token: `_validate_email` + - Impersonating: `_impersonate`, `_impersonating` + - Resetting the password with a unique token sent by mail: `_request_reset_password`, `_set_password` + - Sending an invite mail when registering a partner from odoo interface for the partner to enter a password: `_send_invite`, `_set_password` + +For a routing implementation, see the `fastapi_auth_partner <../fastapi_auth_partner>`_ module. diff --git a/auth_partner/readme/USAGE.rst b/auth_partner/readme/USAGE.rst new file mode 100644 index 000000000..39cb46f62 --- /dev/null +++ b/auth_partner/readme/USAGE.rst @@ -0,0 +1,8 @@ +This module isn't meant to be used standalone but you can still see the directories and authenticable partners in: + +Settings > Technical > Partner Authentication > Partner + +and + +Settings > Technical > Partner Authentication > Directory + diff --git a/auth_partner/security/ir.model.access.csv b/auth_partner/security/ir.model.access.csv new file mode 100644 index 000000000..cdcead759 --- /dev/null +++ b/auth_partner/security/ir.model.access.csv @@ -0,0 +1,8 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_auth_directory,auth_directory_system,model_auth_directory,base.group_system,1,1,1,1 +access_auth_directory_read,auth_directory_manager,model_auth_directory,group_auth_partner_manager,1,0,0,0 +access_auth_partner,auth_partner_manager,model_auth_partner,group_auth_partner_manager,1,1,1,1 +api_access_auth_partner,auth_partner_api,model_auth_partner,group_auth_partner_api,1,1,0,0 +api_access_res_partner,res_partner_api,base.model_res_partner,group_auth_partner_api,1,0,0,0 +api_access_wizard_auth_partner_reset_password,wizard_auth_partner_reset_password,model_wizard_auth_partner_reset_password,group_auth_partner_manager,1,1,1,1 +api_access_wizard_auth_partner_force_set_password,wizard_auth_partner_force_set_password,model_wizard_auth_partner_force_set_password,group_auth_partner_manager,1,1,1,1 diff --git a/auth_partner/security/ir_rule.xml b/auth_partner/security/ir_rule.xml new file mode 100644 index 000000000..01a9e6020 --- /dev/null +++ b/auth_partner/security/ir_rule.xml @@ -0,0 +1,26 @@ + + + + Auth API (res_partner) + + + [('id','=', authenticated_partner_id)] + + + + + + + + Auth API (auth_partner) + + + [('partner_id','=', authenticated_partner_id)] + + + + + + diff --git a/auth_partner/security/res_group.xml b/auth_partner/security/res_group.xml new file mode 100644 index 000000000..a912c7d2f --- /dev/null +++ b/auth_partner/security/res_group.xml @@ -0,0 +1,16 @@ + + + + API Partner Auth Manager + + + + + + API Partner Auth Access + + + diff --git a/auth_partner/static/description/icon.png b/auth_partner/static/description/icon.png new file mode 100644 index 000000000..1dcc49c24 Binary files /dev/null and b/auth_partner/static/description/icon.png differ diff --git a/auth_partner/static/description/index.html b/auth_partner/static/description/index.html new file mode 100644 index 000000000..fe776ea15 --- /dev/null +++ b/auth_partner/static/description/index.html @@ -0,0 +1,453 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Partner Auth

+ +

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

+

This module adds to the partners the ability to authenticate through directories.

+

This module does not implement any routing, it only provides the basic mechanisms in a directory for:

+
+
    +
  • Registering a partner and sending an welcome email (to validate email address): _signup
  • +
  • Authenticating a partner: _login
  • +
  • Validating a partner email using a token: _validate_email
  • +
  • Impersonating: _impersonate, _impersonating
  • +
  • Resetting the password with a unique token sent by mail: _request_reset_password, _set_password
  • +
  • Sending an invite mail when registering a partner from odoo interface for the partner to enter a password: _send_invite, _set_password
  • +
+
+

For a routing implementation, see the fastapi_auth_partner module.

+

Table of contents

+ +
+

Usage

+

This module isn’t meant to be used standalone but you can still see the directories and authenticable partners in:

+

Settings > Technical > Partner Authentication > Partner

+

and

+

Settings > Technical > Partner Authentication > Directory

+
+
+

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

+
    +
  • Akretion:
      +
    • Sébastien Beau
    • +
    • Florian Mounier
    • +
    +
  • +
+
+
+

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.

+

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/auth_partner/tests/__init__.py b/auth_partner/tests/__init__.py new file mode 100644 index 000000000..ee9a639f6 --- /dev/null +++ b/auth_partner/tests/__init__.py @@ -0,0 +1 @@ +from . import test_auth_partner diff --git a/auth_partner/tests/common.py b/auth_partner/tests/common.py new file mode 100644 index 000000000..6c6f777f6 --- /dev/null +++ b/auth_partner/tests/common.py @@ -0,0 +1,60 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from contextlib import contextmanager +from typing import Any + +from odoo.tests.common import TransactionCase + +from odoo.addons.mail.tests.common import MockEmail + + +class CommonTestAuthPartner(TransactionCase, MockEmail): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env(context=dict(cls.env.context, queue_job__no_delay=True)) + + cls.partner = cls.env.ref("auth_partner.res_partner_auth_demo") + cls.other_partner = cls.partner.copy( + {"name": "Other Partner", "email": "other-partner-auth@example.org"} + ) + cls.auth_partner = cls.partner.auth_partner_ids + + cls.directory = cls.env.ref("auth_partner.demo_directory") + cls.directory.impersonating_user_ids = cls.env.ref("base.user_admin") + + cls.other_auth_partner = cls.env["auth.partner"].create( + { + "login": cls.other_partner.email, + "password": "Super-secret3", + "directory_id": cls.directory.id, + "partner_id": cls.other_partner.id, + } + ) + cls.other_directory = cls.directory.copy({"name": "Other Directory"}) + + @contextmanager + def new_mails(self): + mailmail = self.env["mail.mail"] + + class MailsProxy(mailmail.__class__): + __slots__ = ["_prev", "__weakref__"] + + def __init__(self): + object.__setattr__(self, "_prev", mailmail.search([])) + + def __getattribute__(self, name: str) -> Any: + mails = mailmail.search([]) - object.__getattribute__(self, "_prev") + return object.__getattribute__(mails, name) + + new_mails = MailsProxy() + with self.mock_mail_gateway(): + yield new_mails + + @contextmanager + def assert_no_new_mail(self): + with self.new_mails() as new_mails: + yield + self.assertFalse(new_mails) diff --git a/auth_partner/tests/test_auth_partner.py b/auth_partner/tests/test_auth_partner.py new file mode 100644 index 000000000..40d9e8478 --- /dev/null +++ b/auth_partner/tests/test_auth_partner.py @@ -0,0 +1,357 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from contextlib import contextmanager +from datetime import datetime, timedelta + +from freezegun import freeze_time + +from odoo.exceptions import AccessDenied, UserError + +from .common import CommonTestAuthPartner + + +class TestAuthPartner(CommonTestAuthPartner): + @contextmanager + def assert_no_new_mail(self): + with self.new_mails() as new_mails: + yield + self.assertFalse(new_mails) + + def test_default_secret_key(self): + self.assertGreaterEqual(len(self.directory.secret_key), 64) + + def test_login_ok(self): + with self.assert_no_new_mail(): + auth_partner = self.env["auth.partner"]._login( + self.directory, + login="partner-auth@example.org", + password="Super-secret$1", + ) + self.assertTrue(auth_partner) + + def test_login_inactive_partner(self): + self.partner.active = False + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, + login="partner-auth@example.org", + password="Super-secret$1", + ) + + def test_login_no_auth(self): + self.auth_partner.unlink() + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, + login="partner-auth@example.org", + password="Super-secret$1", + ) + + def test_login_wrong_password(self): + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, login="partner-auth@example.org", password="wrong" + ) + + def test_login_mail_not_verified(self): + self.directory.force_verified_email = True + with self.assertRaisesRegex(AccessDenied, "Email address not validated"): + self.env["auth.partner"]._login( + self.directory, + login="partner-auth@example.org", + password="Super-secret$1", + ) + + def test_login_wrong_login(self): + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, + login="partner-auth@example.com", + password="Super-secret$1", + ) + + def test_login_wrong_directory(self): + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.other_directory, + login="partner-auth@example.com", + password="Super-secret$1", + ) + + def test_signup(self): + with self.new_mails() as new_mails: + new_auth_partner = self.env["auth.partner"]._signup( + self.directory, + name="New Partner", + login="new-partner-auth@example.org", + password="NewSecret", + ) + self.assertTrue(new_auth_partner) + # Ensure we can't read the password + self.assertNotEqual(new_auth_partner.password, "NewSecret") + + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Welcome") + self.assertIn("Welcome to the site, please", new_mails.body) + + auth_partner = self.env["auth.partner"]._login( + self.directory, login="new-partner-auth@example.org", password="NewSecret" + ) + self.assertTrue(auth_partner) + self.assertEqual(auth_partner, new_auth_partner) + + def test_signup_wrong_directory(self): + new_auth_partner = self.env["auth.partner"]._signup( + self.other_directory, + name="New Partner", + login="new-partner-auth@example.org", + password="NewSecret", + ) + self.assertTrue(new_auth_partner) + + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, + login="new-partner-auth@example.org", + password="NewSecret", + ) + + def test_signup_same_login_other_directory(self): + new_auth_partner = self.env["auth.partner"]._signup( + self.directory, + name="New Partner", + login="new-partner-auth@example.org", + password="NewSecret", + ) + self.assertTrue(new_auth_partner) + new_auth_partner_2 = self.env["auth.partner"]._signup( + self.other_directory, + name="New Partner", + login="new-partner-auth@example.org", + password="NewSecret2", + ) + self.assertTrue(new_auth_partner_2) + self.assertNotEqual(new_auth_partner, new_auth_partner_2) + + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, + login="new-partner-auth@example.org", + password="NewSecret2", + ) + + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.other_directory, + login="new-partner-auth@example.org", + password="NewSecret", + ) + + def test_validate_email_ok(self): + self.assertFalse(self.auth_partner.mail_verified) + token = self.auth_partner._generate_validate_email_token() + self.auth_partner._validate_email(self.directory, token) + self.assertTrue(self.auth_partner.mail_verified) + + def test_validate_email_required_login(self): + self.directory.force_verified_email = True + token = self.auth_partner._generate_validate_email_token() + self.auth_partner._validate_email(self.directory, token) + with self.assert_no_new_mail(): + auth_partner = self.env["auth.partner"]._login( + self.directory, + login="partner-auth@example.org", + password="Super-secret$1", + ) + self.assertTrue(auth_partner) + + def test_validate_email_wrong_token(self): + self.assertFalse(self.auth_partner.mail_verified) + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.auth_partner._validate_email(self.directory, "wrong") + self.assertFalse(self.auth_partner.mail_verified) + + def test_validate_email_token(self): + with self.new_mails() as new_mails: + new_auth_partner = self.env["auth.partner"]._signup( + self.directory, + name="New Partner", + login="new-partner-auth@example.org", + password="NewSecret", + ) + self.assertFalse(new_auth_partner.mail_verified) + token = new_mails.body.split("token=")[1].split('">')[0] + new_auth_partner._validate_email(self.directory, token) + self.assertTrue(new_auth_partner.mail_verified) + + def test_impersonate_ok(self): + action = self.auth_partner.with_user( + self.env.ref("base.user_admin") + ).impersonate() + token = action["url"].split("/")[-1] + + auth_partner = self.env["auth.partner"]._impersonating(self.directory, token) + self.assertEqual(auth_partner, self.auth_partner) + + def test_impersonate_once(self): + action = self.auth_partner.with_user( + self.env.ref("base.user_admin") + ).impersonate() + token = action["url"].split("/")[-1] + + self.env["auth.partner"]._impersonating(self.directory, token) + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.env["auth.partner"]._impersonating(self.directory, token) + + def test_impersonate_wrong_directory(self): + action = self.auth_partner.with_user( + self.env.ref("base.user_admin") + ).impersonate() + token = action["url"].split("/")[-1] + + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.env["auth.partner"]._impersonating(self.other_directory, token) + + def test_impersonate_wrong_user(self): + with self.assertRaisesRegex(AccessDenied, "not allowed to impersonate"): + self.auth_partner.with_user(self.env.ref("base.default_user")).impersonate() + + def test_impersonate_not_expired_token(self): + self.directory.impersonating_token_duration = 100 + action = self.auth_partner.with_user( + self.env.ref("base.user_admin") + ).impersonate() + token = action["url"].split("/")[-1] + + with freeze_time(datetime.now() + timedelta(hours=1)): + self.env["auth.partner"]._impersonating(self.directory, token) + + def test_impersonate_expired_token(self): + self.directory.impersonating_token_duration = 100 + action = self.auth_partner.with_user( + self.env.ref("base.user_admin") + ).impersonate() + token = action["url"].split("/")[-1] + + with ( + freeze_time(datetime.now() + timedelta(hours=2)), + self.assertRaisesRegex(UserError, "Invalid Token"), + ): + self.env["auth.partner"]._impersonating(self.directory, token) + + def test_set_password_ok(self): + self.auth_partner._set_password( + self.directory, + self.auth_partner._generate_set_password_token(), + "ResetSecret", + ) + auth_partner = self.env["auth.partner"]._login( + self.directory, login="partner-auth@example.org", password="ResetSecret" + ) + self.assertTrue(auth_partner) + + def test_set_password_wrong_token(self): + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.auth_partner._set_password(self.directory, "wrong", "ResetSecret") + + def test_set_password_once(self): + token = self.auth_partner._generate_set_password_token() + self.auth_partner._set_password(self.directory, token, "ResetSecret") + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.auth_partner._set_password(self.directory, token, "ResetSecret") + + def test_set_password_not_expired_token(self): + self.directory.set_password_token_duration = 100 + token = self.auth_partner._generate_set_password_token() + + with freeze_time(datetime.now() + timedelta(hours=1)): + self.auth_partner._set_password(self.directory, token, "ResetSecret") + + auth_partner = self.env["auth.partner"]._login( + self.directory, login="partner-auth@example.org", password="ResetSecret" + ) + self.assertTrue(auth_partner) + + def test_set_password_expired_token(self): + self.directory.set_password_token_duration = 100 + token = self.auth_partner._generate_set_password_token() + + with ( + freeze_time(datetime.now() + timedelta(hours=2)), + self.assertRaisesRegex(UserError, "Invalid Token"), + ): + self.auth_partner._set_password(self.directory, token, "ResetSecret") + + def test_reset_password_ok(self): + with self.new_mails() as new_mails: + self.auth_partner._request_reset_password() + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Reset Password") + self.assertIn( + "Click on the following link to reset your password", new_mails.body + ) + + token = new_mails.body.split("token=")[1].split('">')[0] + self.auth_partner._set_password(self.directory, token, "ResetSecret") + auth_partner = self.env["auth.partner"]._login( + self.directory, login="partner-auth@example.org", password="ResetSecret" + ) + self.assertTrue(auth_partner) + + def test_reset_password_wrong_partner(self): + with self.new_mails() as new_mails: + self.auth_partner._request_reset_password() + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Reset Password") + self.assertIn( + "Click on the following link to reset your password", new_mails.body + ) + + token = new_mails.body.split("token=")[1].split('">')[0] + # This should probably raise instead of reseting the auth_partner password + self.other_auth_partner._set_password(self.directory, token, "ResetSecret") + with self.assertRaisesRegex(AccessDenied, "Invalid Login or Password"): + self.env["auth.partner"]._login( + self.directory, + login="other-partner-auth@example.org", + password="ResetSecret", + ) + + def test_reset_password_once(self): + with self.new_mails() as new_mails: + self.auth_partner._request_reset_password() + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Reset Password") + token = new_mails.body.split("token=")[1].split('">')[0] + self.auth_partner._set_password(self.directory, token, "ResetSecret") + + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.auth_partner._set_password(self.directory, token, "ResetSecret2") + + def test_send_invite_set_password_ok(self): + with self.new_mails() as new_mails: + self.auth_partner._send_invite() + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Welcome") + self.assertIn("your account have been created", new_mails.body) + token = new_mails.body.split("token=")[1].split('">')[0] + + self.auth_partner._set_password(self.directory, token, "ResetSecret") + auth_partner = self.env["auth.partner"]._login( + self.directory, login="partner-auth@example.org", password="ResetSecret" + ) + self.assertTrue(auth_partner) + + def test_send_invite_set_password_once(self): + with self.new_mails() as new_mails: + self.auth_partner._send_invite() + self.assertEqual(len(new_mails), 1) + self.assertEqual(new_mails.subject, "Welcome") + + token = new_mails.body.split("token=")[1].split('">')[0] + self.auth_partner._set_password(self.directory, token, "ResetSecret") + + with self.assertRaisesRegex(UserError, "Invalid Token"): + self.auth_partner._set_password(self.directory, token, "ResetSecret2") diff --git a/auth_partner/views/auth_directory_view.xml b/auth_partner/views/auth_directory_view.xml new file mode 100644 index 000000000..1d8c58cfd --- /dev/null +++ b/auth_partner/views/auth_directory_view.xml @@ -0,0 +1,92 @@ + + + + auth.directory + + + + + + + + + auth.directory + +
+
+
+ +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + +
+
+
+
+ + + auth.directory + + + + + + + + + Directory + ir.actions.act_window + auth.directory + tree,form + + [] + {} + + + +
diff --git a/auth_partner/views/auth_partner_view.xml b/auth_partner/views/auth_partner_view.xml new file mode 100644 index 000000000..bc9459442 --- /dev/null +++ b/auth_partner/views/auth_partner_view.xml @@ -0,0 +1,98 @@ + + + + auth.partner + + + + + + + + + + + + + + + auth.partner + +
+
+
+ +
+

+ + +

+
+ + + + + + + +
+
+
+
+ + + auth.partner + + + + + + + + + + + + + Partner + ir.actions.act_window + auth.partner + tree,form + + [] + {} + + + + +
diff --git a/auth_partner/views/res_partner_view.xml b/auth_partner/views/res_partner_view.xml new file mode 100644 index 000000000..9978e92b2 --- /dev/null +++ b/auth_partner/views/res_partner_view.xml @@ -0,0 +1,22 @@ + + + + res.partner + + +
+ +
+
+
+
diff --git a/auth_partner/wizards/__init__.py b/auth_partner/wizards/__init__.py new file mode 100644 index 000000000..2f8025a36 --- /dev/null +++ b/auth_partner/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import wizard_auth_partner_reset_password +from . import wizard_auth_partner_force_set_password diff --git a/auth_partner/wizards/wizard_auth_partner_force_set_password.py b/auth_partner/wizards/wizard_auth_partner_force_set_password.py new file mode 100644 index 000000000..66952d3bb --- /dev/null +++ b/auth_partner/wizards/wizard_auth_partner_force_set_password.py @@ -0,0 +1,37 @@ +# 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 +from odoo.exceptions import UserError, ValidationError + + +class WizardAuthPartnerForceSetPassword(models.TransientModel): + _name = "wizard.auth.partner.force.set.password" + _description = "Wizard Partner Auth Reset Password" + + password = fields.Char(required=True) + password_confirm = fields.Char(string="Confirm Password", required=True) + + @api.constrains("password", "password_confirm") + def _check_password(self): + for wizard in self: + if wizard.password != wizard.password_confirm: + raise ValidationError( + _("Password and Confirm Password must be the same") + ) + + def action_force_set_password(self): + self.ensure_one() + if self.env.context.get("active_model") != "auth.partner": + raise UserError(_("This wizard can only be used on auth.partner")) + auth_partner_id = self.env.context.get("active_id") + if not auth_partner_id: + raise UserError(_("No active_id in context")) + + auth_partner = self.env["auth.partner"].browse(auth_partner_id) + + auth_partner.write({"password": self.password}) + + return {"type": "ir.actions.act_window_close"} diff --git a/auth_partner/wizards/wizard_auth_partner_force_set_password_view.xml b/auth_partner/wizards/wizard_auth_partner_force_set_password_view.xml new file mode 100644 index 000000000..8742520c5 --- /dev/null +++ b/auth_partner/wizards/wizard_auth_partner_force_set_password_view.xml @@ -0,0 +1,38 @@ + + + + + + wizard.auth.partner.force.set.password + +
+ + + + +
+
+ +
+
+
+ + + Set Password + wizard.auth.partner.force.set.password + form + new + + +
diff --git a/auth_partner/wizards/wizard_auth_partner_reset_password.py b/auth_partner/wizards/wizard_auth_partner_reset_password.py new file mode 100644 index 000000000..e69610877 --- /dev/null +++ b/auth_partner/wizards/wizard_auth_partner_reset_password.py @@ -0,0 +1,59 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import datetime, timedelta + +from odoo import api, fields, models + + +class WizardAuthPartnerResetPassword(models.TransientModel): + _name = "wizard.auth.partner.reset.password" + _description = "Wizard Partner Auth Reset Password" + + delay = fields.Selection( + [ + ("manually", "Manually"), + ("6-hours", "6 Hours"), + ("2-days", "2-days"), + ("7-days", "7 Days"), + ("14-days", "14 Days"), + ], + default="6-hours", + required=True, + ) + template_id = fields.Many2one( + "mail.template", + "Mail Template", + required=True, + domain=[("model_id", "=", "auth.partner")], + ) + date_validity = fields.Datetime( + compute="_compute_date_validity", store=True, readonly=False + ) + + @api.depends("delay") + def _compute_date_validity(self): + for record in self: + if record.delay != "manually": + duration, key = record.delay.split("-") + record.date_validity = datetime.now() + timedelta( + **{key: float(duration)} + ) + + def action_reset_password(self): + expiration_delta = None + if self.delay != "manually": + duration, key = self.delay.split("-") + expiration_delta = timedelta(**{key: float(duration)}) + + for auth_partner in self.env["auth.partner"].browse( + self._context["active_ids"] + ): + auth_partner.directory_id._send_mail_background( + self.template_id, + auth_partner, + callback_job=auth_partner.delayable()._on_reset_password_sent(), + token=auth_partner._generate_set_password_token(expiration_delta), + ) diff --git a/auth_partner/wizards/wizard_auth_partner_reset_password_view.xml b/auth_partner/wizards/wizard_auth_partner_reset_password_view.xml new file mode 100644 index 000000000..f35f4ec29 --- /dev/null +++ b/auth_partner/wizards/wizard_auth_partner_reset_password_view.xml @@ -0,0 +1,42 @@ + + + + + wizard.auth.partner.reset.password + +
+ An email will be send with a token to each customer, you can specify the date until the link is valid + + + + + +
+
+ +
+
+
+ + + Send Reset Password Instruction + wizard.auth.partner.reset.password + ir.actions.act_window + form + new + + + + +
diff --git a/base_rest/README.rst b/base_rest/README.rst index 0bac51c24..3556e64b2 100644 --- a/base_rest/README.rst +++ b/base_rest/README.rst @@ -7,7 +7,7 @@ Base Rest !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:35fb4211e3cdea0ba35463860d4d0cc7bc2909ab39e5b5af9a6733ced5b96232 + !! source digest: sha256:517d5b1d74542047b404d2130e5d9239fe591f43b1a89ca02339766c8c8a6584 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/base_rest/__manifest__.py b/base_rest/__manifest__.py index 27f38e2c5..1b6d113ad 100644 --- a/base_rest/__manifest__.py +++ b/base_rest/__manifest__.py @@ -6,7 +6,7 @@ "summary": """ Develop your own high level REST APIs for Odoo thanks to this addon. """, - "version": "16.0.1.0.3", + "version": "16.0.1.0.4", "development_status": "Beta", "license": "LGPL-3", "author": "ACSONE SA/NV, " "Odoo Community Association (OCA)", diff --git a/base_rest/controllers/main.py b/base_rest/controllers/main.py index b4166d741..a768fb70f 100644 --- a/base_rest/controllers/main.py +++ b/base_rest/controllers/main.py @@ -96,13 +96,6 @@ class ControllerB(ControllerB): @classmethod def __init_subclass__(cls): - if ( - "RestController" in globals() - and RestController in cls.__bases__ - and Controller not in cls.__bases__ - ): - # Ensure that Controller's __init_subclass__ kicks in. - cls.__bases__ += (Controller,) super().__init_subclass__() if "RestController" not in globals() or not any( issubclass(b, RestController) for b in cls.__bases__ diff --git a/base_rest/static/description/index.html b/base_rest/static/description/index.html index 459de1d5e..b26ae50ef 100644 --- a/base_rest/static/description/index.html +++ b/base_rest/static/description/index.html @@ -367,7 +367,7 @@

Base Rest

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:35fb4211e3cdea0ba35463860d4d0cc7bc2909ab39e5b5af9a6733ced5b96232 +!! source digest: sha256:517d5b1d74542047b404d2130e5d9239fe591f43b1a89ca02339766c8c8a6584 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

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

This addon is deprecated and not fully supported anymore on Odoo 16. @@ -450,10 +450,10 @@

Usage

  • Other methods are only accessible via HTTP POST routes <string:_service_name> or <string:_service_name>/<string:method_name> or <string:_service_name>/<int:_id> or <string:_service_name>/<int:_id>/<string:method_name>
  • -from odoo.addons.component.core import Component
    +from odoo.addons.component.core import Component
     
     
    -class PingService(Component):
    +class PingService(Component):
         _inherit = 'base.rest.service'
         _name = 'ping.service'
         _usage = 'ping'
    @@ -461,46 +461,46 @@ 

    Usage

    # The following method are 'public' and can be called from the controller. - def get(self, _id, message): + def get(self, _id, message): return { 'response': 'Get called with message ' + message} - def search(self, message): + def search(self, message): return { 'response': 'Search called search with message ' + message} - def update(self, _id, message): + def update(self, _id, message): return {'response': 'PUT called with message ' + message} # pylint:disable=method-required-super - def create(self, **params): + def create(self, **params): return {'response': 'POST called with message ' + params['message']} - def delete(self, _id): + def delete(self, _id): return {'response': 'DELETE called with id %s ' % _id} # Validator - def _validator_search(self): + def _validator_search(self): return {'message': {'type': 'string'}} # Validator - def _validator_get(self): + def _validator_get(self): # no parameters by default return {} - def _validator_update(self): + def _validator_update(self): return {'message': {'type': 'string'}} - def _validator_create(self): + def _validator_create(self): return {'message': {'type': 'string'}}

    Once you have implemented your services (ping, …), you must tell to Odoo how to access to these services. This process is done by implementing a controller that inherits from odoo.addons.base_rest.controllers.main.RestController

    -from odoo.addons.base_rest.controllers import main
    +from odoo.addons.base_rest.controllers import main
     
    -class MyRestController(main.RestController):
    +class MyRestController(main.RestController):
         _root_path = '/my_services_api/'
         _collection_name = my_module.services
     
    @@ -516,7 +516,7 @@

    Usage

    ROOT_PATH + '<string:_service_name>/<int:_id>', ROOT_PATH + '<string:_service_name>/<int:_id>/get' ], methods=['GET'], auth="user", csrf=False) -def get(self, _service_name, _id=None, **params): +def get(self, _service_name, _id=None, **params): method_name = 'get' if _id else 'search' return self._process_method(_service_name, method_name, _id, params) @@ -526,7 +526,7 @@

    Usage

    ROOT_PATH + '<string:_service_name>/<int:_id>', ROOT_PATH + '<string:_service_name>/<int:_id>/<string:method_name>' ], methods=['POST'], auth="user", csrf=False) -def modify(self, _service_name, _id=None, method_name=None, **params): +def modify(self, _service_name, _id=None, method_name=None, **params): if not method_name: method_name = 'update' if _id else 'create' if method_name == 'get': @@ -538,13 +538,13 @@

    Usage

    @route([ ROOT_PATH + '<string:_service_name>/<int:_id>', ], methods=['PUT'], auth="user", csrf=False) -def update(self, _service_name, _id, **params): +def update(self, _service_name, _id, **params): return self._process_method(_service_name, 'update', _id, params) @route([ ROOT_PATH + '<string:_service_name>/<int:_id>', ], methods=['DELETE'], auth="user", csrf=False) -def delete(self, _service_name, _id): +def delete(self, _service_name, _id): return self._process_method(_service_name, 'delete', _id)

    As result an HTTP GET call to ‘http://my_odoo/my_services_api/ping’ will be @@ -565,7 +565,7 @@

    Usage

    of a python decorator to explicitly mark a method as being available via the REST API: odoo.addons.base_rest.restapi.method.

    -class PartnerNewApiService(Component):
    +class PartnerNewApiService(Component):
         _inherit = "base.rest.service"
         _name = "partner.new_api.service"
         _usage = "partner"
    @@ -580,10 +580,10 @@ 

    Usage

    output_param=restapi.CerberusValidator("_get_partner_schema"), auth="public", ) - def get(self, _id): + def get(self, _id): return {"name": self.env["res.partner"].browse(_id).name} - def _get_partner_schema(self): + def _get_partner_schema(self): return { "name": {"type": "string", "required": True} } @@ -593,7 +593,7 @@

    Usage

    output_param=restapi.CerberusListValidator("_get_partner_schema"), auth="public", ) - def list(self): + def list(self): partners = self.env["res.partner"].search([]) return [{"name": p.name} for p in partners]
    @@ -602,28 +602,28 @@

    Usage

    For example, base_rest_datamodel allows you to use Datamodel object instance into your services.

    -from marshmallow import fields
    +from marshmallow import fields
     
    -from odoo.addons.base_rest import restapi
    -from odoo.addons.component.core import Component
    -from odoo.addons.datamodel.core import Datamodel
    +from odoo.addons.base_rest import restapi
    +from odoo.addons.component.core import Component
    +from odoo.addons.datamodel.core import Datamodel
     
     
    -class PartnerSearchParam(Datamodel):
    +class PartnerSearchParam(Datamodel):
         _name = "partner.search.param"
     
         id = fields.Integer(required=False, allow_none=False)
         name = fields.String(required=False, allow_none=False)
     
     
    -class PartnerShortInfo(Datamodel):
    +class PartnerShortInfo(Datamodel):
         _name = "partner.short.info"
     
         id = fields.Integer(required=True, allow_none=False)
         name = fields.String(required=True, allow_none=False)
     
     
    -class PartnerNewApiService(Component):
    +class PartnerNewApiService(Component):
         _inherit = "base.rest.service"
         _name = "partner.new_api.service"
         _usage = "partner"
    @@ -639,7 +639,7 @@ 

    Usage

    output_param=restapi.Datamodel("partner.short.info", is_list=True), auth="public", ) - def search(self, partner_search_param): + def search(self, partner_search_param): """ Search for partners :param partner_search_param: An instance of partner.search.param diff --git a/base_rest_demo/README.rst b/base_rest_demo/README.rst index fe4048f21..b07df2cc4 100644 --- a/base_rest_demo/README.rst +++ b/base_rest_demo/README.rst @@ -7,7 +7,7 @@ Base Rest Demo !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:50ac989c1e6343bcb3f97a2f8df7392224cb7d06989f88fba9a14a17ae40d3dd + !! source digest: sha256:18e5691a569699452f29ef673266f29b874bf5c931221af24817846eb59f85a1 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/base_rest_demo/__manifest__.py b/base_rest_demo/__manifest__.py index 3a161363d..564a8536c 100644 --- a/base_rest_demo/__manifest__.py +++ b/base_rest_demo/__manifest__.py @@ -5,7 +5,7 @@ "name": "Base Rest Demo", "summary": """ Demo addon for Base REST""", - "version": "16.0.2.0.2", + "version": "16.0.2.0.4", "development_status": "Beta", "license": "LGPL-3", "author": "ACSONE SA/NV, " "Odoo Community Association (OCA)", diff --git a/base_rest_demo/services/exception_services.py b/base_rest_demo/services/exception_services.py index afc39988b..d07d02774 100644 --- a/base_rest_demo/services/exception_services.py +++ b/base_rest_demo/services/exception_services.py @@ -1,6 +1,8 @@ # Copyright 2018 ACSONE SA/NV # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). +from psycopg2 import errorcodes +from psycopg2.errors import OperationalError from werkzeug.exceptions import MethodNotAllowed from odoo import _ @@ -12,9 +14,13 @@ ValidationError, ) from odoo.http import SessionExpiredException +from odoo.service.model import MAX_TRIES_ON_CONCURRENCY_FAILURE +from odoo.addons.base_rest.components.service import to_int from odoo.addons.component.core import Component +_CPT_RETRY = 0 + class ExceptionService(Component): _inherit = "base.rest.service" @@ -90,6 +96,23 @@ def bare_exception(self): """ raise IOError("My IO error") + def retryable_error(self, nbr_retries): + """This method is used in the test suite to check that the retrying + functionality in case of concurrency error on the database is working + correctly for retryable exceptions. + + The output will be the number of retries that have been done. + + This method is mainly used to test the retrying functionality + """ + global _CPT_RETRY + if _CPT_RETRY < nbr_retries: + _CPT_RETRY += 1 + raise FakeConcurrentUpdateError("fake error") + tryno = _CPT_RETRY + _CPT_RETRY = 0 + return {"retries": tryno} + # Validator def _validator_user_error(self): return {} @@ -138,3 +161,22 @@ def _validator_bare_exception(self): def _validator_return_bare_exception(self): return {} + + def _validator_retryable_error(self): + return { + "nbr_retries": { + "type": "integer", + "required": True, + "default": MAX_TRIES_ON_CONCURRENCY_FAILURE, + "coerce": to_int, + } + } + + def _validator_return_retryable_error(self): + return {"retries": {"type": "integer"}} + + +class FakeConcurrentUpdateError(OperationalError): + @property + def pgcode(self): + return errorcodes.SERIALIZATION_FAILURE diff --git a/base_rest_demo/static/description/index.html b/base_rest_demo/static/description/index.html index 58bb0ebf0..62b5d66ed 100644 --- a/base_rest_demo/static/description/index.html +++ b/base_rest_demo/static/description/index.html @@ -1,4 +1,3 @@ - @@ -9,10 +8,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +275,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -367,7 +367,7 @@

    Base Rest Demo

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:50ac989c1e6343bcb3f97a2f8df7392224cb7d06989f88fba9a14a17ae40d3dd +!! source digest: sha256:18e5691a569699452f29ef673266f29b874bf5c931221af24817846eb59f85a1 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

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

    Demo addon to illustrate how to develop self documented REST services thanks @@ -442,7 +442,9 @@

    Contributors

    Maintainers

    This module is maintained by the OCA.

    -Odoo Community Association + +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.

    diff --git a/base_rest_demo/tests/test_controller.py b/base_rest_demo/tests/test_controller.py index 678e52618..43a84f489 100644 --- a/base_rest_demo/tests/test_controller.py +++ b/base_rest_demo/tests/test_controller.py @@ -18,20 +18,15 @@ def test_controller_registry(self): # at the end of the start process, our tow controllers must into the # controller registered controllers = Controller.children_classes.get("base_rest_demo", []) - - self.assertIn( - BaseRestDemoPrivateApiController, - controllers, + self.assertTrue( + any([issubclass(x, BaseRestDemoPrivateApiController) for x in controllers]) ) - self.assertIn( - BaseRestDemoPublicApiController, - controllers, + self.assertTrue( + any([issubclass(x, BaseRestDemoPublicApiController) for x in controllers]) ) - self.assertIn( - BaseRestDemoNewApiController, - controllers, + self.assertTrue( + any([issubclass(x, BaseRestDemoNewApiController) for x in controllers]) ) - self.assertIn( - BaseRestDemoJwtApiController, - controllers, + self.assertTrue( + any([issubclass(x, BaseRestDemoJwtApiController) for x in controllers]) ) diff --git a/base_rest_demo/tests/test_exception.py b/base_rest_demo/tests/test_exception.py index 3f5aa134d..9d955e914 100644 --- a/base_rest_demo/tests/test_exception.py +++ b/base_rest_demo/tests/test_exception.py @@ -104,3 +104,17 @@ def test_bare_exception(self): self.assertEqual(response.headers["content-type"], "application/json") body = json.loads(response.content.decode("utf-8")) self.assertDictEqual(body, {"code": 500, "name": "Internal Server Error"}) + + @odoo.tools.mute_logger("odoo.addons.base_rest.http", "odoo.http") + def test_retrying(self): + """Test that the retrying mechanism is working as expected with the + FastAPI endpoints in case of POST request with a file. + """ + nbr_retries = 3 + response = self.url_open( + "%s/retryable_error" % self.url, + '{"nbr_retries": %d}' % nbr_retries, + timeout=20000, + ) + self.assertEqual(response.status_code, 200, response.content) + self.assertDictEqual(response.json(), {"retries": nbr_retries}) diff --git a/datamodel/README.rst b/datamodel/README.rst index 1209730ea..bad3f8c95 100644 --- a/datamodel/README.rst +++ b/datamodel/README.rst @@ -7,7 +7,7 @@ Datamodel !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:5411d4f742eb933a4d05f5f6e1784a7ddc042e7f22b1c08d535e225c306a6955 + !! source digest: sha256:220702c6d930c27e2dbb4016e1f6bef6e1b9c7b96cff4b3c5ad27fdc5733ad26 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/datamodel/__manifest__.py b/datamodel/__manifest__.py index fbef3d494..97b1a6310 100644 --- a/datamodel/__manifest__.py +++ b/datamodel/__manifest__.py @@ -6,12 +6,14 @@ "summary": """ This addon allows you to define simple data models supporting serialization/deserialization""", - "version": "16.0.1.0.1", + "version": "16.0.1.0.2", "license": "LGPL-3", "development_status": "Beta", "author": "ACSONE SA/NV, " "Odoo Community Association (OCA)", "maintainers": ["lmignon"], "website": "https://github.com/OCA/rest-framework", - "external_dependencies": {"python": ["marshmallow", "marshmallow-objects>=2.0.0"]}, + "external_dependencies": { + "python": ["marshmallow<4.0.0", "marshmallow-objects>=2.0.0"] + }, "installable": True, } diff --git a/datamodel/static/description/index.html b/datamodel/static/description/index.html index 7b11ce833..b290f07ce 100644 --- a/datamodel/static/description/index.html +++ b/datamodel/static/description/index.html @@ -1,4 +1,3 @@ - @@ -9,10 +8,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +275,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -367,7 +367,7 @@

    Datamodel

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:5411d4f742eb933a4d05f5f6e1784a7ddc042e7f22b1c08d535e225c306a6955 +!! source digest: sha256:220702c6d930c27e2dbb4016e1f6bef6e1b9c7b96cff4b3c5ad27fdc5733ad26 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

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

    This addon allows you to define simple data models supporting serialization/deserialization @@ -393,20 +393,20 @@

    Usage

    To define your own datamodel you just need to create a class that inherits from odoo.addons.datamodel.core.Datamodel

    -from marshmallow import fields
    +from marshmallow import fields
     
    -from odoo.addons.base_rest import restapi
    -from odoo.addons.component.core import Component
    -from odoo.addons.datamodel.core import Datamodel
    +from odoo.addons.base_rest import restapi
    +from odoo.addons.component.core import Component
    +from odoo.addons.datamodel.core import Datamodel
     
     
    -class PartnerShortInfo(Datamodel):
    +class PartnerShortInfo(Datamodel):
         _name = "partner.short.info"
     
         id = fields.Integer(required=True, allow_none=False)
         name = fields.String(required=True, allow_none=False)
     
    -class PartnerInfo(Datamodel):
    +class PartnerInfo(Datamodel):
         _name = "partner.info"
         _inherit = "partner.short.info"
     
    @@ -419,18 +419,18 @@ 

    Usage

    As for odoo models, you can extend the base datamodel by inheriting of base.

    -class Base(Datamodel):
    +class Base(Datamodel):
         _inherit = "base"
     
    -    def _my_method(self):
    +    def _my_method(self):
             pass
     

    Datamodels are available through the datamodels registry provided by the Odoo’s environment.

    -class ResPartner(Model):
    +class ResPartner(Model):
         _inherit = "res.partner"
     
    -    def _to_partner_info(self):
    +    def _to_partner_info(self):
             PartnerInfo = self.env.datamodels["partner.info"]
             partner_info = PartnerInfo(partial=True)
             partner_info.id = partner.id
    @@ -445,10 +445,10 @@ 

    Usage

    The Odoo’s environment is also available into the datamodel instance.

    -class MyDataModel(Datamodel):
    +class MyDataModel(Datamodel):
         _name = "my.data.model"
     
    -    def _my_method(self):
    +    def _my_method(self):
             partners = self.env["res.partner"].search([])
     
    @@ -498,7 +498,9 @@

    Contributors

    Maintainers

    This module is maintained by the OCA.

    -Odoo Community Association + +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.

    diff --git a/extendable_fastapi/README.rst b/extendable_fastapi/README.rst index 4213d94dc..2a93a308f 100644 --- a/extendable_fastapi/README.rst +++ b/extendable_fastapi/README.rst @@ -7,7 +7,7 @@ Extendable Fastapi !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:4e4f5d96294f860ce7f0c4e023431f8ed9ca011c318b5ba4a3cfcd15c31eac1a + !! source digest: sha256:1c3abf7259ae8390271785fb3b8f7754dadf175dc1530a27386ae9a77635e4b2 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/extendable_fastapi/__manifest__.py b/extendable_fastapi/__manifest__.py index 56bb68bc1..44bc64aa1 100644 --- a/extendable_fastapi/__manifest__.py +++ b/extendable_fastapi/__manifest__.py @@ -5,7 +5,7 @@ "name": "Extendable Fastapi", "summary": """ Allows the use of extendable into fastapi apps""", - "version": "16.0.2.1.1", + "version": "16.0.2.1.2", "license": "LGPL-3", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", "maintainers": ["lmignon"], diff --git a/extendable_fastapi/fastapi_dispatcher.py b/extendable_fastapi/fastapi_dispatcher.py index b940df9ce..10fcfaf22 100644 --- a/extendable_fastapi/fastapi_dispatcher.py +++ b/extendable_fastapi/fastapi_dispatcher.py @@ -3,6 +3,8 @@ from contextlib import contextmanager +from odoo.http import _dispatchers + from odoo.addons.extendable.registry import _extendable_registries_database from odoo.addons.fastapi.fastapi_dispatcher import ( FastApiDispatcher as BaseFastApiDispatcher, @@ -11,7 +13,9 @@ from extendable import context -class FastApiDispatcher(BaseFastApiDispatcher): +# 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): diff --git a/extendable_fastapi/static/description/index.html b/extendable_fastapi/static/description/index.html index 88bfd690b..fdf779b2d 100644 --- a/extendable_fastapi/static/description/index.html +++ b/extendable_fastapi/static/description/index.html @@ -1,4 +1,3 @@ - @@ -9,10 +8,11 @@ /* :Author: David Goodger (goodger@python.org) -:Id: $Id: html4css1.css 8954 2022-01-20 10:10:25Z milde $ +:Id: $Id: html4css1.css 9511 2024-01-13 09:50:07Z milde $ :Copyright: This stylesheet has been placed in the public domain. Default cascading style sheet for the HTML output of Docutils. +Despite the name, some widely supported CSS2 features are used. See https://docutils.sourceforge.io/docs/howto/html-stylesheets.html for how to customize this style sheet. @@ -275,7 +275,7 @@ margin-left: 2em ; margin-right: 2em } -pre.code .ln { color: grey; } /* line numbers */ +pre.code .ln { color: gray; } /* line numbers */ pre.code, code { background-color: #eeeeee } pre.code .comment, code .comment { color: #5C6576 } pre.code .keyword, code .keyword { color: #3B0D06; font-weight: bold } @@ -301,7 +301,7 @@ span.pre { white-space: pre } -span.problematic { +span.problematic, pre.problematic { color: red } span.section-subtitle { @@ -367,7 +367,7 @@

    Extendable Fastapi

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:4e4f5d96294f860ce7f0c4e023431f8ed9ca011c318b5ba4a3cfcd15c31eac1a +!! source digest: sha256:1c3abf7259ae8390271785fb3b8f7754dadf175dc1530a27386ae9a77635e4b2 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

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

    This addon is a technical addon used to allows the use of @@ -443,7 +443,9 @@

    Contributors

    Maintainers

    This module is maintained by the OCA.

    -Odoo Community Association + +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.

    diff --git a/fastapi/README.rst b/fastapi/README.rst index 574ecc086..b30166c11 100644 --- a/fastapi/README.rst +++ b/fastapi/README.rst @@ -1,3 +1,7 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + ============ Odoo FastAPI ============ @@ -7,13 +11,13 @@ Odoo FastAPI !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:ccbcb06116d31f370fa16dda9fb82b273ff770e72e77a346c20ef68a4150500f + !! source digest: sha256:d7b9919d3058c69a37cd990e0d0a3e4b0fa55d146ab2713f8834e4833313ddd7 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |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-LGPL--3-blue.png +.. |badge2| image:: https://img.shields.io/badge/license-LGPL--3-blue.png :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html :alt: License: LGPL-3 .. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github diff --git a/fastapi/__manifest__.py b/fastapi/__manifest__.py index fa0e6a0d4..3f0c5d810 100644 --- a/fastapi/__manifest__.py +++ b/fastapi/__manifest__.py @@ -5,7 +5,7 @@ "name": "Odoo FastAPI", "summary": """ Odoo FastAPI endpoint""", - "version": "16.0.1.4.5", + "version": "16.0.1.7.0", "license": "LGPL-3", "author": "ACSONE SA/NV,Odoo Community Association (OCA)", "maintainers": ["lmignon"], diff --git a/fastapi/demo/fastapi_endpoint_demo.xml b/fastapi/demo/fastapi_endpoint_demo.xml index a1c34e34b..ad3fd9da0 100644 --- a/fastapi/demo/fastapi_endpoint_demo.xml +++ b/fastapi/demo/fastapi_endpoint_demo.xml @@ -44,4 +44,15 @@ methods. See documentation to learn more about how to create a new app. http_basic + + + Fastapi Multi-Slash Demo Endpoint + + Like the other demo endpoint but with multi-slash + + demo + /fastapi/demo-multi + http_basic + + diff --git a/fastapi/fastapi_dispatcher.py b/fastapi/fastapi_dispatcher.py index bfb56825c..79b80b753 100644 --- a/fastapi/fastapi_dispatcher.py +++ b/fastapi/fastapi_dispatcher.py @@ -8,6 +8,7 @@ from .context import odoo_env_ctx from .error_handlers import convert_exception_to_status_body +from .pools import fastapi_app_pool class FastApiDispatcher(Dispatcher): @@ -26,21 +27,20 @@ def dispatch(self, endpoint, args): # don't parse the httprequest let starlette parse the stream self.request.params = {} # dict(self.request.get_http_params(), **args) environ = self._get_environ() - root_path = "/" + environ["PATH_INFO"].split("/")[1] + path = environ["PATH_INFO"] # TODO store the env into contextvar to be used by the odoo_env # depends method - fastapi_endpoint = self.request.env["fastapi.endpoint"].sudo() - app = fastapi_endpoint.get_app(root_path) - uid = fastapi_endpoint.get_uid(root_path) - data = BytesIO() - with self._manage_odoo_env(uid): - for r in app(environ, self._make_response): - data.write(r) - if self.inner_exception: - raise self.inner_exception - return self.request.make_response( - data.getvalue(), headers=self.headers, status=self.status - ) + with fastapi_app_pool.get_app(env=request.env, root_path=path) as app: + uid = request.env["fastapi.endpoint"].sudo().get_uid(path) + data = BytesIO() + with self._manage_odoo_env(uid): + for r in app(environ, self._make_response): + data.write(r) + if self.inner_exception: + raise self.inner_exception + return self.request.make_response( + data.getvalue(), headers=self.headers, status=self.status + ) def handle_error(self, exc): headers = getattr(exc, "headers", None) @@ -51,7 +51,8 @@ def handle_error(self, exc): def _make_response(self, status_mapping, headers_tuple, content): self.status = status_mapping[:3] - self.headers = dict(headers_tuple) + self.headers = headers_tuple + self.inner_exception = None # in case of exception, the method asgi_done_callback of the # ASGIResponder will trigger an "a2wsgi.error" event with the exception # instance stored in a tuple with the type of the exception and the traceback. @@ -114,5 +115,10 @@ def _manage_odoo_env(self, uid=None): token = odoo_env_ctx.set(env) try: yield + # Flush here to ensure all pending computations are being executed with + # authenticated fastapi user before exiting this context manager, as it + # would otherwise be done using the public user on the commit of the DB + # cursor, what could potentially lead to inconsistencies or AccessError. + env.flush_all() finally: odoo_env_ctx.reset(token) diff --git a/fastapi/i18n/fastapi.pot b/fastapi/i18n/fastapi.pot index 834c779cf..ca38189a1 100644 --- a/fastapi/i18n/fastapi.pot +++ b/fastapi/i18n/fastapi.pot @@ -47,7 +47,7 @@ msgstr "" #. module: fastapi #: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__demo_auth_method -msgid "Authenciation method" +msgid "Authentication method" msgstr "" #. module: fastapi diff --git a/fastapi/i18n/it.po b/fastapi/i18n/it.po index a1ab5e904..818698a62 100644 --- a/fastapi/i18n/it.po +++ b/fastapi/i18n/it.po @@ -6,7 +6,7 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 16.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-10-03 10:06+0000\n" +"PO-Revision-Date: 2025-06-04 09:40+0000\n" "Last-Translator: mymage \n" "Language-Team: none\n" "Language: it\n" @@ -14,7 +14,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: \n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.6.2\n" +"X-Generator: Weblate 5.10.4\n" #. module: fastapi #: model:ir.model.fields,help:fastapi.field_fastapi_endpoint__description @@ -50,7 +50,7 @@ msgstr "In archivio" #. module: fastapi #: model:ir.model.fields,field_description:fastapi.field_fastapi_endpoint__demo_auth_method -msgid "Authenciation method" +msgid "Authentication method" msgstr "Metodo autenticazione" #. module: fastapi @@ -104,7 +104,7 @@ msgstr "FastAPI" #: model:ir.model,name:fastapi.model_fastapi_endpoint #: model:ir.ui.menu,name:fastapi.fastapi_endpoint_menu msgid "FastAPI Endpoint" -msgstr "Endopoint FastAPI" +msgstr "Endpoint FastAPI" #. module: fastapi #: model:res.groups,name:fastapi.group_fastapi_endpoint_runner @@ -264,3 +264,6 @@ msgstr "" #, python-format msgid "`%(name)s` uses a blacklisted root_path = `%(root_path)s`" msgstr "`%(name)s` utilizza un root_path bloccato = `%(root_path)s`" + +#~ msgid "Authenciation method" +#~ msgstr "Metodo autenticazione" diff --git a/fastapi/middleware.py b/fastapi/middleware.py new file mode 100644 index 000000000..b88c40652 --- /dev/null +++ b/fastapi/middleware.py @@ -0,0 +1,40 @@ +# Copyright 2025 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). +""" +ASGI middleware for FastAPI. + +This module provides an ASGI middleware for FastAPI applications. The middleware +is designed to ensure managed the lifecycle of the threads used to as event loop +for the ASGI application. + +""" + +from typing import Iterable + +import a2wsgi +from a2wsgi.asgi import ASGIResponder +from a2wsgi.asgi_typing import ASGIApp +from a2wsgi.wsgi_typing import Environ, StartResponse + +from .pools import event_loop_pool + + +class ASGIMiddleware(a2wsgi.ASGIMiddleware): + def __init__( + self, + app: ASGIApp, + wait_time: float | None = None, + ) -> None: + # We don't want to use the default event loop policy + # because we want to manage the event loop ourselves + # using the event loop pool. + # Since the the base class check if the given loop is + # None, we can pass False to avoid the initialization + # of the default event loop + super().__init__(app, wait_time, False) + + def __call__( + self, environ: Environ, start_response: StartResponse + ) -> Iterable[bytes]: + with event_loop_pool.get_event_loop() as loop: + return ASGIResponder(self.app, loop)(environ, start_response) diff --git a/fastapi/models/fastapi_endpoint.py b/fastapi/models/fastapi_endpoint.py index 1312f07d1..93130c69d 100644 --- a/fastapi/models/fastapi_endpoint.py +++ b/fastapi/models/fastapi_endpoint.py @@ -6,7 +6,6 @@ from itertools import chain from typing import Any, Callable, Dict, List, Tuple -from a2wsgi import ASGIMiddleware from starlette.middleware import Middleware from starlette.routing import Mount @@ -15,6 +14,7 @@ from fastapi import APIRouter, Depends, FastAPI from .. import dependencies +from ..middleware import ASGIMiddleware _logger = logging.getLogger(__name__) @@ -121,10 +121,10 @@ def _registered_endpoint_rule_keys(self): return tuple(res) @api.model - def _routing_impacting_fields(self) -> Tuple[str]: + def _routing_impacting_fields(self) -> Tuple[str, ...]: """The list of fields requiring to refresh the mount point of the pp into odoo if modified""" - return ("root_path",) + return ("root_path", "save_http_session") # # end of endpoint.route.sync.mixin methods implementation @@ -198,16 +198,86 @@ def _endpoint_registry_route_unique_key(self, routing: Dict[str, Any]): return f"{self._name}:{self.id}:{path}" def _reset_app(self): - self.get_app.clear_cache(self) + self._get_id_by_root_path_map.clear_cache(self) + self._get_id_for_path.clear_cache(self) + self._reset_app_cache_marker.clear_cache(self) + + @tools.ormcache() + def _reset_app_cache_marker(self): + """This methos is used to get a way to mark the orm cache as dirty + when the app is reset. By marking the cache as dirty, the system + will signal to others instances that the cache is not up to date + and that they should invalidate their cache as well. This is required + to ensure that any change requiring a reset of the app is propagated + to all the running instances. + """ + + @api.model + def _normalize_url_path(self, path) -> str: + """ + Normalize a URL path: + * Remove redundant slashes, + * Remove trailing slash (unless it's the root), + * Lowercase for case-insensitive matching + """ + parts = [part.lower() for part in path.strip().split("/") if part] + return "/" + "/".join(parts) + + @api.model + def _is_suburl(self, path, prefix) -> bool: + """ + Check if 'path' is a subpath of 'prefix' in URL logic: + * Must start with the prefix followed by a slash + This will ensure that the matching is done one the path + parts and ensures that e.g. /a/b is not prefix of /a/bc. + """ + path = self._normalize_url_path(path) + prefix = self._normalize_url_path(prefix) + + if path == prefix: + return True + if path.startswith(prefix + "/"): + return True + return False + + @api.model + def _find_first_matching_url_path(self, paths, prefix) -> str | None: + """ + Return the first path that is a subpath of 'prefix', + ordered by longest URL path first (most number of segments). + """ + # Sort by number of segments (shallowest first) + sorted_paths = sorted( + paths, + key=lambda p: len(self._normalize_url_path(p).split("/")), + reverse=True, + ) + + for path in sorted_paths: + if self._is_suburl(prefix, path): + return path + return None + + @api.model + @tools.ormcache() + def _get_id_by_root_path_map(self): + return {r.root_path: r.id for r in self.search([])} + + @api.model + @tools.ormcache("path") + def _get_id_for_path(self, path): + id_by_path = self._get_id_by_root_path_map() + root_path = self._find_first_matching_url_path(id_by_path.keys(), path) + return id_by_path.get(root_path) + + @api.model + def _get_endpoint(self, path): + id_ = self._get_id_for_path(path) + return self.browse(id_) if id_ else None @api.model - @tools.ormcache("root_path") - # TODO cache on thread local by db to enable to get 1 middelware by - # thread when odoo runs in multi threads mode and to allows invalidate - # specific entries in place og the overall cache as we have to do into - # the _rest_app method - def get_app(self, root_path): - record = self.search([("root_path", "=", root_path)]) + def get_app(self, path): + record = self._get_endpoint(path) if not record: return None app = FastAPI() @@ -231,9 +301,9 @@ def _clear_fastapi_exception_handlers(self, app: FastAPI) -> None: self._clear_fastapi_exception_handlers(route.app) @api.model - @tools.ormcache("root_path") - def get_uid(self, root_path): - record = self.search([("root_path", "=", root_path)]) + @tools.ormcache("path") + def get_uid(self, path): + record = self._get_endpoint(path) if not record: return None return record.user_id.id diff --git a/fastapi/pools/__init__.py b/fastapi/pools/__init__.py new file mode 100644 index 000000000..31f1fb388 --- /dev/null +++ b/fastapi/pools/__init__.py @@ -0,0 +1,11 @@ +from .event_loop import EventLoopPool +from .fastapi_app import FastApiAppPool +from odoo.service.server import CommonServer + +event_loop_pool = EventLoopPool() +fastapi_app_pool = FastApiAppPool() + + +CommonServer.on_stop(event_loop_pool.shutdown) + +__all__ = ["event_loop_pool", "fastapi_app_pool"] diff --git a/fastapi/pools/event_loop.py b/fastapi/pools/event_loop.py new file mode 100644 index 000000000..a0a02a8f3 --- /dev/null +++ b/fastapi/pools/event_loop.py @@ -0,0 +1,58 @@ +# Copyright 2025 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + +import asyncio +import queue +import threading +from contextlib import contextmanager +from typing import Generator + + +class EventLoopPool: + def __init__(self): + self.pool = queue.Queue[tuple[asyncio.AbstractEventLoop, threading.Thread]]() + + def __get_event_loop_and_thread( + self, + ) -> tuple[asyncio.AbstractEventLoop, threading.Thread]: + """ + Get an event loop from the pool. If no event loop is available, create a new one. + """ + try: + return self.pool.get_nowait() + except queue.Empty: + loop = asyncio.new_event_loop() + thread = threading.Thread(target=loop.run_forever, daemon=True) + thread.start() + return loop, thread + + def __return_event_loop( + self, loop: asyncio.AbstractEventLoop, thread: threading.Thread + ) -> None: + """ + Return an event loop to the pool for reuse. + """ + self.pool.put((loop, thread)) + + def shutdown(self): + """ + Shutdown all event loop threads in the pool. + """ + while not self.pool.empty(): + loop, thread = self.pool.get_nowait() + loop.call_soon_threadsafe(loop.stop) + thread.join() + loop.close() + + @contextmanager + def get_event_loop(self) -> Generator[asyncio.AbstractEventLoop, None, None]: + """ + Get an event loop from the pool. If no event loop is available, create a new one. + + After the context manager exits, the event loop is returned to the pool for reuse. + """ + loop, thread = self.__get_event_loop_and_thread() + try: + yield loop + finally: + self.__return_event_loop(loop, thread) diff --git a/fastapi/pools/fastapi_app.py b/fastapi/pools/fastapi_app.py new file mode 100644 index 000000000..29725c49c --- /dev/null +++ b/fastapi/pools/fastapi_app.py @@ -0,0 +1,129 @@ +# Copyright 2025 ACSONE SA/NV +# License LGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). +import logging +import queue +import threading +from collections import defaultdict +from contextlib import contextmanager +from typing import Generator + +from odoo.api import Environment + +from fastapi import FastAPI + +_logger = logging.getLogger(__name__) + + +class FastApiAppPool: + """Pool of FastAPI apps. + + This class manages a pool of FastAPI apps. The pool is organized by database name + and root path. Each pool is a queue of FastAPI apps. + + The pool is used to reuse FastAPI apps across multiple requests. This is useful + to avoid the overhead of creating a new FastAPI app for each request. The pool + ensures that only one request at a time uses an app. + + The proper way to use the pool is to use the get_app method as a context manager. + This ensures that the app is returned to the pool after the context manager exits. + The get_app method is designed to ensure that the app made available to the + caller is unique and not used by another caller at the same time. + + .. code-block:: python + + with fastapi_app_pool.get_app(env=request.env, root_path=root_path) as app: + # use the app + + The pool is invalidated when the cache registry is updated. This ensures that + the pool is always up-to-date with the latest app configuration. It also + ensures that the invalidation is done even in the case of a modification occurring + in a different worker process or thread or server instance. This mechanism + works because every time an attribute of the fastapi.endpoint model is modified + and this attribute is part of the list returned by the `_fastapi_app_fields`, + or `_routing_impacting_fields` methods, we reset the cache of a marker method + `_reset_app_cache_marker`. As side effect, the cache registry is marked to be + updated by the increment of the `cache_sequence` SQL sequence. This cache sequence + on the registry is reloaded from the DB on each request made to a specific database. + When an app is retrieved from the pool, we always compare the cache sequence of + the pool with the cache sequence of the registry. If the two sequences are different, + we invalidate the pool and save the new cache sequence on the pool. + + The cache is based on a defaultdict of defaultdict of queue.Queue. We are cautious + that the use of defaultdict is not thread-safe for operations that modify the + dictionary. However the only operation that modifies the dictionary is the + first access to a new key. If two threads access the same key at the same time, + the two threads will create two different queues. This is not a problem since + at the time of returning an app to the pool, we are sure that a queue exists + for the key into the cache and all the created apps are returned to the same + valid queue. And the end, the lack of thread-safety for the defaultdict could + only lead to a negligible overhead of creating a new queue that will never be + used. This is why we consider that the use of defaultdict is safe in this context. + """ + + def __init__(self): + self._queue_by_db_by_root_path: dict[ + str, dict[str, queue.Queue[FastAPI]] + ] = defaultdict(lambda: defaultdict(queue.Queue)) + self.__cache_sequence = 0 + self._lock = threading.Lock() + + def __get_pool(self, env: Environment, root_path: str) -> queue.Queue[FastAPI]: + db_name = env.cr.dbname + return self._queue_by_db_by_root_path[db_name][root_path] + + def __get_app(self, env: Environment, root_path: str) -> FastAPI: + pool = self.__get_pool(env, root_path) + try: + return pool.get_nowait() + except queue.Empty: + return env["fastapi.endpoint"].sudo().get_app(root_path) + + def __return_app(self, env: Environment, app: FastAPI, root_path: str) -> None: + pool = self.__get_pool(env, root_path) + pool.put(app) + + @contextmanager + def get_app( + self, env: Environment, root_path: str + ) -> Generator[FastAPI, None, None]: + """Return a FastAPI app to be used in a context manager. + + The app is retrieved from the pool if available, otherwise a new one is created. + The app is returned to the pool after the context manager exits. + + When used into the FastApiDispatcher class this ensures that the app is reused + across multiple requests but only one request at a time uses an app. + """ + self._check_cache(env) + app = self.__get_app(env, root_path) + try: + yield app + finally: + self.__return_app(env, app, root_path) + + @property + def cache_sequence(self) -> int: + return self.__cache_sequence + + @cache_sequence.setter + def cache_sequence(self, value: int) -> None: + if value != self.__cache_sequence: + with self._lock: + self.__cache_sequence = value + + def _check_cache(self, env: Environment) -> None: + cache_sequence = env.registry.cache_sequence + if cache_sequence != self.cache_sequence and self.cache_sequence != 0: + _logger.info( + "Cache registry updated, reset fastapi_app pool for the current " + "database" + ) + self.invalidate(env) + self.cache_sequence = cache_sequence + + def invalidate(self, env: Environment, root_path: str | None = None) -> None: + db_name = env.cr.dbname + if root_path: + self._queue_by_db_by_root_path[db_name][root_path] = queue.Queue() + elif db_name in self._queue_by_db_by_root_path: + del self._queue_by_db_by_root_path[db_name] diff --git a/fastapi/security/ir_rule+acl.xml b/fastapi/security/ir_rule+acl.xml index 592a4646f..76640c423 100644 --- a/fastapi/security/ir_rule+acl.xml +++ b/fastapi/security/ir_rule+acl.xml @@ -39,7 +39,7 @@ ['|', ('user_id', '=', user.id), ('id', '=', authenticated_partner_id)] + > ['|', ('user_ids', '=', user.id), ('id', '=', authenticated_partner_id)] diff --git a/fastapi/static/description/index.html b/fastapi/static/description/index.html index 3e40636da..704315102 100644 --- a/fastapi/static/description/index.html +++ b/fastapi/static/description/index.html @@ -3,7 +3,7 @@ -Odoo FastAPI +README.rst -
    -

    Odoo FastAPI

    +
    + + +Odoo Community Association + +
    +

    Odoo FastAPI

    -

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

    +

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

    This addon provides the basis to smoothly integrate the FastAPI framework into Odoo.

    This integration allows you to use all the goodies from FastAPI to build custom @@ -453,9 +458,9 @@

    Odoo FastAPI

    -

    Usage

    +

    Usage

    -

    What’s building an API with fastapi?

    +

    What’s building an API with fastapi?

    FastAPI is a modern, fast (high-performance), web framework for building APIs with Python 3.7+ based on standard Python type hints. This addons let’s you keep advantage of the fastapi framework and use it with Odoo.

    @@ -505,9 +510,9 @@

    What’s building an API with fas

    Then, you need to declare your app by defining a model that inherits from ‘fastapi.endpoint’ and add your app name into the app field. For example:

    -from odoo import fields, models
    +from odoo import fields, models
     
    -class FastapiEndpoint(models.Model):
    +class FastapiEndpoint(models.Model):
     
         _inherit = "fastapi.endpoint"
     
    @@ -530,10 +535,10 @@ 

    What’s building an API with fas

    Now, you can create your first router. For that, you need to define a global variable into your fastapi_endpoint module called for example ‘demo_api_router’

    -from fastapi import APIRouter
    -from odoo import fields, models
    +from fastapi import APIRouter
    +from odoo import fields, models
     
    -class FastapiEndpoint(models.Model):
    +class FastapiEndpoint(models.Model):
     
         _inherit = "fastapi.endpoint"
     
    @@ -547,10 +552,10 @@ 

    What’s building an API with fas

    To make your router available to your app, you need to add it to the list of routers returned by the _get_fastapi_routers method of your fastapi_endpoint model.

    -from fastapi import APIRouter
    -from odoo import api, fields, models
    +from fastapi import APIRouter
    +from odoo import api, fields, models
     
    -class FastapiEndpoint(models.Model):
    +class FastapiEndpoint(models.Model):
     
         _inherit = "fastapi.endpoint"
     
    @@ -558,7 +563,7 @@ 

    What’s building an API with fas selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} ) - def _get_fastapi_routers(self): + def _get_fastapi_routers(self): if self.app == "demo": return [demo_api_router] return super()._get_fastapi_routers() @@ -569,17 +574,17 @@

    What’s building an API with fas

    Now, you can start adding routes to your router. For example, let’s add a route that returns a list of partners.

    -from typing import Annotated
    +from typing import Annotated
     
    -from fastapi import APIRouter
    -from pydantic import BaseModel
    +from fastapi import APIRouter
    +from pydantic import BaseModel
     
    -from odoo import api, fields, models
    -from odoo.api import Environment
    +from odoo import api, fields, models
    +from odoo.api import Environment
     
    -from odoo.addons.fastapi.dependencies import odoo_env
    +from odoo.addons.fastapi.dependencies import odoo_env
     
    -class FastapiEndpoint(models.Model):
    +class FastapiEndpoint(models.Model):
     
         _inherit = "fastapi.endpoint"
     
    @@ -587,7 +592,7 @@ 

    What’s building an API with fas selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} ) - def _get_fastapi_routers(self): + def _get_fastapi_routers(self): if self.app == "demo": return [demo_api_router] return super()._get_fastapi_routers() @@ -595,12 +600,12 @@

    What’s building an API with fas # create a router demo_api_router = APIRouter() -class PartnerInfo(BaseModel): +class PartnerInfo(BaseModel): name: str email: str @demo_api_router.get("/partners", response_model=list[PartnerInfo]) -def get_partners(env: Annotated[Environment, Depends(odoo_env)]) -> list[PartnerInfo]: +def get_partners(env: Annotated[Environment, Depends(odoo_env)]) -> list[PartnerInfo]: return [ PartnerInfo(name=partner.name, email=partner.email) for partner in env["res.partner"].search([]) @@ -659,19 +664,19 @@

    What’s building an API with fas

    -

    Dealing with the odoo environment

    +

    Dealing with the odoo environment

    The ‘odoo.addons.fastapi.dependencies’ module provides a set of functions that you can use to inject reusable dependencies into your routes. For example, the ‘odoo_env’ function returns the current odoo environment. You can use it to access the odoo models and the database from your route handlers.

    -from typing import Annotated
    +from typing import Annotated
     
    -from odoo.api import Environment
    -from odoo.addons.fastapi.dependencies import odoo_env
    +from odoo.api import Environment
    +from odoo.addons.fastapi.dependencies import odoo_env
     
     @demo_api_router.get("/partners", response_model=list[PartnerInfo])
    -def get_partners(env: Annotated[Environment, Depends(odoo_env)]) -> list[PartnerInfo]:
    +def get_partners(env: Annotated[Environment, Depends(odoo_env)]) -> list[PartnerInfo]:
         return [
             PartnerInfo(name=partner.name, email=partner.email)
             for partner in env["res.partner"].search([])
    @@ -707,7 +712,7 @@ 

    Dealing with the odoo environment

    -

    The authentication mechanism

    +

    The authentication mechanism

    To make our app not tightly coupled with a specific authentication mechanism, we will use the ‘authenticated_partner’ dependency. As for the ‘fastapi_endpoint’ this dependency depends on an abstract dependency.

    When you define a route handler, you can inject the ‘authenticated_partner’ dependency as a parameter of your route handler.

    -from odoo.addons.base.models.res_partner import Partner
    +from odoo.addons.base.models.res_partner import Partner
     
     
     @demo_api_router.get("/partners", response_model=list[PartnerInfo])
    -def get_partners(
    +def get_partners(
         env: Annotated[Environment, Depends(odoo_env)], partner: Annotated[Partner, Depends(authenticated_partner)]
     ) -> list[PartnerInfo]:
         return [
    @@ -784,7 +789,7 @@ 

    The authentication mechanism< ‘odoo.addons.fastapi.dependencies’ module and relies on functionalities provided by the ‘fastapi.security’ module.

    -def authenticated_partner(
    +def authenticated_partner(
         env: Annotated[Environment, Depends(odoo_env)],
         security: Annotated[HTTPBasicCredentials, Depends(HTTPBasic())],
     ) -> "res.partner":
    @@ -822,9 +827,9 @@ 

    The authentication mechanism< authentication by using an api key or via basic auth. Since basic auth is already implemented, we will only implement the api key authentication mechanism.

    -from fastapi.security import APIKeyHeader
    +from fastapi.security import APIKeyHeader
     
    -def api_key_based_authenticated_partner_impl(
    +def api_key_based_authenticated_partner_impl(
         api_key: Annotated[str, Depends(
             APIKeyHeader(
                 name="api-key",
    @@ -849,9 +854,9 @@ 

    The authentication mechanism< can allows the user to select one of these authentication mechanisms by adding a selection field on the fastapi endpoint model.

    -from odoo import fields, models
    +from odoo import fields, models
     
    -class FastapiEndpoint(models.Model):
    +class FastapiEndpoint(models.Model):
     
         _inherit = "fastapi.endpoint"
     
    @@ -874,8 +879,8 @@ 

    The authentication mechanism< provide the right implementation of the ‘authenticated_partner’ dependency when the app is instantiated.

    -from odoo.addons.fastapi.dependencies import authenticated_partner
    -class FastapiEndpoint(models.Model):
    +from odoo.addons.fastapi.dependencies import authenticated_partner
    +class FastapiEndpoint(models.Model):
     
         _inherit = "fastapi.endpoint"
     
    @@ -887,7 +892,7 @@ 

    The authentication mechanism< string="Authenciation method", ) - def _get_app(self) -> FastAPI: + def _get_app(self) -> FastAPI: app = super()._get_app() if self.app == "demo": # Here we add the overrides to the authenticated_partner_impl method @@ -918,7 +923,7 @@

    The authentication mechanism<

    -

    Managing configuration parameters for your app

    +

    Managing configuration parameters for your app

    As we have seen in the previous section, you can add configuration fields on the fastapi endpoint model to allow the user to configure your app (as for any odoo model you extend). When you need to access these configuration fields @@ -926,10 +931,10 @@

    Managing configuration parameters dependency method to retrieve the ‘fastapi.endpoint’ record associated to the current request.

    -from pydantic import BaseModel, Field
    -from odoo.addons.fastapi.dependencies import fastapi_endpoint
    +from pydantic import BaseModel, Field
    +from odoo.addons.fastapi.dependencies import fastapi_endpoint
     
    -class EndpointAppInfo(BaseModel):
    +class EndpointAppInfo(BaseModel):
       id: str
       name: str
       app: str
    @@ -943,7 +948,7 @@ 

    Managing configuration parameters response_model=EndpointAppInfo, dependencies=[Depends(authenticated_partner)], ) - async def endpoint_app_info( + async def endpoint_app_info( endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], ) -> EndpointAppInfo: """Returns the current endpoint configuration""" @@ -962,7 +967,7 @@

    Managing configuration parameters name of the fields that impact the instantiation of the app into the returned list.

    -class FastapiEndpoint(models.Model):
    +class FastapiEndpoint(models.Model):
     
         _inherit = "fastapi.endpoint"
     
    @@ -975,14 +980,14 @@ 

    Managing configuration parameters ) @api.model - def _fastapi_app_fields(self) -> List[str]: + def _fastapi_app_fields(self) -> List[str]: fields = super()._fastapi_app_fields() fields.append("demo_auth_method") return fields

    -

    Dealing with languages

    +

    Dealing with languages

    The fastapi addon parses the Accept-Language header of the request to determine the language to use. This parsing is done by respecting the RFC 7231 specification. That means that the language is determined by the first language found in the header that is @@ -994,7 +999,7 @@

    Dealing with languages

    of your app to instruct the api consumers how to request a specific language.

    -

    How to extend an existing app

    +

    How to extend an existing app

    When you develop a fastapi app, in a native python app it’s not possible to extend an existing one. This limitation doesn’t apply to the fastapi addon because the fastapi endpoint model is designed to be extended. However, the @@ -1022,7 +1027,7 @@

    How to extend an existing app

    -

    Changing the implementation of the route handler

    +

    Changing the implementation of the route handler

    Let’s say that you want to change the implementation of the route handler ‘/demo/echo’. Since a route handler is just a python method, it could seems a tedious task since we are not into a model method and therefore we can’t @@ -1035,16 +1040,16 @@

    Changing the implementation of th inherit from the model where the implementation is defined and override the method ‘echo’.

    -from pydantic import BaseModel
    -from fastapi import Depends, APIRouter
    -from odoo import models
    -from odoo.addons.fastapi.dependencies import odoo_env
    +from pydantic import BaseModel
    +from fastapi import Depends, APIRouter
    +from odoo import models
    +from odoo.addons.fastapi.dependencies import odoo_env
     
    -class FastapiEndpoint(models.Model):
    +class FastapiEndpoint(models.Model):
     
         _inherit = "fastapi.endpoint"
     
    -    def _get_fastapi_routers(self) -> List[APIRouter]:
    +    def _get_fastapi_routers(self) -> List[APIRouter]:
             routers = super()._get_fastapi_routers()
             routers.append(demo_api_router)
             return routers
    @@ -1056,29 +1061,29 @@ 

    Changing the implementation of th response_model=EchoResponse, dependencies=[Depends(odoo_env)], ) -async def echo( +async def echo( message: str, odoo_env: Annotated[Environment, Depends(odoo_env)], ) -> EchoResponse: """Echo the message""" return EchoResponse(message=odoo_env["demo.fastapi.endpoint"].echo(message)) -class EchoResponse(BaseModel): +class EchoResponse(BaseModel): message: str -class DemoEndpoint(models.AbstractModel): +class DemoEndpoint(models.AbstractModel): _name = "demo.fastapi.endpoint" _description = "Demo Endpoint" - def echo(self, message: str) -> str: + def echo(self, message: str) -> str: return message -class DemoEndpointInherit(models.AbstractModel): +class DemoEndpointInherit(models.AbstractModel): _inherit = "demo.fastapi.endpoint" - def echo(self, message: str) -> str: + def echo(self, message: str) -> str: return f"Hello {message}"

    -

    Overriding the dependencies of the route handler

    +

    Overriding the dependencies of the route handler

    As you’ve previously seen, the dependency injection mechanism of fastapi is very powerful. By designing your route handler to rely on dependencies with a specific functional scope, you can easily change the implementation of the @@ -1103,20 +1108,20 @@

    Overriding the dependencies of t ‘odoo/addons/fastapi/models/fastapi_endpoint_demo.py’)

    -

    Adding a new route handler

    +

    Adding a new route handler

    Let’s say that you want to add a new route handler ‘/demo/echo2’. You could be tempted to add this new route handler in your new addons by importing the router of the existing app and adding the new route handler to it.

    -from odoo.addons.fastapi.models.fastapi_endpoint_demo import demo_api_router
    +from odoo.addons.fastapi.models.fastapi_endpoint_demo import demo_api_router
     
     @demo_api_router.get(
         "/echo2",
         response_model=EchoResponse,
         dependencies=[Depends(odoo_env)],
     )
    -async def echo2(
    +async def echo2(
         message: str,
         odoo_env: Annotated[Environment, Depends(odoo_env)],
     ) -> EchoResponse:
    @@ -1131,11 +1136,11 @@ 

    Adding a new route handler‘_get_fastapi_routers’ of the model ‘fastapi.endpoint’ you are inheriting from into your new addon.

    -class FastapiEndpoint(models.Model):
    +class FastapiEndpoint(models.Model):
     
         _inherit = "fastapi.endpoint"
     
    -    def _get_fastapi_routers(self) -> List[APIRouter]:
    +    def _get_fastapi_routers(self) -> List[APIRouter]:
             routers = super()._get_fastapi_routers()
             if self.app == "demo":
                 routers.append(additional_demo_api_router)
    @@ -1148,7 +1153,7 @@ 

    Adding a new route handler response_model=EchoResponse, dependencies=[Depends(odoo_env)], ) -async def echo2( +async def echo2( message: str, odoo_env: Annotated[Environment, Depends(odoo_env)], ) -> EchoResponse: @@ -1160,7 +1165,7 @@

    Adding a new route handler

    -

    Extending the model used as parameter or as response of the route handler

    +

    Extending the model used as parameter or as response of the route handler

    The fastapi python library uses the pydantic library to define the models. By default, once a model is defined, it’s not possible to extend it. However, a companion python library called @@ -1174,10 +1179,10 @@

    Extending the model used as para

    When you want to allow other addons to extend a pydantic model, you must first define the model as an extendable model by using a dedicated metaclass

    -from pydantic import BaseModel
    -from extendable_pydantic import ExtendableModelMeta
    +from pydantic import BaseModel
    +from extendable_pydantic import ExtendableModelMeta
     
    -class Partner(BaseModel, metaclass=ExtendableModelMeta):
    +class Partner(BaseModel, metaclass=ExtendableModelMeta):
       name = 0.1
       model_config = ConfigDict(from_attributes=True)
     
    @@ -1190,7 +1195,7 @@

    Extending the model used as para response_model=Location, dependencies=[Depends(authenticated_partner)], ) -async def partner( +async def partner( partner: Annotated[ResPartner, Depends(authenticated_partner)], ) -> Partner: """Return the location""" @@ -1199,10 +1204,10 @@

    Extending the model used as para

    If you need to add a new field into the model ‘Partner’, you can extend it in your new addon by defining a new model that inherits from the model ‘Partner’.

    -from typing import Optional
    -from odoo.addons.fastapi.models.fastapi_endpoint_demo import Partner
    +from typing import Optional
    +from odoo.addons.fastapi.models.fastapi_endpoint_demo import Partner
     
    -class PartnerExtended(Partner, extends=Partner):
    +class PartnerExtended(Partner, extends=Partner):
         email: Optional[str]
     

    If your new addon is installed in a database, a call to the route handler @@ -1228,7 +1233,7 @@

    Extending the model used as para default values for the new optional fields.

    -

    Managing security into the route handlers

    +

    Managing security into the route handlers

    By default the route handlers are processed using the user configured on the ‘fastapi.endpoint’ model instance. (default is the Public user). You have seen previously how to define a dependency that will be used to enforce @@ -1298,7 +1303,7 @@

    Managing security into the route

    -

    How to test your fastapi app

    +

    How to test your fastapi app

    Thanks to the starlette test client, it’s possible to test your fastapi app in a very simple way. With the test client, you can call your route handlers as if they were real http endpoints. The test client is available in the @@ -1320,20 +1325,20 @@

    How to test your fastapi app tests for routers in an addon that doesn’t provide a fastapi endpoint).

    With this base class, writing a test for a route handler is as simple as:

    -from odoo.fastapi.tests.common import FastAPITransactionCase
    +from odoo.fastapi.tests.common import FastAPITransactionCase
     
    -from odoo.addons.fastapi import dependencies
    -from odoo.addons.fastapi.routers import demo_router
    +from odoo.addons.fastapi import dependencies
    +from odoo.addons.fastapi.routers import demo_router
     
    -class FastAPIDemoCase(FastAPITransactionCase):
    +class FastAPIDemoCase(FastAPITransactionCase):
     
         @classmethod
    -    def setUpClass(cls) -> None:
    +    def setUpClass(cls) -> None:
             super().setUpClass()
             cls.default_fastapi_running_user = cls.env.ref("fastapi.my_demo_app_user")
             cls.default_fastapi_authenticated_partner = cls.env["res.partner"].create({"name": "FastAPI Demo"})
     
    -    def test_hello_world(self) -> None:
    +    def test_hello_world(self) -> None:
             with self._create_test_client(router=demo_router) as test_client:
                 response: Response = test_client.get("/demo/")
             self.assertEqual(response.status_code, status.HTTP_200_OK)
    @@ -1343,20 +1348,20 @@ 

    How to test your fastapi app have created a test client for the whole app by not specifying the router but the app instead.

    -from odoo.fastapi.tests.common import FastAPITransactionCase
    +from odoo.fastapi.tests.common import FastAPITransactionCase
     
    -from odoo.addons.fastapi import dependencies
    -from odoo.addons.fastapi.routers import demo_router
    +from odoo.addons.fastapi import dependencies
    +from odoo.addons.fastapi.routers import demo_router
     
    -class FastAPIDemoCase(FastAPITransactionCase):
    +class FastAPIDemoCase(FastAPITransactionCase):
     
         @classmethod
    -    def setUpClass(cls) -> None:
    +    def setUpClass(cls) -> None:
             super().setUpClass()
             cls.default_fastapi_running_user = cls.env.ref("fastapi.my_demo_app_user")
             cls.default_fastapi_authenticated_partner = cls.env["res.partner"].create({"name": "FastAPI Demo"})
     
    -    def test_hello_world(self) -> None:
    +    def test_hello_world(self) -> None:
             demo_endpoint = self.env.ref("fastapi.fastapi_endpoint_demo")
             with self._create_test_client(app=demo_endpoint._get_app()) as test_client:
                 response: Response = test_client.get(f"{demo_endpoint.root_path}/demo/")
    @@ -1365,7 +1370,7 @@ 

    How to test your fastapi app

    -

    Overall considerations when you develop an fastapi app

    +

    Overall considerations when you develop an fastapi app

    Developing a fastapi app requires to follow some good practices to ensure that the app is robust and easy to maintain. Here are some of them:

    -

    Error handling

    +

    Error handling

    The error handling is a very important topic in the design of the fastapi integration with odoo. By default, when instantiating the fastapi app, the fastapi library declare a default exception handler that will catch any exception raised by the @@ -1547,7 +1552,7 @@

    Error handling

    add a custom exception handler in your app, it will be ignored.

    -

    FastAPI addons directory structure

    +

    FastAPI addons directory structure

    When you develop a new addon to expose an api with fastapi, it’s a good practice to follow the same directory structure and naming convention for the files related to the api. It will help you to easily find the files related to the api @@ -1600,15 +1605,15 @@

    FastAPI addons directory structu router = APIRouter(tags=["items"]) router.get("/items", response_model=List[Item]) -def list_items(): +def list_items(): pass

    In the ‘__init__.py’ file, you will import the router and add it to the global router or your addon.

    -from fastapi import APIRouter
    +from fastapi import APIRouter
     
    -from .items import router as items_router
    +from .items import router as items_router
     
     router = APIRouter()
     router.include_router(items_router)
    @@ -1621,22 +1626,22 @@ 

    FastAPI addons directory structu For example, in your ‘my_model.py’ file, you will define a model like this:

    -from pydantic import BaseModel
    +from pydantic import BaseModel
     
    -class MyModel(BaseModel):
    +class MyModel(BaseModel):
         name: str
         description: str = None
     

    In the ‘__init__.py’ file, you will import the model’s classes from the files in the directory.

    -from .my_model import MyModel
    +from .my_model import MyModel
     

    This will allow to always import the models from the schemas module whatever the models are spread across different files or defined in the ‘schemas.py’ file.

    -from x_api_addon.schemas import MyModel
    +from x_api_addon.schemas import MyModel
     
  • The ‘dependencies.py’ file contains the custom dependencies that you @@ -1651,14 +1656,14 @@

    FastAPI addons directory structu

  • -

    What’s next?

    +

    What’s next?

    The ‘odoo-addon-fastapi’ module is still in its early stage of development. It will evolve over time to integrate your feedback and to provide the missing features. It’s now up to you to try it and to provide your feedback.

    -

    Known issues / Roadmap

    +

    Known issues / Roadmap

    The roadmap and known issues can be found on GitHub.

    @@ -1670,9 +1675,9 @@

    Known issues / Roadmap

    WebSockets and to stream large responses.

    -

    Changelog

    +

    Changelog

    -

    16.0.1.4.1 (2024-07-08)

    +

    16.0.1.4.1 (2024-07-08)

    Bugfixes

    -

    16.0.1.4.0 (2024-06-06)

    +

    16.0.1.4.0 (2024-06-06)

    Bugfixes

    • This change is a complete rewrite of the way the transactions are managed when @@ -1724,7 +1729,7 @@

      16.0.1.4.0 (2024-06-06)

    -

    16.0.1.2.6 (2024-02-20)

    +

    16.0.1.2.6 (2024-02-20)

    Bugfixes

    -

    16.0.1.2.5 (2024-01-17)

    +

    16.0.1.2.5 (2024-01-17)

    Bugfixes

    • Odoo has done an update and now, it checks domains of ir.rule on creation and modification.

      @@ -1747,7 +1752,7 @@

      16.0.1.2.5 (2024-01-17)

    -

    16.0.1.2.3 (2023-12-21)

    +

    16.0.1.2.3 (2023-12-21)

    Bugfixes

    • In case of exception in endpoint execution, close the database cursor after rollback.

      @@ -1757,7 +1762,7 @@

      16.0.1.2.3 (2023-12-21)

    -

    16.0.1.2.2 (2023-12-12)

    +

    16.0.1.2.2 (2023-12-12)

    Bugfixes

    • When using the ‘FastAPITransactionCase’ class, allows to specify a specific @@ -1772,7 +1777,7 @@

      16.0.1.2.2 (2023-12-12)

    -

    16.0.1.2.1 (2023-11-03)

    +

    16.0.1.2.1 (2023-11-03)

    Bugfixes

    • Fix a typo in the Field declaration of the ‘count’ attribute of the ‘PagedCollection’ schema.

      @@ -1781,7 +1786,7 @@

      16.0.1.2.1 (2023-11-03)

    -

    16.0.1.2.0 (2023-10-13)

    +

    16.0.1.2.0 (2023-10-13)

    Features

    • The field total in the PagedCollection schema is replaced by the field count. @@ -1795,7 +1800,7 @@

      16.0.1.2.0 (2023-10-13)

    -

    Bug Tracker

    +

    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 @@ -1803,21 +1808,21 @@

    Bug Tracker

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

    + diff --git a/fastapi/tests/test_fastapi.py b/fastapi/tests/test_fastapi.py index 37b11a961..fbda932fb 100644 --- a/fastapi/tests/test_fastapi.py +++ b/fastapi/tests/test_fastapi.py @@ -20,7 +20,11 @@ class FastAPIHttpCase(HttpCase): 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_multi_demo_app = cls.env.ref( + "fastapi.fastapi_endpoint_multislash_demo" + ) + cls.fastapi_apps = cls.fastapi_demo_app + cls.fastapi_multi_demo_app + cls.fastapi_apps._handle_registry_sync() lang = ( cls.env["res.lang"] .with_context(active_test=False) @@ -169,3 +173,29 @@ def test_no_commit_on_exception(self) -> None: expected_message="test", expected_status_code=status.HTTP_409_CONFLICT, ) + + def test_url_matching(self): + # Test the URL mathing method on the endpoint + paths = ["/fastapi", "/fastapi_demo", "/fastapi/v1"] + EndPoint = self.env["fastapi.endpoint"] + self.assertEqual( + EndPoint._find_first_matching_url_path(paths, "/fastapi_demo/test"), + "/fastapi_demo", + ) + self.assertEqual( + EndPoint._find_first_matching_url_path(paths, "/fastapi/test"), "/fastapi" + ) + self.assertEqual( + EndPoint._find_first_matching_url_path(paths, "/fastapi/v2/test"), + "/fastapi", + ) + self.assertEqual( + EndPoint._find_first_matching_url_path(paths, "/fastapi/v1/test"), + "/fastapi/v1", + ) + + def test_multi_slash(self): + route = "/fastapi/demo-multi/demo/" + response = self.url_open(route, timeout=20) + self.assertEqual(response.status_code, 200) + self.assertIn(self.fastapi_multi_demo_app.root_path, str(response.url)) diff --git a/fastapi_auth_jwt_demo/i18n/it.po b/fastapi_auth_jwt_demo/i18n/it.po index 8fb0db61e..5bb4f8b21 100644 --- a/fastapi_auth_jwt_demo/i18n/it.po +++ b/fastapi_auth_jwt_demo/i18n/it.po @@ -6,15 +6,15 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 16.0\n" "Report-Msgid-Bugs-To: \n" -"PO-Revision-Date: 2024-01-15 11:35+0000\n" -"Last-Translator: mymage \n" +"PO-Revision-Date: 2025-04-24 12:23+0000\n" +"Last-Translator: Sebastiano Picchi \n" "Language-Team: none\n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: \n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 4.17\n" +"X-Generator: Weblate 5.10.4\n" #. module: fastapi_auth_jwt_demo #: model:ir.model.fields,field_description:fastapi_auth_jwt_demo.field_fastapi_endpoint__app @@ -29,4 +29,4 @@ msgstr "Autorizzazione endpoint demo JWT" #. module: fastapi_auth_jwt_demo #: model:ir.model,name:fastapi_auth_jwt_demo.model_fastapi_endpoint msgid "FastAPI Endpoint" -msgstr "Endopoint FastAPI" +msgstr "Endpoint FastAPI" diff --git a/fastapi_auth_partner/README.rst b/fastapi_auth_partner/README.rst new file mode 100644 index 000000000..fc8471c13 --- /dev/null +++ b/fastapi_auth_partner/README.rst @@ -0,0 +1,140 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +==================== +Fastapi Auth Partner +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:2ebd9377ca7b035ab9fb0383513aacb5ca8645f69d5d85c171883b40b439017e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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_auth_partner + :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_auth_partner + :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 is the FastAPI implementation of `auth_partner <../auth_partner>`_ +it provides all the routes to manage the authentication of partners. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +First you have to add the auth router to your FastAPI endpoint and the authentication dependency to your app dependencies: + +.. code-block:: python + + from odoo.addons.fastapi import dependencies + from odoo.addons.fastapi_auth_partner.dependencies import ( + auth_partner_authenticated_partner, + ) + from odoo.addons.fastapi_auth_partner.routers.auth import auth_router + + class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + def _get_fastapi_routers(self): + if self.app == "myapp": + return [ + auth_router, + ] + return super()._get_fastapi_routers() + + def _get_app_dependencies_overrides(self): + res = super()._get_app_dependencies_overrides() + if self.app == "myapp": + res.update( + { + dependencies.authenticated_partner_impl: auth_partner_authenticated_partner, + } + ) + return res + +Next you can manage your authenticable partners and directories in the Odoo interface: + +FastAPI > Authentication > Partner + +and + +FastAPI > Authentication > Directory + +Next you must set the directory used for the authentication in the FastAPI endpoint: + +FastAPI > FastAPI Endpoint > myapp > Directory + +Then you can use the auth router to authenticate your requests: + +- POST /auth/register to register a partner +- POST /auth/login to authenticate a partner +- POST /auth/logout to unauthenticate a partner +- POST /auth/validate_email to validate a partner email +- POST /auth/request_reset_password to request a password reset +- POST /auth/set_password to set a new password +- GET /auth/profile to get the partner profile +- GET /auth/impersonate to impersonate a partner + +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 +~~~~~~~~~~~~ + +* `Akretion `_: + + * Sébastien Beau + * Florian Mounier + +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. + +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_auth_partner/__init__.py b/fastapi_auth_partner/__init__.py new file mode 100644 index 000000000..3f274f8d1 --- /dev/null +++ b/fastapi_auth_partner/__init__.py @@ -0,0 +1,4 @@ +from . import models +from . import routers +from . import schemas +from . import wizards diff --git a/fastapi_auth_partner/__manifest__.py b/fastapi_auth_partner/__manifest__.py new file mode 100644 index 000000000..e5b486e4c --- /dev/null +++ b/fastapi_auth_partner/__manifest__.py @@ -0,0 +1,32 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Fastapi Auth Partner", + "summary": """This provides an implementation of auth_partner for FastAPI""", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/rest-framework", + "depends": [ + "extendable_fastapi", + "auth_partner", + ], + "data": [ + "security/res_group.xml", + "security/ir.model.access.csv", + "views/auth_partner_view.xml", + "views/auth_directory_view.xml", + "views/fastapi_endpoint_view.xml", + "wizards/wizard_auth_partner_impersonate_view.xml", + "wizards/wizard_auth_partner_reset_password_view.xml", + ], + "demo": [ + "demo/fastapi_endpoint_demo.xml", + ], + "external_dependencies": { + "python": ["itsdangerous"], + }, +} diff --git a/fastapi_auth_partner/demo/fastapi_endpoint_demo.xml b/fastapi_auth_partner/demo/fastapi_endpoint_demo.xml new file mode 100644 index 000000000..bf017151c --- /dev/null +++ b/fastapi_auth_partner/demo/fastapi_endpoint_demo.xml @@ -0,0 +1,26 @@ + + + + Fastapi Auth Partner Demo Endpoint + + demo + /fastapi_auth_partner_demo + auth_partner + + + https://api.example.com/ + https://www.example.com/ + + + + + + diff --git a/fastapi_auth_partner/dependencies.py b/fastapi_auth_partner/dependencies.py new file mode 100644 index 000000000..5d336e59c --- /dev/null +++ b/fastapi_auth_partner/dependencies.py @@ -0,0 +1,74 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import sys +from typing import Any, Dict, Union + +from itsdangerous import URLSafeTimedSerializer +from starlette.status import HTTP_401_UNAUTHORIZED + +from odoo.api import Environment + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi.dependencies import fastapi_endpoint, odoo_env +from odoo.addons.fastapi.models import FastapiEndpoint + +from fastapi import Cookie, Depends, HTTPException, Request, Response + +if sys.version_info >= (3, 9): + from typing import Annotated +else: + from typing_extensions import Annotated + +_logger = logging.getLogger(__name__) + + +Payload = Dict[str, Any] + + +class AuthPartner: + def __init__(self, allow_unauthenticated: bool = False): + self.allow_unauthenticated = allow_unauthenticated + + def __call__( + self, + request: Request, + response: Response, + env: Annotated[ + Environment, + Depends(odoo_env), + ], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + fastapi_auth_partner: Annotated[Union[str, None], Cookie()] = None, + ) -> Partner: + if not fastapi_auth_partner and self.allow_unauthenticated: + return env["res.partner"].with_user(env.ref("base.public_user")).browse() + + elif fastapi_auth_partner: + directory = endpoint.sudo().directory_id + try: + vals = URLSafeTimedSerializer( + directory.cookie_secret_key or directory.secret_key + ).loads(fastapi_auth_partner, max_age=directory.cookie_duration * 60) + except Exception as e: + _logger.error("Invalid cookies error %s", e) + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) from e + if vals["did"] == directory.id and vals["pid"]: + partner = env["res.partner"].browse(vals["pid"]).exists() + if partner: + auth_partner = partner._get_auth_partner_for_directory(directory) + if auth_partner: + if directory.sliding_session: + helper = env["fastapi.auth.service"].new( + {"endpoint_id": endpoint} + ) + helper._set_auth_cookie(auth_partner, request, response) + return partner + _logger.info("Could not determine partner from 'fastapi_auth_partner' cookie.") + raise HTTPException(status_code=HTTP_401_UNAUTHORIZED) + + +auth_partner_authenticated_partner = AuthPartner() +auth_partner_optionally_authenticated_partner = AuthPartner(allow_unauthenticated=True) diff --git a/fastapi_auth_partner/i18n/fastapi_auth_partner.pot b/fastapi_auth_partner/i18n/fastapi_auth_partner.pot new file mode 100644 index 000000000..fd3569e94 --- /dev/null +++ b/fastapi_auth_partner/i18n/fastapi_auth_partner.pot @@ -0,0 +1,263 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fastapi_auth_partner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__app +msgid "App" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_auth_directory +msgid "Auth Directory" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__auth_partner_id +msgid "Auth Partner" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.ui.menu,name:fastapi_auth_partner.fastapi_auth +msgid "Authentication" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__demo_auth_method +msgid "Authentication method" +msgstr "" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Cancel" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__cookie_duration +msgid "Cookie Duration" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__cookie_secret_key +msgid "Cookie Secret Key" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__create_uid +msgid "Created by" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__create_date +msgid "Created on" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields.selection,name:fastapi_auth_partner.selection__fastapi_endpoint__app__demo +msgid "Demo Endpoint" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_auth_service__directory_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__directory_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__auth_directory_id +#: model:ir.ui.menu,name:fastapi_auth_partner.auth_directory_menu +msgid "Directory" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__display_name +msgid "Display Name" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_auth_service__endpoint_id +msgid "Endpoint" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_fastapi_endpoint +msgid "FastAPI Endpoint" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__fastapi_endpoint_ids +msgid "FastAPI Endpoints" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_fastapi_auth_service +msgid "Fastapi Auth Service" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__fastapi_endpoint_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_reset_password__fastapi_endpoint_id +msgid "Fastapi Endpoint" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__id +msgid "ID" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.actions.act_window,name:fastapi_auth_partner.auth_partner_action_impersonate +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Impersonate" +msgstr "" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Impersonation successful" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_auth_directory__cookie_duration +msgid "In minute, default 525600 minutes => 1 year" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__is_auth_partner +msgid "Is Auth Partner" +msgstr "" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Label" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate____last_update +msgid "Last Modified on" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__write_date +msgid "Last Updated on" +msgstr "" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.auth_partner_view_form +msgid "Local Impersonate" +msgstr "" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/routers/auth.py:0 +#, python-format +msgid "No cookie secret key defined" +msgstr "" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Only admin can impersonate locally" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.ui.menu,name:fastapi_auth_partner.auth_partner_menu +msgid "Partner" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields.selection,name:fastapi_auth_partner.selection__fastapi_endpoint__demo_auth_method__auth_partner +msgid "Partner Auth" +msgstr "" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Please choose an endpoint:" +msgstr "" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Please install base_future_response for local impersonate to work" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__public_api_url +msgid "Public Api Url" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__public_url +msgid "Public Url" +msgstr "" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.auth_directory_view_form +msgid "Regenerate cookie secret key" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__sliding_session +msgid "Sliding Session" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__is_auth_partner +msgid "Technical field to know if the auth method is partner" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__public_api_url +msgid "" +"The public URL of the API.\n" +"This URL is used in impersonation to set the cookie on the right API domain if you use a reverse proxy to serve the API.\n" +"Defaults to the public_url if not set or the odoo url if not set either." +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__public_url +msgid "" +"The public URL of the site.\n" +"This URL is used for the impersonation final redirect. And can also be used in the mail template to construct links.\n" +"Default to the public_api_url if not set or the odoo url if not set either." +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_auth_directory__cookie_secret_key +msgid "The secret key used to sign the cookie" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_wizard_auth_partner_impersonate +msgid "Wizard Partner Auth Impersonate" +msgstr "" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_wizard_auth_partner_reset_password +msgid "Wizard Partner Auth Reset Password" +msgstr "" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "" +"You are now impersonating %s\n" +"%%s" +msgstr "" diff --git a/fastapi_auth_partner/i18n/it.po b/fastapi_auth_partner/i18n/it.po new file mode 100644 index 000000000..6add7ce35 --- /dev/null +++ b/fastapi_auth_partner/i18n/it.po @@ -0,0 +1,278 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fastapi_auth_partner +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-06-04 16:26+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.4\n" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__app +msgid "App" +msgstr "Applicazione" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_auth_directory +msgid "Auth Directory" +msgstr "Cartella autorizzazione" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__auth_partner_id +msgid "Auth Partner" +msgstr "Partner autorizzazione" + +#. module: fastapi_auth_partner +#: model:ir.ui.menu,name:fastapi_auth_partner.fastapi_auth +msgid "Authentication" +msgstr "Autenticazione" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__demo_auth_method +msgid "Authentication method" +msgstr "Metodo autenticazione" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Cancel" +msgstr "Annulla" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__cookie_duration +msgid "Cookie Duration" +msgstr "Durata cookie" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__cookie_secret_key +msgid "Cookie Secret Key" +msgstr "Chiave segreta cookie" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: fastapi_auth_partner +#: model:ir.model.fields.selection,name:fastapi_auth_partner.selection__fastapi_endpoint__app__demo +msgid "Demo Endpoint" +msgstr "Endpoint esempio" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_auth_service__directory_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__directory_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__auth_directory_id +#: model:ir.ui.menu,name:fastapi_auth_partner.auth_directory_menu +msgid "Directory" +msgstr "Cartella" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_auth_service__endpoint_id +msgid "Endpoint" +msgstr "Endpoint" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_fastapi_endpoint +msgid "FastAPI Endpoint" +msgstr "Endopoint FastAPI" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__fastapi_endpoint_ids +msgid "FastAPI Endpoints" +msgstr "Endpoint FastAPI" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_fastapi_auth_service +msgid "Fastapi Auth Service" +msgstr "Servizio autenticazione FastAPI" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__fastapi_endpoint_id +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_reset_password__fastapi_endpoint_id +msgid "Fastapi Endpoint" +msgstr "Endopoint FastAPI" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__id +msgid "ID" +msgstr "ID" + +#. module: fastapi_auth_partner +#: model:ir.actions.act_window,name:fastapi_auth_partner.auth_partner_action_impersonate +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Impersonate" +msgstr "Imita" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Impersonation successful" +msgstr "Imitazione riuscita" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_auth_directory__cookie_duration +msgid "In minute, default 525600 minutes => 1 year" +msgstr "In minuti, predefinito minuti => 1 anno" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__is_auth_partner +msgid "Is Auth Partner" +msgstr "È partner autorizzazione" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Label" +msgstr "Etichetta" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_wizard_auth_partner_impersonate__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.auth_partner_view_form +msgid "Local Impersonate" +msgstr "Imitazione locale" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/routers/auth.py:0 +#, python-format +msgid "No cookie secret key defined" +msgstr "Nessuna chiave segreta cookie definita" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Only admin can impersonate locally" +msgstr "Solo l'amministratore può imitare localmente" + +#. module: fastapi_auth_partner +#: model:ir.ui.menu,name:fastapi_auth_partner.auth_partner_menu +msgid "Partner" +msgstr "Partner" + +#. module: fastapi_auth_partner +#: model:ir.model.fields.selection,name:fastapi_auth_partner.selection__fastapi_endpoint__demo_auth_method__auth_partner +msgid "Partner Auth" +msgstr "Autorizzazione partner" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.wizard_auth_partner_impersonate_view_form +msgid "Please choose an endpoint:" +msgstr "Scegliere un endpoint:" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "Please install base_future_response for local impersonate to work" +msgstr "Installare base_future_response per far funzionare l'imitazione locale" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__public_api_url +msgid "Public Api Url" +msgstr "URL API pubblico" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_fastapi_endpoint__public_url +msgid "Public Url" +msgstr "URL pubblico" + +#. module: fastapi_auth_partner +#: model_terms:ir.ui.view,arch_db:fastapi_auth_partner.auth_directory_view_form +msgid "Regenerate cookie secret key" +msgstr "Rigenera chiave segreta cookie" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,field_description:fastapi_auth_partner.field_auth_directory__sliding_session +msgid "Sliding Session" +msgstr "Sessione scorrevole" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__is_auth_partner +msgid "Technical field to know if the auth method is partner" +msgstr "Campo tecnico per sapere se il metodo di autorizzazione è partner" + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__public_api_url +msgid "" +"The public URL of the API.\n" +"This URL is used in impersonation to set the cookie on the right API domain if you use a reverse proxy to serve the API.\n" +"Defaults to the public_url if not set or the odoo url if not set either." +msgstr "" +"URL pubblico dell'API.\n" +"Questo URL viene utilizzato nell'imitazione per impostare il cookie sul " +"dominio API corretto se si utilizza un reverse proxy per servire l'API.\n" +"Il valore predefinito è public_url se non impostato, oppure l'URL di Odoo se " +"non impostato." + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_fastapi_endpoint__public_url +msgid "" +"The public URL of the site.\n" +"This URL is used for the impersonation final redirect. And can also be used in the mail template to construct links.\n" +"Default to the public_api_url if not set or the odoo url if not set either." +msgstr "" +"URL pubblico del sito.\n" +"Questo URL viene utilizzato per il reindirizzamento finale dell'imitazione. " +"Può anche essere utilizzato nel modello di posta per creare link.\n" +"Impostato di default su public_api_url se non impostato, oppure su Odoo URL " +"se non impostato." + +#. module: fastapi_auth_partner +#: model:ir.model.fields,help:fastapi_auth_partner.field_auth_directory__cookie_secret_key +msgid "The secret key used to sign the cookie" +msgstr "La chiave segreta usata per firmare il cookie" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_wizard_auth_partner_impersonate +msgid "Wizard Partner Auth Impersonate" +msgstr "Procedura guidata imitazione autorizzazione partner" + +#. module: fastapi_auth_partner +#: model:ir.model,name:fastapi_auth_partner.model_wizard_auth_partner_reset_password +msgid "Wizard Partner Auth Reset Password" +msgstr "Procedura guidata reset password autorizzazione partner" + +#. module: fastapi_auth_partner +#. odoo-python +#: code:addons/fastapi_auth_partner/models/auth_partner.py:0 +#, python-format +msgid "" +"You are now impersonating %s\n" +"%%s" +msgstr "" +"Osa si sta imitando %s\n" +"%%s" diff --git a/fastapi_auth_partner/models/__init__.py b/fastapi_auth_partner/models/__init__.py new file mode 100644 index 000000000..526f7a263 --- /dev/null +++ b/fastapi_auth_partner/models/__init__.py @@ -0,0 +1,3 @@ +from . import auth_directory +from . import auth_partner +from . import fastapi_endpoint diff --git a/fastapi_auth_partner/models/auth_directory.py b/fastapi_auth_partner/models/auth_directory.py new file mode 100644 index 000000000..b671bf96d --- /dev/null +++ b/fastapi_auth_partner/models/auth_directory.py @@ -0,0 +1,51 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class AuthDirectory(models.Model): + _inherit = "auth.directory" + + fastapi_endpoint_ids = fields.One2many( + "fastapi.endpoint", + "directory_id", + string="FastAPI Endpoints", + ) + + cookie_secret_key = fields.Char( + groups="base.group_system", + help="The secret key used to sign the cookie", + required=True, + default=lambda self: self._generate_default_secret_key(), + ) + cookie_duration = fields.Integer( + default=525600, + help="In minute, default 525600 minutes => 1 year", + required=True, + ) + sliding_session = fields.Boolean() + + def action_regenerate_cookie_secret_key(self): + self.ensure_one() + self.cookie_secret_key = self._generate_default_secret_key() + + def _prepare_mail_context(self, context): + rv = super()._prepare_mail_context(context) + endpoint_id = self.env.context.get("_fastapi_endpoint_id") + + if endpoint_id: + endpoint = self.env["fastapi.endpoint"].browse(endpoint_id) + rv["public_url"] = endpoint.public_url or endpoint.public_api_url + + return rv + + @property + def _server_env_fields(self): + return { + **super()._server_env_fields, + "cookie_secret_key": {}, + } diff --git a/fastapi_auth_partner/models/auth_partner.py b/fastapi_auth_partner/models/auth_partner.py new file mode 100644 index 000000000..5cba6581b --- /dev/null +++ b/fastapi_auth_partner/models/auth_partner.py @@ -0,0 +1,82 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, models +from odoo.exceptions import AccessDenied, UserError +from odoo.http import request + + +class AuthPartner(models.Model): + _inherit = "auth.partner" + + def local_impersonate(self): + """Local impersonate for dev mode""" + self.ensure_one() + + if not self.env.user._is_admin(): + raise AccessDenied(_("Only admin can impersonate locally")) + + if not hasattr(request, "future_response"): + raise UserError( + _("Please install base_future_response for local impersonate to work") + ) + + for endpoint in self.directory_id.fastapi_endpoint_ids: + helper = self.env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + helper._set_auth_cookie(self, request.httprequest, request.future_response) + + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": _("Impersonation successful"), + "message": _("You are now impersonating %s\n%%s") % self.login, + "links": [ + { + "label": f"{endpoint.app.title()} api docs", + "url": endpoint.docs_url, + } + for endpoint in self.directory_id.fastapi_endpoint_ids + ], + "type": "success", + "sticky": False, + }, + } + + def _get_impersonate_url(self, token, **kwargs): + endpoint = kwargs.get("endpoint") + if not endpoint: + return super()._get_impersonate_url(token, **kwargs) + + base = ( + endpoint.public_api_url + or endpoint.public_url + or ( + self.env["ir.config_parameter"].sudo().get_param("web.base.url") + + endpoint.root_path + ) + ) + return f"{base.rstrip('/')}/auth/impersonate/{token}" + + def _get_impersonate_action(self, token, **kwargs): + # Get the endpoint from a wizard + endpoint_id = self.env.context.get("fastapi_endpoint_id") + endpoint = None + + if endpoint_id: + endpoint = self.env["fastapi.endpoint"].browse(endpoint_id) + + if not endpoint: + endpoints = self.directory_id.fastapi_endpoint_ids + if len(endpoints) == 1: + endpoint = endpoints + else: + wizard = self.env["ir.actions.act_window"]._for_xml_id( + "fastapi_auth_partner.auth_partner_action_impersonate" + ) + wizard["context"] = {"default_auth_partner_id": self.id} + return wizard + + return super()._get_impersonate_action(token, endpoint=endpoint, **kwargs) diff --git a/fastapi_auth_partner/models/fastapi_endpoint.py b/fastapi_auth_partner/models/fastapi_endpoint.py new file mode 100644 index 000000000..cab4f80c5 --- /dev/null +++ b/fastapi_auth_partner/models/fastapi_endpoint.py @@ -0,0 +1,55 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from typing import List + +from odoo import fields, models + +from fastapi import APIRouter + +from ..routers.auth import auth_router + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + app = fields.Selection( + selection_add=[("demo", "Demo Endpoint")], ondelete={"demo": "cascade"} + ) + demo_auth_method = fields.Selection( + selection_add=[ + ("auth_partner", "Partner Auth"), + ], + string="Authentication method", + ) + directory_id = fields.Many2one("auth.directory") + + is_auth_partner = fields.Boolean( + compute="_compute_is_auth_partner", + help="Technical field to know if the auth method is partner", + ) + public_api_url: str = fields.Char( + help="The public URL of the API.\n" + "This URL is used in impersonation to set the cookie on the right API " + "domain if you use a reverse proxy to serve the API.\n" + "Defaults to the public_url if not set or the odoo url if not set either." + ) + # More info in https://github.com/OCA/rest-framework/pull/438/files + public_url: str = fields.Char( + help="The public URL of the site.\n" + "This URL is used for the impersonation final redirect. " + "And can also be used in the mail template to construct links.\n" + "Default to the public_api_url if not set or the odoo url if not set either." + ) + + def _get_fastapi_routers(self) -> List[APIRouter]: + routers = super()._get_fastapi_routers() + if self.app == "demo" and self.demo_auth_method == "auth_partner": + routers.append(auth_router) + return routers + + def _compute_is_auth_partner(self): + for rec in self: + rec.is_auth_partner = auth_router in rec._get_fastapi_routers() diff --git a/fastapi_auth_partner/readme/CONTRIBUTORS.rst b/fastapi_auth_partner/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..bae3cc9a1 --- /dev/null +++ b/fastapi_auth_partner/readme/CONTRIBUTORS.rst @@ -0,0 +1,4 @@ +* `Akretion `_: + + * Sébastien Beau + * Florian Mounier diff --git a/fastapi_auth_partner/readme/DESCRIPTION.rst b/fastapi_auth_partner/readme/DESCRIPTION.rst new file mode 100644 index 000000000..e2fa8ca8d --- /dev/null +++ b/fastapi_auth_partner/readme/DESCRIPTION.rst @@ -0,0 +1,2 @@ +This module is the FastAPI implementation of `auth_partner <../auth_partner>`_ +it provides all the routes to manage the authentication of partners. diff --git a/fastapi_auth_partner/readme/USAGE.rst b/fastapi_auth_partner/readme/USAGE.rst new file mode 100644 index 000000000..316655690 --- /dev/null +++ b/fastapi_auth_partner/readme/USAGE.rst @@ -0,0 +1,52 @@ +First you have to add the auth router to your FastAPI endpoint and the authentication dependency to your app dependencies: + +.. code-block:: python + + from odoo.addons.fastapi import dependencies + from odoo.addons.fastapi_auth_partner.dependencies import ( + auth_partner_authenticated_partner, + ) + from odoo.addons.fastapi_auth_partner.routers.auth import auth_router + + class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + def _get_fastapi_routers(self): + if self.app == "myapp": + return [ + auth_router, + ] + return super()._get_fastapi_routers() + + def _get_app_dependencies_overrides(self): + res = super()._get_app_dependencies_overrides() + if self.app == "myapp": + res.update( + { + dependencies.authenticated_partner_impl: auth_partner_authenticated_partner, + } + ) + return res + +Next you can manage your authenticable partners and directories in the Odoo interface: + +FastAPI > Authentication > Partner + +and + +FastAPI > Authentication > Directory + +Next you must set the directory used for the authentication in the FastAPI endpoint: + +FastAPI > FastAPI Endpoint > myapp > Directory + +Then you can use the auth router to authenticate your requests: + +- POST /auth/register to register a partner +- POST /auth/login to authenticate a partner +- POST /auth/logout to unauthenticate a partner +- POST /auth/validate_email to validate a partner email +- POST /auth/request_reset_password to request a password reset +- POST /auth/set_password to set a new password +- GET /auth/profile to get the partner profile +- GET /auth/impersonate to impersonate a partner diff --git a/fastapi_auth_partner/routers/__init__.py b/fastapi_auth_partner/routers/__init__.py new file mode 100644 index 000000000..582cb2cd7 --- /dev/null +++ b/fastapi_auth_partner/routers/__init__.py @@ -0,0 +1 @@ +from .auth import auth_router diff --git a/fastapi_auth_partner/routers/auth.py b/fastapi_auth_partner/routers/auth.py new file mode 100644 index 000000000..b06f4fcff --- /dev/null +++ b/fastapi_auth_partner/routers/auth.py @@ -0,0 +1,252 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import sys + +if sys.version_info >= (3, 9): + from typing import Annotated +else: + from typing_extensions import Annotated + +from datetime import datetime, timedelta, timezone + +from itsdangerous import URLSafeTimedSerializer + +from odoo import _, fields, models, tools +from odoo.api import Environment +from odoo.exceptions import ValidationError + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi.dependencies import fastapi_endpoint, odoo_env +from odoo.addons.fastapi.models import FastapiEndpoint + +from fastapi import APIRouter, Depends, Request, Response +from fastapi.responses import RedirectResponse + +from ..dependencies import auth_partner_authenticated_partner +from ..schemas import ( + AuthForgetPasswordInput, + AuthLoginInput, + AuthPartnerResponse, + AuthRegisterInput, + AuthSetPasswordInput, + AuthValidateEmailInput, +) + +COOKIE_AUTH_NAME = "fastapi_auth_partner" + +auth_router = APIRouter(tags=["auth"]) + + +@auth_router.post("/auth/register", status_code=201) +def register( + data: AuthRegisterInput, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + request: Request, + response: Response, +) -> AuthPartnerResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._signup(data) + helper._set_auth_cookie(auth_partner, request, response) + return AuthPartnerResponse.from_auth_partner(auth_partner) + + +@auth_router.post("/auth/login") +def login( + data: AuthLoginInput, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + request: Request, + response: Response, +) -> AuthPartnerResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._login(data) + helper._set_auth_cookie(auth_partner, request, response) + return AuthPartnerResponse.from_auth_partner(auth_partner) + + +@auth_router.post("/auth/logout", status_code=205) +def logout( + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + response: Response, +): + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + helper._logout() + helper._clear_auth_cookie(response) + return {} + + +@auth_router.post("/auth/validate_email") +def validate_email( + data: AuthValidateEmailInput, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], +): + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + helper._validate_email(data) + return {} + + +@auth_router.post("/auth/request_reset_password") +def request_reset_password( + data: AuthForgetPasswordInput, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], +): + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + helper._request_reset_password(data) + return {} + + +@auth_router.post("/auth/set_password") +def set_password( + data: AuthSetPasswordInput, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + request: Request, + response: Response, +) -> AuthPartnerResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._set_password(data) + helper._set_auth_cookie(auth_partner, request, response) + return AuthPartnerResponse.from_auth_partner(auth_partner) + + +@auth_router.get("/auth/profile") +def profile( + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + partner: Annotated[Partner, Depends(auth_partner_authenticated_partner)], +) -> AuthPartnerResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._get_auth_from_partner(partner) + return AuthPartnerResponse.from_auth_partner(auth_partner) + + +@auth_router.get("/auth/impersonate/{token}") +def impersonate( + token: str, + env: Annotated[Environment, Depends(odoo_env)], + endpoint: Annotated[FastapiEndpoint, Depends(fastapi_endpoint)], + request: Request, +) -> RedirectResponse: + helper = env["fastapi.auth.service"].new({"endpoint_id": endpoint}) + auth_partner = helper._impersonate(token) + base = ( + endpoint.public_url + or endpoint.public_api_url + or ( + env["ir.config_parameter"].sudo().get_param("web.base.url") + + endpoint.root_path + ) + ) + response = RedirectResponse(url=base) + helper._set_auth_cookie(auth_partner, request, response) + return response + + +class AuthService(models.AbstractModel): + _name = "fastapi.auth.service" + _description = "Fastapi Auth Service" + + endpoint_id = fields.Many2one("fastapi.endpoint", required=True) + directory_id = fields.Many2one("auth.directory") + + def new(self, vals, **kwargs): + rec = super().new(vals, **kwargs) + # Can't have computed / related field in AbstractModel + rec.directory_id = rec.endpoint_id.directory_id + # Auto add endpoint context for mail context + return rec.with_context(_fastapi_endpoint_id=vals["endpoint_id"].id) + + def _get_auth_from_partner(self, partner): + return partner._get_auth_partner_for_directory(self.directory_id) + + def _signup(self, data): + auth_partner = ( + self.env["auth.partner"].sudo()._signup(self.directory_id, **data.dict()) + ) + return auth_partner + + def _login(self, data): + return self.env["auth.partner"].sudo()._login(self.directory_id, **data.dict()) + + def _impersonate(self, token): + return self.env["auth.partner"].sudo()._impersonating(self.directory_id, token) + + def _logout(self): + pass + + def _set_password(self, data): + return ( + self.env["auth.partner"] + .sudo() + ._set_password(self.directory_id, data.token, data.password) + ) + + def _request_reset_password(self, data): + # There can be only one auth_partner per login per directory + auth_partner = ( + self.env["auth.partner"] + .sudo() + .search( + [ + ("directory_id", "=", self.directory_id.id), + ("login", "=", data.login.lower()), + ] + ) + ) + + if not auth_partner: + # do not leak information, no partner no mail sent + return + + return auth_partner.sudo()._request_reset_password() + + def _validate_email(self, data): + return ( + self.env["auth.partner"] + .sudo() + ._validate_email(self.directory_id, data.token) + ) + + def _prepare_cookie_payload(self, partner): + # use short key to reduce cookie size + return { + "did": self.directory_id.id, + "pid": partner.id, + } + + def _prepare_cookie(self, partner): + secret = self.directory_id.cookie_secret_key or self.directory_id.secret_key + if not secret: + raise ValidationError(_("No cookie secret key defined")) + payload = self._prepare_cookie_payload(partner) + value = URLSafeTimedSerializer(secret).dumps(payload) + exp = ( + datetime.now(timezone.utc) + + timedelta(minutes=self.directory_id.cookie_duration) + ).timestamp() + vals = { + "value": value, + "expires": exp, + "httponly": True, + "secure": True, + "samesite": "strict", + } + if tools.config.get("test_enable"): + # do not force https for test + vals["secure"] = False + return vals + + def _set_auth_cookie(self, auth_partner, request, response): + response.set_cookie( + COOKIE_AUTH_NAME, **self.sudo()._prepare_cookie(auth_partner.partner_id) + ) + + def _clear_auth_cookie(self, response): + response.set_cookie(COOKIE_AUTH_NAME, max_age=0) diff --git a/fastapi_auth_partner/schemas.py b/fastapi_auth_partner/schemas.py new file mode 100644 index 000000000..27bec5f05 --- /dev/null +++ b/fastapi_auth_partner/schemas.py @@ -0,0 +1,40 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from extendable_pydantic import StrictExtendableBaseModel + + +class AuthLoginInput(StrictExtendableBaseModel): + login: str + password: str + + +class AuthRegisterInput(StrictExtendableBaseModel): + name: str + login: str + password: str + + +class AuthForgetPasswordInput(StrictExtendableBaseModel): + login: str + + +class AuthSetPasswordInput(StrictExtendableBaseModel): + token: str + password: str + + +class AuthValidateEmailInput(StrictExtendableBaseModel): + token: str + + +class AuthPartnerResponse(StrictExtendableBaseModel): + login: str + mail_verified: bool + + @classmethod + def from_auth_partner(cls, odoo_rec): + return cls.model_construct( + login=odoo_rec.login, mail_verified=odoo_rec.mail_verified + ) diff --git a/fastapi_auth_partner/security/ir.model.access.csv b/fastapi_auth_partner/security/ir.model.access.csv new file mode 100644 index 000000000..b52ae0751 --- /dev/null +++ b/fastapi_auth_partner/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +api_access_fastapi_wizard_auth_partner_impersonate,fastapi_wizard_auth_partner_impersonate,model_wizard_auth_partner_impersonate,auth_partner.group_auth_partner_manager,1,1,1,1 diff --git a/fastapi_auth_partner/security/res_group.xml b/fastapi_auth_partner/security/res_group.xml new file mode 100644 index 000000000..c7f87fb9e --- /dev/null +++ b/fastapi_auth_partner/security/res_group.xml @@ -0,0 +1,13 @@ + + + + + + + + diff --git a/fastapi_auth_partner/static/description/icon.png b/fastapi_auth_partner/static/description/icon.png new file mode 100644 index 000000000..1dcc49c24 Binary files /dev/null and b/fastapi_auth_partner/static/description/icon.png differ diff --git a/fastapi_auth_partner/static/description/index.html b/fastapi_auth_partner/static/description/index.html new file mode 100644 index 000000000..46472f2f2 --- /dev/null +++ b/fastapi_auth_partner/static/description/index.html @@ -0,0 +1,483 @@ + + + + + +README.rst + + + +
    + + + +Odoo Community Association + +
    +

    Fastapi Auth Partner

    + +

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

    +

    This module is the FastAPI implementation of auth_partner +it provides all the routes to manage the authentication of partners.

    +

    Table of contents

    + +
    +

    Usage

    +

    First you have to add the auth router to your FastAPI endpoint and the authentication dependency to your app dependencies:

    +
    +from odoo.addons.fastapi import dependencies
    +from odoo.addons.fastapi_auth_partner.dependencies import (
    +  auth_partner_authenticated_partner,
    +)
    +from odoo.addons.fastapi_auth_partner.routers.auth import auth_router
    +
    +class FastapiEndpoint(models.Model):
    +    _inherit = "fastapi.endpoint"
    +
    +    def _get_fastapi_routers(self):
    +      if self.app == "myapp":
    +          return [
    +              auth_router,
    +          ]
    +      return super()._get_fastapi_routers()
    +
    +    def _get_app_dependencies_overrides(self):
    +        res = super()._get_app_dependencies_overrides()
    +        if self.app == "myapp":
    +            res.update(
    +                {
    +                    dependencies.authenticated_partner_impl: auth_partner_authenticated_partner,
    +                }
    +            )
    +        return res
    +
    +

    Next you can manage your authenticable partners and directories in the Odoo interface:

    +

    FastAPI > Authentication > Partner

    +

    and

    +

    FastAPI > Authentication > Directory

    +

    Next you must set the directory used for the authentication in the FastAPI endpoint:

    +

    FastAPI > FastAPI Endpoint > myapp > Directory

    +

    Then you can use the auth router to authenticate your requests:

    +
      +
    • POST /auth/register to register a partner
    • +
    • POST /auth/login to authenticate a partner
    • +
    • POST /auth/logout to unauthenticate a partner
    • +
    • POST /auth/validate_email to validate a partner email
    • +
    • POST /auth/request_reset_password to request a password reset
    • +
    • POST /auth/set_password to set a new password
    • +
    • GET /auth/profile to get the partner profile
    • +
    • GET /auth/impersonate to impersonate a partner
    • +
    +
    +
    +

    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

    +
      +
    • Akretion:
        +
      • Sébastien Beau
      • +
      • Florian Mounier
      • +
      +
    • +
    +
    +
    +

    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.

    +

    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_auth_partner/tests/__init__.py b/fastapi_auth_partner/tests/__init__.py new file mode 100644 index 000000000..021c23763 --- /dev/null +++ b/fastapi_auth_partner/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_auth +from . import test_fastapi_auth_partner_demo diff --git a/fastapi_auth_partner/tests/test_auth.py b/fastapi_auth_partner/tests/test_auth.py new file mode 100644 index 000000000..04f24ccf5 --- /dev/null +++ b/fastapi_auth_partner/tests/test_auth.py @@ -0,0 +1,243 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import json +from contextlib import contextmanager +from functools import partial + +from requests import Response + +from odoo.tests.common import tagged +from odoo.tools import mute_logger + +from odoo.addons.auth_partner.tests.common import CommonTestAuthPartner +from odoo.addons.extendable_fastapi.tests.common import FastAPITransactionCase +from odoo.addons.fastapi.dependencies import fastapi_endpoint + +from fastapi import status + +from ..routers.auth import auth_router + + +class CommonTestAuth(FastAPITransactionCase): + @contextmanager + def _create_test_client(self, **kwargs): + self.env.invalidate_all() + with mute_logger("httpx"): + with super()._create_test_client(**kwargs) as test_client: + yield test_client + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.demo_app = cls.env.ref("fastapi_auth_partner.fastapi_endpoint_demo") + cls.env = cls.env(context=dict(cls.env.context, queue_job__no_delay=True)) + cls.default_fastapi_router = auth_router + cls.default_fastapi_app = cls.demo_app._get_app() + cls.default_fastapi_dependency_overrides = { + fastapi_endpoint: partial(lambda a: a, cls.demo_app) + } + cls.default_fastapi_odoo_env = cls.env + cls.default_fastapi_running_user = cls.demo_app.user_id + + def _register_partner(self): + with self._create_test_client() as test_client, self.new_mails() as new_mails: + response: Response = test_client.post( + "/auth/register", + content=json.dumps( + { + "name": "Loriot", + "login": "loriot@example.org", + "password": "supersecret", + } + ), + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.text) + return response, new_mails + + def _login(self, test_client, password="supersecret"): + response: Response = test_client.post( + "/auth/login", + content=json.dumps( + { + "login": "loriot@example.org", + "password": password, + } + ), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + return response + + +@tagged("post_install", "-at_install") +class TestFastapiAuthPartner(CommonTestAuth, CommonTestAuthPartner): + def test_register(self): + response, new_mails = self._register_partner() + self.assertEqual(response.status_code, status.HTTP_201_CREATED, response.text) + self.assertEqual( + response.json(), {"login": "loriot@example.org", "mail_verified": False} + ) + self.assertEqual(len(new_mails), 1) + self.assertIn( + "please click on the following link to verify your email", + str(new_mails.body), + ) + + def test_login(self): + self._register_partner() + with self._create_test_client() as test_client: + response = self._login(test_client) + self.assertEqual( + response.json(), {"login": "loriot@example.org", "mail_verified": False} + ) + + def test_logout(self): + self._register_partner() + with self._create_test_client() as test_client: + response: Response = test_client.post("/auth/logout") + self.assertEqual( + response.status_code, status.HTTP_205_RESET_CONTENT, response.text + ) + + def test_request_reset_password(self): + self._register_partner() + with self._create_test_client() as test_client, self.new_mails() as new_mails: + response: Response = test_client.post( + "/auth/request_reset_password", + content=json.dumps({"login": "loriot@example.org"}), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + self.assertFalse( + self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .mail_verified, + ) + self.assertEqual(len(new_mails), 1) + self.assertIn( + "Click on the following link to reset your password", + str(new_mails.body), + ) + token = str(new_mails.body).split("token=")[1].split('">')[0] + response: Response = test_client.post( + "/auth/set_password", + content=json.dumps( + { + "password": "megasecret", + "token": token, + } + ), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + + self.assertTrue( + self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .mail_verified, + ) + response = self._login(test_client, password="megasecret") + self.assertEqual( + response.json(), {"login": "loriot@example.org", "mail_verified": True} + ) + + def test_validate_email(self): + self._register_partner() + mail = self.env["mail.mail"].search([], limit=1, order="id desc") + self.assertIn( + "please click on the following link to verify your email", str(mail.body) + ) + self.assertFalse( + self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .mail_verified, + ) + token = str(mail.body).split("token=")[1].split('">')[0] + with self._create_test_client() as test_client: + response: Response = test_client.post( + "/auth/validate_email", + content=json.dumps({"token": token}), + ) + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + + self.assertTrue( + self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .mail_verified, + ) + + def test_impersonate(self): + self.demo_app.public_url = self.demo_app.public_api_url = False + self._register_partner() + auth_partner = self.env["auth.partner"].search( + [("login", "=", "loriot@example.org")] + ) + self.assertEqual(len(auth_partner), 1) + action = auth_partner.with_user(self.env.ref("base.user_admin")).impersonate() + url = action["url"].split("fastapi_auth_partner_demo", 1)[1] + + with self._create_test_client() as test_client: + response: Response = test_client.get(url, follow_redirects=False) + self.assertEqual(response.status_code, status.HTTP_307_TEMPORARY_REDIRECT) + self.assertTrue( + response.headers["location"].endswith("/fastapi_auth_partner_demo") + ) + self.assertIn("fastapi_auth_partner", response.cookies) + + def test_impersonate_api_url(self): + self._register_partner() + auth_partner = self.env["auth.partner"].search( + [("login", "=", "loriot@example.org")] + ) + self.assertEqual(len(auth_partner), 1) + action = auth_partner.with_user(self.env.ref("base.user_admin")).impersonate() + self.assertTrue( + action["url"].startswith("https://api.example.com/auth/impersonate/") + ) + action["url"].split("auth/impersonate/", 1)[1] + + def test_wizard_auth_partner_impersonate(self): + self._register_partner() + action = ( + self.env["wizard.auth.partner.impersonate"] + .create( + { + "auth_partner_id": self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .id, + "fastapi_endpoint_id": self.demo_app.id, + } + ) + .with_user(self.env.ref("base.user_admin")) + .action_impersonate() + ) + self.assertTrue( + action["url"].startswith("https://api.example.com/auth/impersonate/") + ) + + def test_wizard_auth_partner_reset_password(self): + self._register_partner() + + template = self.env.ref("auth_partner.email_reset_password") + template.body_html = template.body_html.replace( + "https://example.org/", "{{ object.env.context['public_url'] }}" + ) + with self.new_mails() as new_mails: + self.env["wizard.auth.partner.reset.password"].create( + { + "delay": "2-days", + "template_id": template.id, + "fastapi_endpoint_id": self.demo_app.id, + } + ).with_context( + active_ids=self.env["auth.partner"] + .search([("login", "=", "loriot@example.org")]) + .ids + ).action_reset_password() + + self.assertEqual(len(new_mails), 1) + self.assertIn( + "Click on the following link to reset your password", str(new_mails.body) + ) + self.assertIn( + "https://www.example.com/password/reset?token=", str(new_mails.body) + ) diff --git a/fastapi_auth_partner/tests/test_fastapi_auth_partner_demo.py b/fastapi_auth_partner/tests/test_fastapi_auth_partner_demo.py new file mode 100644 index 000000000..0539cef17 --- /dev/null +++ b/fastapi_auth_partner/tests/test_fastapi_auth_partner_demo.py @@ -0,0 +1,93 @@ +# Copyright 2023 ACSONE SA/NV +# Copyright 2023 Akretion (https://www.akretion.com). +# @author Sébastien BEAU +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import json +import sys + +from odoo import tests + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi_auth_partner.dependencies import AuthPartner + +from fastapi import Depends, status + +if sys.version_info >= (3, 9): + from typing import Annotated +else: + from typing_extensions import Annotated + +from odoo.addons.fastapi_auth_partner.routers.auth import auth_router +from odoo.addons.fastapi_auth_partner.schemas import AuthPartnerResponse + + +@auth_router.get("/auth/whoami-public-or-partner") +def whoami_public_or_partner( + partner: Annotated[ + Partner, + Depends(AuthPartner(allow_unauthenticated=True)), + ], +) -> AuthPartnerResponse: + if partner: + return AuthPartnerResponse.from_auth_partner(partner.auth_partner_ids) + return AuthPartnerResponse(login="no-one", mail_verified=False) + + +@tests.tagged("post_install", "-at_install") +class TestEndToEnd(tests.HttpCase): + def setUp(self): + super().setUp() + endpoint = self.env.ref("fastapi_auth_partner.fastapi_endpoint_demo") + endpoint._handle_registry_sync() + + self.fastapi_demo_app = self.env.ref("fastapi.fastapi_endpoint_demo") + self.fastapi_demo_app._handle_registry_sync() + + def _register_partner(self): + return self.url_open( + "/fastapi_auth_partner_demo/auth/register", + timeout=1000, + data=json.dumps( + { + "name": "Loriot", + "login": "loriot@example.org", + "password": "supersecret", + } + ), + ) + + def test_register(self): + response = self._register_partner() + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual( + response.json(), {"login": "loriot@example.org", "mail_verified": False} + ) + self.assertIn("fastapi_auth_partner", response.cookies) + + def test_profile(self): + self._register_partner() + resp = self.url_open("/fastapi_auth_partner_demo/auth/profile") + resp.raise_for_status() + data = resp.json() + self.assertEqual( + data, + {"login": "loriot@example.org", "mail_verified": False}, + ) + + def test_profile_forbidden(self): + """A end-to-end test with negative authentication.""" + resp = self.url_open("/fastapi_auth_partner_demo/auth/profile") + self.assertEqual(resp.status_code, 401) + + def test_public(self): + """A end-to-end test for anonymous/public access.""" + resp = self.url_open("/fastapi_auth_partner_demo/auth/whoami-public-or-partner") + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.json(), {"login": "no-one", "mail_verified": False}) + + self._register_partner() + resp = self.url_open("/fastapi_auth_partner_demo/auth/whoami-public-or-partner") + self.assertEqual( + resp.json(), {"login": "loriot@example.org", "mail_verified": False} + ) diff --git a/fastapi_auth_partner/views/auth_directory_view.xml b/fastapi_auth_partner/views/auth_directory_view.xml new file mode 100644 index 000000000..fd115531c --- /dev/null +++ b/fastapi_auth_partner/views/auth_directory_view.xml @@ -0,0 +1,29 @@ + + + + auth.directory + + +
    +
    + + + + + + +
    + + +
    diff --git a/fastapi_auth_partner/views/auth_partner_view.xml b/fastapi_auth_partner/views/auth_partner_view.xml new file mode 100644 index 000000000..0417e1f32 --- /dev/null +++ b/fastapi_auth_partner/views/auth_partner_view.xml @@ -0,0 +1,31 @@ + + + + auth.partner + + + + + + + + + diff --git a/fastapi_auth_partner/views/fastapi_endpoint_view.xml b/fastapi_auth_partner/views/fastapi_endpoint_view.xml new file mode 100644 index 000000000..10c72e35d --- /dev/null +++ b/fastapi_auth_partner/views/fastapi_endpoint_view.xml @@ -0,0 +1,25 @@ + + + + fastapi.endpoint + + + + + + + + + + + + + + + diff --git a/fastapi_auth_partner/wizards/__init__.py b/fastapi_auth_partner/wizards/__init__.py new file mode 100644 index 000000000..adc3f5233 --- /dev/null +++ b/fastapi_auth_partner/wizards/__init__.py @@ -0,0 +1,2 @@ +from . import wizard_auth_partner_impersonate +from . import wizard_auth_partner_reset_password diff --git a/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate.py b/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate.py new file mode 100644 index 000000000..8d04cef3c --- /dev/null +++ b/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate.py @@ -0,0 +1,29 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + + +from odoo import fields, models + + +class WizardAuthPartnerImpersonate(models.TransientModel): + _name = "wizard.auth.partner.impersonate" + _description = "Wizard Partner Auth Impersonate" + + auth_partner_id = fields.Many2one( + "auth.partner", + required=True, + ) + auth_directory_id = fields.Many2one( + "auth.directory", + related="auth_partner_id.directory_id", + ) + fastapi_endpoint_id = fields.Many2one( + "fastapi.endpoint", + required=True, + ) + + def action_impersonate(self): + return self.auth_partner_id.with_context( + fastapi_endpoint_id=self.fastapi_endpoint_id.id + ).impersonate() diff --git a/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate_view.xml b/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate_view.xml new file mode 100644 index 000000000..e9640c5df --- /dev/null +++ b/fastapi_auth_partner/wizards/wizard_auth_partner_impersonate_view.xml @@ -0,0 +1,43 @@ + + + + + wizard.auth.partner.impersonate + +
    + Please choose an endpoint: + + + + + +
    +
    + +
    +
    +
    + + + Impersonate + wizard.auth.partner.impersonate + ir.actions.act_window + form + new + + + +
    diff --git a/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password.py b/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password.py new file mode 100644 index 000000000..eed208113 --- /dev/null +++ b/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password.py @@ -0,0 +1,18 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class WizardAuthPartnerResetPassword(models.TransientModel): + _inherit = "wizard.auth.partner.reset.password" + + fastapi_endpoint_id = fields.Many2one( + "fastapi.endpoint", + ) + + def action_reset_password(self): + if self.fastapi_endpoint_id: + self = self.with_context(_fastapi_endpoint_id=self.fastapi_endpoint_id.id) + return super(WizardAuthPartnerResetPassword, self).action_reset_password() diff --git a/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password_view.xml b/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password_view.xml new file mode 100644 index 000000000..1495e72d0 --- /dev/null +++ b/fastapi_auth_partner/wizards/wizard_auth_partner_reset_password_view.xml @@ -0,0 +1,17 @@ + + + + + wizard.auth.partner.reset.password + + + + + + + + + diff --git a/fastapi_encrypted_errors/README.rst b/fastapi_encrypted_errors/README.rst new file mode 100644 index 000000000..2688aba39 --- /dev/null +++ b/fastapi_encrypted_errors/README.rst @@ -0,0 +1,103 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +======================== +FastAPI Encrypted Errors +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:15dc8276c61dfb1e08999782de2a451f1d9531ada4979f1d4e02ae39e873406c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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/license-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_encrypted_errors + :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_encrypted_errors + :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 adds a "ref" field in the error response of FastAPI. +This field is an AES encrypted string that contains the error message / traceback. +This encrypted string can be decrypted using the endpoint decrypt error wizard. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +First you have to enable the encryption for an endpoint by checking the `Encrypt Errors` checkbox +in the endpoint configuration. + +To decrypt an error message, you can use the "Decrypt Error" wizard in the +FastAPI menu. + +You can regenerate a new key by clicking on the "Regenerate Key" button next to the `Errors Secret Key` field. + +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 +~~~~~~~~~~~~ + +* `Akretion `_: + + * Florian Mounier + +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_encrypted_errors/__init__.py b/fastapi_encrypted_errors/__init__.py new file mode 100644 index 000000000..d7f65dfa1 --- /dev/null +++ b/fastapi_encrypted_errors/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import wizards +from . import fastapi_dispatcher diff --git a/fastapi_encrypted_errors/__manifest__.py b/fastapi_encrypted_errors/__manifest__.py new file mode 100644 index 000000000..c0a5ca515 --- /dev/null +++ b/fastapi_encrypted_errors/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "FastAPI Encrypted Errors", + "summary": "Adds encrypted error messages to FastAPI error responses.", + "version": "16.0.1.0.1", + "license": "AGPL-3", + "author": "Akretion,Odoo Community Association (OCA)", + "maintainers": ["paradoxxxzero"], + "website": "https://github.com/OCA/rest-framework", + "depends": [ + "fastapi", + ], + "data": [ + "security/ir.model.access.csv", + "views/fastapi_endpoint_views.xml", + "wizards/wizard_fastapi_decrypt_errors_views.xml", + ], + "demo": [], + "external_dependencies": { + "python": ["cryptography"], + }, +} diff --git a/fastapi_encrypted_errors/fastapi_dispatcher.py b/fastapi_encrypted_errors/fastapi_dispatcher.py new file mode 100644 index 000000000..a3f0524a2 --- /dev/null +++ b/fastapi_encrypted_errors/fastapi_dispatcher.py @@ -0,0 +1,35 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo.http import _dispatchers + +from odoo.addons.fastapi.error_handlers import convert_exception_to_status_body +from odoo.addons.fastapi.fastapi_dispatcher import ( + FastApiDispatcher as BaseFastApiDispatcher, +) + + +# Inherit from last registered fastapi dispatcher +# This handles multiple overload of dispatchers +class FastApiDispatcher(_dispatchers.get("fastapi", BaseFastApiDispatcher)): + routing_type = "fastapi" + + def handle_error(self, exc): + 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.encrypt_errors: + headers = getattr(exc, "headers", None) + status_code, body = convert_exception_to_status_body(exc) + if body: + body["ref"] = fastapi_endpoint._encrypt_error(exc) + return self.request.make_json_response( + body, status=status_code, headers=headers + ) + + return super().handle_error(exc) diff --git a/fastapi_encrypted_errors/i18n/fastapi_encrypted_errors.pot b/fastapi_encrypted_errors/i18n/fastapi_encrypted_errors.pot new file mode 100644 index 000000000..c15568724 --- /dev/null +++ b/fastapi_encrypted_errors/i18n/fastapi_encrypted_errors.pot @@ -0,0 +1,113 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fastapi_encrypted_errors +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: fastapi_encrypted_errors +#: model_terms:ir.ui.view,arch_db:fastapi_encrypted_errors.wizard_fastapi_decrypt_errors_view_form +msgid "Close" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__create_uid +msgid "Created by" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__create_date +msgid "Created on" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.actions.act_window,name:fastapi_encrypted_errors.wizard_fastapi_decrypt_errors_action_decrypt_error +#: model:ir.ui.menu,name:fastapi_encrypted_errors.menu_fastapi_decrypt_errors +#: model_terms:ir.ui.view,arch_db:fastapi_encrypted_errors.wizard_fastapi_decrypt_errors_view_form +msgid "Decrypt Error" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__decrypted_error +msgid "Decrypted Error" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__display_name +msgid "Display Name" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_fastapi_endpoint__encrypt_errors +msgid "Encrypt Errors" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,help:fastapi_encrypted_errors.field_fastapi_endpoint__encrypt_errors +msgid "Encrypt errors before sending them to the client." +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_fastapi_endpoint__encrypted_errors_secret_key +msgid "Encrypted Errors Secret Key" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__error +msgid "Error" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model,name:fastapi_encrypted_errors.model_fastapi_endpoint +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__fastapi_endpoint_id +msgid "FastAPI Endpoint" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model_terms:ir.ui.view,arch_db:fastapi_encrypted_errors.fastapi_endpoint_form_view +msgid "Generate Secret Key" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__id +msgid "ID" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model_terms:ir.ui.view,arch_db:fastapi_encrypted_errors.wizard_fastapi_decrypt_errors_view_form +msgid "Label" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors____last_update +msgid "Last Modified on" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__write_date +msgid "Last Updated on" +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,help:fastapi_encrypted_errors.field_fastapi_endpoint__encrypted_errors_secret_key +msgid "" +"The secret key used to encrypt errors before sending them to the client." +msgstr "" + +#. module: fastapi_encrypted_errors +#: model:ir.model,name:fastapi_encrypted_errors.model_wizard_fastapi_decrypt_errors +msgid "Wizard to decrypt FastAPI errors" +msgstr "" diff --git a/fastapi_encrypted_errors/i18n/it.po b/fastapi_encrypted_errors/i18n/it.po new file mode 100644 index 000000000..1dea61661 --- /dev/null +++ b/fastapi_encrypted_errors/i18n/it.po @@ -0,0 +1,118 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * fastapi_encrypted_errors +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2025-06-04 16:26+0000\n" +"Last-Translator: mymage \n" +"Language-Team: none\n" +"Language: it\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 5.10.4\n" + +#. module: fastapi_encrypted_errors +#: model_terms:ir.ui.view,arch_db:fastapi_encrypted_errors.wizard_fastapi_decrypt_errors_view_form +msgid "Close" +msgstr "Chiudi" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__create_uid +msgid "Created by" +msgstr "Creato da" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__create_date +msgid "Created on" +msgstr "Creato il" + +#. module: fastapi_encrypted_errors +#: model:ir.actions.act_window,name:fastapi_encrypted_errors.wizard_fastapi_decrypt_errors_action_decrypt_error +#: model:ir.ui.menu,name:fastapi_encrypted_errors.menu_fastapi_decrypt_errors +#: model_terms:ir.ui.view,arch_db:fastapi_encrypted_errors.wizard_fastapi_decrypt_errors_view_form +msgid "Decrypt Error" +msgstr "Decifra errore" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__decrypted_error +msgid "Decrypted Error" +msgstr "Errore decifrato" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__display_name +msgid "Display Name" +msgstr "Nome visualizzato" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_fastapi_endpoint__encrypt_errors +msgid "Encrypt Errors" +msgstr "Errori cifrati" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,help:fastapi_encrypted_errors.field_fastapi_endpoint__encrypt_errors +msgid "Encrypt errors before sending them to the client." +msgstr "Cifra gli errori prima di inviarli al client." + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_fastapi_endpoint__encrypted_errors_secret_key +msgid "Encrypted Errors Secret Key" +msgstr "Chiave segreta errori cifrati" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__error +msgid "Error" +msgstr "Errore" + +#. module: fastapi_encrypted_errors +#: model:ir.model,name:fastapi_encrypted_errors.model_fastapi_endpoint +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__fastapi_endpoint_id +msgid "FastAPI Endpoint" +msgstr "Endpoint FastAPI" + +#. module: fastapi_encrypted_errors +#: model_terms:ir.ui.view,arch_db:fastapi_encrypted_errors.fastapi_endpoint_form_view +msgid "Generate Secret Key" +msgstr "Genera chiave segreta" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__id +msgid "ID" +msgstr "ID" + +#. module: fastapi_encrypted_errors +#: model_terms:ir.ui.view,arch_db:fastapi_encrypted_errors.wizard_fastapi_decrypt_errors_view_form +msgid "Label" +msgstr "Etichetta" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors____last_update +msgid "Last Modified on" +msgstr "Ultima modifica il" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__write_uid +msgid "Last Updated by" +msgstr "Ultimo aggiornamento di" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,field_description:fastapi_encrypted_errors.field_wizard_fastapi_decrypt_errors__write_date +msgid "Last Updated on" +msgstr "Ultimo aggiornamento il" + +#. module: fastapi_encrypted_errors +#: model:ir.model.fields,help:fastapi_encrypted_errors.field_fastapi_endpoint__encrypted_errors_secret_key +msgid "" +"The secret key used to encrypt errors before sending them to the client." +msgstr "" +"La chiave segreta utilizzata per cifrare gli errori prima di inviarli al " +"client." + +#. module: fastapi_encrypted_errors +#: model:ir.model,name:fastapi_encrypted_errors.model_wizard_fastapi_decrypt_errors +msgid "Wizard to decrypt FastAPI errors" +msgstr "Procedura guidata per decifrare gli errori FastAPI" diff --git a/fastapi_encrypted_errors/models/__init__.py b/fastapi_encrypted_errors/models/__init__.py new file mode 100644 index 000000000..b825fab92 --- /dev/null +++ b/fastapi_encrypted_errors/models/__init__.py @@ -0,0 +1 @@ +from . import fastapi_endpoint diff --git a/fastapi_encrypted_errors/models/fastapi_endpoint.py b/fastapi_encrypted_errors/models/fastapi_endpoint.py new file mode 100644 index 000000000..add3ca2ec --- /dev/null +++ b/fastapi_encrypted_errors/models/fastapi_endpoint.py @@ -0,0 +1,47 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import traceback +import zlib + +from cryptography.fernet import Fernet + +from odoo import fields, models + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + encrypt_errors = fields.Boolean( + help="Encrypt errors before sending them to the client.", + ) + encrypted_errors_secret_key = fields.Char( + help="The secret key used to encrypt errors before sending them to the client.", + default=lambda _: Fernet.generate_key(), + readonly=True, + ) + + def action_generate_encrypted_errors_secret_key(self): + for record in self: + record.encrypted_errors_secret_key = Fernet.generate_key() + + def _encrypt_error(self, exc): + self.ensure_one() + if not self.encrypt_errors or not self.encrypted_errors_secret_key: + return + + # Get full traceback + error = "".join(traceback.format_exception(exc)) + # zlib compression works quite well on tracebacks + error = zlib.compress(error.encode("utf-8")) + f = Fernet(self.encrypted_errors_secret_key) + return f.encrypt(error) + + def _decrypt_error(self, error): + self.ensure_one() + if not self.encrypt_errors or not self.encrypted_errors_secret_key: + return + + f = Fernet(self.encrypted_errors_secret_key) + error = f.decrypt(error) + return zlib.decompress(error).decode("utf-8") diff --git a/fastapi_encrypted_errors/readme/CONTRIBUTORS.rst b/fastapi_encrypted_errors/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..a4d0ad922 --- /dev/null +++ b/fastapi_encrypted_errors/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Akretion `_: + + * Florian Mounier diff --git a/fastapi_encrypted_errors/readme/DESCRIPTION.rst b/fastapi_encrypted_errors/readme/DESCRIPTION.rst new file mode 100644 index 000000000..3ea245c61 --- /dev/null +++ b/fastapi_encrypted_errors/readme/DESCRIPTION.rst @@ -0,0 +1,3 @@ +This module adds a "ref" field in the error response of FastAPI. +This field is an AES encrypted string that contains the error message / traceback. +This encrypted string can be decrypted using the endpoint decrypt error wizard. diff --git a/fastapi_encrypted_errors/readme/USAGE.rst b/fastapi_encrypted_errors/readme/USAGE.rst new file mode 100644 index 000000000..041077d9b --- /dev/null +++ b/fastapi_encrypted_errors/readme/USAGE.rst @@ -0,0 +1,7 @@ +First you have to enable the encryption for an endpoint by checking the `Encrypt Errors` checkbox +in the endpoint configuration. + +To decrypt an error message, you can use the "Decrypt Error" wizard in the +FastAPI menu. + +You can regenerate a new key by clicking on the "Regenerate Key" button next to the `Errors Secret Key` field. diff --git a/fastapi_encrypted_errors/security/ir.model.access.csv b/fastapi_encrypted_errors/security/ir.model.access.csv new file mode 100644 index 000000000..d102f0c82 --- /dev/null +++ b/fastapi_encrypted_errors/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fastapi_wizard_auth_partner_impersonate,wizard_fastapi_decrypt_errors,model_wizard_fastapi_decrypt_errors,fastapi.group_fastapi_manager,1,1,1,1 diff --git a/fastapi_encrypted_errors/static/description/icon.png b/fastapi_encrypted_errors/static/description/icon.png new file mode 100644 index 000000000..1dcc49c24 Binary files /dev/null and b/fastapi_encrypted_errors/static/description/icon.png differ diff --git a/fastapi_encrypted_errors/static/description/index.html b/fastapi_encrypted_errors/static/description/index.html new file mode 100644 index 000000000..77532340a --- /dev/null +++ b/fastapi_encrypted_errors/static/description/index.html @@ -0,0 +1,445 @@ + + + + + +README.rst + + + +
    + + + +Odoo Community Association + +
    +

    FastAPI Encrypted Errors

    + +

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

    +

    This module adds a “ref” field in the error response of FastAPI. +This field is an AES encrypted string that contains the error message / traceback. +This encrypted string can be decrypted using the endpoint decrypt error wizard.

    +

    Table of contents

    + +
    +

    Usage

    +

    First you have to enable the encryption for an endpoint by checking the Encrypt Errors checkbox +in the endpoint configuration.

    +

    To decrypt an error message, you can use the “Decrypt Error” wizard in the +FastAPI menu.

    +

    You can regenerate a new key by clicking on the “Regenerate Key” button next to the Errors Secret Key field.

    +
    +
    +

    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/fastapi_encrypted_errors/tests/__init__.py b/fastapi_encrypted_errors/tests/__init__.py new file mode 100644 index 000000000..8fe3d60a6 --- /dev/null +++ b/fastapi_encrypted_errors/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fastapi_encrypted_errors diff --git a/fastapi_encrypted_errors/tests/test_fastapi_encrypted_errors.py b/fastapi_encrypted_errors/tests/test_fastapi_encrypted_errors.py new file mode 100644 index 000000000..8aa1174f7 --- /dev/null +++ b/fastapi_encrypted_errors/tests/test_fastapi_encrypted_errors.py @@ -0,0 +1,74 @@ +# Copyright 2024 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.tests.common import HttpCase + +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({"encrypt_errors": True}) + lang = ( + cls.env["res.lang"] + .with_context(active_test=False) + .search([("code", "=", "fr_BE")]) + ) + lang.active = True + + def test_encrypted_errors_in_response(self): + 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) + res = response.json() + self.assertEqual(res["detail"], "User Error") + self.assertIn("ref", res) + + 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) + res = response.json() + self.assertEqual(res["detail"], "Internal Server Error") + self.assertIn("ref", res) + + def test_encrypted_errors_decrypt(self): + 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) + res = response.json() + self.assertEqual(res["detail"], "Internal Server Error") + self.assertIn("ref", res) + ref = res["ref"] + self.assertNotIn("Traceback (most recent call last)", ref) + self.assertNotIn("NotImplementedError: Internal Server Error", ref) + + wizard = self.env["wizard.fastapi.decrypt.errors"].create({"error": ref}) + wizard.action_decrypt_error() + self.assertIn("Traceback (most recent call last)", wizard.decrypted_error) + self.assertIn( + "NotImplementedError: Internal Server Error", wizard.decrypted_error + ) diff --git a/fastapi_encrypted_errors/views/fastapi_endpoint_views.xml b/fastapi_encrypted_errors/views/fastapi_endpoint_views.xml new file mode 100644 index 000000000..fce00f36f --- /dev/null +++ b/fastapi_encrypted_errors/views/fastapi_endpoint_views.xml @@ -0,0 +1,31 @@ + + + + + + fastapi.endpoint + + + + +
    +
    +
    +
    +
    +
    diff --git a/fastapi_encrypted_errors/wizards/__init__.py b/fastapi_encrypted_errors/wizards/__init__.py new file mode 100644 index 000000000..a160dabbb --- /dev/null +++ b/fastapi_encrypted_errors/wizards/__init__.py @@ -0,0 +1 @@ +from . import wizard_fastapi_decrypt_errors diff --git a/fastapi_encrypted_errors/wizards/wizard_fastapi_decrypt_errors.py b/fastapi_encrypted_errors/wizards/wizard_fastapi_decrypt_errors.py new file mode 100644 index 000000000..d19bd6d3a --- /dev/null +++ b/fastapi_encrypted_errors/wizards/wizard_fastapi_decrypt_errors.py @@ -0,0 +1,40 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import traceback + +from odoo import fields, models + + +class WizardFastapiDecryptErrors(models.TransientModel): + _name = "wizard.fastapi.decrypt.errors" + _description = "Wizard to decrypt FastAPI errors" + + error = fields.Text(required=True) + fastapi_endpoint_id = fields.Many2one( + "fastapi.endpoint", + string="FastAPI Endpoint", + required=True, + default=lambda self: self.env["fastapi.endpoint"].search([], limit=1), + ) + decrypted_error = fields.Text(readonly=True) + + def action_decrypt_error(self): + self.ensure_one() + try: + error = self.fastapi_endpoint_id._decrypt_error(self.error.encode("utf-8")) + except Exception: + self.decrypted_error = ( + "Error while decrypting error: \n\n" + traceback.format_exc() + ) + else: + self.decrypted_error = error + + return { + "type": "ir.actions.act_window", + "res_model": "wizard.fastapi.decrypt.errors", + "view_mode": "form", + "view_type": "form", + "res_id": self.id, + "target": "new", + } diff --git a/fastapi_encrypted_errors/wizards/wizard_fastapi_decrypt_errors_views.xml b/fastapi_encrypted_errors/wizards/wizard_fastapi_decrypt_errors_views.xml new file mode 100644 index 000000000..62b8a803d --- /dev/null +++ b/fastapi_encrypted_errors/wizards/wizard_fastapi_decrypt_errors_views.xml @@ -0,0 +1,47 @@ + + + + + wizard.fastapi.decrypt.errors + +
    + + + + + +
    +
    + +
    +
    +
    + + + Decrypt Error + wizard.fastapi.decrypt.errors + ir.actions.act_window + form + new + + + + + +
    diff --git a/fastapi_log/README.rst b/fastapi_log/README.rst new file mode 100644 index 000000000..fdb54e937 --- /dev/null +++ b/fastapi_log/README.rst @@ -0,0 +1,102 @@ +=========== +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 +- `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/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..27938d00e --- /dev/null +++ b/fastapi_log/__manifest__.py @@ -0,0 +1,24 @@ +# 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.1.0", + "author": "Akretion, Odoo Community Association (OCA)", + "summary": "Log Fastapi requests in database", + "category": "Tools", + "depends": [ + "api_log", + "fastapi", + ], + "website": "https://github.com/OCA/rest-framework", + "data": [ + "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..ba5071083 --- /dev/null +++ b/fastapi_log/fastapi_dispatcher.py @@ -0,0 +1,59 @@ +# 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.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() + fastapi_endpoint = ( + self.request.env["fastapi.endpoint"] + .sudo() + ._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: + try: + 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) + + 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/__init__.py b/fastapi_log/models/__init__.py new file mode 100644 index 000000000..23ac9cf0b --- /dev/null +++ b/fastapi_log/models/__init__.py @@ -0,0 +1,2 @@ +from . import api_log +from . import fastapi_endpoint diff --git a/fastapi_log/models/api_log.py b/fastapi_log/models/api_log.py new file mode 100644 index 000000000..086e656b4 --- /dev/null +++ b/fastapi_log/models/api_log.py @@ -0,0 +1,65 @@ +# 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, models + + +class FastapiLog(models.Model): + _inherit = "api.log" + + @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): + # 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["collection_ref"] = "%s,%s" % ( + endpoint._name, + 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 new file mode 100644 index 000000000..4770dcf24 --- /dev/null +++ b/fastapi_log/models/fastapi_endpoint.py @@ -0,0 +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 models + + +class FastapiEndpoint(models.Model): + _name = "fastapi.endpoint" + _inherit = [ + "api.log_collection.mixin", + "fastapi.endpoint", + ] diff --git a/fastapi_log/readme/CONTRIBUTORS.md b/fastapi_log/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..1e935bfb5 --- /dev/null +++ b/fastapi_log/readme/CONTRIBUTORS.md @@ -0,0 +1,3 @@ +- Florian Mounier +- [PyTech](https://www.pytech.it): + - Simone Rubino \<\> 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/static/description/index.html b/fastapi_log/static/description/index.html new file mode 100644 index 000000000..b0b206a30 --- /dev/null +++ b/fastapi_log/static/description/index.html @@ -0,0 +1,442 @@ + + + + + +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
    • +
    +
    +
    +

    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/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/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 new file mode 100644 index 000000000..dc8efff62 --- /dev/null +++ b/fastapi_log/tests/test_fastapi_log.py @@ -0,0 +1,113 @@ +# 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.fastapi.schemas import DemoExceptionType +from odoo.addons.fastapi_log.tests.common import Common + +from fastapi import status + + +@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}) + + with self.log_capturer() as capturer: + 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/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/test/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/test/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/test/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/test/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/test/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/test/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) + for log in capturer.records[0:-1]: + 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) + 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[-1] + 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) + 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) diff --git a/fastapi_log/views/fastapi_endpoint_views.xml b/fastapi_log/views/fastapi_endpoint_views.xml new file mode 100644 index 000000000..ac50560e8 --- /dev/null +++ b/fastapi_log/views/fastapi_endpoint_views.xml @@ -0,0 +1,41 @@ + + + + + + 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..3c19ba737 --- /dev/null +++ b/fastapi_log/views/fastapi_log_views.xml @@ -0,0 +1,25 @@ + + + + + Fastapi Logs + api.log + [ + ("collection_model", "=", "fastapi.endpoint"), + ] + tree,form + + + + diff --git a/fastapi_log_mail/README.rst b/fastapi_log_mail/README.rst new file mode 100644 index 000000000..af99233ca --- /dev/null +++ b/fastapi_log_mail/README.rst @@ -0,0 +1,92 @@ +======================== +FastAPI 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/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 create an activity when an exception is logged in +a fastapi endpoint. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Configure a fastapi endpoint as explained in ``api_log_mail``. + +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..e69de29bb diff --git a/fastapi_log_mail/__manifest__.py b/fastapi_log_mail/__manifest__.py new file mode 100644 index 000000000..8bcbc0621 --- /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 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": "Notify logged exceptions.", + "category": "Tools", + "depends": [ + "fastapi_log", + "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/readme/CONFIGURE.md b/fastapi_log_mail/readme/CONFIGURE.md new file mode 100644 index 000000000..ca1622a8b --- /dev/null +++ b/fastapi_log_mail/readme/CONFIGURE.md @@ -0,0 +1 @@ +Configure a fastapi endpoint as explained in `api_log_mail`. 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..e92d7f261 --- /dev/null +++ b/fastapi_log_mail/readme/DESCRIPTION.md @@ -0,0 +1 @@ +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 new file mode 100644 index 000000000..026bfe3b8 --- /dev/null +++ b/fastapi_log_mail/static/description/index.html @@ -0,0 +1,434 @@ + + + + + +FastAPI Log notification + + + +
    +

    FastAPI 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 +a fastapi endpoint.

    +

    Table of contents

    + +
    +

    Configuration

    +

    Configure a fastapi endpoint as explained in api_log_mail.

    +
    +
    +

    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..e4bde480a --- /dev/null +++ b/fastapi_log_mail/tests/test_fastapi_log_mail.py @@ -0,0 +1,84 @@ +# 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.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"), "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[ + "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( + { + "name": "Test exception email template", + "model_id": cls.env.ref("api_log.model_api_log").id, + } + ) + + 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 + app = self.fastapi_demo_app + mail_template = app.api_log_mail_exception_template_id + route = ( + "/fastapi_demo/test/demo/exception?" + f"exception_type={DemoExceptionType.user_error.value}" + "&error_message=An error happened" + ) + # 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..6e1d27886 --- /dev/null +++ b/fastapi_log_mail/views/fastapi_endpoint_views.xml @@ -0,0 +1,34 @@ + + + + + Add log mail fields to endpoint form view + fastapi.endpoint + + + + + + + + + diff --git a/requirements.txt b/requirements.txt index 7e0b84839..7806c32f6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,18 +4,22 @@ apispec apispec>=4.0.0 cerberus contextvars +cryptography extendable-pydantic extendable-pydantic>=1.2.0 extendable>=0.0.4 fastapi>=0.110.0 graphene graphql_server +itsdangerous jsondiff marshmallow marshmallow-objects>=2.0.0 +marshmallow<4.0.0 parse-accept-language pydantic pydantic>=2.0.0 +pyjwt pyquerystring python-multipart typing-extensions diff --git a/rest_log/README.rst b/rest_log/README.rst index d7dcae803..dfed37602 100644 --- a/rest_log/README.rst +++ b/rest_log/README.rst @@ -7,7 +7,7 @@ REST Log !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - !! source digest: sha256:f93e58dbd77af1254adb0cd7ace54af0b0609569883ed4a22028f11f0b663139 + !! source digest: sha256:ed7eb7cec756c78c39eb3dc04ca382ea64926defada6d97aa72e859db389d8b2 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! .. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png diff --git a/rest_log/__manifest__.py b/rest_log/__manifest__.py index 60efce45b..478bee1cd 100644 --- a/rest_log/__manifest__.py +++ b/rest_log/__manifest__.py @@ -5,7 +5,7 @@ { "name": "REST Log", "summary": "Track REST API calls into DB", - "version": "16.0.1.0.2", + "version": "16.0.1.0.3", "development_status": "Beta", "website": "https://github.com/OCA/rest-framework", "author": "Camptocamp, ACSONE, Odoo Community Association (OCA)", diff --git a/rest_log/components/service.py b/rest_log/components/service.py index bbb45503f..9aedbf11b 100644 --- a/rest_log/components/service.py +++ b/rest_log/components/service.py @@ -7,10 +7,12 @@ import logging import traceback +from psycopg2.errors import OperationalError from werkzeug.urls import url_encode, url_join from odoo import exceptions, registry from odoo.http import Response, request +from odoo.service.model import PG_CONCURRENCY_ERRORS_TO_RETRY from odoo.addons.base_rest.http import JSONEncoder from odoo.addons.component.core import AbstractComponent @@ -111,6 +113,15 @@ def _dispatch_exception( log_entry_url = self._get_log_entry_url(log_entry) except Exception as e: _logger.exception("Rest Log Error Creation: %s", e) + # let the OperationalError bubble up to the retrying mechanism + # We can't wrap the OperationalError because we want to let it + # bubble up to the retrying mechanism, it will be handled by + # the default handler at the end of the chain. + if ( + isinstance(orig_exception, OperationalError) + and orig_exception.pgcode in PG_CONCURRENCY_ERRORS_TO_RETRY + ): + raise orig_exception raise exception_klass(exc_msg, log_entry_url) from orig_exception def _get_exception_message(self, exception): diff --git a/rest_log/static/description/index.html b/rest_log/static/description/index.html index 3c9f139e5..56f26b2bf 100644 --- a/rest_log/static/description/index.html +++ b/rest_log/static/description/index.html @@ -367,7 +367,7 @@

    REST Log

    !! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:f93e58dbd77af1254adb0cd7ace54af0b0609569883ed4a22028f11f0b663139 +!! source digest: sha256:ed7eb7cec756c78c39eb3dc04ca382ea64926defada6d97aa72e859db389d8b2 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

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

    When exposing REST services is often useful to see what’s happening diff --git a/rest_log/tests/common.py b/rest_log/tests/common.py index f43a4db47..63e16bd01 100644 --- a/rest_log/tests/common.py +++ b/rest_log/tests/common.py @@ -4,6 +4,9 @@ import contextlib +from psycopg2 import errorcodes +from psycopg2.errors import OperationalError + from odoo import exceptions from odoo.addons.base_rest import restapi @@ -42,6 +45,7 @@ def fail(self, how): "value": ValueError, "validation": exceptions.ValidationError, "user": exceptions.UserError, + "retryable": FakeConcurrentUpdateError, } raise exc[how]("Failed as you wanted!") @@ -61,3 +65,9 @@ def _get_mocked_request(self, env=None, httprequest=None, extra_headers=None): headers.update(extra_headers or {}) mocked_request.httprequest.headers = headers yield mocked_request + + +class FakeConcurrentUpdateError(OperationalError): + @property + def pgcode(self): + return errorcodes.SERIALIZATION_FAILURE diff --git a/rest_log/tests/test_db_logging.py b/rest_log/tests/test_db_logging.py index 01c1991ae..998f0c8e0 100644 --- a/rest_log/tests/test_db_logging.py +++ b/rest_log/tests/test_db_logging.py @@ -13,7 +13,7 @@ from odoo.addons.component.tests.common import new_rollbacked_env from odoo.addons.rest_log import exceptions as log_exceptions # pylint: disable=W7950 -from .common import TestDBLoggingMixin +from .common import FakeConcurrentUpdateError, TestDBLoggingMixin class TestDBLogging(TransactionRestServiceRegistryCase, TestDBLoggingMixin): @@ -374,3 +374,66 @@ def test_log_exception_value(self): self._test_exception( "value", log_exceptions.RESTServiceDispatchException, "ValueError", "severe" ) + + +class TestDBLoggingRetryableError( + TransactionRestServiceRegistryCase, TestDBLoggingMixin +): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls._setup_registry(cls) + + @classmethod + def tearDownClass(cls): + # pylint: disable=W8110 + cls._teardown_registry(cls) + super().tearDownClass() + + def _test_exception(self, test_type, wrapping_exc, exc_name, severity): + log_model = self.env["rest.log"].sudo() + initial_entries = log_model.search([]) + # Context: we are running in a transaction case which uses savepoints. + # The log machinery is going to rollback the transation when catching errors. + # Hence we need a completely separated env for the service. + with new_rollbacked_env() as new_env: + # Init fake collection w/ new env + collection = _PseudoCollection(self._collection_name, new_env) + service = self._get_service(self, collection=collection) + with self._get_mocked_request(env=new_env): + try: + service.dispatch("fail", test_type) + except Exception as err: + # Not using `assertRaises` to inspect the exception directly + self.assertTrue(isinstance(err, wrapping_exc)) + self.assertEqual( + service._get_exception_message(err), "Failed as you wanted!" + ) + + with new_rollbacked_env() as new_env: + log_model = new_env["rest.log"].sudo() + entry = log_model.search([]) - initial_entries + expected = { + "collection": service._collection, + "state": "failed", + "result": "null", + "exception_name": exc_name, + "exception_message": "Failed as you wanted!", + "severity": severity, + } + self.assertRecordValues(entry, [expected]) + + @staticmethod + def _get_test_controller(class_or_instance, root_path=None): + return super()._get_test_controller( + class_or_instance, root_path="/test_log_exception_retryable/" + ) + + def test_log_exception_retryable(self): + # retryable error must bubble up to the retrying mechanism + self._test_exception( + "retryable", + FakeConcurrentUpdateError, + "odoo.addons.rest_log.tests.common.FakeConcurrentUpdateError", + "warning", + ) diff --git a/setup/_metapackage/VERSION.txt b/setup/_metapackage/VERSION.txt index c641082ff..13f42e886 100644 --- a/setup/_metapackage/VERSION.txt +++ b/setup/_metapackage/VERSION.txt @@ -1 +1 @@ -16.0.20231212.0 \ No newline at end of file +16.0.20250603.1 \ No newline at end of file diff --git a/setup/_metapackage/setup.py b/setup/_metapackage/setup.py index e1afa5ea8..f06d16f9e 100644 --- a/setup/_metapackage/setup.py +++ b/setup/_metapackage/setup.py @@ -8,6 +8,7 @@ description="Meta package for oca-rest-framework Odoo addons", version=version, install_requires=[ + 'odoo-addon-auth_partner>=16.0dev,<16.1dev', 'odoo-addon-base_rest>=16.0dev,<16.1dev', 'odoo-addon-base_rest_auth_api_key>=16.0dev,<16.1dev', 'odoo-addon-base_rest_datamodel>=16.0dev,<16.1dev', @@ -19,6 +20,8 @@ 'odoo-addon-fastapi>=16.0dev,<16.1dev', 'odoo-addon-fastapi_auth_jwt>=16.0dev,<16.1dev', 'odoo-addon-fastapi_auth_jwt_demo>=16.0dev,<16.1dev', + 'odoo-addon-fastapi_auth_partner>=16.0dev,<16.1dev', + 'odoo-addon-fastapi_encrypted_errors>=16.0dev,<16.1dev', 'odoo-addon-graphql_base>=16.0dev,<16.1dev', 'odoo-addon-graphql_demo>=16.0dev,<16.1dev', 'odoo-addon-pydantic>=16.0dev,<16.1dev', diff --git a/setup/api_log/odoo/addons/api_log b/setup/api_log/odoo/addons/api_log new file mode 120000 index 000000000..bcddf69a4 --- /dev/null +++ b/setup/api_log/odoo/addons/api_log @@ -0,0 +1 @@ +../../../../api_log \ No newline at end of file diff --git a/setup/api_log/setup.py b/setup/api_log/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/api_log/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) 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, +) diff --git a/setup/auth_partner/odoo/addons/auth_partner b/setup/auth_partner/odoo/addons/auth_partner new file mode 120000 index 000000000..736694d4a --- /dev/null +++ b/setup/auth_partner/odoo/addons/auth_partner @@ -0,0 +1 @@ +../../../../auth_partner \ No newline at end of file diff --git a/setup/auth_partner/setup.py b/setup/auth_partner/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/auth_partner/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/fastapi_auth_partner/odoo/addons/fastapi_auth_partner b/setup/fastapi_auth_partner/odoo/addons/fastapi_auth_partner new file mode 120000 index 000000000..481ffc2a2 --- /dev/null +++ b/setup/fastapi_auth_partner/odoo/addons/fastapi_auth_partner @@ -0,0 +1 @@ +../../../../fastapi_auth_partner \ No newline at end of file diff --git a/setup/fastapi_auth_partner/setup.py b/setup/fastapi_auth_partner/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/fastapi_auth_partner/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/fastapi_encrypted_errors/odoo/addons/fastapi_encrypted_errors b/setup/fastapi_encrypted_errors/odoo/addons/fastapi_encrypted_errors new file mode 120000 index 000000000..101a9234a --- /dev/null +++ b/setup/fastapi_encrypted_errors/odoo/addons/fastapi_encrypted_errors @@ -0,0 +1 @@ +../../../../fastapi_encrypted_errors \ No newline at end of file diff --git a/setup/fastapi_encrypted_errors/setup.py b/setup/fastapi_encrypted_errors/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/fastapi_encrypted_errors/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) 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, +) 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, +)