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.
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.
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.
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/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
+
\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
+
+
+
+
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.
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/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
+
+
+
+
+
+
+ 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
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
This addon is deprecated and not fully supported anymore on Odoo 16.
@@ -450,10 +450,10 @@
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>
# The following method are 'public' and can be called from the controller.
-defget(self,_id,message):
+defget(self,_id,message):return{'response':'Get called with message '+message}
-defsearch(self,message):
+defsearch(self,message):return{'response':'Search called search with message '+message}
-defupdate(self,_id,message):
+defupdate(self,_id,message):return{'response':'PUT called with message '+message}# pylint:disable=method-required-super
-defcreate(self,**params):
+defcreate(self,**params):return{'response':'POST called with message '+params['message']}
-defdelete(self,_id):
+defdelete(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 defaultreturn{}
-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
output_param=restapi.Datamodel("partner.short.info",is_list=True),auth="public",)
-defsearch(self,partner_search_param):
+defsearch(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
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
Demo addon to illustrate how to develop self documented REST services thanks
@@ -442,7 +442,9 @@
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
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
This addon allows you to define simple data models supporting serialization/deserialization
@@ -393,20 +393,20 @@
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
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
This addon is a technical addon used to allows the use of
@@ -443,7 +443,9 @@
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
-
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.
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.
The ‘odoo_env’ dependency relies on a simple implementation that retrieves
the current odoo environment from ContextVar variable initialized at the start
of the request processing by the specific request dispatcher processing the
@@ -719,7 +724,7 @@
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.
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.
The authentication mechanism<
can allows the user to select one of these authentication mechanisms by adding
a selection field on the fastapi endpoint model.
string="Authenciation method",)
-def_get_app(self)->FastAPI:
+def_get_app(self)->FastAPI:app=super()._get_app()ifself.app=="demo":# Here we add the overrides to the authenticated_partner_impl method
@@ -918,7 +923,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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.
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 @@
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 @@
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 @@
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 @@
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 @@
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.
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 @@
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.
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 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.
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.
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.
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.
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.
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.
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.
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_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
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->
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,
+)