From 9de2654b0f6ee815dfd7df803b8ac84475636711 Mon Sep 17 00:00:00 2001
From: xtremxpert
Date: Mon, 5 May 2025 10:29:07 -0400
Subject: [PATCH] st-laurent
---
portal_partner_manager/README.md | 118 +
portal_partner_manager/__init__.py | 2 +
portal_partner_manager/__manifest__.py | 46 +
portal_partner_manager/architecture.md | 106 +
.../controllers/__init__.py | 1 +
portal_partner_manager/controllers/portal.py | 903 +++
portal_partner_manager/doc/Spécifications.md | 302 +
portal_partner_manager/doc/js.md | 498 ++
portal_partner_manager/doc/models.md | 132 +
portal_partner_manager/models/__init__.py | 5 +
.../models/portal_access.py | 126 +
.../models/portal_logging_mixin.py | 118 +
portal_partner_manager/models/portal_mixin.py | 127 +
portal_partner_manager/models/res_partner.py | 195 +
portal_partner_manager/models/res_users.py | 88 +
.../security/ir.model.access.csv | 7 +
.../security/portal_partner_manager_rules.xml | 13 +
.../security/portal_security.xml | 103 +
.../static/src/img/company.svg | 40 +
.../static/src/img/contacts.svg | 45 +
.../static/src/js/debug_tools.js | 247 +
.../static/src/js/jquery_early_fix.js | 95 +
.../static/src/js/jquery_safety.js | 210 +
.../static/src/js/portal_fix.js | 50 +
.../static/src/js/portal_partner.js | 330 ++
.../static/src/js/portal_partner_utils.js | 69 +
.../static/src/scss/portal_partner.scss | 65 +
portal_partner_manager/tests/__init__.py | 1 +
.../tests/test_email_login_sync.py | 41 +
.../tests/test_portal_partner.py | 147 +
portal_partner_manager/todo.md | 19 +
.../views/portal_activity_log_views.xml | 115 +
.../views/portal_archive_contact_confirm.xml | 38 +
.../views/portal_company_templates.xml | 392 ++
.../views/portal_contact_templates.xml | 577 ++
.../views/portal_menu_templates.xml | 49 +
.../views/portal_set_password.xml | 80 +
.../views/portal_sibling_templates.xml | 538 ++
.../views/res_partner_views.xml | 155 +
st_laurent_portal_vendor/__init__.py | 5 +
st_laurent_portal_vendor/__manifest__.py | 72 +
.../controllers/__init__.py | 3 +
.../controllers/portal.py | 441 ++
.../controllers/vendor_categories.py | 95 +
.../controllers/vendor_portal.py | 218 +
.../controllers/vendor_shop_controller.py | 29 +
.../data/mail_template_vendor_request_ack.xml | 19 +
.../mail_template_vendor_request_approved.xml | 22 +
.../mail_template_vendor_request_rejected.xml | 22 +
.../data/vendor_request_sequence.xml | 13 +
.../doc/Odoo-Enterprise.md | 244 +
.../doc/Odoo-Federateur.md | 228 +
st_laurent_portal_vendor/doc/Odoo-Local.md | 204 +
st_laurent_portal_vendor/doc/Odoo-Module.md | 115 +
st_laurent_portal_vendor/doc/analyse.md | 278 +
.../doc/portal_vendor_modules_analysis.md | 71 +
.../doc/projet-st-laurent.md | 895 +++
st_laurent_portal_vendor/models/__init__.py | 8 +
.../models/res_config_settings.py | 95 +
.../models/res_partner.py | 92 +
st_laurent_portal_vendor/models/res_users.py | 43 +
.../models/vendor_config.py | 68 +
.../models/vendor_product.py | 296 +
.../models/vendor_request.py | 259 +
.../models/vendor_shop.py | 34 +
.../security/ir.model.access.csv | 11 +
.../security/security.xml | 52 +
.../static/description/icon.png | Bin 0 -> 72686 bytes
.../static/description/icon.svg | 45 +
.../static/lib/cropperjs/cropper.min.css | 9 +
.../static/lib/cropperjs/cropper.min.js | 10 +
.../static/src/img/icon.svg | 48 +
.../static/src/js/image_cropper.js | 148 +
.../static/src/js/image_cropper_simple.js | 143 +
.../static/src/scss/image_cropper.scss | 56 +
.../src/scss/st_laurent_portal_vendor.scss | 48 +
.../views/portal_home_vendor_banner.xml | 47 +
.../views/portal_menu_templates.xml | 65 +
.../views/portal_templates.xml | 335 ++
.../views/portal_vendor_home_template.xml | 151 +
.../views/portal_vendor_request_templates.xml | 487 ++
.../views/res_config_settings_views.xml | 89 +
.../views/res_partner_views.xml | 53 +
.../views/res_users_views.xml | 31 +
.../views/vendor_config_views.xml | 89 +
.../views/vendor_menu.xml | 11 +
.../views/vendor_portal_templates.xml | 152 +
.../views/vendor_product_action.xml | 37 +
.../vendor_product_categories_templates.xml | 150 +
.../views/vendor_product_portal_templates.xml | 130 +
.../views/vendor_product_views.xml | 158 +
.../views/vendor_request_views.xml | 138 +
.../views/vendor_shop_templates.xml | 52 +
st_laurent_portal_vendor/wizards/__init__.py | 3 +
.../wizards/vendor_request_reject_wizard.py | 37 +
.../wizards/vendor_request_reject_wizard.xml | 21 +
st_laurent_vendor_orders/__init__.py | 5 +
st_laurent_vendor_orders/__manifest__.py | 48 +
.../controllers/__init__.py | 3 +
.../controllers/portal.py | 132 +
.../controllers/vendor_shop_controller.py | 11 +
.../data/mail_templates.xml | 74 +
st_laurent_vendor_orders/models/__init__.py | 6 +
.../models/res_partner.py | 26 +
st_laurent_vendor_orders/models/sale_order.py | 79 +
.../models/vendor_order.py | 68 +
.../models/vendor_order_line.py | 33 +
.../security/ir.model.access.csv | 3 +
.../security/security.xml | 24 +
.../static/description/icon.png | Bin 0 -> 72686 bytes
.../static/description/icon.svg | 45 +
.../static/src/js/vendor_orders.js | 88 +
.../static/src/scss/vendor_orders.scss | 75 +
.../views/portal_menu_templates.xml | 34 +
.../views/vendor_order_portal_templates.xml | 227 +
.../views/vendor_order_views.xml | 164 +
.../views/vendor_shop_portal_template.xml | 22 +
st_laurent_vendor_orders/wizards/__init__.py | 2 +
unifi_integration/__manifest__.py | 2 +-
unifi_integration/doc/mixins.md | 195 +
unifi_integration/doc/todo.md | 345 +-
unifi_integration/models/__init__.py | 2 +
.../models/refactorisation_plan.md | 35 +
unifi_integration/models/unifi_api_log.py | 27 +
.../models/unifi_controller_api_mixin.py | 790 +++
unifi_integration/models/unifi_site.py | 4873 ++++-------------
unifi_integration/models/unifi_site.py.old | 3776 +++++++++++++
.../models/unifi_site_manager_api_mixin.py | 553 ++
unifi_integration/views/unifi_site_views.xml | 3 +
129 files changed, 20584 insertions(+), 4054 deletions(-)
create mode 100644 portal_partner_manager/README.md
create mode 100644 portal_partner_manager/__init__.py
create mode 100644 portal_partner_manager/__manifest__.py
create mode 100644 portal_partner_manager/architecture.md
create mode 100644 portal_partner_manager/controllers/__init__.py
create mode 100644 portal_partner_manager/controllers/portal.py
create mode 100644 portal_partner_manager/doc/Spécifications.md
create mode 100644 portal_partner_manager/doc/js.md
create mode 100644 portal_partner_manager/doc/models.md
create mode 100644 portal_partner_manager/models/__init__.py
create mode 100644 portal_partner_manager/models/portal_access.py
create mode 100644 portal_partner_manager/models/portal_logging_mixin.py
create mode 100644 portal_partner_manager/models/portal_mixin.py
create mode 100644 portal_partner_manager/models/res_partner.py
create mode 100644 portal_partner_manager/models/res_users.py
create mode 100644 portal_partner_manager/security/ir.model.access.csv
create mode 100644 portal_partner_manager/security/portal_partner_manager_rules.xml
create mode 100644 portal_partner_manager/security/portal_security.xml
create mode 100644 portal_partner_manager/static/src/img/company.svg
create mode 100644 portal_partner_manager/static/src/img/contacts.svg
create mode 100644 portal_partner_manager/static/src/js/debug_tools.js
create mode 100644 portal_partner_manager/static/src/js/jquery_early_fix.js
create mode 100644 portal_partner_manager/static/src/js/jquery_safety.js
create mode 100644 portal_partner_manager/static/src/js/portal_fix.js
create mode 100644 portal_partner_manager/static/src/js/portal_partner.js
create mode 100644 portal_partner_manager/static/src/js/portal_partner_utils.js
create mode 100644 portal_partner_manager/static/src/scss/portal_partner.scss
create mode 100644 portal_partner_manager/tests/__init__.py
create mode 100644 portal_partner_manager/tests/test_email_login_sync.py
create mode 100644 portal_partner_manager/tests/test_portal_partner.py
create mode 100644 portal_partner_manager/todo.md
create mode 100644 portal_partner_manager/views/portal_activity_log_views.xml
create mode 100644 portal_partner_manager/views/portal_archive_contact_confirm.xml
create mode 100644 portal_partner_manager/views/portal_company_templates.xml
create mode 100644 portal_partner_manager/views/portal_contact_templates.xml
create mode 100644 portal_partner_manager/views/portal_menu_templates.xml
create mode 100644 portal_partner_manager/views/portal_set_password.xml
create mode 100644 portal_partner_manager/views/portal_sibling_templates.xml
create mode 100644 portal_partner_manager/views/res_partner_views.xml
create mode 100644 st_laurent_portal_vendor/__init__.py
create mode 100644 st_laurent_portal_vendor/__manifest__.py
create mode 100644 st_laurent_portal_vendor/controllers/__init__.py
create mode 100644 st_laurent_portal_vendor/controllers/portal.py
create mode 100644 st_laurent_portal_vendor/controllers/vendor_categories.py
create mode 100644 st_laurent_portal_vendor/controllers/vendor_portal.py
create mode 100644 st_laurent_portal_vendor/controllers/vendor_shop_controller.py
create mode 100644 st_laurent_portal_vendor/data/mail_template_vendor_request_ack.xml
create mode 100644 st_laurent_portal_vendor/data/mail_template_vendor_request_approved.xml
create mode 100644 st_laurent_portal_vendor/data/mail_template_vendor_request_rejected.xml
create mode 100644 st_laurent_portal_vendor/data/vendor_request_sequence.xml
create mode 100644 st_laurent_portal_vendor/doc/Odoo-Enterprise.md
create mode 100644 st_laurent_portal_vendor/doc/Odoo-Federateur.md
create mode 100644 st_laurent_portal_vendor/doc/Odoo-Local.md
create mode 100644 st_laurent_portal_vendor/doc/Odoo-Module.md
create mode 100644 st_laurent_portal_vendor/doc/analyse.md
create mode 100644 st_laurent_portal_vendor/doc/portal_vendor_modules_analysis.md
create mode 100644 st_laurent_portal_vendor/doc/projet-st-laurent.md
create mode 100644 st_laurent_portal_vendor/models/__init__.py
create mode 100644 st_laurent_portal_vendor/models/res_config_settings.py
create mode 100644 st_laurent_portal_vendor/models/res_partner.py
create mode 100644 st_laurent_portal_vendor/models/res_users.py
create mode 100644 st_laurent_portal_vendor/models/vendor_config.py
create mode 100644 st_laurent_portal_vendor/models/vendor_product.py
create mode 100644 st_laurent_portal_vendor/models/vendor_request.py
create mode 100644 st_laurent_portal_vendor/models/vendor_shop.py
create mode 100644 st_laurent_portal_vendor/security/ir.model.access.csv
create mode 100644 st_laurent_portal_vendor/security/security.xml
create mode 100644 st_laurent_portal_vendor/static/description/icon.png
create mode 100644 st_laurent_portal_vendor/static/description/icon.svg
create mode 100644 st_laurent_portal_vendor/static/lib/cropperjs/cropper.min.css
create mode 100644 st_laurent_portal_vendor/static/lib/cropperjs/cropper.min.js
create mode 100644 st_laurent_portal_vendor/static/src/img/icon.svg
create mode 100644 st_laurent_portal_vendor/static/src/js/image_cropper.js
create mode 100644 st_laurent_portal_vendor/static/src/js/image_cropper_simple.js
create mode 100644 st_laurent_portal_vendor/static/src/scss/image_cropper.scss
create mode 100644 st_laurent_portal_vendor/static/src/scss/st_laurent_portal_vendor.scss
create mode 100644 st_laurent_portal_vendor/views/portal_home_vendor_banner.xml
create mode 100644 st_laurent_portal_vendor/views/portal_menu_templates.xml
create mode 100644 st_laurent_portal_vendor/views/portal_templates.xml
create mode 100644 st_laurent_portal_vendor/views/portal_vendor_home_template.xml
create mode 100644 st_laurent_portal_vendor/views/portal_vendor_request_templates.xml
create mode 100644 st_laurent_portal_vendor/views/res_config_settings_views.xml
create mode 100644 st_laurent_portal_vendor/views/res_partner_views.xml
create mode 100644 st_laurent_portal_vendor/views/res_users_views.xml
create mode 100644 st_laurent_portal_vendor/views/vendor_config_views.xml
create mode 100644 st_laurent_portal_vendor/views/vendor_menu.xml
create mode 100644 st_laurent_portal_vendor/views/vendor_portal_templates.xml
create mode 100644 st_laurent_portal_vendor/views/vendor_product_action.xml
create mode 100644 st_laurent_portal_vendor/views/vendor_product_categories_templates.xml
create mode 100644 st_laurent_portal_vendor/views/vendor_product_portal_templates.xml
create mode 100644 st_laurent_portal_vendor/views/vendor_product_views.xml
create mode 100644 st_laurent_portal_vendor/views/vendor_request_views.xml
create mode 100644 st_laurent_portal_vendor/views/vendor_shop_templates.xml
create mode 100644 st_laurent_portal_vendor/wizards/__init__.py
create mode 100644 st_laurent_portal_vendor/wizards/vendor_request_reject_wizard.py
create mode 100644 st_laurent_portal_vendor/wizards/vendor_request_reject_wizard.xml
create mode 100644 st_laurent_vendor_orders/__init__.py
create mode 100644 st_laurent_vendor_orders/__manifest__.py
create mode 100644 st_laurent_vendor_orders/controllers/__init__.py
create mode 100644 st_laurent_vendor_orders/controllers/portal.py
create mode 100644 st_laurent_vendor_orders/controllers/vendor_shop_controller.py
create mode 100644 st_laurent_vendor_orders/data/mail_templates.xml
create mode 100644 st_laurent_vendor_orders/models/__init__.py
create mode 100644 st_laurent_vendor_orders/models/res_partner.py
create mode 100644 st_laurent_vendor_orders/models/sale_order.py
create mode 100644 st_laurent_vendor_orders/models/vendor_order.py
create mode 100644 st_laurent_vendor_orders/models/vendor_order_line.py
create mode 100644 st_laurent_vendor_orders/security/ir.model.access.csv
create mode 100644 st_laurent_vendor_orders/security/security.xml
create mode 100644 st_laurent_vendor_orders/static/description/icon.png
create mode 100644 st_laurent_vendor_orders/static/description/icon.svg
create mode 100644 st_laurent_vendor_orders/static/src/js/vendor_orders.js
create mode 100644 st_laurent_vendor_orders/static/src/scss/vendor_orders.scss
create mode 100644 st_laurent_vendor_orders/views/portal_menu_templates.xml
create mode 100644 st_laurent_vendor_orders/views/vendor_order_portal_templates.xml
create mode 100644 st_laurent_vendor_orders/views/vendor_order_views.xml
create mode 100644 st_laurent_vendor_orders/views/vendor_shop_portal_template.xml
create mode 100644 st_laurent_vendor_orders/wizards/__init__.py
create mode 100644 unifi_integration/doc/mixins.md
create mode 100644 unifi_integration/models/refactorisation_plan.md
create mode 100644 unifi_integration/models/unifi_controller_api_mixin.py
create mode 100644 unifi_integration/models/unifi_site.py.old
create mode 100644 unifi_integration/models/unifi_site_manager_api_mixin.py
diff --git a/portal_partner_manager/README.md b/portal_partner_manager/README.md
new file mode 100644
index 0000000..a8c7ee5
--- /dev/null
+++ b/portal_partner_manager/README.md
@@ -0,0 +1,118 @@
+# Module Portal Partner Manager
+
+## Introduction
+
+Le module Portal Partner Manager est une extension pour Odoo 18.0 Enterprise qui permet aux utilisateurs du portail de modifier les informations de leur société parente et d'ajouter de nouveaux contacts à cette société. Cette fonctionnalité n'est pas disponible dans Odoo standard, car les utilisateurs du portail n'ont normalement que des droits de lecture.
+
+## Fonctionnalités principales
+
+- **Modification des informations de la société parente** : Les utilisateurs du portail peuvent modifier les informations générales de leur société parente (nom, adresse, téléphone, email, etc.).
+- **Visualisation des contacts** : Les utilisateurs du portail peuvent voir la liste des contacts existants de leur société parente.
+- **Ajout de nouveaux contacts** : Les utilisateurs du portail peuvent ajouter de nouveaux contacts à leur société parente, avec validation de l'email.
+- **Tracking des modifications** : Toutes les modifications effectuées par les utilisateurs du portail sont tracées et journalisées.
+- **Configuration des accès** : Les administrateurs peuvent configurer quels utilisateurs du portail peuvent modifier quelles sociétés et quels champs.
+
+## Installation
+
+1. Téléchargez le module et placez-le dans le répertoire des modules additionnels d'Odoo.
+2. Mettez à jour la liste des modules dans Odoo.
+3. Installez le module "Portal Partner Manager".
+4. Configurez les accès portail pour les sociétés concernées.
+
+## Configuration
+
+### Configuration des accès portail
+
+1. Accédez à **Contacts > Accès portail > Configurations d'accès**.
+2. Créez une nouvelle configuration d'accès :
+ - Sélectionnez la société pour laquelle vous souhaitez configurer l'accès.
+ - Cochez "Autoriser modification" si vous souhaitez que les utilisateurs du portail puissent modifier les informations de cette société.
+ - Cochez "Autoriser ajout de contacts" si vous souhaitez que les utilisateurs du portail puissent ajouter de nouveaux contacts à cette société.
+ - Sélectionnez les utilisateurs du portail qui auront accès à cette configuration.
+ - Optionnellement, sélectionnez les champs spécifiques que les utilisateurs du portail sont autorisés à modifier.
+
+### Configuration au niveau de la société
+
+Vous pouvez également configurer l'accès portail directement depuis la fiche de la société :
+1. Accédez à la fiche de la société.
+2. Allez dans l'onglet "Accès portail".
+3. Cochez ou décochez "Autoriser modification par portail" selon vos besoins.
+
+## Utilisation
+
+### Pour les utilisateurs du portail
+
+1. Connectez-vous au portail Odoo.
+2. Accédez à la section "Ma société" depuis le tableau de bord du portail.
+3. Visualisez les informations de votre société parente.
+4. Cliquez sur "Modifier" pour mettre à jour les informations de la société.
+5. Accédez à la section "Contacts" pour voir la liste des contacts existants.
+6. Cliquez sur "Ajouter un contact" pour créer un nouveau contact pour votre société.
+
+### Pour les administrateurs
+
+1. Accédez à **Contacts > Accès portail > Configurations d'accès** pour gérer les configurations d'accès.
+2. Accédez à **Contacts > Accès portail > Journaux d'accès** pour consulter l'historique des actions effectuées par les utilisateurs du portail.
+
+## Sécurité
+
+Le module implémente plusieurs niveaux de sécurité :
+
+1. **Règles d'accès** : Les utilisateurs du portail ne peuvent accéder qu'à leur propre société parente et aux contacts associés.
+2. **Validation des données** : Les données saisies par les utilisateurs du portail sont validées avant d'être enregistrées.
+3. **Journalisation** : Toutes les actions effectuées par les utilisateurs du portail sont journalisées pour audit.
+4. **Configuration granulaire** : Les administrateurs peuvent configurer précisément quels utilisateurs peuvent modifier quelles sociétés et quels champs.
+
+## Modèles de données
+
+### res.partner (Extension)
+
+Le module étend le modèle `res.partner` pour ajouter les champs suivants :
+- `portal_last_update` : Date de la dernière mise à jour effectuée par un utilisateur du portail.
+- `portal_updated_by` : Utilisateur du portail qui a effectué la dernière mise à jour.
+- `allow_portal_parent_edit` : Si coché, les utilisateurs du portail associés à des contacts de cette société peuvent modifier ses informations.
+
+### portal.access
+
+Ce modèle gère les configurations d'accès portail :
+- `name` : Nom de la configuration.
+- `partner_id` : Société pour laquelle configurer l'accès portail.
+- `allow_edit` : Si coché, les utilisateurs du portail peuvent modifier les informations de cette société.
+- `allow_add_contacts` : Si coché, les utilisateurs du portail peuvent ajouter de nouveaux contacts à cette société.
+- `allowed_fields_ids` : Champs que les utilisateurs du portail sont autorisés à modifier.
+- `portal_user_ids` : Utilisateurs du portail qui ont accès à cette configuration.
+- `log_ids` : Journaux d'accès associés à cette configuration.
+
+### portal.access.log
+
+Ce modèle enregistre les actions effectuées par les utilisateurs du portail :
+- `access_id` : Configuration d'accès associée.
+- `user_id` : Utilisateur qui a effectué l'action.
+- `action` : Type d'action (consultation, modification, ajout de contact).
+- `details` : Détails de l'action.
+- `create_date` : Date de l'action.
+- `partner_id` : Société concernée par l'action.
+
+## Développement technique
+
+### Architecture
+
+Le module suit une architecture MVC (Modèle-Vue-Contrôleur) :
+- **Modèles** : Extension de `res.partner` et nouveaux modèles `portal.access` et `portal.access.log`.
+- **Vues** : Vues backend pour la configuration et templates frontend pour le portail.
+- **Contrôleurs** : Extension du contrôleur de portail pour ajouter de nouvelles routes.
+
+### Surmonter les limitations du portail
+
+Par défaut, les utilisateurs du portail n'ont que des droits de lecture. Pour surmonter cette limitation, le module utilise plusieurs approches :
+1. Extension des contrôleurs de portail pour gérer les opérations d'écriture.
+2. Utilisation de méthodes avec `sudo()` contrôlées par des règles de sécurité strictes.
+3. Implémentation de règles d'enregistrement (record rules) spécifiques.
+
+## Support et maintenance
+
+Pour toute question ou problème concernant ce module, veuillez contacter l'équipe de support Odoo.
+
+## Licence
+
+Ce module est distribué sous licence LGPL-3.
diff --git a/portal_partner_manager/__init__.py b/portal_partner_manager/__init__.py
new file mode 100644
index 0000000..f7209b1
--- /dev/null
+++ b/portal_partner_manager/__init__.py
@@ -0,0 +1,2 @@
+from . import models
+from . import controllers
diff --git a/portal_partner_manager/__manifest__.py b/portal_partner_manager/__manifest__.py
new file mode 100644
index 0000000..75ebb8f
--- /dev/null
+++ b/portal_partner_manager/__manifest__.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# Portal Partner Manager Module Manifest
+{
+ "name": "Portal Partner Manager",
+ "version": "18.0.1.0.0",
+ "category": "Website/Website",
+ "summary": "Allows portal users to edit their parent company and add contacts",
+ "description": """
+ This module allows portal users to modify their parent company and add contacts from the portal. It includes advanced access management, company edit restrictions, and contact management directly from the portal interface.
+ """,
+ "author": "Odoo SA",
+ "website": "https://www.odoo.com",
+ "depends": [
+ "base",
+ "portal",
+ "contacts",
+ "mail",
+ "web_editor",
+ "website",
+ "website_mail",
+ "portal_rating",
+ "http_routing"
+ ],
+ "data": [
+ "security/portal_security.xml",
+ "security/ir.model.access.csv",
+ "security/portal_partner_manager_rules.xml",
+ "views/res_partner_views.xml",
+ "views/portal_company_templates.xml",
+ "views/portal_contact_templates.xml",
+ #"views/portal_sibling_templates.xml",
+ "views/portal_menu_templates.xml",
+ "views/portal_set_password.xml",
+ "views/portal_archive_contact_confirm.xml",
+ "views/portal_activity_log_views.xml"
+ ],
+ "assets": {
+ "web.assets_frontend": [
+
+ ]
+ },
+ "installable": True,
+ "application": False,
+ "auto_install": False,
+ "license": "LGPL-3",
+}
diff --git a/portal_partner_manager/architecture.md b/portal_partner_manager/architecture.md
new file mode 100644
index 0000000..555da30
--- /dev/null
+++ b/portal_partner_manager/architecture.md
@@ -0,0 +1,106 @@
+# Architecture du Module Portal Partner Manager
+
+## Vue d'ensemble
+
+Le module Portal Partner Manager permettra aux utilisateurs du portail de modifier les informations de leur société parente et d'ajouter de nouveaux contacts à cette société. Cette fonctionnalité n'est pas disponible dans Odoo standard, car les utilisateurs du portail n'ont normalement que des droits de lecture.
+
+## Structure du module
+
+```
+portal_partner_manager/
+├── __init__.py
+├── __manifest__.py
+├── models/
+│ ├── __init__.py
+│ ├── res_partner.py
+│ └── portal_access.py
+├── controllers/
+│ ├── __init__.py
+│ └── portal.py
+├── views/
+│ ├── res_partner_views.xml
+│ └── portal_templates.xml
+├── security/
+│ ├── ir.model.access.csv
+│ └── portal_security.xml
+└── static/
+ ├── src/
+ │ └── js/
+ │ └── portal_partner.js
+ └── src/
+ └── scss/
+ └── portal_partner.scss
+```
+
+## Composants principaux
+
+### 1. Extension du modèle res.partner
+
+Nous allons étendre le modèle `res.partner` pour ajouter des fonctionnalités spécifiques :
+
+- Ajout d'un champ pour suivre les modifications effectuées par les utilisateurs du portail
+- Surcharge des méthodes de contrôle d'accès pour permettre aux utilisateurs du portail de modifier certains champs
+- Implémentation d'un mécanisme de validation si nécessaire
+
+### 2. Modèle portal_access
+
+Nous créerons un nouveau modèle `portal.access` pour gérer les droits d'accès spécifiques :
+
+- Définition des champs que les utilisateurs du portail peuvent modifier
+- Configuration des règles d'accès par utilisateur ou groupe d'utilisateurs
+- Journalisation des modifications pour l'audit
+
+### 3. Contrôleur de portail
+
+Nous étendrons le contrôleur de portail existant pour ajouter de nouvelles routes :
+
+- Route pour afficher et modifier les informations de la société parente
+- Route pour afficher la liste des contacts existants
+- Route pour ajouter de nouveaux contacts
+- Gestion des formulaires et validation des données
+
+### 4. Templates de portail
+
+Nous créerons de nouveaux templates pour l'interface utilisateur :
+
+- Template pour afficher et modifier les informations de la société
+- Template pour afficher la liste des contacts
+- Formulaire pour ajouter de nouveaux contacts
+- Messages de confirmation et notifications
+
+### 5. Règles de sécurité
+
+Nous implémenterons des règles de sécurité strictes :
+
+- Règles d'accès pour limiter les modifications aux seules sociétés parentes de l'utilisateur
+- Validation des données pour éviter les modifications non autorisées
+- Journalisation des modifications pour l'audit
+
+## Flux utilisateur
+
+1. L'utilisateur du portail se connecte à son compte
+2. Il accède à une nouvelle section "Ma société" dans le portail
+3. Il peut voir et modifier les informations générales de sa société parente
+4. Il peut voir la liste des contacts existants de sa société
+5. Il peut ajouter de nouveaux contacts à sa société, avec validation de l'email
+
+## Considérations techniques
+
+### Surmonter les limitations du portail
+
+Par défaut, les utilisateurs du portail n'ont que des droits de lecture. Pour surmonter cette limitation, nous utiliserons plusieurs approches :
+
+1. Extension des contrôleurs de portail pour gérer les opérations d'écriture
+2. Utilisation de méthodes avec `sudo()` contrôlées par des règles de sécurité strictes
+3. Implémentation de règles d'enregistrement (record rules) spécifiques
+
+### Tracking des modifications
+
+Tous les champs modifiables auront l'attribut `tracking=True` comme demandé, ce qui permettra de suivre toutes les modifications apportées par les utilisateurs du portail.
+
+### Validation des données
+
+Nous implémenterons une validation stricte des données, notamment :
+- Validation de l'email pour les nouveaux contacts
+- Vérification que l'utilisateur ne modifie que sa propre société parente
+- Contrôle des champs autorisés à la modification
diff --git a/portal_partner_manager/controllers/__init__.py b/portal_partner_manager/controllers/__init__.py
new file mode 100644
index 0000000..8c3feb6
--- /dev/null
+++ b/portal_partner_manager/controllers/__init__.py
@@ -0,0 +1 @@
+from . import portal
diff --git a/portal_partner_manager/controllers/portal.py b/portal_partner_manager/controllers/portal.py
new file mode 100644
index 0000000..eabe8ce
--- /dev/null
+++ b/portal_partner_manager/controllers/portal.py
@@ -0,0 +1,903 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from odoo import http, _
+from odoo.http import request
+from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
+from odoo.exceptions import AccessError, ValidationError
+from odoo.osv import expression
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class PortalPartnerController(CustomerPortal):
+
+ def _log_portal_activity(self, record, action, details, user_id=None):
+ """
+ Méthode utilitaire pour journaliser les activités du portail avec l'adresse IP
+
+ :param record: Enregistrement sur lequel journaliser l'activité
+ :param action: Type d'action (view, edit, create, etc.)
+ :param details: Détails de l'action
+ :param user_id: ID de l'utilisateur (par défaut, l'utilisateur connecté)
+ :return: L'entrée de journal créée
+ """
+ if not user_id:
+ user_id = request.env.user.id
+
+ # Récupérer l'adresse IP
+ ip = request.httprequest.remote_addr
+
+ return record.sudo().log_portal_activity(
+ user_id=user_id,
+ action=action,
+ details=details,
+ ip=ip
+ )
+
+ def _prepare_portal_layout_values(self):
+ """
+ Add values to the portal layout
+ """
+ values = super(PortalPartnerController, self)._prepare_portal_layout_values()
+
+ # Get the connected user's partner
+ partner = request.env.user.partner_id
+
+ # Add a variable to know if the user has a parent company
+ values['has_company'] = bool(partner.parent_id)
+
+ # Check if the partner has a parent company
+ parent_company = partner.parent_id
+ if parent_company and parent_company.is_company:
+ values['parent_company'] = parent_company
+
+ # Count the number of contacts
+ contact_count = len(parent_company.get_portal_children())
+ values['contact_count'] = contact_count
+
+ Partner = request.env['res.partner']
+
+ return values
+
+ @http.route(['/my/company'], type='http', auth="user", website=True)
+ def portal_my_company(self, **kw):
+ """
+ Display the parent company information
+ """
+ values = self._prepare_portal_layout_values()
+
+ # Check if the user has a parent company
+ partner = request.env.user.partner_id
+ parent_company = partner.parent_id
+
+ if not parent_company or not parent_company.is_company:
+ return request.redirect('/my')
+
+ # Check if the user has the right to edit the company
+ can_edit = parent_company.allow_portal_parent_edit
+ values['can_edit'] = can_edit
+
+ # Get the company information
+ values['company'] = parent_company
+
+ # Get countries for the selection fields
+ countries = request.env['res.country'].sudo().search([])
+ # Get ALL states (will be filtered in the template by country)
+ states = request.env['res.country.state'].sudo().search([])
+ # Log country for debugging
+ if parent_company.country_id:
+ _logger.info("%s found: %r ", parent_company.country_id.name, parent_company.country_id)
+ # Also log the provinces of that country
+ provinces = states.filtered(lambda s: s.country_id == parent_company.country_id)
+ _logger.info("Provinces found: %r", provinces)
+ values.update({
+ 'countries': countries,
+ 'states': states,
+ })
+
+ # Get contacts for the kanban view (including archived ones)
+ Contact = request.env['res.partner']
+ base_domain = [('parent_id', '=', parent_company.id)]
+
+ # Include archived contacts
+ base_domain = expression.AND([base_domain, ['|', ('active', '=', True), ('active', '=', False)]])
+
+ # Get contacts (type='contact')
+ contact_domain = expression.AND([base_domain, [('type', '=', 'contact')]])
+ contacts = Contact.search(contact_domain, order='name asc')
+
+ # Get other addresses (type != 'contact')
+ address_domain = expression.AND([base_domain, [('type', '!=', 'contact')]])
+ addresses = Contact.search(address_domain, order='name asc')
+
+ values.update({
+ 'contacts': contacts,
+ 'addresses': addresses,
+ })
+
+ # Journaliser l'accès à la société directement sur l'objet partenaire
+ self._log_portal_activity(
+ record=parent_company,
+ action='view',
+ details='User viewed company information via portal'
+ )
+
+ return request.render("portal_partner_manager.portal_my_company", values)
+
+ @http.route(['/my/company/edit'], type='http', auth="user", website=True)
+ def portal_my_company_edit(self, **kw):
+ """
+ Display the parent company edit form
+ """
+ values = self._prepare_portal_layout_values()
+
+ # Check if the user has a parent company
+ partner = request.env.user.partner_id
+ parent_company = partner.parent_id
+
+ if not parent_company or not parent_company.is_company:
+ return request.redirect('/my')
+
+ # Check if the user has the right to edit the company
+ if not parent_company.allow_portal_parent_edit:
+ return request.redirect('/my/company')
+
+ # Get the company information
+ values['company'] = parent_company
+
+ # Get countries for the selection fields
+ countries = request.env['res.country'].sudo().search([])
+ # Get ALL states (will be filtered in the template by country)
+ states = request.env['res.country.state'].sudo().search([])
+ # Log country for debugging
+ if parent_company.country_id:
+ _logger.info("%s found: %r ", parent_company.country_id.name, parent_company.country_id)
+ # Also log the provinces of that country
+ provinces = states.filtered(lambda s: s.country_id == parent_company.country_id)
+ _logger.info("Provinces found: %r", provinces)
+ values.update({
+ 'countries': countries,
+ 'states': states,
+ })
+
+ return request.render("portal_partner_manager.portal_my_company_edit", values)
+
+ @http.route(['/my/company/update'], type='http', auth="user", methods=['POST'], website=True)
+ def portal_my_company_update(self, **kw):
+ """
+ Update the parent company information
+ """
+ # Check if the user has a parent company
+ partner = request.env.user.partner_id
+ parent_company = partner.parent_id
+
+ if not parent_company or not parent_company.is_company:
+ return request.redirect('/my')
+
+ # Check if the user has the right to edit the company
+ if not parent_company.allow_portal_parent_edit:
+ return request.redirect('/my/company')
+
+ # Prepare the values to update
+ vals = {}
+ allowed_fields = parent_company._get_portal_allowed_fields()
+
+ for field in allowed_fields:
+ if field in kw:
+ # Special handling for many2one fields
+ if field in ['state_id', 'country_id']:
+ if kw[field] and kw[field].isdigit():
+ vals[field] = int(kw[field])
+ else:
+ vals[field] = kw[field]
+
+ # Update the company
+ try:
+ parent_company.write(vals)
+
+ # Journaliser la modification directement sur l'objet partenaire
+ details = ', '.join([f"{field}: {vals[field]}" for field in vals])
+ self._log_portal_activity(
+ record=parent_company,
+ action='edit',
+ details=f"Company information updated: {', '.join([f'{field}: {vals[field]}' for field in vals])}"
+ )
+
+ return request.redirect('/my/company?update=success')
+ except Exception as e:
+ _logger.error("Error while updating company: %s", str(e))
+ return request.redirect('/my/company/edit?error=1')
+
+ @http.route(['/my/contacts', '/my/contacts/page/'], type='http', auth="user", website=True)
+ def portal_my_contacts(self, page=1, date_begin=None, date_end=None, sortby=None, **kw):
+ """
+ Display the list of contacts for the parent company
+ """
+ values = self._prepare_portal_layout_values()
+
+ # Check if the user has a parent company
+ partner = request.env.user.partner_id
+ parent_company = partner.parent_id
+
+ if not parent_company or not parent_company.is_company:
+ return request.redirect('/my')
+
+ # Get contacts (including archived ones)
+ Contact = request.env['res.partner']
+ domain = [('parent_id', '=', parent_company.id)]
+
+ # Include archived contacts
+ domain = expression.AND([domain, ['|', ('active', '=', True), ('active', '=', False)]])
+
+ # Count the total number of contacts
+ contact_count = Contact.search_count(domain)
+
+ # Pagination
+ pager = portal_pager(
+ url="/my/contacts",
+ url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby},
+ total=contact_count,
+ page=page,
+ step=self._items_per_page
+ )
+
+ # Sorting
+ if sortby == 'name':
+ order = 'name asc'
+ elif sortby == 'date':
+ order = 'create_date desc'
+ else:
+ order = 'name asc'
+
+ # Search contacts with pagination
+ contacts = Contact.search(
+ domain,
+ order=order,
+ limit=self._items_per_page,
+ offset=pager['offset']
+ )
+
+ values.update({
+ 'contacts': contacts,
+ 'page_name': 'contacts',
+ 'pager': pager,
+ 'default_url': '/my/contacts',
+ 'sortby': sortby,
+ 'can_add_contact': parent_company.allow_portal_parent_edit,
+ })
+
+ return request.render("portal_partner_manager.portal_my_contacts", values)
+
+ @http.route(['/my/contacts/add'], type='http', auth="user", website=True)
+ def portal_my_contacts_add(self, **kw):
+ """
+ Display the form to add a new contact
+ """
+ values = self._prepare_portal_layout_values()
+
+ # Check if the user has a parent company
+ partner = request.env.user.partner_id
+ parent_company = partner.parent_id
+
+ if not parent_company or not parent_company.is_company:
+ return request.redirect('/my')
+
+ # Check if the user has the right to add contacts
+ if not parent_company.allow_portal_parent_edit:
+ return request.redirect('/my/contacts')
+
+ # Get countries for selection fields
+ countries = request.env['res.country'].sudo().search([])
+
+ # Get only Canadian provinces
+ canada = request.env['res.country'].sudo().search([('code', '=', 'CA')], limit=1)
+ _logger.info('Canada found: %s', canada)
+ canadian_provinces = request.env['res.country.state'].sudo().search([('country_id', '=', canada.id)])
+ _logger.info('Provinces found: %s', canadian_provinces)
+
+ values.update({
+ 'countries': countries,
+ 'canadian_provinces': canadian_provinces,
+ 'company': parent_company,
+ })
+
+ return request.render("portal_partner_manager.portal_my_contacts_add", values)
+
+ @http.route(['/my/contacts/create'], type='http', auth="user", methods=['POST'], website=True)
+ def portal_my_contacts_create(self, **kw):
+ """
+ Create a new contact for the parent company
+ """
+ # Check if the user has a parent company
+ partner = request.env.user.partner_id
+ parent_company = partner.parent_id
+
+ if not parent_company or not parent_company.is_company:
+ return request.redirect('/my')
+
+ # Check if the user has the right to add contacts
+ if not parent_company.allow_portal_parent_edit:
+ return request.redirect('/my/contacts')
+
+ # Check if email is provided
+ if not kw.get('email'):
+ return request.redirect('/my/contacts/add?error=email_required')
+
+ # Prepare values for creation
+ vals = {}
+ allowed_fields = parent_company._get_portal_allowed_fields()
+
+ for field in allowed_fields:
+ if field in kw:
+ # Special handling for many2one fields
+ if field in ['state_id', 'country_id']:
+ if kw[field] and kw[field].isdigit():
+ vals[field] = int(kw[field])
+ else:
+ vals[field] = kw[field]
+
+ # Handle contact type
+ if 'type' in kw and kw.get('type') in ['contact', 'invoice', 'delivery', 'origin', 'other']:
+ vals['type'] = kw.get('type')
+ else:
+ vals['type'] = 'contact' # Default type
+
+ # Validate email for contact type
+ if vals.get('type') == 'contact' and not vals.get('email'):
+ return request.redirect('/my/company?error_message=' + _('Email is required for Contact type'))
+
+ # Create the contact
+ try:
+ new_contact = parent_company.create_portal_contact(parent_company.id, vals)
+
+ # Journaliser l'ajout directement sur la société parente
+ details = f"New contact: {new_contact.name} ({new_contact.email})"
+ self._log_portal_activity(
+ record=parent_company,
+ action='create',
+ details=details
+ )
+
+ # Journaliser également sur le nouveau contact
+ self._log_portal_activity(
+ record=new_contact,
+ action='create',
+ details=f"Contact created under {parent_company.name}"
+ )
+
+ return request.redirect('/my/company?create=success')
+ except Exception as e:
+ _logger.error("Error while creating contact: %s", str(e))
+ return request.redirect('/my/contacts/add?error=1')
+
+ @http.route(['/my/contacts/edit/'], type='http', auth="user", website=True)
+ def portal_my_contacts_edit(self, contact_id, **kw):
+ """
+ Display the form to edit an existing contact
+ """
+ values = self._prepare_portal_layout_values()
+
+ # Check if the user has a parent company
+ partner = request.env.user.partner_id
+ parent_company = partner.parent_id
+
+ if not parent_company or not parent_company.is_company:
+ return request.redirect('/my')
+
+ # Check if the user has the right to edit contacts
+ if not parent_company.allow_portal_parent_edit:
+ return request.redirect('/my/contacts')
+
+ # Get the contact to edit
+ Contact = request.env['res.partner']
+ contact = Contact.sudo().browse(contact_id)
+
+ # Check that the contact belongs to the parent company
+ if not contact.exists() or contact.parent_id.id != parent_company.id:
+ return request.redirect('/my/contacts')
+
+ # Get countries for selection fields
+ countries = request.env['res.country'].sudo().search([])
+
+ # Get only Canadian provinces
+ canada = request.env['res.country'].sudo().search([('code', '=', 'CA')], limit=1)
+ _logger.info('Canada found: %s', canada)
+ canadian_provinces = request.env['res.country.state'].sudo().search([('country_id', '=', canada.id)])
+ _logger.info('Provinces found: %s', canadian_provinces)
+
+ values.update({
+ 'contact': contact,
+ 'countries': countries,
+ 'canadian_provinces': canadian_provinces,
+ 'company': parent_company,
+ })
+
+ return request.render("portal_partner_manager.portal_my_contacts_edit", values)
+
+ # Helper method to check contact management rights
+ def _check_contact_management_rights(self):
+ """Check if the user has the right to manage contacts"""
+ partner = request.env.user.partner_id
+ parent_company = partner.commercial_partner_id
+
+ if not parent_company.allow_portal_parent_edit:
+ return False
+ return parent_company
+
+ @http.route(['/my/contacts/grant_access/'], type='http', auth="user", website=True)
+ def portal_my_contacts_grant_access(self, contact_id=None, **kw):
+ """Grant portal access to a contact"""
+ if not contact_id:
+ return request.redirect('/my/company')
+
+ # Check management rights
+ parent_company = self._check_contact_management_rights()
+ if not parent_company:
+ return request.redirect('/my/company?error_message=' + _('You do not have permission to manage contacts'))
+
+ contact = request.env['res.partner'].sudo().browse(contact_id)
+ if not contact.exists() or contact.parent_id != request.env.user.partner_id.commercial_partner_id:
+ return request.redirect('/my/company')
+
+ # Check if this is a contact (not an address)
+ if contact.type != 'contact':
+ return request.redirect('/my/company?error_message=' + _('Only contacts can be granted portal access'))
+
+ # Check if user already has portal access
+ if contact.user_ids:
+ # Reset password instead
+ try:
+ contact.user_ids[0].action_reset_password()
+ return request.redirect('/my/company?success_message=' + _('Password reset email sent'))
+ except Exception as e:
+ _logger.error("Error while resetting password: %s", str(e))
+ return request.redirect('/my/company?error_message=' + _('Failed to reset password'))
+
+ # Create portal user
+ try:
+ # Generate a random password
+ password = self._generate_password()
+
+ # Check if a user with this email already exists
+ Users = request.env['res.users']
+ existing_user = Users.sudo().search([('login', '=', contact.email)], limit=1)
+ archived_user = Users.sudo().search([('login', '=', contact.email), ('active', '=', False)], limit=1)
+
+ if existing_user and existing_user.active:
+ # Active user with same login exists
+ return request.redirect('/my/company?error_message=' + _('A user with this email already exists'))
+ elif archived_user:
+ # Reactivate the archived user and reassign it to this partner
+ user = archived_user
+ user.sudo().write({
+ 'active': True,
+ 'partner_id': contact.id,
+ 'email': contact.email,
+ 'name': contact.name,
+ 'groups_id': [(6, 0, [request.env.ref('base.group_portal').id])]
+ })
+ else:
+ # Create a new portal user
+ user_values = {
+ 'partner_id': contact.id,
+ 'login': contact.email,
+ 'email': contact.email,
+ 'name': contact.name,
+ 'groups_id': [(6, 0, [request.env.ref('base.group_portal').id])]
+ }
+ user = Users.sudo().create(user_values)
+
+ # Journaliser l'action sur le contact
+ details = f"Portal access granted to: {contact.name} ({contact.email})"
+ self._log_portal_activity(
+ record=contact,
+ action='grant_access',
+ details=details
+ )
+
+ # Journaliser également sur la société parente
+ self._log_portal_activity(
+ record=parent_company,
+ action='grant_access',
+ details=f"Granted portal access to contact: {contact.name}"
+ )
+
+ # Ajouter des détails supplémentaires dans le log du contact pour inclure l'information sur l'utilisateur
+ self._log_portal_activity(
+ record=contact,
+ action='create_user',
+ details=f"Portal user {user.login} created/updated for this contact"
+ )
+
+ # For new users, send an invitation email
+ # For reactivated users, redirect to set password page
+ is_new_user = not archived_user
+
+ if is_new_user:
+ # Send invitation email for new users
+ try:
+ user.sudo().with_context(create_user=True).action_reset_password()
+ return request.redirect('/my/contacts?success=invitation_sent')
+ except Exception as e:
+ _logger.error("Error sending invitation email: %s", str(e))
+ return request.redirect('/my/contacts?error=invitation_failed')
+ else:
+ # Redirect to set password page for reactivated users
+ return request.redirect(f'/my/contacts/set_password_form/{contact_id}')
+ except Exception as e:
+ _logger.error("Error while granting portal access: %s", str(e))
+ return request.redirect('/my/contacts?error=grant_access')
+
+ @http.route(['/my/contacts/set_password_form/'], type='http', auth="user", website=True)
+ def portal_my_contacts_set_password_form(self, contact_id, **kw):
+ """
+ Display the form to set a password for a contact's portal user
+ """
+ values = self._prepare_portal_layout_values()
+
+ # Check if the user has a parent company
+ partner = request.env.user.partner_id
+ parent_company = partner.parent_id
+
+ if not parent_company or not parent_company.is_company:
+ return request.redirect('/my')
+
+ # Check if the user has the right to manage contacts
+ if not parent_company.allow_portal_parent_edit:
+ return request.redirect('/my/contacts')
+
+ # Get the contact
+ Contact = request.env['res.partner']
+ contact = Contact.sudo().browse(contact_id)
+
+ # Check that the contact belongs to the parent company
+ if not contact.exists() or contact.parent_id.id != parent_company.id:
+ return request.redirect('/my/contacts')
+
+ # Check if the contact has a user
+ if not contact.user_ids:
+ return request.redirect('/my/contacts?error=no_user')
+
+ values.update({
+ 'contact': contact,
+ 'company': parent_company,
+ })
+
+ return request.render("portal_partner_manager.portal_set_password", values)
+
+ @http.route(['/my/contacts/set_password'], type='http', auth="user", methods=['POST'], website=True)
+ def portal_my_contacts_set_password(self, **kw):
+ """
+ Set a password for a contact's portal user
+ """
+ # Check if the user has a parent company
+ partner = request.env.user.partner_id
+ parent_company = partner.parent_id
+
+ if not parent_company or not parent_company.is_company:
+ return request.redirect('/my')
+
+ # Check if the user has the right to manage contacts
+ if not parent_company.allow_portal_parent_edit:
+ return request.redirect('/my/contacts')
+
+ # Get the contact ID from the form
+ contact_id = int(kw.get('contact_id', 0))
+ if not contact_id:
+ return request.redirect('/my/contacts')
+
+ # Get the contact
+ Contact = request.env['res.partner']
+ contact = Contact.sudo().browse(contact_id)
+
+ # Check that the contact belongs to the parent company
+ if not contact.exists() or contact.parent_id.id != parent_company.id:
+ return request.redirect('/my/contacts')
+
+ # Check if the contact has a user
+ if not contact.user_ids:
+ return request.redirect('/my/contacts?error=no_user')
+
+ # Get the passwords from the form
+ password = kw.get('password')
+ confirm_password = kw.get('confirm_password')
+
+ # Validate the passwords
+ if not password or len(password) < 8:
+ return request.redirect(f'/my/contacts/set_password_form/{contact_id}?error=password_too_short')
+
+ if password != confirm_password:
+ return request.redirect(f'/my/contacts/set_password_form/{contact_id}?error=password_mismatch')
+
+ try:
+ # Set the password
+ user = contact.user_ids[0]
+ user.sudo().write({'password': password})
+
+ # Journaliser l'action sur le contact
+ self._log_portal_activity(
+ record=contact,
+ action='edit',
+ details=f"Password set for associated user account"
+ )
+
+ return request.redirect('/my/contacts?password_set=success')
+ except Exception as e:
+ _logger.error("Error while setting password: %s", str(e))
+ return request.redirect(f'/my/contacts/set_password_form/{contact_id}?error=general')
+
+ @http.route(['/my/contacts/change_status/'], type='http', auth="user", website=True)
+ def portal_my_contacts_change_status(self, contact_id, status=None, **kw):
+ """
+ Change the status of a contact (Portal Access, No Access, Archived)
+
+ :param contact_id: ID of the contact to change status
+ :param status: New status (portal, no_access, archived)
+ """
+ # Check if the user has a parent company
+ partner = request.env.user.partner_id
+ parent_company = partner.parent_id
+
+ if not parent_company or not parent_company.is_company:
+ return request.redirect('/my')
+
+ # Check if the user has the right to manage contacts
+ if not parent_company.allow_portal_parent_edit:
+ return request.redirect('/my/contacts')
+
+ # Get the contact
+ Contact = request.env['res.partner']
+ contact = Contact.sudo().browse(contact_id)
+
+ # Check that the contact belongs to the parent company
+ if not contact.exists() or contact.parent_id.id != parent_company.id:
+ return request.redirect('/my/contacts')
+
+ # Validate the requested status
+ if status not in ['portal', 'no_access', 'archived']:
+ return request.redirect('/my/contacts')
+
+ try:
+ # Handle the status change based on the requested status
+ # Initialize variables for action details and redirect parameters
+ action_details = ""
+ redirect_params = ""
+
+ if status == 'portal':
+ # Grant portal access if not already granted
+ if not contact.user_ids:
+ # Check if email is available
+ if not contact.email:
+ return request.redirect('/my/contacts?error=email_required')
+
+ # Check if a user with this email already exists
+ Users = request.env['res.users']
+ existing_user = Users.sudo().search([('login', '=', contact.email)], limit=1)
+ if existing_user:
+ return request.redirect('/my/contacts?error=user_exists')
+
+ # Create the portal user
+ user_values = {
+ 'partner_id': contact.id,
+ 'login': contact.email,
+ 'email': contact.email,
+ 'name': contact.name,
+ 'groups_id': [(6, 0, [request.env.ref('base.group_portal').id])]
+ }
+ user = Users.sudo().create(user_values)
+
+ # Always send an invitation email for new portal users
+ try:
+ user.sudo().with_context(create_user=True).action_reset_password()
+ redirect_params = "status_change=portal_granted&invitation_sent=true"
+ except Exception as e:
+ _logger.error("Error sending invitation email: %s", str(e))
+ redirect_params = "status_change=portal_granted&invitation_failed=true"
+
+ # Ensure the contact is active
+ if not contact.active:
+ contact.sudo().write({'active': True})
+
+ # Log the action
+ action_details = "Portal access granted"
+ if not redirect_params:
+ redirect_params = "status_change=portal_granted"
+
+ elif status == 'no_access':
+ # Remove portal access if granted
+ if contact.user_ids:
+ # Only handle portal users, not internal users
+ portal_users = contact.sudo().user_ids.filtered(
+ lambda u: u.has_group('base.group_portal') and not u.has_group('base.group_user'))
+ if portal_users:
+ # Deactivate the users instead of deleting them to preserve history
+ portal_users.sudo().write({'active': False})
+
+ # Ensure the contact is active
+ if not contact.active:
+ contact.sudo().write({'active': True})
+
+ # Log the action
+ action_details = "Portal access removed"
+ redirect_params = "status_change=access_removed"
+
+ elif status == 'archived':
+ # Check if the contact has active users
+ if contact.user_ids:
+ active_users = contact.sudo().user_ids.filtered(lambda u: u.active)
+ if active_users:
+ # Deactivate the users
+ portal_users = active_users.filtered(
+ lambda u: u.has_group('base.group_portal') and not u.has_group('base.group_user'))
+ if portal_users:
+ portal_users.sudo().write({'active': False})
+
+ # Archive the contact
+ contact.sudo().write({'active': False})
+
+ # Log the action
+ action_details = "Contact archived"
+ redirect_params = "status_change=archived"
+
+ # Journaliser l'action sur le contact
+ details = f"{action_details}: {contact.name} ({contact.email})"
+ self._log_portal_activity(
+ record=contact,
+ action=status,
+ details=details
+ )
+
+ # Journaliser également sur la société parente
+ self._log_portal_activity(
+ record=parent_company,
+ action=status,
+ details=f"Contact status changed: {contact.name} to {status}"
+ )
+
+ # Si le statut est 'archived', vérifier et journaliser la désactivation des utilisateurs du portail
+ if status == 'archived':
+ # Rechercher directement les utilisateurs du portail qui ont été affectés
+ # sans dépendre de la variable portal_users définie ailleurs
+ affected_users = contact.sudo().user_ids.filtered(
+ lambda u: not u.active and u.has_group('base.group_portal') and not u.has_group('base.group_user')
+ )
+
+ # Journaliser pour chaque utilisateur affecté
+ for affected_user in affected_users:
+ contact.sudo().log_portal_activity(
+ user_id=request.env.user.id,
+ action='archive',
+ details=f"User {affected_user.name} deactivated due to contact archival"
+ )
+
+ return request.redirect(f'/my/contacts?{redirect_params}')
+
+ except Exception as e:
+ _logger.error("Error while changing contact status: %s", str(e))
+ return request.redirect('/my/contacts?error=status_change')
+
+ @http.route(['/my/contacts/archive/'], type='http', auth="user", website=True)
+ def portal_my_contacts_archive(self, contact_id, archive_user=None, **kw):
+ """
+ Archive or unarchive a contact (Legacy route, redirects to change_status)
+ """
+ # Redirect to the new change_status route
+ if archive_user == '1':
+ return request.redirect(f'/my/contacts/change_status/{contact_id}?status=archived')
+ else:
+ # Check the current status to determine the redirect
+ Contact = request.env['res.partner']
+ contact = Contact.sudo().browse(contact_id)
+
+ if contact.exists():
+ if contact.active:
+ return request.redirect(f'/my/contacts/change_status/{contact_id}?status=archived')
+ else:
+ return request.redirect(f'/my/contacts/change_status/{contact_id}?status=no_access')
+
+ return request.redirect('/my/contacts')
+
+ @http.route(['/my/contacts/update'], type='http', auth="user", methods=['POST'], website=True)
+ def portal_my_contacts_update(self, **kw):
+ """
+ Update an existing contact
+ """
+ # Check if the user has a parent company
+ partner = request.env.user.partner_id
+ parent_company = partner.parent_id
+
+ if not parent_company or not parent_company.is_company:
+ return request.redirect('/my')
+
+ # Check if the user has the right to edit contacts
+ if not parent_company.allow_portal_parent_edit:
+ return request.redirect('/my/contacts')
+
+ # Get the ID of the contact to update
+ contact_id = kw.get('contact_id')
+ if not contact_id or not contact_id.isdigit():
+ return request.redirect('/my/contacts')
+
+ contact_id = int(contact_id)
+ Contact = request.env['res.partner']
+ contact = Contact.sudo().browse(contact_id)
+
+ # Check that the contact belongs to the parent company
+ if not contact.exists() or contact.parent_id.id != parent_company.id:
+ return request.redirect('/my/contacts')
+
+ # Prepare the values to update
+ vals = {}
+ allowed_fields = parent_company._get_portal_allowed_fields()
+
+ for field in allowed_fields:
+ if field in kw:
+ # Special handling for many2one fields
+ if field in ['state_id', 'country_id']:
+ if kw[field] and kw[field].isdigit():
+ vals[field] = int(kw[field])
+ else:
+ vals[field] = kw[field]
+
+ # Handle contact type
+ if 'type' in kw and kw.get('type') in ['contact', 'invoice', 'delivery', 'origin', 'other']:
+ vals['type'] = kw.get('type')
+ else:
+ vals['type'] = 'contact' # Default type
+
+ # Check if email is being updated
+ old_email = contact.email
+ email_changed = 'email' in vals and vals['email'] != old_email
+
+ # Validate email for contact type
+ if vals.get('type') == 'contact' and not vals.get('email'):
+ return request.redirect('/my/company?error_message=' + _('Email is required for Contact type'))
+
+ # Update the contact
+ try:
+ contact.write(vals)
+
+ # If email changed and contact has a portal user, update the login
+ if email_changed and contact.user_ids:
+ for user in contact.sudo().user_ids:
+ # Only update users in the portal group (not internal users)
+ if user.has_group('base.group_portal') and not user.has_group('base.group_user'):
+ user.sudo().write({'login': vals['email']})
+ _logger.info(f"Updated login for user {user.id} from {old_email} to {vals['email']}")
+
+ # Journaliser la modification directement sur le contact
+ details = ', '.join([f"{field}: {vals[field]}" for field in vals])
+ contact.sudo().log_portal_activity(
+ user_id=request.env.user.id,
+ action='edit',
+ details=f"Contact updated via portal: {details}"
+ )
+
+ # Si l'email a changé et qu'un utilisateur a été mis à jour, journaliser l'action pour les utilisateurs affectés
+ if email_changed and contact.user_ids:
+ # Récupérer tous les utilisateurs du portail associés à ce contact
+ portal_users = contact.sudo().user_ids.filtered(
+ lambda u: u.has_group('base.group_portal') and not u.has_group('base.group_user')
+ )
+ # Pour chaque utilisateur affecté, créer une entrée de log sur le contact
+ for portal_user in portal_users:
+ contact.sudo().log_portal_activity(
+ user_id=request.env.user.id,
+ action='edit_user',
+ details=f"Portal user login updated from {old_email} to {vals['email']} for user {portal_user.name}"
+ )
+
+ return request.redirect('/my/company?update=success')
+ except Exception as e:
+ _logger.error("Error while updating contact: %s", str(e))
+ return request.redirect(f'/my/contacts/edit/{contact_id}?error=1')
+
+
+
+ def _generate_password(self, length=12):
+ """Generate a random password"""
+ import random
+ import string
+ chars = string.ascii_letters + string.digits + '!@#$%^&*()'
+ return ''.join(random.choice(chars) for _ in range(length))
diff --git a/portal_partner_manager/doc/Spécifications.md b/portal_partner_manager/doc/Spécifications.md
new file mode 100644
index 0000000..90ea7b4
--- /dev/null
+++ b/portal_partner_manager/doc/Spécifications.md
@@ -0,0 +1,302 @@
+# Spécifications du Module Portal Partner Manager
+
+## 1. Présentation Générale
+
+### 1.1 Objectif du Module
+
+Le module Portal Partner Manager est conçu pour étendre les fonctionnalités du portail standard d'Odoo en permettant aux utilisateurs du portail (généralement des clients) de gérer leur société parente et leurs contacts depuis l'interface du portail. Ce module offre une flexibilité accrue aux clients pour maintenir à jour leurs informations sans intervention de l'administrateur Odoo.
+
+### 1.2 Fonctionnalités Principales
+
+- Affichage et modification des informations de la société parente
+- Gestion complète des contacts (création, modification, archivage)
+- Attribution d'accès portail aux contacts
+- Définition des mots de passe pour les utilisateurs du portail
+- Journalisation des actions effectuées via le portail
+- Configuration fine des permissions d'édition
+
+## 2. Configuration Technique
+
+### 2.1 Dépendances
+
+Le module dépend des modules Odoo suivants :
+- base
+- portal
+- contacts
+- mail
+- web_editor
+- website
+- website_mail
+- portal_rating
+- http_routing
+
+### 2.2 Installation et Activation
+
+Le module s'installe comme tout module standard d'Odoo et ne nécessite pas de configuration particulière après installation. Il est compatible avec Odoo 18.0.
+
+## 3. Architecture du Module
+
+### 3.1 Modèles de Données
+
+#### 3.1.1 Mixin Portal Editable (`portal.editable.mixin`)
+
+Un modèle abstrait qui ajoute des capacités d'édition via le portail à n'importe quel modèle.
+
+**Champs :**
+- `portal_last_update` (Datetime) : Date de la dernière mise à jour via le portail
+- `portal_updated_by` (Many2one vers res.users) : Utilisateur ayant effectué la dernière mise à jour
+- `allow_portal_edit` (Boolean) : Autorise l'édition via le portail si coché
+
+**Méthodes principales :**
+- `write()` : Surcharge pour gérer les mises à jour via le portail
+- `_check_portal_edit_access()` : Vérifie les permissions d'édition
+- `_get_portal_allowed_fields()` : Retourne la liste des champs éditables via le portail
+
+#### 3.1.2 Extension de Partenaire (`res.partner`)
+
+Étend le modèle `res.partner` pour ajouter des fonctionnalités d'édition via le portail.
+
+**Champs ajoutés :**
+- Extension des champs du mixin avec descriptions spécifiques
+- `allow_portal_parent_edit` (Boolean, related à `allow_portal_edit`) : Champ hérité pour compatibilité
+
+**Méthodes principales :**
+- `_check_portal_edit_access()` : Implémentation spécifique pour les partenaires
+- `_get_portal_allowed_fields()` : Liste des champs modifiables pour les partenaires
+- `get_portal_children()` : Retourne les contacts enfants visibles pour l'utilisateur du portail
+- `create_portal_contact()` : Crée un nouveau contact via le portail
+
+#### 3.1.3 Configuration d'Accès au Portail (`portal.access`)
+
+Modèle pour configurer les accès au portail par entreprise.
+
+**Champs :**
+- `name` (Char) : Nom de la configuration
+- `active` (Boolean) : Statut actif/inactif
+- `partner_id` (Many2one vers res.partner) : Société concernée
+- `allow_edit` (Boolean) : Autoriser l'édition des informations de la société
+- `allow_add_contacts` (Boolean) : Autoriser l'ajout de contacts
+- `allowed_fields_ids` (Many2many vers ir.model.fields) : Champs autorisés à l'édition
+- `portal_user_ids` (Many2many vers res.users) : Utilisateurs du portail ayant accès
+- `log_ids` (One2many vers portal.access.log) : Journaux d'accès
+
+**Méthodes principales :**
+- `create()` et `write()` : Mettent à jour les permissions sur le partenaire
+- `get_allowed_fields()` : Retourne les champs autorisés pour un partenaire
+- `log_access()` : Enregistre une entrée dans le journal d'accès
+
+#### 3.1.4 Journal d'Activités du Portail (`portal.activity.log`)
+
+Ce modèle remplace l'ancien `portal.access.log` et enregistre toutes les activités des utilisateurs du portail.
+
+**Champs principaux :**
+| Champ | Type | Description |
+|------------------|------------|---------------------------------------------------------------|
+| `user_id` | Many2one | Utilisateur ayant effectué l'action |
+| `ip` | Char | Adresse IP de l'utilisateur |
+| `model` | Char | Modèle de l'enregistrement concerné |
+| `res_id` | Integer | ID de l'enregistrement |
+| `action` | Selection | Type d'action (view, edit, create, archive, grant_access, etc.)|
+| `details` | Text | Détails supplémentaires |
+| `create_date` | Datetime | Date de l'action |
+| `resource_name` | Char | Nom d'affichage de l'enregistrement ou mention s'il est supprimé |
+
+#### 3.1.5 Mixin de Journalisation (`portal.logging.mixin`)
+
+Fournit la méthode `log_portal_activity(user_id, action, details=None, ip=None)` pour ajouter facilement des entrées de journal d'activité depuis n'importe quel modèle.
+
+### 3.2 Contrôleurs
+
+Le module définit un contrôleur principal `PortalPartnerController` qui étend `CustomerPortal` et implémente les routes suivantes :
+
+#### 3.2.1 Routes pour la Gestion de la Société
+
+- `/my/company` : Affiche les informations de la société parente
+- `/my/company/edit` : Formulaire d'édition de la société
+- `/my/company/update` : Traitement de la mise à jour de la société
+
+#### 3.2.2 Routes pour la Gestion des Contacts
+
+- `/my/contacts` : Liste des contacts de la société
+- `/my/contacts/add` : Formulaire d'ajout d'un contact
+- `/my/contacts/create` : Traitement de la création d'un contact
+- `/my/contacts/edit/` : Édition d'un contact existant
+- `/my/contacts/update` : Mise à jour d'un contact
+
+#### 3.2.3 Routes pour la Gestion des Accès Portail
+
+- `/my/contacts/grant_access/` : Attribution d'un accès portail
+- `/my/contacts/set_password_form/` : Formulaire de définition de mot de passe
+- `/my/contacts/set_password` : Traitement du mot de passe
+- `/my/contacts/change_status/` : Changement de statut d'un contact (archivage, accès)
+- `/my/contacts/archive/` : Route héritée pour l'archivage (redirection)
+
+### 3.3 Sécurité et Règles d'Accès
+
+#### 3.3.1 Groupes de Sécurité
+
+- `group_portal_manager` : Groupe pour les gestionnaires des accès portail
+
+#### 3.3.2 Règles d'Accès
+
+- `portal_partner_rule` : Accès en lecture pour les utilisateurs du portail (propre profil, société parente, contacts frères)
+- `portal_partner_write_rule` : Accès en écriture à la société parente (si autorisé)
+- `portal_partner_self_write_rule` : Accès en écriture à son propre profil
+- Règles spécifiques définies dans `portal_partner_manager_rules.xml`
+
+### 3.4 Vues et Templates
+
+#### 3.4.1 Vues Backend
+
+- Vues pour les partenaires (`res_partner_views.xml`)
+- Vues pour les configurations d'accès
+
+#### 3.4.2 Templates Portail
+
+- `portal_company_templates.xml` : Templates pour la gestion de la société
+- `portal_contact_templates.xml` : Templates pour la gestion des contacts
+- `portal_menu_templates.xml` : Items de menu du portail
+- `portal_set_password.xml` : Formulaire de définition de mot de passe
+- `portal_archive_contact_confirm.xml` : Confirmation d'archivage
+- `portal_fix_template.xml` : Correctifs pour le portail standard
+
+### 3.5 Assets Frontend
+
+- CSS : `/static/src/scss/portal_partner.scss`
+- JS : `/static/src/js/portal_fix.js` (chargé directement)
+- JS : `/static/src/js/portal_partner.js` (chargé en lazy-loading)
+
+## 4. Fonctionnalités Détaillées
+
+### 4.1 Gestion de la Société
+
+Les utilisateurs du portail peuvent visualiser et modifier les informations de leur société parente si celle-ci a activé l'option `allow_portal_edit`. Les modifications sont journalisées et seuls les champs autorisés peuvent être modifiés.
+
+### 4.2 Gestion des Contacts
+
+#### 4.2.1 Affichage des Contacts
+
+Les utilisateurs du portail peuvent voir tous les contacts de leur société parente, y compris les contacts archivés, avec pagination et tri.
+
+#### 4.2.2 Ajout de Contacts
+
+Si autorisé, les utilisateurs peuvent ajouter de nouveaux contacts à leur société. Si un contact avec le même email existe mais est archivé, il sera réactivé plutôt que de créer un doublon.
+
+#### 4.2.3 Modification de Contacts
+
+Les utilisateurs peuvent modifier les informations des contacts existants, avec les mêmes restrictions que pour la société.
+
+#### 4.2.4 Archivage de Contacts
+
+Les contacts peuvent être archivés temporairement et restaurés ultérieurement.
+
+### 4.3 Gestion des Accès Portail
+
+#### 4.3.1 Attribution d'Accès
+
+Les utilisateurs peuvent attribuer des accès portail à d'autres contacts de leur société. Le système vérifie si un utilisateur avec le même email existe déjà et gère les cas appropriés.
+
+#### 4.3.2 Définition de Mot de Passe
+
+Pour les nouveaux utilisateurs du portail, un email d'invitation est envoyé. Pour les utilisateurs réactivés, un formulaire permet de définir un nouveau mot de passe directement.
+
+#### 4.3.3 Changement de Statut
+
+Les utilisateurs peuvent changer le statut des contacts entre trois états :
+- Accès portail (avec compte utilisateur)
+- Accès standard (contact sans compte utilisateur)
+- Archivé (contact désactivé)
+
+### 4.4 Journalisation et Suivi
+
+Toutes les actions effectuées via le portail sont journalisées pour assurer un suivi et un audit complet, à l'aide du modèle `portal.activity.log` et du mixin `portal.logging.mixin` :
+- Consultation des informations
+- Modifications apportées
+- Ajout de contacts
+- Attribution d'accès portail
+
+### 4.5 Journal d'Activités du Portail
+
+Ce modèle remplace l'ancien `portal.access.log` et enregistre toutes les activités des utilisateurs du portail.
+
+## 5. Personnalisation et Extension
+
+### 5.1 Configuration des Champs Autorisés
+
+Les administrateurs peuvent configurer précisément quels champs peuvent être modifiés par les utilisateurs du portail via le modèle `portal.access`.
+
+### 5.2 Extension du Module
+
+Le module est conçu pour être facilement extensible :
+- Le mixin `portal.editable.mixin` peut être appliqué à d'autres modèles
+- Les méthodes de vérification d'accès peuvent être surchargées
+- De nouvelles fonctionnalités peuvent être ajoutées aux contrôleurs existants
+
+## 6. Tests et Qualité
+
+Le module inclut des tests automatisés pour vérifier :
+- La synchronisation des emails et logins
+- Les fonctionnalités de gestion des partenaires via le portail
+
+## 7. État Actuel et Améliorations Futures
+
+### 7.1 État d'Avancement
+
+**État actuel:** Fonctionnel mais avec des opportunités d'amélioration
+
+Le module est globalement fonctionnel et implémente toutes les fonctionnalités principales décrites dans les spécifications. Les utilisateurs du portail peuvent consulter et modifier leur société parente, ainsi que gérer leurs contacts. Cependant, certaines tâches du fichier `todo.md` restent marquées comme non complétées, ce qui indique que le module pourrait bénéficier d'améliorations supplémentaires.
+
+### 7.2 Tâches Restantes
+
+1. **Finalisation de la documentation**
+ - Compléter la documentation utilisateur avec des captures d'écran
+ - Ajouter plus d'exemples d'utilisation dans README.md
+
+2. **Tests additionnels**
+ - Augmenter la couverture des tests pour inclure les cas limites
+ - Ajouter des tests pour les fonctionnalités d'archivage et de restauration
+ - Tester les scénarios multi-utilisateurs (plusieurs utilisateurs portail pour une même société)
+
+3. **Optimisations visuelles**
+ - Améliorer le design responsive des formulaires sur mobile
+ - Ajouter des indicateurs de chargement pendant les actions AJAX
+ - Améliorer l'accessibilité des formulaires et boutons
+
+### 7.3 Commentaires et Suggestions
+
+#### 7.3.1 Améliorations Fonctionnelles
+
+1. **Gestion avancée des permissions**
+ - Implémenter un système de rôles pour les utilisateurs du portail (admin portail, utilisateur standard)
+ - Permettre de configurer les permissions par champ et par utilisateur
+
+2. **Intégration avec d'autres modules**
+ - Ajouter une intégration avec les modules de signature électronique pour la validation des modifications
+ - Intégrer avec le module CRM pour permettre aux contacts de gérer leurs opportunités
+
+3. **Fonctionnalités de collaboration**
+ - Ajouter un système de commentaires/notes sur les contacts
+ - Implémenter un fil d'activité pour suivre les modifications sur les contacts
+
+#### 7.3.2 Améliorations Techniques
+
+1. **Performance**
+ - Optimiser les requêtes SQL pour les listes de contacts volumineuses
+ - Implémenter le chargement paresseux des informations non critiques
+
+2. **Sécurité**
+ - Ajouter un système de vérification par email pour les modifications sensibles
+ - Renforcer la validation des données côté serveur
+
+3. **Extensibilité**
+ - Extraire certaines fonctionnalités génériques dans des mixins réutilisables
+ - Documenter les points d'extension du module pour faciliter les personnalisations
+
+#### 7.3.3 Priorités Recommandées
+
+Les tâches suivantes devraient être considérées comme prioritaires pour améliorer le module :
+
+1. Compléter les tests pour garantir la stabilité des fonctionnalités existantes
+2. Améliorer la gestion des permissions pour les environnements multi-utilisateurs
+3. Optimiser l'expérience mobile pour les utilisateurs du portail
+4. Documenter les cas d'utilisation avancés pour faciliter l'adoption
\ No newline at end of file
diff --git a/portal_partner_manager/doc/js.md b/portal_partner_manager/doc/js.md
new file mode 100644
index 0000000..088f67c
--- /dev/null
+++ b/portal_partner_manager/doc/js.md
@@ -0,0 +1,498 @@
+# Documentation JavaScript - Portal Partner Manager
+
+Cette documentation détaille les fichiers JavaScript utilisés dans le module Portal Partner Manager pour améliorer l'expérience utilisateur du portail Odoo.
+
+## 1. Fichiers JavaScript du module portal_partner_manager
+
+### 1.1 portal_fix.js
+
+#### Objectif
+
+Ce script corrige une erreur courante dans le portail standard d'Odoo : `Cannot read properties of null (reading 'remove')` qui se produit dans le widget `PortalHomeCounters`.
+
+#### Fonctionnement
+
+Le script applique deux correctifs principaux :
+
+1. **Patch de `Element.prototype.remove`** :
+ - Remplace la méthode native `remove()` des éléments DOM
+ - Ajoute une gestion d'erreur pour éviter les exceptions quand la méthode est appelée sur des éléments null ou undefined
+ - Capture silencieusement les erreurs pour ne pas perturber la console
+
+2. **Patch de `jQuery.fn.remove`** (si jQuery est disponible) :
+ - Remplace la méthode jQuery `remove()`
+ - Ajoute une gestion d'erreur similaire
+ - Retourne l'objet jQuery pour maintenir la chaîne de méthodes
+
+#### Intégration
+
+Ce script est chargé directement dans les templates du portail et s'exécute immédiatement pour corriger les problèmes potentiels avant que d'autres scripts ne s'exécutent.
+
+### 1.2 jquery_early_fix.js
+
+#### Objectif
+
+Ce script est chargé très tôt dans le processus de chargement de la page et intercepte l'erreur "$ is not defined" avant qu'elle ne se produise.
+
+#### Fonctionnement
+
+1. **Définition précoce de jQuery** :
+ - Définit une version globale de `$` avant tout autre script
+ - Fournit une implémentation minimale des fonctionnalités de base de jQuery
+
+2. **Interception des erreurs** :
+ - Intercepte spécifiquement les erreurs liées à jQuery
+ - Examine les scripts de la page qui utilisent `$` sans vérification
+
+3. **Injection de correctifs** :
+ - Injecte un correctif dans le document pour les scripts inline
+ - S'exécute dès que possible dans le cycle de vie du document
+
+#### Intégration
+
+Ce script est chargé en premier dans l'ordre des assets pour s'assurer qu'il est exécuté avant tout autre script qui pourrait utiliser jQuery.
+
+### 1.3 jquery_safety.js
+
+#### Objectif
+
+Ce script assure que `$` est défini et fournit une implémentation de secours si nécessaire. Il résout également le problème "Cannot read properties of null (reading 'remove')" en ajoutant des vérifications de nullité.
+
+#### Fonctionnement
+
+1. **Remplacement de jQuery** :
+ - Crée un remplacement minimal pour jQuery si celui-ci n'est pas défini
+ - Implémente les méthodes les plus couramment utilisées (each, on, val, find, parent, show, hide, etc.)
+
+2. **Protection contre les erreurs** :
+ - Ajoute des vérifications de nullité aux méthodes critiques
+ - Intercepte les erreurs courantes liées à jQuery
+
+3. **Initialisation multiple** :
+ - S'exécute à plusieurs moments du cycle de vie de la page
+ - Assure que les protections sont en place même si jQuery est chargé dynamiquement
+
+#### Intégration
+
+Ce script est chargé après jquery_early_fix.js mais avant les autres scripts qui dépendent de jQuery.
+
+### 1.4 debug_tools.js
+
+#### Objectif
+
+Cet outil de débogage avancé capture et analyse les erreurs JavaScript. Il intercepte toutes les erreurs et les affiche de manière détaillée dans la console, avec des outils spécifiques pour identifier les scripts qui utilisent jQuery sans vérifier son existence.
+
+#### Fonctionnement
+
+1. **Interception des erreurs** :
+ - Remplace le gestionnaire d'erreurs global
+ - Intercepte les rejets de promesses non gérés
+
+2. **Analyse des erreurs** :
+ - Extrait la stack trace des erreurs
+ - Récupère le contenu des scripts externes
+ - Analyse les scripts pour trouver les utilisations problématiques de jQuery
+
+3. **Surveillance du DOM** :
+ - Observe les mutations du DOM
+ - Détecte les scripts ajoutés dynamiquement
+
+#### Intégration
+
+Ce script est chargé en mode développement pour aider à identifier et résoudre les problèmes JavaScript.
+
+### 1.5 portal_partner.js
+
+#### Objectif
+
+Ce module implémente plusieurs widgets pour la gestion des formulaires d'adresse dans le portail, notamment pour filtrer dynamiquement les provinces/états en fonction du pays sélectionné.
+
+#### Widgets
+
+1. **bemadeCustomAddressManager** :
+ - **Sélecteur** : `#bemade_company_edit_form`
+ - **Événements** : `change select[name="country_id"]`
+ - Adapte les options de province en fonction du pays sélectionné
+
+2. **bemadeParentCompanyDetails** :
+ - Gère le formulaire d'adresse du partenaire parent
+ - Réutilise le même code que le widget principal avec un sélecteur différent
+
+3. **bemadeSiblingDetails** :
+ - Gère les formulaires d'adresse des partenaires frères
+ - Supporte plusieurs formulaires sur la même page
+
+#### Fonctions utilitaires
+
+- **adaptAddressForm** : Adapte le formulaire d'adresse en fonction du pays sélectionné
+- **initAddressForm** : Initialise les éléments du formulaire d'adresse
+
+#### Intégration
+
+Ce script est chargé dans les pages du portail qui contiennent des formulaires d'adresse, comme la page d'édition de la société ou des contacts.
+
+### 1.6 portal_partner_utils.js
+
+#### Objectif
+
+Ce module fournit des fonctions utilitaires pour la gestion des partenaires dans le portail, en réutilisant au maximum le code standard d'Odoo.
+
+#### Fonctions
+
+1. **debugLog** :
+ - Fonction de débogage pour afficher des messages dans la console
+ - Utilise un formatage spécifique pour identifier facilement les messages
+
+2. **adaptAddressForm** :
+ - Adapte le formulaire d'adresse en fonction du pays sélectionné
+ - Gère l'affichage conditionnel du champ état/province
+
+3. **initAddressForm** :
+ - Initialise les champs d'adresse pour un formulaire
+ - Réutilisable pour le partenaire principal, parent ou frères
+
+#### Intégration
+
+Ce module est importé par portal_partner.js et utilisé dans les différents widgets pour gérer les formulaires d'adresse.
+
+## 2. Intégration avec Odoo
+
+### 2.1 Surcharge des widgets standard
+
+Le module surcharge certains widgets standard d'Odoo pour éviter les conflits :
+
+```javascript
+publicWidget.registry.portal_details = publicWidget.Widget.extend({
+ selector: '.o_portal_details',
+ start: function () {
+ // Ne rien faire pour éviter les conflits
+ return this._super.apply(this, arguments);
+ },
+});
+```
+
+### 2.2 Gestion des dépendances
+
+Les scripts sont chargés dans un ordre spécifique pour assurer que les correctifs sont en place avant l'exécution des autres scripts :
+
+1. jquery_early_fix.js
+2. jquery_safety.js
+3. portal_fix.js
+4. debug_tools.js (en mode développement)
+5. portal_partner_utils.js
+6. portal_partner.js
+
+## 3. Résolution des problèmes courants
+
+### 3.1 Erreur "Cannot read properties of null (reading 'remove')"
+
+Cette erreur est résolue par plusieurs mécanismes :
+
+1. **portal_fix.js** : Patch direct des méthodes `remove()`
+2. **jquery_safety.js** : Vérifications de nullité dans les méthodes jQuery
+
+### 3.2 Erreur "$ is not defined"
+
+Cette erreur est résolue par :
+
+1. **jquery_early_fix.js** : Définition précoce de `$`
+2. **jquery_safety.js** : Implémentation de secours pour jQuery
+
+### 3.3 Problèmes de formulaires d'adresse
+
+Les problèmes liés aux formulaires d'adresse (affichage des provinces, validation, etc.) sont gérés par :
+
+1. **portal_partner.js** : Widgets spécifiques pour chaque type de formulaire
+2. **portal_partner_utils.js** : Fonctions utilitaires réutilisables**Méthodes principales** :
+
+1. **`start()`** :
+ - Initialise le widget
+ - Capture les références aux éléments du formulaire
+ - Appelle `_adaptAddressFormParent()` pour configurer initialement le formulaire
+
+2. **`_adaptAddressFormParent()`** :
+ - Filtre les options du champ province/état en fonction du pays sélectionné
+ - Affiche uniquement les provinces/états correspondant au pays choisi
+ - Masque complètement le champ province/état si aucune option n'est disponible pour le pays sélectionné
+
+3. **`_onCountryChangeParent()`** :
+ - Gestionnaire d'événement pour le changement de pays
+ - Appelle `_adaptAddressFormParent()` pour mettre à jour le formulaire
+
+### Intégration
+
+Ce widget est enregistré dans le registre des widgets publics d'Odoo et est automatiquement initialisé lorsque les éléments correspondant au sélecteur `.o_partner_manager_portal_details` sont présents dans la page.
+
+### 1.3 Bonnes pratiques implémentées
+
+1. **Gestion des erreurs** : Les deux scripts incluent une gestion robuste des erreurs pour éviter les interruptions d'exécution
+
+2. **Commentaires détaillés** : Le code est bien documenté avec des commentaires expliquant le fonctionnement et l'objectif de chaque section
+
+3. **Encapsulation** : Les scripts utilisent des IIFE (Immediately Invoked Function Expressions) ou le système de modules d'Odoo pour éviter de polluer l'espace de noms global
+
+4. **Compatibilité** : Le code prend en compte différents scénarios (présence ou absence de jQuery, éléments manquants, etc.)
+
+5. **Séparation des préoccupations** : Chaque fichier a une responsabilité unique et bien définie
+
+## 2. Fichiers JavaScript du module portal standard d'Odoo
+
+Cette section documente les fichiers JavaScript du module portal standard d'Odoo (`odoo/addons/portal/static/src/js`) qui servent de base au module Portal Partner Manager.
+
+### Comparaison des méthodes de gestion des provinces/états
+
+#### Comparaison entre `_adaptAddressForm()` (standard) et `_adaptAddressFormParent()` (personnalisé)
+
+| Aspect | Solution 1 (portal_partner.js personnalisé) | Solution 2 (portal.js standard) |
+|--------|-------------------------------------------|----------------------------------|
+| **Sélecteur** | `.o_partner_manager_portal_details` | `.o_portal_details` |
+| **Gestion des erreurs** | Utilise try/catch pour éviter les erreurs JS | Aucune |
+| **Visibilité du champ état** | Option configurable (commentée) pour toujours afficher le champ ou le masquer | Masque le champ si aucun état n'est disponible |
+| **Commentaires** | Détaillés expliquant le fonctionnement et les options | Minimalistes |
+| **Flexibilité** | Propose deux approches (lignes 50-55) | Fixe |
+
+#### Réutilisabilité de la solution 2 (standard)
+
+La solution 2 (standard) d'Odoo pourrait-elle être remplacée par la solution 1 (personnalisée) ? Oui, et cela présenterait plusieurs avantages :
+
+1. **Robustesse améliorée** : L'ajout de la gestion des erreurs avec try/catch éviterait les interruptions potentielles
+
+2. **Flexibilité** : Le code personnalisé propose deux options commentées pour la visibilité du champ état :
+ - Option 1 (ligne 51-52) : Toujours afficher le champ état, même sans options disponibles
+ - Option 2 (ligne 55) : N'afficher que si des provinces sont disponibles (comportement standard)
+
+3. **Maintenabilité** : Les commentaires détaillés faciliteraient la compréhension et la maintenance
+
+4. **Compatibilité** : La solution 1 reste fonctionnellement équivalente à la solution 2 standard, assurant une compatibilité totale
+
+Pour appliquer la solution personnalisée en remplacement de la solution standard, il suffirait de :
+1. Copier la méthode `_adaptAddressFormParent()` dans le code standard
+2. La renommer en `_adaptAddressForm()`
+3. Ajuster le sélecteur pour utiliser `.o_portal_details`
+4. Choisir l'option de visibilité souhaitée (décommenter l'option 1 ou conserver l'option 2)
+
+## 3. Réutilisation maximale du code standard d'Odoo
+
+### 3.1 Utilisation directe du code de `portal.js` pour les partenaires
+
+Voici comment réutiliser directement le code standard d'Odoo (`odoo/addons/portal/static/src/js`) pour gérer l'édition du partenaire parent et des partenaires frères :
+
+#### 1. Importation directe des modules standard
+
+```javascript
+// Dans votre fichier portal_partner.js
+import { PortalHomeCounters } from "@portal/static/src/js/portal.js";
+import PortalComposer from "@portal/static/src/js/portal_composer.js";
+
+// Réutiliser directement les classes existantes
+```
+
+#### 2. Utilisation du widget portalDetails avec un minimum de modifications
+
+```javascript
+// Utiliser le widget portalDetails avec un minimum de modifications
+publicWidget.registry.bemadePortalDetails = publicWidget.Widget.extend({
+ selector: '.o_partner_manager_portal_details',
+ events: {
+ 'change select[name="country_id"]': '_onCountryChangeParent',
+ },
+
+ start: function () {
+ // Code identique à portalDetails.start()
+ var def = this._super.apply(this, arguments);
+ this.$state = this.$('select[name="state_id"]');
+ this.$stateOptions = this.$state.filter(':enabled').find('option:not(:first)');
+ this._adaptAddressFormParent();
+ return def;
+ },
+
+ _adaptAddressFormParent: function () {
+ // Code presque identique à _adaptAddressForm() avec try/catch en plus
+ try {
+ var $country = this.$('select[name="country_id"]');
+ var countryID = $country.val() || 0;
+ this.$stateOptions.detach();
+ var $displayedState = this.$stateOptions.filter('[data-country_id="' + countryID + '"]');
+ var nb = $displayedState.appendTo(this.$state).removeClass('d-none').show().length;
+ this.$state.parent().toggle(nb >= 1);
+ } catch (e) {
+ console.error('Erreur lors de l\'adaptation du formulaire d\'adresse:', e);
+ }
+ },
+
+ _onCountryChangeParent: function () {
+ // Identique à _onCountryChange()
+ this._adaptAddressFormParent();
+ },
+});
+```
+
+#### 3. Réutilisation du code pour les compteurs et la recherche
+
+```javascript
+// Réutiliser directement les widgets PortalHomeCounters et portalSearchPanel
+export const PortalPartnerHomeCounters = PortalHomeCounters.extend({
+ // Ajouter uniquement les méthodes spécifiques aux partenaires
+ _getCountersAlwaysDisplayed() {
+ // Surcharger pour ajouter les compteurs de partenaires
+ return [...super._getCountersAlwaysDisplayed(), 'parent_company', 'sibling_partners'];
+ },
+});
+```
+
+### 3.2 Modifications minimales des templates
+
+```xml
+
+
+
+
+
+
+
+```
+
+### 3.3 Stratégie concrète pour maximiser la réutilisation
+
+1. **Copier-coller stratégique** : Pour les fonctions comme `_adaptAddressForm()`, copier le code standard et ajouter uniquement les améliorations nécessaires (try/catch)
+
+2. **Héritage minimal** : Hériter des widgets standard uniquement lorsque nécessaire, en préservant au maximum le comportement d'origine
+
+3. **Partage de code** : Utiliser les mêmes noms de variables et de fonctions pour faciliter la maintenance
+
+4. **Modifications CSS plutôt que HTML** : Utiliser CSS pour modifier l'apparence sans changer la structure HTML
+
+5. **Conserver la compatibilité des événements** : Maintenir les mêmes noms d'événements et sélecteurs pour assurer la compatibilité
+
+### 3.4 Exemple de mise en œuvre pour l'édition du partenaire parent
+
+```javascript
+// Exemple concret de réutilisation maximale
+
+// 1. Copier directement le code de portal.js pour les fonctions de base
+const adaptAddressForm = function($state, $country) {
+ // Code copié directement de portal.js avec try/catch ajouté
+ try {
+ var countryID = ($country.val() || 0);
+ var $stateOptions = $state.filter(':enabled').find('option:not(:first)');
+ $stateOptions.detach();
+ var $displayedState = $stateOptions.filter('[data-country_id=' + countryID + ']');
+ var nb = $displayedState.appendTo($state).removeClass('d-none').show().length;
+ $state.parent().toggle(nb >= 1);
+ } catch (e) {
+ console.error('Error in adaptAddressForm:', e);
+ }
+};
+
+// 2. Utiliser cette fonction dans les widgets pour le partenaire parent et les contacts
+publicWidget.registry.portalParentCompanyDetails = publicWidget.Widget.extend({
+ selector: '.o_portal_parent_company',
+ events: {
+ 'change select[name="country_id"]': '_onCountryChange',
+ },
+
+ start: function () {
+ this.$state = this.$('select[name="state_id"]');
+ this.$country = this.$('select[name="country_id"]');
+ adaptAddressForm(this.$state, this.$country);
+ return this._super.apply(this, arguments);
+ },
+
+ _onCountryChange: function () {
+ adaptAddressForm(this.$state, this.$country);
+ },
+});
+```
+
+### 2.1 portal.js
+
+Ce fichier contient les widgets principaux du portail standard d'Odoo.
+
+#### Widgets principaux
+
+1. **portalDetails** - Widget pour gérer les formulaires d'adresse
+ - **Sélecteur** : `.o_portal_details`
+ - **Fonctionnalités** : Gère l'affichage dynamique des états/provinces en fonction du pays sélectionné
+ - **Méthodes clés** :
+ - `_adaptAddressForm()` : Filtre les options d'états selon le pays
+ - `_onCountryChange()` : Gère l'événement de changement de pays
+
+2. **PortalHomeCounters** - Widget pour afficher les compteurs sur la page d'accueil du portail
+ - **Sélecteur** : `.o_portal_my_home`
+ - **Fonctionnalités** : Met à jour dynamiquement les compteurs de documents (factures, commandes, etc.)
+ - **Méthodes clés** :
+ - `_updateCounters()` : Récupère et affiche les compteurs via RPC
+ - `_getCountersAlwaysDisplayed()` : Liste des compteurs à toujours afficher
+
+3. **portalSearchPanel** - Widget pour gérer la recherche dans le portail
+ - **Sélecteur** : `.o_portal_search_panel`
+ - **Fonctionnalités** : Gère les filtres de recherche et le champ de recherche
+ - **Méthodes clés** :
+ - `_adaptSearchLabel()` : Met à jour le placeholder du champ de recherche
+ - `_search()` : Exécute la recherche avec les paramètres sélectionnés
+
+### 2.2 portal_composer.js
+
+Gère le composeur de messages dans le portail, permettant aux utilisateurs d'envoyer des messages et des pièces jointes.
+
+#### Widget PortalComposer
+
+- **Template** : `portal.Composer`
+- **Fonctionnalités** :
+ - Composition de messages
+ - Gestion des pièces jointes (ajout/suppression)
+ - Envoi de messages avec pièces jointes
+
+- **Méthodes clés** :
+ - `_onFileInputChange()` : Gère l'ajout de fichiers
+ - `_onAttachmentDeleteClick()` : Supprime une pièce jointe
+ - `_onSubmitButtonClick()` : Envoie le message
+ - `_chatterPostMessage()` : Effectue l'appel RPC pour poster le message
+
+### 2.3 portal_security.js
+
+Gère les fonctionnalités de sécurité du portail, notamment la gestion des clés API et la déconnexion des appareils.
+
+#### Widgets principaux
+
+1. **NewAPIKeyButton** - Gère la création de nouvelles clés API
+ - **Sélecteur** : `.o_portal_new_api_key`
+ - **Fonctionnalités** : Affiche un dialogue pour créer une nouvelle clé API avec description et durée
+
+2. **RemoveAPIKeyButton** - Gère la suppression des clés API
+ - **Sélecteur** : `.o_portal_remove_api_key`
+ - **Fonctionnalités** : Supprime une clé API existante après confirmation
+
+3. **LogOutAllDevicesButton** - Gère la déconnexion de tous les appareils
+ - **Fonctionnalités** : Déconnecte l'utilisateur de toutes les sessions actives
+
+#### Fonction utilitaire
+
+- `handleCheckIdentity()` : Gère la vérification d'identité pour les opérations sensibles
+
+### 2.4 portal_sidebar.js
+
+Gère l'affichage de la barre latérale du portail, notamment les informations d'échéance.
+
+#### Widget PortalSidebar
+
+- **Fonctionnalités** :
+ - Affichage des délais ("Dû aujourd'hui", "Dû dans X jours", "X jours de retard")
+ - Impression de contenu via iframe
+
+- **Méthodes clés** :
+ - `_setDelayLabel()` : Calcule et affiche les informations de délai
+ - `_printIframeContent()` : Gère l'impression de contenu
+
+### 2.5 components/input_confirmation_dialog/input_confirmation_dialog.js
+
+Composant OWL pour afficher une boîte de dialogue de confirmation avec un champ de saisie.
+
+#### Classe InputConfirmationDialog
+
+- **Hérite de** : `ConfirmationDialog`
+- **Template** : `portal.InputConfirmationDialog`
+- **Fonctionnalités** :
+ - Affiche une boîte de dialogue avec un champ de saisie
+ - Gère la validation par la touche Entrée
+ - Permet de récupérer la valeur saisie lors de la confirmation
\ No newline at end of file
diff --git a/portal_partner_manager/doc/models.md b/portal_partner_manager/doc/models.md
new file mode 100644
index 0000000..38ea141
--- /dev/null
+++ b/portal_partner_manager/doc/models.md
@@ -0,0 +1,132 @@
+# Portal Partner Manager Module - Models Documentation
+
+This document provides detailed information about the models implemented in the Portal Partner Manager module. This module enhances Odoo's portal functionality by allowing portal users to manage their company information and contacts.
+
+## 1. Portal Editable Mixin (`portal.editable.mixin`)
+
+This mixin provides a standardized way to add portal editing capabilities to any model. It includes fields for tracking portal updates and methods for controlling access.
+
+### 1.1 Fields
+
+| Field Name | Type | Description |
+|------------|------|-------------|
+| `portal_last_update` | Datetime | Tracks when a portal user last updated the record |
+| `portal_updated_by` | Many2one | References the portal user who made the last update |
+| `allow_portal_edit` | Boolean | Controls whether portal users can edit this record |
+
+### 1.2 Key Methods
+
+#### `write(vals)`
+Overrides the standard write method to:
+- Check if the user has permission to edit the record
+- Add tracking information when updated via the portal
+- Filter fields that can be edited via the portal
+
+#### `_check_portal_edit_access(user)`
+Checks if a specific user has permission to edit the record. To be overridden by inheriting models to implement specific access rules.
+
+#### `_get_portal_allowed_fields()`
+Returns the list of fields that portal users are allowed to edit. To be overridden by inheriting models to specify allowed fields.
+
+## 2. Enhanced Partner Model (`res.partner`)
+
+The module extends the standard `res.partner` model to add portal-specific functionality.
+
+### 2.1 Added Fields
+
+| Field Name | Type | Description |
+|------------|------|-------------|
+| `allow_portal_parent_edit` | Boolean | Legacy field, related to `allow_portal_edit` for backward compatibility |
+
+### 2.2 Key Methods
+
+#### `_check_portal_edit_access(user)`
+Overrides the mixin's method to implement partner-specific access rules:
+- Users can always edit their own profile
+- Users can edit their parent company if it allows editing
+- Users can edit contacts of their parent company if the parent allows it
+
+#### `_get_portal_allowed_fields()`
+Returns the list of fields that portal users are allowed to edit:
+- Basic information: name, comment
+- Contact details: phone, mobile, email, website
+- Address fields: street, street2, zip, city, state_id, country_id
+- Company information: vat
+
+#### `get_portal_children(user_id=None)`
+Returns the child contacts visible to a specific portal user:
+- For administrators, returns all child contacts
+- For portal users, returns child contacts only if the user is associated with the company
+- Returns empty recordset if the user doesn't have access
+
+#### `create_portal_contact(parent_id, vals)`
+Creates a new child contact via the portal:
+- Validates that the user has permission to add contacts to the company
+- Ensures required fields like email are provided
+- Filters the input values to only allow editing of permitted fields
+
+## 3. Portal Access Configuration (`portal.access`)
+
+This model manages access configurations for portal users, controlling what they can view and edit.
+
+### 3.1 Fields
+
+| Field Name | Type | Description |
+|------------|------|-------------|
+| `name` | Char | Name of the access configuration |
+| `active` | Boolean | Whether this configuration is active |
+| `partner_id` | Many2one | The company for which this configuration applies |
+| `allow_edit` | Boolean | Whether portal users can edit company information |
+| `allow_add_contacts` | Boolean | Whether portal users can add contacts to the company |
+| `allowed_fields_ids` | Many2many | Specific fields that portal users are allowed to edit |
+| `portal_user_ids` | Many2many | Portal users who have access to this configuration |
+| `log_ids` | One2many | Access logs related to this configuration |
+
+### 3.2 Key Methods
+
+#### `create(vals)` and `write(vals)`
+Overrides the standard methods to automatically update the related partner's `allow_portal_parent_edit` field when the configuration changes.
+
+#### `get_allowed_fields(partner_id=None)`
+Returns the list of fields allowed to be edited for a specific partner:
+- If specific fields are configured, returns those fields
+- Otherwise, returns a default list of common fields
+
+#### `log_access(user_id, action, details=None)`
+Records an entry in the access log when a portal user accesses or modifies company information.
+
+## 4. Portal Activity Log (`portal.activity.log`)
+This model tracks portal user activity related to company and other records in the portal.
+
+### 4.1 Fields
+
+| Field Name | Type | Description |
+|----------------|------------|-----------------------------------------------------------------------|
+| `user_id` | Many2one | The user who performed the action |
+| `ip` | Char | IP address of the user performing the action |
+| `model` | Char | Model of the record the action pertains to |
+| `res_id` | Integer | ID of the record |
+| `action` | Selection | Type of action: view, edit, edit_user, create, archive, unarchive, grant_access, revoke_access, other |
+| `details` | Text | Additional details about the action |
+| `create_date` | Datetime | When the action was performed |
+| `resource_name`| Char | Display name of the record or indication if it was deleted |
+
+### 4.2 Portal Logging Mixin (`portal.logging.mixin`)
+
+Abstract model providing a logging method on any model.
+
+- `log_portal_activity(user_id, action, details=None, ip=None)`: creates a new `portal.activity.log` entry for the current record.
+
+## Implementation Notes
+
+1. **Security**: The module implements careful permission checking to ensure portal users can only access and modify information they're authorized to.
+
+2. **Tracking**: All modifications made via the portal are tracked with timestamps and user information.
+
+3. **Flexibility**: The configuration system allows administrators to precisely control which fields portal users can edit.
+
+4. **Auditability**: The logging system provides a complete audit trail of portal user activity.
+
+5. **Integration**: The module seamlessly integrates with Odoo's existing portal framework, enhancing it with company management capabilities.
+
+6. **Reusability**: The `portal.editable.mixin` can be used to quickly add portal editing capabilities to any model in other modules.
diff --git a/portal_partner_manager/models/__init__.py b/portal_partner_manager/models/__init__.py
new file mode 100644
index 0000000..9b7371f
--- /dev/null
+++ b/portal_partner_manager/models/__init__.py
@@ -0,0 +1,5 @@
+from . import portal_mixin
+from . import portal_logging_mixin
+from . import res_partner
+from . import res_users
+from . import portal_access
diff --git a/portal_partner_manager/models/portal_access.py b/portal_partner_manager/models/portal_access.py
new file mode 100644
index 0000000..c861c84
--- /dev/null
+++ b/portal_partner_manager/models/portal_access.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+from odoo.exceptions import ValidationError
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class PortalAccess(models.Model):
+ _name = 'portal.access'
+ _description = 'Portal Access Configuration'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+
+ name = fields.Char(
+ string='Name',
+ required=True,
+ tracking=True
+ )
+
+ active = fields.Boolean(
+ string='Active',
+ default=True,
+ tracking=True
+ )
+
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string='Company',
+ required=True,
+ domain=[('is_company', '=', True)],
+ tracking=True,
+ help="Company for which to configure portal access"
+ )
+
+ allow_edit = fields.Boolean(
+ string='Allow Edit',
+ default=True,
+ tracking=True,
+ help="If checked, portal users can edit this company's information"
+ )
+
+ allow_add_contacts = fields.Boolean(
+ string='Allow Add Contacts',
+ default=True,
+ tracking=True,
+ help="If checked, portal users can add new contacts to this company"
+ )
+
+ allowed_fields_ids = fields.Many2many(
+ 'ir.model.fields',
+ string='Allowed Fields',
+ domain=[('model', '=', 'res.partner')],
+ tracking=True,
+ help="Fields that portal users are allowed to edit"
+ )
+
+ portal_user_ids = fields.Many2many(
+ 'res.users',
+ string='Portal Users',
+ domain=[('groups_id', 'in', [('base.group_portal')])],
+ tracking=True,
+ help="Portal users who have access to this configuration"
+ )
+
+ # Le champ log_ids a été remplacé par le nouveau système de journalisation portal.activity.log
+ # et n'est plus utilisé
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Override the create method to update the partner
+
+ This method properly handles batch creation of records.
+ """
+ records = super(PortalAccess, self).create(vals_list)
+ for record in records:
+ if record.partner_id:
+ record.partner_id.write({'allow_portal_parent_edit': record.allow_edit})
+ return records
+
+ def write(self, vals):
+ """Override the write method to update the partner"""
+ res = super(PortalAccess, self).write(vals)
+ if 'allow_edit' in vals:
+ for record in self:
+ record.partner_id.write({'allow_portal_parent_edit': record.allow_edit})
+ return res
+
+ @api.model
+ def get_allowed_fields(self, partner_id=None):
+ """
+ Returns the list of allowed fields for a given partner
+ """
+ if not partner_id:
+ return []
+
+ access = self.search([('partner_id', '=', partner_id), ('active', '=', True)], limit=1)
+ if not access:
+ return []
+
+ if not access.allow_edit:
+ return []
+
+ if access.allowed_fields_ids:
+ return access.allowed_fields_ids.mapped('name')
+ else:
+ # Return the default list if no specific fields are configured
+ return [
+ 'name', 'street', 'street2', 'zip', 'city', 'state_id', 'country_id',
+ 'phone', 'mobile', 'email', 'website', 'vat', 'comment'
+ ]
+
+ def log_access(self, user_id, action, details=None):
+ """
+ Cette méthode est obsolète.
+ Pour journaliser les activités du portail, utilisez plutôt la méthode
+ _log_portal_activity de PortalPartnerController ou la méthode
+ log_portal_activity du mixin portal.logging.mixin.
+ """
+ _logger.warning(
+ "La méthode log_access est obsolète. Utilisez la nouvelle méthode _log_portal_activity."
+ )
+ return True
+
+# La classe PortalAccessLog a été remplacée par portal.activity.log
+# et a été supprimée pour éviter les confusions
diff --git a/portal_partner_manager/models/portal_logging_mixin.py b/portal_partner_manager/models/portal_logging_mixin.py
new file mode 100644
index 0000000..e6758e6
--- /dev/null
+++ b/portal_partner_manager/models/portal_logging_mixin.py
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class PortalActivityLog(models.Model):
+ _name = 'portal.activity.log'
+ _description = 'Portal Activity Log'
+ _order = 'create_date desc'
+
+ user_id = fields.Many2one(
+ 'res.users',
+ string='User',
+ required=True,
+ readonly=True,
+ index=True
+ )
+
+ ip = fields.Char(
+ string='IP Address',
+ readonly=True,
+ help="IP address of the user performing the action"
+ )
+
+ model = fields.Char(
+ string='Model',
+ required=True,
+ readonly=True,
+ index=True
+ )
+
+ res_id = fields.Integer(
+ string='Resource ID',
+ required=True,
+ readonly=True,
+ index=True
+ )
+
+ action = fields.Selection([
+ ('view', 'View'),
+ ('edit', 'Edit'),
+ ('edit_user', 'Edit User'),
+ ('create', 'Create'),
+ ('archive', 'Archive'),
+ ('unarchive', 'Unarchive'),
+ ('grant_access', 'Grant Access'),
+ ('revoke_access', 'Revoke Access'),
+ ('other', 'Other')
+ ], string='Action', required=True, readonly=True)
+
+ details = fields.Text(
+ string='Details',
+ readonly=True
+ )
+
+ create_date = fields.Datetime(
+ string='Date',
+ readonly=True
+ )
+
+ resource_name = fields.Char(
+ string='Resource Name',
+ compute='_compute_resource_name',
+ store=True
+ )
+
+ @api.depends('model', 'res_id')
+ def _compute_resource_name(self):
+ """Calcule le nom du modèle associé à chaque log"""
+ for log in self:
+ if log.model and log.res_id:
+ record = self.env[log.model].sudo().browse(log.res_id).exists()
+ if record:
+ log.resource_name = record.display_name
+ else:
+ log.resource_name = f"{log.model},{log.res_id} (Deleted)"
+ else:
+ log.resource_name = False
+
+
+class PortalLoggingMixin(models.AbstractModel):
+ _name = 'portal.logging.mixin'
+ _description = 'Portal Activity Logging Mixin'
+
+ log_ids = fields.One2many(
+ 'portal.activity.log',
+ 'res_id',
+ string="Activity Logs",
+ domain=lambda self: [('model', '=', self._name)],
+ readonly=True
+ )
+
+ def log_portal_activity(self, user_id, action, details=None, ip=None):
+ """
+ Records a portal activity in the log
+
+ :param user_id: ID of the user performing the action
+ :param action: Type of action performed (view, edit, create, etc.)
+ :param details: Optional details about the action
+ :param ip: IP address of the user performing the action
+ :return: Created log entry
+ """
+ self.ensure_one()
+
+ # L'adresse IP doit être passée depuis le contrôleur
+ # car request n'est pas directement accessible depuis les modèles
+
+ return self.env['portal.activity.log'].create({
+ 'user_id': user_id,
+ 'model': self._name,
+ 'res_id': self.id,
+ 'action': action,
+ 'details': details or '',
+ 'ip': ip or False,
+ })
diff --git a/portal_partner_manager/models/portal_mixin.py b/portal_partner_manager/models/portal_mixin.py
new file mode 100644
index 0000000..cdc87d8
--- /dev/null
+++ b/portal_partner_manager/models/portal_mixin.py
@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+from odoo.exceptions import AccessError
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class PortalEditableMixin(models.AbstractModel):
+ """
+ Mixin to add portal editing capabilities to any model.
+ This allows tracking when portal users update records and controlling
+ which records can be edited via the portal.
+ """
+ _name = 'portal.editable.mixin'
+ _description = 'Portal Editable Mixin'
+
+ portal_last_update = fields.Datetime(
+ string='Last Update via Portal',
+ readonly=True,
+ tracking=True,
+ help="Date of the last update made by a portal user"
+ )
+
+ portal_updated_by = fields.Many2one(
+ 'res.users',
+ string='Updated by',
+ readonly=True,
+ tracking=True,
+ help="Portal user who made the last update"
+ )
+
+ allow_portal_edit = fields.Boolean(
+ string='Allow Edit via Portal',
+ default=True,
+ help="If checked, portal users with proper access rights can edit this record"
+ )
+
+ def write(self, vals):
+ """
+ Override the write method to handle updates via the portal
+ and record tracking information
+ """
+ portal_user = self.env.user
+
+ # If the user is a portal user
+ if portal_user.has_group('base.group_portal') and not portal_user.has_group('base.group_user'):
+ # Check if editing is allowed for each record
+ for record in self:
+ if not record.allow_portal_edit:
+ raise AccessError(_("Editing this record is not allowed via the portal."))
+
+ # Additional permission checks can be implemented in inheriting models
+ # by overriding the _check_portal_edit_access method
+ if not record._check_portal_edit_access(portal_user):
+ raise AccessError(_("You don't have permission to edit this record."))
+
+ # Add tracking information
+ vals.update({
+ 'portal_last_update': fields.Datetime.now(),
+ 'portal_updated_by': portal_user.id,
+ })
+
+ # Filter the fields allowed to be edited via the portal
+ allowed_fields = self._get_portal_allowed_fields()
+ for field in list(vals.keys()):
+ if field not in allowed_fields and field not in ['portal_last_update', 'portal_updated_by']:
+ vals.pop(field)
+
+ return super(PortalEditableMixin, self).write(vals)
+
+ def _check_portal_edit_access(self, user):
+ """
+ Check if the given user has permission to edit this record via the portal.
+
+ By default, this implementation allows a portal user to edit:
+ 1. Objects that belong to themselves (where user is the owner/related user)
+ 2. Objects that belong to their parent (parent company/organization)
+ 3. Objects that belong to their siblings (other contacts of the same parent)
+
+ This method should be overridden by inheriting models to implement
+ model-specific access rules based on ownership and relationships.
+
+ :param user: The user attempting to edit the record
+ :return: True if the user has permission, False otherwise
+ """
+ # This is a generic implementation that should be overridden
+ # by specific models to implement proper access control
+
+ # Check if the record has an owner field and if the user is the owner
+ owner_fields = ['user_id', 'partner_id', 'create_uid']
+ for field in owner_fields:
+ if hasattr(self, field) and getattr(self, field, False):
+ # Check if user is the owner
+ if field == 'user_id' and self.user_id.id == user.id:
+ return True
+ # Check if user's partner is the owner
+ if field == 'partner_id' and self.partner_id.id == user.partner_id.id:
+ return True
+ # Check if user created the record
+ if field == 'create_uid' and self.create_uid.id == user.id:
+ return True
+
+ # Check for parent relationship (if applicable)
+ if hasattr(self, 'parent_id') and self.parent_id and hasattr(user, 'partner_id') and user.partner_id:
+ # Check if user's partner is the parent
+ if self.parent_id.id == user.partner_id.id:
+ return True
+
+ # Check if user's partner and this record share the same parent (siblings)
+ if hasattr(user.partner_id, 'parent_id') and user.partner_id.parent_id:
+ if self.parent_id.id == user.partner_id.parent_id.id:
+ return True
+
+ # If no specific relationship is found, fall back to the allow_portal_edit flag
+ return self.allow_portal_edit
+
+ @api.model
+ def _get_portal_allowed_fields(self):
+ """
+ Returns the list of fields that portal users are allowed to edit.
+ To be overridden by inheriting models to specify allowed fields.
+
+ :return: List of field names that can be edited via the portal
+ """
+ return []
diff --git a/portal_partner_manager/models/res_partner.py b/portal_partner_manager/models/res_partner.py
new file mode 100644
index 0000000..8129b1e
--- /dev/null
+++ b/portal_partner_manager/models/res_partner.py
@@ -0,0 +1,195 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+from odoo.exceptions import ValidationError, AccessError
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class Partner(models.Model):
+ _name = 'res.partner'
+ _inherit = [
+ 'res.partner',
+ 'portal.editable.mixin',
+ 'portal.logging.mixin']
+
+ # Extend the type selection field to add the 'origin' type
+ type = fields.Selection(selection_add=[
+ ('origin', 'Origin Address'),
+ ], ondelete={'origin': 'set default'})
+
+ # Override the field to provide a more specific help text for partners
+ allow_portal_edit = fields.Boolean(
+ string='Allow Edit via Portal',
+ default=True,
+ help="If checked, portal users associated with contacts of this company can edit its information"
+ )
+
+ # For backward compatibility with existing code
+ allow_portal_parent_edit = fields.Boolean(
+ string='Allow Edit via Portal (Legacy)',
+ related='allow_portal_edit',
+ readonly=False,
+ store=True,
+ help="Legacy field, use allow_portal_edit instead"
+ )
+
+ # Override the mixin's methods to implement partner-specific behavior
+ def _check_portal_edit_access(self, user):
+ """
+ Check if the given user has permission to edit this partner via the portal.
+
+ :param user: The user attempting to edit the record
+ :return: True if the user has permission, False otherwise
+ """
+ user_partner = user.partner_id
+
+ # The user can always edit their own profile
+ if self.id == user_partner.id:
+ return True
+
+ # Check if the partner is the user's parent company
+ if self.id == user_partner.parent_id.id:
+ return self.allow_portal_edit
+
+ # Check if the partner is a contact of the user's parent company
+ if self.parent_id and self.parent_id.id == user_partner.parent_id.id:
+ return self.parent_id.allow_portal_edit
+
+ return False
+
+ @api.model
+ def _get_portal_allowed_fields(self):
+ """
+ Returns the list of fields that portal users are allowed to edit
+ """
+ return [
+ 'name',
+ 'street',
+ 'street2',
+ 'zip',
+ 'city',
+ 'state_id',
+ 'country_id',
+ 'phone',
+ 'mobile',
+ 'email',
+ 'website',
+ 'vat',
+ 'comment',
+ 'type'
+ ]
+
+ def get_portal_children(self, user_id=None):
+ """
+ Returns the visible child contacts for the portal user
+ """
+ self.ensure_one()
+ if not user_id:
+ user_id = self.env.user.id
+
+ user = self.env['res.users'].browse(user_id)
+
+ # If the user is an administrator, return all children
+ if user.has_group('base.group_system'):
+ return self.child_ids
+
+ # If the user is a portal user
+ if user.has_group('base.group_portal') and not user.has_group('base.group_user'):
+ # Check if the user is associated with a contact of this company
+ user_partner = user.partner_id
+ if user_partner.parent_id.id != self.id:
+ return self.env['res.partner']
+
+ # Return all child contacts
+ return self.child_ids
+
+ return self.env['res.partner']
+
+ @api.model
+ def create_portal_contact(self, parent_id, vals):
+ """
+ Creates a new child contact via the portal or reactivates an archived one
+ """
+ parent = self.browse(parent_id)
+
+ # Check if the user is a portal user
+ user = self.env.user
+ if not user.has_group('base.group_portal') or user.has_group('base.group_user'):
+ raise AccessError(_("Only portal users can use this function."))
+
+ # Check if the user is associated with a contact of this company
+ user_partner = user.partner_id
+ if user_partner.parent_id.id != parent.id:
+ raise AccessError(_("You are not allowed to add contacts to this company."))
+
+ # Check if the email is provided
+ if not vals.get('email'):
+ raise ValidationError(_("The email address is required for new contacts."))
+
+ # Check if a partner with this email already exists but is archived
+ email = vals.get('email')
+ existing_partner = self.sudo().search([
+ ('email', '=ilike', email),
+ ('parent_id', '=', parent.id),
+ ('active', '=', False)
+ ], limit=1)
+
+ if existing_partner:
+ # Partner exists but is archived, reactivate it
+ _logger.info("Reactivating archived partner with email %s", email)
+
+ # Prepare the values for update
+ update_vals = {
+ 'active': True,
+ 'portal_last_update': fields.Datetime.now(),
+ 'portal_updated_by': user.id,
+ }
+
+ # Filter the allowed fields
+ allowed_fields = self._get_portal_allowed_fields()
+ for field in allowed_fields:
+ if field in vals:
+ update_vals[field] = vals[field]
+
+ # Update the partner
+ existing_partner.sudo().write(update_vals)
+
+ # Check if there's an associated user that's archived
+ if existing_partner.user_ids:
+ User = self.env['res.users']
+ archived_users = User.sudo().search([
+ ('partner_id', '=', existing_partner.id),
+ ('active', '=', False)
+ ])
+
+ # Only reactivate portal users, not internal users
+ portal_users = archived_users.filtered(
+ lambda u: u.has_group('base.group_portal') and not u.has_group('base.group_user')
+ )
+
+ if portal_users:
+ portal_users.sudo().write({'active': True})
+
+ return existing_partner
+ else:
+ # No archived partner found, create a new one
+ # Prepare the values for creation
+ create_vals = {
+ 'parent_id': parent.id,
+ 'type': 'contact',
+ 'is_company': False,
+ 'portal_last_update': fields.Datetime.now(),
+ 'portal_updated_by': user.id,
+ 'allow_portal_edit': True, # Enable portal editing by default for new contacts
+ }
+
+ # Filter the allowed fields
+ allowed_fields = self._get_portal_allowed_fields()
+ for field in allowed_fields:
+ if field in vals:
+ create_vals[field] = vals[field]
+
+ # Create the contact
+ return super(Partner, self.sudo()).create(create_vals)
diff --git a/portal_partner_manager/models/res_users.py b/portal_partner_manager/models/res_users.py
new file mode 100644
index 0000000..ea85205
--- /dev/null
+++ b/portal_partner_manager/models/res_users.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api
+import logging
+
+_logger = logging.getLogger(__name__)
+
+class Users(models.Model):
+ _name = 'res.users'
+ _inherit = 'res.users'
+ _description = "Users with Portal Logging"
+
+ # Ajouter les champs du mixin manuellement pour éviter les problèmes avec l'authentification
+ log_ids = fields.One2many(
+ 'portal.activity.log',
+ 'res_id',
+ string="Activity Logs",
+ domain=lambda self: [('model', '=', self._name)],
+ readonly=True
+ )
+
+ # Nous n'avons pas besoin de redéfinir onchange car nous héritons correctement
+
+ # Vous pouvez ajouter ici des champs ou méthodes spécifiques aux utilisateurs
+ # liés à la fonctionnalité de journalisation
+
+ def log_portal_activity(self, user_id, action, details=None, ip=None):
+ """
+ Records a portal activity in the log
+
+ :param user_id: ID of the user performing the action
+ :param action: Type of action performed (view, edit, create, etc.)
+ :param details: Optional details about the action
+ :param ip: IP address of the user performing the action
+ :return: Created log entry
+ """
+ self.ensure_one()
+
+ return self.env['portal.activity.log'].create({
+ 'user_id': user_id,
+ 'model': self._name,
+ 'res_id': self.id,
+ 'action': action,
+ 'details': details or '',
+ 'ip': ip or False,
+ })
+
+ def write(self, vals):
+ """
+ Surcharge de write pour journaliser automatiquement les modifications
+ effectuées via le portail
+ """
+ res = super(Users, self).write(vals)
+
+ # Si l'utilisateur est un utilisateur du portail et modifie son propre profil
+ portal_user = self.env.user
+ if portal_user.has_group('base.group_portal') and portal_user.id in self.ids:
+ # Créer un log d'activité détaillé
+ details = ", ".join([f"{key}: {vals[key]}" for key in vals if key not in ['__last_update', 'write_date']])
+ if details:
+ self.filtered(lambda u: u.id == portal_user.id).log_portal_activity(
+ user_id=portal_user.id,
+ action='edit',
+ details=f"User updated profile: {details}"
+ )
+
+ return res
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """
+ Surcharge de create pour journaliser la création d'utilisateurs du portail
+ """
+ users = super(Users, self).create(vals_list)
+
+ # Journaliser uniquement si l'utilisateur créateur est un utilisateur du portail
+ portal_user = self.env.user
+ if portal_user.has_group('base.group_portal'):
+ for user in users:
+ if user.has_group('base.group_portal'):
+ user.log_portal_activity(
+ user_id=portal_user.id,
+ action='create',
+ details=f"Portal user created: {user.name} ({user.login})"
+ )
+
+ return users
diff --git a/portal_partner_manager/security/ir.model.access.csv b/portal_partner_manager/security/ir.model.access.csv
new file mode 100644
index 0000000..3b0a5be
--- /dev/null
+++ b/portal_partner_manager/security/ir.model.access.csv
@@ -0,0 +1,7 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_portal_access_manager,portal.access manager,model_portal_access,group_portal_manager,1,1,1,1
+access_portal_access_user,portal.access user,model_portal_access,base.group_user,1,0,0,0
+
+access_portal_activity_log_manager,portal.activity.log manager,model_portal_activity_log,group_portal_manager,1,1,1,1
+access_portal_activity_log_user,portal.activity.log user,model_portal_activity_log,base.group_user,1,0,0,0
+access_portal_activity_log_portal,portal.activity.log portal,model_portal_activity_log,base.group_portal,0,0,0,0
diff --git a/portal_partner_manager/security/portal_partner_manager_rules.xml b/portal_partner_manager/security/portal_partner_manager_rules.xml
new file mode 100644
index 0000000..4001bd9
--- /dev/null
+++ b/portal_partner_manager/security/portal_partner_manager_rules.xml
@@ -0,0 +1,13 @@
+
+
+
+ Portal: Read contacts of own company
+
+
+ ['|', ('id', '=', user.partner_id.id), ('parent_id', 'child_of', user.partner_id.id)]
+
+
+
+
+
+
diff --git a/portal_partner_manager/security/portal_security.xml b/portal_partner_manager/security/portal_security.xml
new file mode 100644
index 0000000..2845fab
--- /dev/null
+++ b/portal_partner_manager/security/portal_security.xml
@@ -0,0 +1,103 @@
+
+
+
+
+ Gestionnaire des accès portail
+
+
+
+
+
+
+
+ Portal User: access to parent company
+
+ [
+ '|',
+ ('id', '=', user.partner_id.id),
+ '|',
+ ('id', '=', user.partner_id.parent_id.id),
+ ('parent_id', '=', user.partner_id.parent_id.id)
+ ]
+
+
+
+
+
+
+
+
+
+ Portal User: write access to parent company
+
+ [
+ ('id', '=', user.partner_id.parent_id.id),
+ ('allow_portal_parent_edit', '=', True)
+ ]
+
+
+
+
+
+
+
+
+
+ Portal User: write access to own profile
+
+ [('id', '=', user.partner_id.id)]
+
+
+
+
+
+
+
+
+
+ Portal User: create contacts for parent company
+
+ [
+ ('parent_id', '=', user.partner_id.parent_id.id),
+ ('parent_id.allow_portal_parent_edit', '=', True)
+ ]
+
+
+
+
+
+
+
+
+
+ Portal User: write access to parent company contacts
+
+ [
+ ('parent_id', '=', user.partner_id.parent_id.id),
+ ('parent_id.allow_portal_parent_edit', '=', True)
+ ]
+
+
+
+
+
+
+
+
+
+ Portal Access: employees only
+
+ [(1, '=', 1)]
+
+
+
+
+
+
+ Portal Activity Log: employees only
+
+ [(1, '=', 1)]
+
+
+
diff --git a/portal_partner_manager/static/src/img/company.svg b/portal_partner_manager/static/src/img/company.svg
new file mode 100644
index 0000000..8be4930
--- /dev/null
+++ b/portal_partner_manager/static/src/img/company.svg
@@ -0,0 +1,40 @@
+
+
+
\ No newline at end of file
diff --git a/portal_partner_manager/static/src/img/contacts.svg b/portal_partner_manager/static/src/img/contacts.svg
new file mode 100644
index 0000000..ce5657a
--- /dev/null
+++ b/portal_partner_manager/static/src/img/contacts.svg
@@ -0,0 +1,45 @@
+
+
+
\ No newline at end of file
diff --git a/portal_partner_manager/static/src/js/debug_tools.js b/portal_partner_manager/static/src/js/debug_tools.js
new file mode 100644
index 0000000..7654a93
--- /dev/null
+++ b/portal_partner_manager/static/src/js/debug_tools.js
@@ -0,0 +1,247 @@
+/** @odoo-module **/
+
+/**
+ * Outil de débogage avancé pour capturer et analyser les erreurs JavaScript.
+ * Ce script intercepte toutes les erreurs JavaScript et les affiche de manière détaillée dans la console.
+ * Il inclut également des outils spécifiques pour identifier les scripts qui utilisent jQuery sans vérifier son existence.
+ */
+
+// Sauvegarde de la fonction d'erreur originale
+const originalErrorHandler = window.onerror;
+
+// Fonction pour obtenir la stack trace d'une erreur
+function getStackTrace(error) {
+ if (!error || !error.stack) {
+ return 'Stack trace non disponible';
+ }
+ return error.stack;
+}
+
+// Fonction pour extraire le contenu d'un script à partir d'une URL
+async function fetchScriptContent(url) {
+ try {
+ const response = await fetch(url);
+ if (!response.ok) {
+ throw new Error(`Erreur HTTP: ${response.status}`);
+ }
+ return await response.text();
+ } catch (error) {
+ console.error('Erreur lors de la récupération du script:', error);
+ return null;
+ }
+}
+
+// Fonction pour analyser un script et trouver les utilisations de jQuery
+async function analyzeScriptForJQuery(source, lineno) {
+ if (!source || source === 'edit') {
+ // Pour les scripts inline, nous ne pouvons pas récupérer le contenu directement
+ console.log('%cScript inline détecté', 'font-weight: bold; color: orange;');
+
+ // Rechercher tous les scripts dans le document
+ const scripts = document.querySelectorAll('script');
+ console.log(`%cAnalyse de ${scripts.length} scripts dans le document`, 'font-weight: bold;');
+
+ scripts.forEach((script, index) => {
+ const content = script.textContent;
+ if (content && content.includes('$') && !content.includes('function $') && !content.includes('window.$')) {
+ console.log(`%cScript #${index + 1} utilise jQuery sans vérification:`, 'font-weight: bold; color: red;');
+ console.log('%cContenu du script:', 'font-weight: bold;');
+ console.log(content);
+
+ // Essayer de localiser la ligne exacte
+ const lines = content.split('\n');
+ lines.forEach((line, lineIndex) => {
+ if (line.includes('$') && !line.includes('function $') && !line.includes('window.$')) {
+ console.log(`%cLigne ${lineIndex + 1}: ${line}`, 'color: red;');
+ }
+ });
+ }
+ });
+ return;
+ }
+
+ // Pour les scripts externes, récupérer le contenu
+ const content = await fetchScriptContent(source);
+ if (content) {
+ const lines = content.split('\n');
+ const startLine = Math.max(0, lineno - 10);
+ const endLine = Math.min(lines.length, lineno + 10);
+
+ console.log('%cExtrait du script autour de la ligne d\'erreur:', 'font-weight: bold;');
+ for (let i = startLine; i < endLine; i++) {
+ const lineHighlight = i === lineno - 1 ? 'color: red; font-weight: bold;' : '';
+ console.log(`%c${i + 1}: ${lines[i]}`, lineHighlight);
+ }
+ }
+}
+
+// Fonction pour analyser une erreur
+async function analyzeError(message, source, lineno, colno, error) {
+ console.group('%c🔍 Erreur JavaScript interceptée', 'color: red; font-weight: bold; font-size: 14px;');
+ console.log('%cMessage:', 'font-weight: bold;', message);
+ console.log('%cSource:', 'font-weight: bold;', source);
+ console.log('%cLigne:', 'font-weight: bold;', lineno);
+ console.log('%cColonne:', 'font-weight: bold;', colno);
+
+ if (error) {
+ console.log('%cType d\'erreur:', 'font-weight: bold;', error.name);
+ console.log('%cStack trace:', 'font-weight: bold;');
+ console.log(getStackTrace(error));
+
+ // Analyse spécifique pour les erreurs courantes
+ if (message.includes('Cannot read properties of null')) {
+ console.warn('%cAnalyse:', 'font-weight: bold; color: orange;',
+ 'Tentative d\'accès à une propriété d\'un objet null ou undefined. ' +
+ 'Vérifiez si l\'élément DOM existe avant d\'y accéder.');
+
+ // Essayer d'identifier l'élément manquant
+ if (source && source.includes('assets_frontend')) {
+ console.log('%cErreur dans les assets frontend d\'Odoo', 'font-weight: bold;');
+ console.log('Cela peut être dû à un widget Odoo qui tente d\'accéder à un élément qui n\'existe pas encore.');
+ }
+ } else if (message.includes('$ is not defined')) {
+ console.warn('%cAnalyse:', 'font-weight: bold; color: orange;',
+ 'jQuery ($) n\'est pas disponible. ' +
+ 'Vérifiez que jQuery est chargé avant d\'utiliser $ ou utilisez document.querySelector à la place.');
+
+ // Analyser le script pour trouver l'utilisation de jQuery
+ await analyzeScriptForJQuery(source, lineno);
+ }
+ }
+
+ // Récupérer l'état du DOM au moment de l'erreur
+ try {
+ console.log('%cÉtat du DOM:', 'font-weight: bold;');
+ console.log('Éléments avec la classe "o_portal_details":', document.querySelectorAll('.o_portal_details').length);
+ console.log('Éléments avec l\'ID "bemade_company_edit_form":', document.getElementById('bemade_company_edit_form') ? 1 : 0);
+ console.log('Sélecteurs de pays:', document.querySelectorAll('select[name="country_id"]').length);
+ console.log('Sélecteurs de province:', document.querySelectorAll('select[name="state_id"]').length);
+
+ // Vérifier si jQuery est disponible
+ console.log('jQuery disponible:', typeof jQuery !== 'undefined' ? 'Oui' : 'Non');
+ console.log('$ disponible:', typeof $ !== 'undefined' ? 'Oui' : 'Non');
+
+ // Lister tous les scripts de la page
+ const scripts = document.querySelectorAll('script');
+ console.log(`Nombre de scripts dans la page: ${scripts.length}`);
+ scripts.forEach((script, index) => {
+ if (script.src) {
+ console.log(`Script #${index + 1}: ${script.src}`);
+ } else if (script.textContent && script.textContent.length < 100) {
+ console.log(`Script inline #${index + 1}: ${script.textContent.substring(0, 100)}...`);
+ } else {
+ console.log(`Script inline #${index + 1}: [contenu trop long]`);
+ }
+ });
+ } catch (e) {
+ console.error('Erreur lors de l\'analyse du DOM:', e);
+ }
+
+ console.groupEnd();
+
+ // Appeler le gestionnaire d'erreurs original s'il existe
+ if (originalErrorHandler) {
+ return originalErrorHandler(message, source, lineno, colno, error);
+ }
+
+ // Retourner false pour indiquer que l'erreur a été gérée
+ return false;
+}
+
+// Remplacer le gestionnaire d'erreurs global
+window.onerror = analyzeError;
+
+// Intercepter également les rejets de promesses non gérés
+window.addEventListener('unhandledrejection', function(event) {
+ console.group('%c🔍 Promesse rejetée non gérée', 'color: red; font-weight: bold; font-size: 14px;');
+ console.log('%cRaison:', 'font-weight: bold;', event.reason);
+ if (event.reason instanceof Error) {
+ console.log('%cStack trace:', 'font-weight: bold;');
+ console.log(getStackTrace(event.reason));
+ }
+ console.groupEnd();
+});
+
+// Ajouter un outil pour surveiller les mutations du DOM
+function setupDOMObserver() {
+ // Créer un observateur de mutations
+ const observer = new MutationObserver(function(mutations) {
+ mutations.forEach(function(mutation) {
+ if (mutation.type === 'childList' &&
+ (mutation.target.id === 'bemade_company_edit_form' ||
+ mutation.target.classList.contains('o_portal_details'))) {
+ console.log('%c🔄 Mutation du DOM détectée', 'color: blue; font-weight: bold;', {
+ target: mutation.target,
+ addedNodes: mutation.addedNodes.length,
+ removedNodes: mutation.removedNodes.length
+ });
+ }
+ });
+ });
+
+ // Observer le document entier
+ observer.observe(document.documentElement, {
+ childList: true,
+ subtree: true
+ });
+
+ console.log('%c🔍 Observateur de DOM installé', 'color: green; font-weight: bold;');
+
+ // Rechercher immédiatement les scripts qui utilisent jQuery sans vérification
+ const scripts = document.querySelectorAll('script');
+ let jQueryUsageFound = false;
+
+ scripts.forEach((script, index) => {
+ const content = script.textContent;
+ if (content && content.includes('$') && !content.includes('function $') && !content.includes('window.$') && !content.includes('typeof $')) {
+ jQueryUsageFound = true;
+ console.group('%c🔍 Script utilisant jQuery sans vérification détecté', 'color: red; font-weight: bold;');
+ console.log(`Script #${index + 1}:`);
+ console.log(content);
+ console.groupEnd();
+ }
+ });
+
+ if (!jQueryUsageFound) {
+ console.log('%cAucun script utilisant jQuery sans vérification n\'a été trouvé dans le document actuel', 'color: green; font-weight: bold;');
+ }
+}
+
+// Installer l'observateur de DOM lorsque le document est chargé
+document.addEventListener('DOMContentLoaded', setupDOMObserver);
+
+// Installer également un observateur pour les scripts ajoutés dynamiquement
+function setupScriptObserver() {
+ const observer = new MutationObserver(function(mutations) {
+ mutations.forEach(function(mutation) {
+ if (mutation.type === 'childList') {
+ mutation.addedNodes.forEach(function(node) {
+ if (node.tagName === 'SCRIPT') {
+ console.log('%c🔍 Script ajouté dynamiquement détecté', 'color: blue; font-weight: bold;');
+ console.log(node);
+
+ const content = node.textContent;
+ if (content && content.includes('$') && !content.includes('function $') && !content.includes('window.$') && !content.includes('typeof $')) {
+ console.group('%c🔍 Script dynamique utilisant jQuery sans vérification détecté', 'color: red; font-weight: bold;');
+ console.log(content);
+ console.groupEnd();
+ }
+ }
+ });
+ }
+ });
+ });
+
+ observer.observe(document.documentElement, {
+ childList: true,
+ subtree: true
+ });
+
+ console.log('%c🔍 Observateur de scripts dynamiques installé', 'color: green; font-weight: bold;');
+}
+
+// Installer l'observateur de scripts dynamiques
+document.addEventListener('DOMContentLoaded', setupScriptObserver);
+
+// Afficher un message de démarrage
+console.log('%c🔧 Outils de débogage JavaScript avancés chargés', 'color: green; font-weight: bold; font-size: 14px;');
diff --git a/portal_partner_manager/static/src/js/jquery_early_fix.js b/portal_partner_manager/static/src/js/jquery_early_fix.js
new file mode 100644
index 0000000..a2d7d2e
--- /dev/null
+++ b/portal_partner_manager/static/src/js/jquery_early_fix.js
@@ -0,0 +1,95 @@
+/** @odoo-module **/
+
+/**
+ * Script de correction précoce pour jQuery
+ * Ce script est chargé très tôt dans le processus de chargement de la page
+ * et intercepte l'erreur "$ is not defined" avant qu'elle ne se produise.
+ */
+
+(function() {
+ // Définir une version globale de $ avant tout autre script
+ if (typeof window.$ === 'undefined') {
+ console.log('🔧 Définition précoce de $ avant tout autre script');
+ window.$ = function(selector) {
+ if (typeof selector === 'string') {
+ return document.querySelectorAll(selector);
+ } else if (selector instanceof Element) {
+ return {
+ 0: selector,
+ length: 1,
+ each: function(callback) {
+ callback.call(selector, 0, selector);
+ return this;
+ }
+ };
+ }
+ return { length: 0 };
+ };
+
+ // Ajouter des méthodes minimales
+ window.$.fn = {};
+ }
+
+ // Intercepter toutes les erreurs JavaScript dès le début
+ const originalErrorHandler = window.onerror;
+ window.onerror = function(message, source, lineno, colno, error) {
+ // Intercepter spécifiquement les erreurs jQuery
+ if (message && message.includes('$ is not defined')) {
+ console.warn('⚠️ Erreur jQuery interceptée précocement:', message, 'à', source, 'ligne', lineno);
+
+ // Examiner tous les scripts de la page
+ const scripts = document.querySelectorAll('script');
+ scripts.forEach((script, index) => {
+ if (!script.src && script.textContent && script.textContent.includes('$')) {
+ console.log(`Script inline #${index + 1} qui utilise $:`, script.textContent);
+ }
+ });
+
+ // Empêcher la propagation de l'erreur
+ return true;
+ }
+
+ // Laisser les autres erreurs être gérées normalement
+ if (originalErrorHandler) {
+ return originalErrorHandler(message, source, lineno, colno, error);
+ }
+ return false;
+ };
+
+ // Injecter un correctif dans le document pour les scripts inline
+ function injectFix() {
+ // Créer un script qui définit $ au tout début du document
+ const fixScript = document.createElement('script');
+ fixScript.textContent = `
+ // Définir $ globalement s'il n'existe pas déjà
+ if (typeof window.$ === 'undefined') {
+ window.$ = function(selector) {
+ if (typeof selector === 'string') {
+ return document.querySelectorAll(selector);
+ }
+ return { length: 0 };
+ };
+ window.$.fn = {};
+ }
+ `;
+
+ // Insérer le script au début du document
+ const firstScript = document.querySelector('script');
+ if (firstScript && firstScript.parentNode) {
+ firstScript.parentNode.insertBefore(fixScript, firstScript);
+ } else {
+ document.head.appendChild(fixScript);
+ }
+
+ console.log('🔧 Correctif jQuery injecté au début du document');
+ }
+
+ // Exécuter l'injection dès que possible
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', injectFix);
+ } else {
+ injectFix();
+ }
+
+ console.log('🔧 Correctif précoce jQuery chargé');
+})();
diff --git a/portal_partner_manager/static/src/js/jquery_safety.js b/portal_partner_manager/static/src/js/jquery_safety.js
new file mode 100644
index 0000000..56676ea
--- /dev/null
+++ b/portal_partner_manager/static/src/js/jquery_safety.js
@@ -0,0 +1,210 @@
+/** @odoo-module **/
+
+/**
+ * Script de sécurité pour jQuery
+ * Ce script assure que $ est défini et fournit une implémentation de secours si nécessaire.
+ * Il résout également le problème "Cannot read properties of null (reading 'remove')"
+ * en ajoutant des vérifications de nullité.
+ */
+
+// Exécution immédiate pour définir $ avant tout autre script
+(function() {
+ // Définir une version globale de jQuery immédiatement
+ if (typeof window.$ === 'undefined') {
+ console.log('🛡️ Définition préventive de $ avant chargement de jQuery');
+
+ // Créer un remplacement minimal pour jQuery
+ window.$ = function(selector) {
+ // Version simplifiée de jQuery pour les sélecteurs de base
+ if (typeof selector === 'string') {
+ return document.querySelectorAll(selector);
+ } else if (selector instanceof Element) {
+ // Envelopper un élément DOM dans un objet similaire à jQuery
+ return {
+ 0: selector,
+ length: 1,
+ each: function(callback) {
+ callback.call(selector, 0, selector);
+ return this;
+ },
+ on: function(event, handler) {
+ selector.addEventListener(event, handler);
+ return this;
+ },
+ val: function(value) {
+ if (value === undefined) {
+ return selector.value;
+ }
+ selector.value = value;
+ return this;
+ },
+ find: function(childSelector) {
+ return $(selector.querySelectorAll(childSelector));
+ },
+ parent: function() {
+ return $(selector.parentNode);
+ },
+ show: function() {
+ selector.style.display = '';
+ return this;
+ },
+ hide: function() {
+ selector.style.display = 'none';
+ return this;
+ },
+ addClass: function(className) {
+ selector.classList.add(className);
+ return this;
+ },
+ removeClass: function(className) {
+ selector.classList.remove(className);
+ return this;
+ },
+ hasClass: function(className) {
+ return selector.classList.contains(className);
+ },
+ attr: function(name, value) {
+ if (value === undefined) {
+ return selector.getAttribute(name);
+ }
+ selector.setAttribute(name, value);
+ return this;
+ },
+ removeAttr: function(name) {
+ selector.removeAttribute(name);
+ return this;
+ },
+ data: function(key, value) {
+ const dataKey = 'data-' + key;
+ if (value === undefined) {
+ return selector.getAttribute(dataKey);
+ }
+ selector.setAttribute(dataKey, value);
+ return this;
+ },
+ remove: function() {
+ if (selector && selector.parentNode) {
+ selector.parentNode.removeChild(selector);
+ }
+ return this;
+ }
+ };
+ }
+ return { length: 0 };
+ };
+
+ // Ajouter des méthodes utiles à $.fn
+ window.$.fn = {
+ each: function(callback) {
+ for (let i = 0; i < this.length; i++) {
+ callback.call(this[i], i, this[i]);
+ }
+ return this;
+ },
+ remove: function() {
+ if (this && this.length > 0) {
+ for (let i = 0; i < this.length; i++) {
+ if (this[i] && this[i].parentNode) {
+ this[i].parentNode.removeChild(this[i]);
+ }
+ }
+ }
+ return this;
+ }
+ };
+ }
+
+ // Fonction complète d'initialisation des protections jQuery
+ function initJQuerySafety() {
+ console.log('🛡️ Initialisation des protections jQuery');
+
+ // 1. S'assurer que $ est défini avec le vrai jQuery s'il est disponible
+ if (typeof jQuery !== 'undefined' && window.$ !== jQuery) {
+ // Sauvegarder notre implémentation de secours
+ const backupJQuery = window.$;
+
+ // Remplacer par le vrai jQuery
+ window.$ = jQuery;
+ console.log('🛡️ $ remplacé par le vrai jQuery');
+
+ // Transférer les méthodes personnalisées si nécessaire
+ if (backupJQuery.fn && !$.fn.safeRemove) {
+ $.fn.safeRemove = function() {
+ if (this && this.length > 0) {
+ return this.remove();
+ }
+ return this;
+ };
+ }
+ }
+
+ // 2. Patch pour le problème "Cannot read properties of null"
+ if (Element.prototype.remove) {
+ const originalRemove = Element.prototype.remove;
+ Element.prototype.remove = function() {
+ if (this && this.parentNode) {
+ return originalRemove.apply(this, arguments);
+ }
+ console.warn('⚠️ Tentative de suppression d\'un élément null ou sans parent évitée');
+ return null;
+ };
+ }
+
+ // 3. Patch pour jQuery.remove si disponible
+ if ($ && $.fn && $.fn.remove && !$.fn._safePatchApplied) {
+ const originalJQueryRemove = $.fn.remove;
+ $.fn.remove = function() {
+ if (this && this.length > 0) {
+ return originalJQueryRemove.apply(this, arguments);
+ }
+ console.warn('⚠️ Tentative de $.remove() sur un élément non existant évitée');
+ return this;
+ };
+ $.fn._safePatchApplied = true;
+ }
+
+ // 4. Intercepter les erreurs globales pour les erreurs jQuery
+ if (!window._jqueryErrorHandlerInstalled) {
+ const originalErrorHandler = window.onerror;
+ window.onerror = function(message, source, lineno, colno, error) {
+ // Intercepter spécifiquement les erreurs jQuery
+ if (message && (message.includes('$ is not defined') || message.includes('jQuery is not defined'))) {
+ console.warn('⚠️ Erreur jQuery interceptée:', message);
+ return true; // Empêcher la propagation de l'erreur
+ }
+
+ // Laisser les autres erreurs être gérées normalement
+ if (originalErrorHandler) {
+ return originalErrorHandler(message, source, lineno, colno, error);
+ }
+ return false;
+ };
+ window._jqueryErrorHandlerInstalled = true;
+ }
+
+ console.log('🛡️ Protections jQuery installées avec succès');
+ }
+
+ // Exécuter immédiatement et à plusieurs moments pour s'assurer que les protections sont en place
+ try {
+ // Exécuter immédiatement
+ initJQuerySafety();
+
+ // Exécuter quand le DOM est prêt
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', initJQuerySafety);
+ } else {
+ initJQuerySafety();
+ }
+
+ // Exécuter quand la page est complètement chargée
+ window.addEventListener('load', initJQuerySafety);
+
+ // Exécuter après un court délai pour s'assurer que jQuery est chargé
+ setTimeout(initJQuerySafety, 100);
+ setTimeout(initJQuerySafety, 500);
+ setTimeout(initJQuerySafety, 1000);
+ } catch (e) {
+ console.error('Erreur lors de l\'initialisation des protections jQuery:', e);
+ }
+})();
diff --git a/portal_partner_manager/static/src/js/portal_fix.js b/portal_partner_manager/static/src/js/portal_fix.js
new file mode 100644
index 0000000..41409ee
--- /dev/null
+++ b/portal_partner_manager/static/src/js/portal_fix.js
@@ -0,0 +1,50 @@
+/* Fix for portal.js error */
+
+/**
+ * Ce script corrige l'erreur "Cannot read properties of null (reading 'remove')"
+ * qui se produit dans le widget PortalHomeCounters d'Odoo standard.
+ *
+ * Cette version simplifie l'approche en se concentrant uniquement sur la protection
+ * de la méthode remove() au niveau global, ce qui résout la plupart des erreurs sans
+ * perturber d'autres fonctionnalités.
+ */
+
+(function() {
+ // Patch global Element.prototype.remove pour éviter les erreurs
+ try {
+ const originalElementRemove = Element.prototype.remove;
+
+ // Remplacer la méthode remove native pour gérer les cas null/undefined
+ Element.prototype.remove = function() {
+ try {
+ // Appliquer la méthode originale
+ return originalElementRemove.apply(this, arguments);
+ } catch (e) {
+ // Capturer l'erreur sans l'afficher dans la console (évite la pollution)
+ return undefined;
+ }
+ };
+ } catch (e) {
+ // Échec silencieux, ne pas perturber d'autres scripts
+ }
+
+ // Patch pour jQuery.remove() si jQuery est chargé
+ if (window.jQuery) {
+ try {
+ const originalJQueryRemove = jQuery.fn.remove;
+
+ // Remplacer la méthode jQuery.remove pour gérer les cas problématiques
+ jQuery.fn.remove = function() {
+ try {
+ // Appliquer la méthode originale
+ return originalJQueryRemove.apply(this, arguments);
+ } catch (e) {
+ // Retourner this pour maintenir la chaîne jQuery
+ return this;
+ }
+ };
+ } catch (e) {
+ // Échec silencieux
+ }
+ }
+})();
diff --git a/portal_partner_manager/static/src/js/portal_partner.js b/portal_partner_manager/static/src/js/portal_partner.js
new file mode 100644
index 0000000..d7d3312
--- /dev/null
+++ b/portal_partner_manager/static/src/js/portal_partner.js
@@ -0,0 +1,330 @@
+/** @odoo-module **/
+
+import publicWidget from "@web/legacy/js/public/public_widget";
+
+/**
+ * Surcharge du widget standard d'Odoo pour éviter les conflits.
+ * Cette surcharge empêche le widget standard de s'initialiser et de causer des erreurs.
+ */
+publicWidget.registry.portal_details = publicWidget.Widget.extend({
+ selector: '.o_portal_details',
+ /**
+ * @override
+ */
+ start: function () {
+ console.log('Surcharging standard Odoo widget to prevent errors');
+ // Ne rien faire pour éviter les conflits
+ return this._super.apply(this, arguments);
+ },
+});
+
+/**
+ * Notre widget personnalisé pour la gestion des formulaires d'adresse.
+ */
+publicWidget.registry.bemadeCustomAddressManager = publicWidget.Widget.extend({
+ selector: '#bemade_company_edit_form',
+ events: {
+ 'change select[name="country_id"]': '_onCountryChange',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ console.log('Initializing custom country-state widget');
+ this._adaptCountryState();
+ return this._super.apply(this, arguments);
+ },
+
+ /**
+ * Adapte les options de province en fonction du pays sélectionné
+ *
+ * @private
+ */
+ _adaptCountryState: function() {
+ var $country = this.$('select[name="country_id"]');
+ if ($country.length) {
+ this._onCountryChange({currentTarget: $country[0]});
+ }
+ },
+
+ /**
+ * Gère l'événement de changement de pays.
+ *
+ * @private
+ * @param {Event} ev - L'événement de changement
+ */
+ _onCountryChange: function (ev) {
+ console.log('Country changed in custom widget');
+
+ try {
+ // Approche simplifiée pour éviter les erreurs
+ var $country = $(ev.currentTarget);
+ var countryID = $country.val() || 0;
+ var $state = this.$('select[name="state_id"]');
+
+ if (!$state.length) {
+ console.warn('State element not found');
+ return;
+ }
+
+ // Approche plus simple qui évite d'utiliser detach() et append()
+ // qui peuvent causer des erreurs
+ $state.find('option').each(function() {
+ var $option = $(this);
+ var isFirst = $option.is(':first-child');
+ var dataCountryId = $option.data('country_id') || $option.data('country-id');
+
+ if (isFirst) {
+ // Toujours afficher la première option (vide)
+ $option.removeClass('d-none').removeAttr('hidden');
+ } else if (dataCountryId == countryID) {
+ // Afficher les options correspondant au pays
+ $option.removeClass('d-none').removeAttr('hidden');
+ } else {
+ // Cacher les autres options
+ $option.addClass('d-none').attr('hidden', 'hidden');
+ }
+ });
+
+ // Afficher ou cacher le champ d'état en fonction des options disponibles
+ var hasVisibleOptions = $state.find('option:not(:first-child):not(.d-none)').length > 0;
+ if (hasVisibleOptions) {
+ $state.parent().show();
+ } else {
+ $state.parent().hide();
+ }
+
+ // Réinitialiser la valeur si l'option sélectionnée n'est plus valide
+ var $selectedOption = $state.find('option:selected');
+ if ($selectedOption.hasClass('d-none') || $selectedOption.attr('hidden')) {
+ $state.val('');
+ }
+ } catch (error) {
+ console.error('Error in _onCountryChange:', error);
+ }
+ },
+});
+
+/**
+ * Fonction utilitaire sécurisée pour adapter le formulaire d'adresse
+ * en fonction du pays sélectionné.
+ *
+ * @param {jQuery} $state - L'élément select des états/provinces
+ * @param {jQuery} $country - L'élément select des pays
+ * @param {jQuery} $stateOptions - Les options d'états/provinces
+ * @param {boolean} alwaysShow - Si true, toujours afficher le champ état
+ */
+function adaptAddressForm($state, $country, $stateOptions, alwaysShow = false) {
+ try {
+ if (!$state || !$state.length || !$country || !$country.length) {
+ console.warn('Missing required elements for adaptAddressForm');
+ return;
+ }
+
+ var countryID = ($country.val() || 0);
+
+ // Vérifier si $stateOptions existe avant d'utiliser detach
+ if ($stateOptions && $stateOptions.length) {
+ try {
+ $stateOptions.detach();
+ var $displayedState = $stateOptions.filter('[data-country_id=' + countryID + ']');
+ var nb = $displayedState.appendTo($state).length;
+ $state.parent().toggle(alwaysShow || nb > 0);
+ } catch (err) {
+ console.error('Error in detach/append operations:', err);
+ // Approche alternative sans detach/append
+ $state.find('option').each(function() {
+ var $option = $(this);
+ var isFirst = $option.is(':first-child');
+ var dataCountryId = $option.data('country_id') || $option.attr('data-country_id');
+
+ if (isFirst || dataCountryId == countryID) {
+ $option.removeClass('d-none').removeAttr('hidden');
+ } else {
+ $option.addClass('d-none').attr('hidden', 'hidden');
+ }
+ });
+ }
+ } else {
+ // Approche alternative si $stateOptions n'est pas disponible
+ $state.find('option').each(function() {
+ var $option = $(this);
+ var isFirst = $option.is(':first-child');
+ var dataCountryId = $option.data('country_id') || $option.attr('data-country_id');
+
+ if (isFirst || dataCountryId == countryID) {
+ $option.removeClass('d-none').removeAttr('hidden');
+ } else {
+ $option.addClass('d-none').attr('hidden', 'hidden');
+ }
+ });
+
+ // Afficher ou cacher le champ d'état
+ var hasVisibleOptions = $state.find('option:not(:first-child):not(.d-none)').length > 0;
+ $state.parent().toggle(alwaysShow || hasVisibleOptions);
+ }
+
+ // Réinitialiser la valeur si l'option sélectionnée n'est plus valide
+ var $selectedOption = $state.find('option:selected');
+ if ($selectedOption.hasClass('d-none') || $selectedOption.attr('hidden')) {
+ $state.val('');
+ }
+ } catch (error) {
+ console.error('Error in adaptAddressForm:', error);
+ }
+}
+
+/**
+ * Initialise les éléments du formulaire d'adresse.
+ *
+ * @param {Object} widget - Le widget contenant les éléments
+ * @param {string} stateSelector - Sélecteur pour l'élément state
+ * @param {string} countrySelector - Sélecteur pour l'élément country
+ * @param {boolean} alwaysShow - Si true, toujours afficher le champ état
+ */
+function initAddressForm(widget, stateSelector, countrySelector, alwaysShow = false) {
+ try {
+ if (!widget || !widget.$) {
+ console.warn('Invalid widget for initAddressForm');
+ return;
+ }
+
+ var $state = widget.$(stateSelector);
+ var $country = widget.$(countrySelector);
+
+ if (!$state.length || !$country.length) {
+ console.warn('State or country elements not found');
+ return;
+ }
+
+ // Stocker les références pour une utilisation ultérieure
+ widget.$state = $state;
+ widget.$country = $country;
+ widget.$stateOptions = $state.find('option[data-country_id]').detach();
+
+ // Initialiser l'affichage
+ adaptAddressForm($state, $country, widget.$stateOptions, alwaysShow);
+ } catch (error) {
+ console.error('Error in initAddressForm:', error);
+ }
+}
+
+/**
+ * Widget pour la gestion du formulaire d'adresse du partenaire parent.
+ * Réutilise le même code que le widget principal avec un sélecteur différent.
+ */
+publicWidget.registry.bemadeParentCompanyDetails = publicWidget.Widget.extend({
+ selector: '.o_portal_parent_company',
+ events: {
+ 'change select[name="parent_country_id"]': '_onCountryChange',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+
+ try {
+ // Utilisation de l'utilitaire d'initialisation avec des sélecteurs personnalisés
+ initAddressForm(
+ this,
+ 'select[name="parent_state_id"]',
+ 'select[name="parent_country_id"]',
+ true // Option 1: Toujours afficher le champ état
+ );
+ } catch (error) {
+ console.error('Error initializing parent company form:', error);
+ }
+
+ return def;
+ },
+
+ /**
+ * Gère l'événement de changement de pays pour le partenaire parent.
+ *
+ * @private
+ */
+ _onCountryChange: function () {
+ try {
+ if (this.$state && this.$country && this.$stateOptions) {
+ adaptAddressForm(this.$state, this.$country, this.$stateOptions, true);
+ }
+ } catch (error) {
+ console.error('Error in parent company _onCountryChange:', error);
+ }
+ },
+});
+
+/**
+ * Widget pour la gestion des formulaires d'adresse des partenaires frères.
+ * Réutilise le même code que les autres widgets.
+ */
+publicWidget.registry.bemadeSiblingPartnerDetails = publicWidget.Widget.extend({
+ selector: '.o_portal_sibling_partners',
+ events: {
+ 'change select[name^="sibling_country_id"]': '_onCountryChange',
+ },
+
+ /**
+ * @override
+ */
+ start: function () {
+ var def = this._super.apply(this, arguments);
+
+ try {
+ // Initialiser chaque formulaire de partenaire frère
+ this.$('div[data-sibling-id]').each(function() {
+ try {
+ var siblingId = $(this).data('sibling-id');
+ var widget = {
+ $: function(selector) { return $(this).find(selector); }.bind(this)
+ };
+
+ initAddressForm(
+ widget,
+ `select[name="sibling_state_id_${siblingId}"]`,
+ `select[name="sibling_country_id_${siblingId}"]`,
+ false // Option 2: Comportement standard
+ );
+
+ // Stocker les références pour une utilisation ultérieure
+ $(this).data('widget', widget);
+ } catch (innerError) {
+ console.error('Error initializing sibling form:', innerError);
+ }
+ });
+ } catch (error) {
+ console.error('Error in sibling partners initialization:', error);
+ }
+
+ return def;
+ },
+
+ /**
+ * Gère l'événement de changement de pays pour un partenaire frère.
+ *
+ * @private
+ */
+ _onCountryChange: function (ev) {
+ try {
+ // Identifier le partenaire frère concerné
+ var $target = $(ev.currentTarget);
+ var $siblingContainer = $target.closest('div[data-sibling-id]');
+ var widget = $siblingContainer.data('widget');
+
+ if (widget && widget.$state && widget.$country) {
+ adaptAddressForm(widget.$state, widget.$country, widget.$stateOptions || null, false);
+ }
+ } catch (error) {
+ console.error('Error in sibling _onCountryChange:', error);
+ }
+ },
+});
+
+export default {
+ bemadePortalDetails: publicWidget.registry.bemadePortalDetails,
+ bemadeParentCompanyDetails: publicWidget.registry.bemadeParentCompanyDetails,
+ bemadeSiblingPartnerDetails: publicWidget.registry.bemadeSiblingPartnerDetails,
+};
diff --git a/portal_partner_manager/static/src/js/portal_partner_utils.js b/portal_partner_manager/static/src/js/portal_partner_utils.js
new file mode 100644
index 0000000..26d8796
--- /dev/null
+++ b/portal_partner_manager/static/src/js/portal_partner_utils.js
@@ -0,0 +1,69 @@
+/** @odoo-module **/
+
+/**
+ * Utilitaires pour la gestion des partenaires dans le portail
+ * Réutilise au maximum le code standard d'Odoo
+ */
+
+// Fonction de débogage pour afficher des messages dans la console
+function debugLog(message, obj) {
+ console.log('%c[DEBUG UTILS] ' + message, 'background: #222; color: #bada55', obj || '');
+}
+
+/**
+
+ debugLog('Displayed state options', $displayedState.length);
+
+ var nb = $displayedState.appendTo($state).removeClass('d-none').show().length;
+ debugLog('Number of displayed options', nb);
+
+ // Option 1: Toujours afficher le champ état/province
+ if (alwaysShow) {
+ debugLog('Always show option enabled');
+ $state.parent().show();
+ }
+ // Option 2: N'afficher que si des provinces sont disponibles (comportement standard)
+ else {
+ debugLog('Toggling state visibility based on options count', nb >= 1);
+ $state.parent().toggle(nb >= 1);
+ }
+
+ debugLog('adaptAddressForm completed successfully');
+ } catch (e) {
+ console.error('Erreur dans adaptAddressForm:', e);
+ }
+};
+
+/**
+ * Initialise les champs d'adresse pour un formulaire
+ * Réutilisable pour le partenaire principal, parent ou frères
+ *
+ * @param {Object} widget - Instance du widget contenant les éléments
+ * @param {string} stateSelector - Sélecteur pour le champ état
+ * @param {string} countrySelector - Sélecteur pour le champ pays
+ * @param {boolean} alwaysShowState - Si true, toujours afficher le champ état
+ */
+export const initAddressForm = function(widget, stateSelector = 'select[name="state_id"]',
+ countrySelector = 'select[name="country_id"]',
+ alwaysShowState = false) {
+ try {
+ debugLog('initAddressForm called', {
+ stateSelector: stateSelector,
+ countrySelector: countrySelector,
+ alwaysShowState: alwaysShowState
+ });
+
+ widget.$state = widget.$(stateSelector);
+ widget.$country = widget.$(countrySelector);
+
+ debugLog('Found elements', {
+ state: widget.$state.length,
+ country: widget.$country.length
+ });
+ widget.$stateOptions = widget.$state.filter(':enabled').find('option:not(:first)');
+
+ adaptAddressForm(widget.$state, widget.$country, widget.$stateOptions, alwaysShowState);
+ } catch (e) {
+ console.error('Erreur dans initAddressForm:', e);
+ }
+};
diff --git a/portal_partner_manager/static/src/scss/portal_partner.scss b/portal_partner_manager/static/src/scss/portal_partner.scss
new file mode 100644
index 0000000..46b49fc
--- /dev/null
+++ b/portal_partner_manager/static/src/scss/portal_partner.scss
@@ -0,0 +1,65 @@
+.o_portal_partner_manager {
+ // Styles pour le formulaire de modification de la société
+ .o_portal_company_form {
+ .form-group {
+ margin-bottom: 1rem;
+ }
+
+ label {
+ font-weight: 500;
+ }
+
+ .form-control {
+ border-radius: 3px;
+ }
+
+ .btn-primary {
+ margin-top: 1rem;
+ }
+ }
+
+ // Styles pour la liste des contacts
+ .o_portal_contact_list {
+ .contact-card {
+ border: 1px solid #ddd;
+ border-radius: 5px;
+ padding: 15px;
+ margin-bottom: 15px;
+ transition: all 0.2s ease;
+
+ &:hover {
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
+ }
+
+ .contact-name {
+ font-weight: bold;
+ font-size: 1.1rem;
+ margin-bottom: 5px;
+ }
+
+ .contact-details {
+ color: #666;
+
+ i {
+ width: 20px;
+ text-align: center;
+ margin-right: 5px;
+ }
+ }
+ }
+ }
+
+ // Styles pour le formulaire d'ajout de contact
+ .o_portal_add_contact_form {
+ background-color: #f8f9fa;
+ border-radius: 5px;
+ padding: 20px;
+ margin-top: 20px;
+
+ h4 {
+ margin-bottom: 20px;
+ padding-bottom: 10px;
+ border-bottom: 1px solid #ddd;
+ }
+ }
+}
diff --git a/portal_partner_manager/tests/__init__.py b/portal_partner_manager/tests/__init__.py
new file mode 100644
index 0000000..edf7be9
--- /dev/null
+++ b/portal_partner_manager/tests/__init__.py
@@ -0,0 +1 @@
+from . import test_email_login_sync
diff --git a/portal_partner_manager/tests/test_email_login_sync.py b/portal_partner_manager/tests/test_email_login_sync.py
new file mode 100644
index 0000000..dfd60f2
--- /dev/null
+++ b/portal_partner_manager/tests/test_email_login_sync.py
@@ -0,0 +1,41 @@
+from odoo.tests.common import SavepointCase
+
+class TestEmailLoginSync(SavepointCase):
+ @classmethod
+ def setUpClass(cls):
+ super(TestEmailLoginSync, cls).setUpClass()
+
+ # Create a parent company
+ cls.parent_company = cls.env['res.partner'].create({
+ 'name': 'Test Company',
+ 'is_company': True,
+ 'allow_portal_parent_edit': True,
+ })
+
+ # Create a contact
+ cls.contact = cls.env['res.partner'].create({
+ 'name': 'Test Contact',
+ 'email': 'test@example.com',
+ 'parent_id': cls.parent_company.id,
+ })
+
+ # Create a portal user for the contact
+ cls.portal_user = cls.env['res.users'].create({
+ 'name': 'Test Portal User',
+ 'login': 'test@example.com',
+ 'email': 'test@example.com',
+ 'partner_id': cls.contact.id,
+ 'groups_id': [(6, 0, [cls.env.ref('base.group_portal').id])],
+ })
+
+ def test_email_login_sync(self):
+ """Test that changing a contact's email also updates their login"""
+ # Change the contact's email
+ new_email = 'updated@example.com'
+ self.contact.write({'email': new_email})
+
+ # Check that the user's login was updated
+ self.assertEqual(self.portal_user.login, new_email,
+ "User login should be updated when contact email changes")
+ self.assertEqual(self.portal_user.email, new_email,
+ "User email should match contact email")
diff --git a/portal_partner_manager/tests/test_portal_partner.py b/portal_partner_manager/tests/test_portal_partner.py
new file mode 100644
index 0000000..40d89a1
--- /dev/null
+++ b/portal_partner_manager/tests/test_portal_partner.py
@@ -0,0 +1,147 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import logging
+from odoo.tests import common, tagged
+
+_logger = logging.getLogger(__name__)
+
+@tagged('post_install', '-at_install')
+class TestPortalPartnerManager(common.TransactionCase):
+ def setUp(self):
+ super(TestPortalPartnerManager, self).setUp()
+
+ # Créer une société parente
+ self.parent_company = self.env['res.partner'].create({
+ 'name': 'Test Company',
+ 'is_company': True,
+ 'email': 'company@test.com',
+ 'phone': '+33123456789',
+ 'street': '123 Test Street',
+ 'city': 'Test City',
+ 'zip': '12345',
+ 'allow_portal_parent_edit': True,
+ })
+
+ # Créer un contact pour la société
+ self.contact = self.env['res.partner'].create({
+ 'name': 'Test Contact',
+ 'email': 'contact@test.com',
+ 'phone': '+33987654321',
+ 'parent_id': self.parent_company.id,
+ 'type': 'contact',
+ })
+
+ # Créer un utilisateur du portail
+ self.portal_user = self.env['res.users'].create({
+ 'name': 'Portal User',
+ 'login': 'portal_user@test.com',
+ 'email': 'portal_user@test.com',
+ 'groups_id': [(6, 0, [self.env.ref('base.group_portal').id])],
+ 'partner_id': self.contact.id,
+ })
+
+ # Créer une configuration d'accès
+ self.access_config = self.env['portal.access'].create({
+ 'name': 'Test Access Config',
+ 'partner_id': self.parent_company.id,
+ 'allow_edit': True,
+ 'allow_add_contacts': True,
+ 'portal_user_ids': [(4, self.portal_user.id)],
+ })
+
+ def test_01_portal_user_access(self):
+ """Tester l'accès de l'utilisateur du portail à sa société parente"""
+ # Tester en tant qu'utilisateur du portail
+ parent_company = self.parent_company.with_user(self.portal_user)
+
+ # Vérifier que l'utilisateur peut accéder à sa société parente
+ self.assertEqual(parent_company.name, 'Test Company',
+ "L'utilisateur du portail devrait pouvoir accéder à sa société parente")
+
+ def test_02_portal_user_write(self):
+ """Tester la modification de la société parente par l'utilisateur du portail"""
+ # Tester en tant qu'utilisateur du portail
+ parent_company = self.parent_company.with_user(self.portal_user)
+
+ # Modifier la société parente
+ parent_company.write({
+ 'name': 'Updated Company Name',
+ 'email': 'updated@test.com',
+ })
+
+ # Vérifier que les modifications ont été appliquées
+ self.assertEqual(parent_company.name, 'Updated Company Name',
+ "L'utilisateur du portail devrait pouvoir modifier le nom de sa société parente")
+ self.assertEqual(parent_company.email, 'updated@test.com',
+ "L'utilisateur du portail devrait pouvoir modifier l'email de sa société parente")
+
+ # Vérifier que les champs de tracking ont été mis à jour
+ self.assertEqual(parent_company.portal_updated_by.id, self.portal_user.id,
+ "Le champ portal_updated_by devrait être mis à jour")
+ self.assertTrue(parent_company.portal_last_update,
+ "Le champ portal_last_update devrait être mis à jour")
+
+ def test_03_portal_user_create_contact(self):
+ """Tester la création d'un contact par l'utilisateur du portail"""
+ # Tester en tant qu'utilisateur du portail
+ parent_company = self.parent_company.with_user(self.portal_user)
+
+ # Créer un nouveau contact
+ new_contact_vals = {
+ 'name': 'New Contact',
+ 'email': 'new.contact@test.com',
+ 'phone': '+33555555555',
+ }
+
+ new_contact = parent_company.create_portal_contact(parent_company.id, new_contact_vals)
+
+ # Vérifier que le contact a été créé correctement
+ self.assertEqual(new_contact.name, 'New Contact',
+ "Le nom du contact devrait être correct")
+ self.assertEqual(new_contact.email, 'new.contact@test.com',
+ "L'email du contact devrait être correct")
+ self.assertEqual(new_contact.parent_id.id, parent_company.id,
+ "Le contact devrait être lié à la société parente")
+
+ # Vérifier que les champs de tracking ont été mis à jour
+ self.assertEqual(new_contact.portal_updated_by.id, self.portal_user.id,
+ "Le champ portal_updated_by devrait être mis à jour")
+ self.assertTrue(new_contact.portal_last_update,
+ "Le champ portal_last_update devrait être mis à jour")
+
+ def test_04_portal_user_access_denied(self):
+ """Tester que l'utilisateur du portail ne peut pas accéder à d'autres sociétés"""
+ # Créer une autre société
+ other_company = self.env['res.partner'].create({
+ 'name': 'Other Company',
+ 'is_company': True,
+ 'email': 'other@test.com',
+ })
+
+ # Tester en tant qu'utilisateur du portail
+ with self.assertRaises(Exception):
+ # Essayer de modifier une autre société
+ other_company.with_user(self.portal_user).write({
+ 'name': 'Hacked Company',
+ })
+
+ def test_05_portal_access_config(self):
+ """Tester la configuration d'accès portail"""
+ # Désactiver l'accès à la modification
+ self.access_config.write({
+ 'allow_edit': False,
+ })
+
+ # Vérifier que la société parente a été mise à jour
+ self.assertFalse(self.parent_company.allow_portal_parent_edit,
+ "Le champ allow_portal_parent_edit de la société devrait être mis à jour")
+
+ # Tester en tant qu'utilisateur du portail
+ parent_company = self.parent_company.with_user(self.portal_user)
+
+ # Essayer de modifier la société parente (devrait échouer)
+ with self.assertRaises(Exception):
+ parent_company.write({
+ 'name': 'Should Not Update',
+ })
diff --git a/portal_partner_manager/todo.md b/portal_partner_manager/todo.md
new file mode 100644
index 0000000..dba6e48
--- /dev/null
+++ b/portal_partner_manager/todo.md
@@ -0,0 +1,19 @@
+# Module Portal Partner Manager - Todo List
+
+## Recherche et Analyse
+- [x] Clarifier les besoins du module
+- [x] Rechercher les fonctionnalités du portail dans Odoo 18.0
+- [x] Analyser le modèle res.partner et ses relations parent-enfant
+- [ ] Concevoir l'architecture du module
+
+## Développement
+- [ ] Créer la structure de base du module
+- [ ] Développer les modèles nécessaires
+- [ ] Développer les contrôleurs pour le portail
+- [ ] Créer les vues et templates du portail
+- [ ] Implémenter les règles de sécurité et d'accès
+
+## Finalisation
+- [ ] Tester les fonctionnalités
+- [ ] Rédiger la documentation
+- [ ] Préparer la présentation de la solution
diff --git a/portal_partner_manager/views/portal_activity_log_views.xml b/portal_partner_manager/views/portal_activity_log_views.xml
new file mode 100644
index 0000000..e6df0e3
--- /dev/null
+++ b/portal_partner_manager/views/portal_activity_log_views.xml
@@ -0,0 +1,115 @@
+
+
+
+
+ portal.activity.log.tree
+ portal.activity.log
+ list
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ portal.activity.log.form
+ portal.activity.log
+ form
+
+
+
+
+
+
+
+ portal.activity.log.search
+ portal.activity.log
+ search
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Portal Activity Logs
+ portal.activity.log
+ list,form
+
+
+
+ No activity logs found!
+
+
+ Portal activity logs are automatically created when users interact with the portal.
+
+ ]]>
+
+
+
+
diff --git a/st_laurent_portal_vendor/data/mail_template_vendor_request_rejected.xml b/st_laurent_portal_vendor/data/mail_template_vendor_request_rejected.xml
new file mode 100644
index 0000000..c0064f4
--- /dev/null
+++ b/st_laurent_portal_vendor/data/mail_template_vendor_request_rejected.xml
@@ -0,0 +1,22 @@
+
+
+
+
+ Demande vendeur rejetée
+
+ Votre demande pour devenir vendeur a été rejetée
+ ${(user.email or 'noreply@%s' % (object.company_partner_id and object.company_partner_id.name or 'st-laurent.quebec'))}
+ ${object.partner_id.email}
+ Bonjour ${object.partner_id.name},
+
Nous sommes au regret de vous informer que votre demande pour devenir vendeur sur la plateforme St-Laurent a été rejetée.
+
+
Motif du rejet : ${object.rejection_reason}
+
+
Pour toute question ou pour déposer une nouvelle demande, contactez notre équipe ou rendez-vous sur votre espace personnel.
+
L'équipe St-Laurent
+ ]]>
+
+
+
+
diff --git a/st_laurent_portal_vendor/data/vendor_request_sequence.xml b/st_laurent_portal_vendor/data/vendor_request_sequence.xml
new file mode 100644
index 0000000..d06179e
--- /dev/null
+++ b/st_laurent_portal_vendor/data/vendor_request_sequence.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+ Séquence des demandes de vendeur
+ vendor.request
+ VR/%(year)s/
+ 4
+
+
+
+
diff --git a/st_laurent_portal_vendor/doc/Odoo-Enterprise.md b/st_laurent_portal_vendor/doc/Odoo-Enterprise.md
new file mode 100644
index 0000000..6f268af
--- /dev/null
+++ b/st_laurent_portal_vendor/doc/Odoo-Enterprise.md
@@ -0,0 +1,244 @@
+# Odoo ERP Commerçant : L'Instance de Gestion Intégrée
+
+## Définition et Objectif
+
+L'Odoo ERP Commerçant représente le troisième niveau de la fédération St-Laurent, offrant aux entreprises québécoises une solution complète de gestion intégrée à l'écosystème e-commerce. Disponible en version Community (gratuite) ou Enterprise (sous licence), cette instance permet aux commerçants de gérer l'ensemble de leurs opérations tout en bénéficiant d'une connexion privilégiée avec la plateforme St-Laurent. Le commerçant peut choisir son rayonnement selon sa stratégie commerciale, avec deux options principales : soit se connecter à une instance locale autonome gérée par un organisme local pour un ancrage régional fort, soit se connecter directement au fédérateur provincial pour une visibilité québécoise immédiate.
+
+## Caractéristiques Principales
+
+- **Gestion d'entreprise complète** : Tous les modules Odoo nécessaires au fonctionnement de l'entreprise
+- **Connecteur St-Laurent** : Intégration native avec la fédération e-commerce québécoise
+- **Choix stratégique de rayonnement** :
+ * Option locale : Connexion à une instance autonome pour un ancrage régional fort
+ * Option provinciale : Connexion directe au fédérateur pour une visibilité québécoise immédiate
+- **Synchronisation bidirectionnelle** : Produits, stocks, commandes et clients
+- **Réception des documents** : Obtention de 100% des documents Odoo liés aux ventes
+- **Flexibilité de version** : Choix entre Odoo Community (gratuit) ou Enterprise (licence)
+- **Commission équitable** : Bénéfice de la structure de commission de 2% de St-Laurent (1% pour BEMADE, 1% pour l'organisme local)
+
+## Utilisateurs et Accès
+
+### Qui s'y connecte ?
+
+- **Équipe du commerçant** : Ensemble des collaborateurs de l'entreprise québécoise
+- **Décideurs et propriétaires** : Pour le pilotage et la supervision de l'activité
+- **Responsables e-commerce** : Gestion des produits et commandes St-Laurent
+- **Partenaires commerciaux** : Clients, fournisseurs et prestataires via portail
+- **Équipe St-Laurent** : Support d'intégration et accompagnement
+ * Équipe locale pour les connexions aux instances régionales
+ * Équipe provinciale pour les connexions directes au fédérateur
+- **Support Odoo** : En cas d'utilisation de la version Enterprise
+
+### Types d'accès
+
+- **Utilisateurs internes** : Accès complet aux fonctionnalités ERP (illimités en Community, licences en Enterprise)
+- **Utilisateurs portail** : Accès limité pour partenaires externes (illimités)
+- **Administrateurs système** : Droits étendus pour la configuration globale
+- **Connecteur St-Laurent** : API sécurisée pour synchronisation avec la fédération
+- **API externes** : Intégrations avec d'autres systèmes ou services
+- **Applications mobiles** : Disponibles en version Enterprise uniquement
+
+## Gestion et Administration
+
+### Gouvernance
+
+- **Autonomie complète** : Contrôle total de l'instance par le commerçant
+- **Intégration St-Laurent** : Respect des standards de la fédération
+- **Choix de licence** :
+ - Community : Gratuit, sans contrat, support communautaire
+ - Enterprise : Contrat avec Odoo SA, support officiel, modules avancés
+- **Accompagnement BEMADE** : Expertise locale pour l'intégration et le développement
+- **Cycle de mise à jour** coordonné avec l'écosystème St-Laurent
+
+### Infrastructure et Déploiement
+
+- **Au choix du commerçant** : Liberté complète d'hébergement
+- **Options recommandées** :
+ - **Cloud québécois** : Hébergement souverain (OVH, CloudWatt)
+ - **On-premise** : Installation sur infrastructure propre
+ - **Odoo.sh** : Plateforme cloud officielle (version Enterprise)
+ - **Infrastructure BEMADE** : Service géré par notre équipe
+- **Connectivité garantie** : Connexion sécurisée avec les instances St-Laurent
+
+### Cycle de vie
+
+- **Base commune Odoo 18.0** : Alignement avec l'écosystème St-Laurent
+- **Évolution progressive** : Possibilité de démarrer en Community et migrer vers Enterprise
+- **Mises à jour coordonnées** avec le connecteur St-Laurent
+- **Support et maintenance** :
+ - Community : Assurés par BEMADE ou en autonomie
+ - Enterprise : Garantis par Odoo SA et complétés par BEMADE
+- **Migration assistée** entre versions par l'équipe St-Laurent
+
+## Modules et Fonctionnalités
+
+### Modules de Base (Community et Enterprise)
+
+- **Gestion des ventes** : Commandes, facturation, suivi client
+- **Gestion des achats** : Demandes de prix, commandes fournisseurs
+- **Gestion des stocks** : Inventaire, mouvements, traçabilité
+- **Comptabilité de base** : Grand livre, comptes clients/fournisseurs
+- **CRM** : Gestion des prospects et opportunités
+- **Fabrication** : Ordres de fabrication, BOM, planification
+- **Site web** : Création et gestion de contenu
+- **E-commerce** : Boutique en ligne, panier, paiement
+
+### Modules Exclusifs Enterprise
+
+- **Comptabilité avancée** : Analytique, budgets, immobilisations
+- **RH et paie** : Adaptés au contexte québécois
+- **Marketing automation** : Campagnes, scoring de leads
+- **Studio** : Personnalisation sans code
+- **Applications mobiles** : iOS et Android
+- **BI et rapports** : Tableaux de bord avancés
+- **Signature électronique** : Documents et contrats
+- **IoT** : Connexion avec équipements industriels
+
+### Modules St-Laurent Spécifiques
+
+- **st_laurent_connector** : Connecteur vers les plateformes St-Laurent
+ - Synchronisation bidirectionnelle
+ - Mapping flexible des champs et modèles
+ - Choix stratégique du rayonnement :
+ * Option 1 : Connexion à une instance locale autonome (pour un ancrage régional)
+ * Option 2 : Connexion directe au fédérateur provincial (pour un rayonnement québécois)
+ - Intégration avec les index Elasticsearch et dictionnaires de synonymes
+ - Réception de 100% des documents Odoo liés aux ventes
+ - Respect de l'autonomie des instances locales
+ - Gestion des erreurs et conflits
+ - Tableau de bord de santé des connecteurs
+
+- **st_laurent_vendor_dashboard** : Tableau de bord vendeur
+ - Suivi des ventes sur la fédération
+ - Analyse des performances par région
+ - Comparaison avec moyennes du marché
+ - Analyse des termes de recherche et synonymes menant aux produits
+ - Suggestions d'optimisation des descriptions produits
+ - Alertes et notifications
+
+- **st_laurent_logistics** : Gestion logistique intégrée
+ - Expédition multi-commandes
+ - Étiquetage standardisé
+ - Intégration transporteurs québécois
+ - Suivi des livraisons
+
+## Intégration avec St-Laurent
+
+### Fonctionnalités d'Intégration
+
+- **Synchronisation des produits** : Publication automatique sur la plateforme
+- **Gestion des commandes** : Réception et traitement des commandes St-Laurent
+- **Gestion des stocks** : Mise à jour en temps réel des disponibilités
+- **Optimisation de recherche** : Intégration avec Elasticsearch et dictionnaires de synonymes
+- **Tarification spécifique** : Gestion des prix et promotions sur St-Laurent
+- **Gestion des commissions** : Transparence sur la répartition de la commission de 2% (1% BEMADE, 1% organisme local)
+- **Expédition intégrée** : Gestion des livraisons multi-régionales
+- **Facturation automatisée** : Génération des factures pour les commandes St-Laurent
+
+### Options de Rayonnement
+
+#### Rayonnement Local
+- Intégration avec une instance régionale autonome spécifique
+- Respect du processus de modération de l'organisme local
+- Visibilité limitée à la région choisie
+- Réception directe des documents Odoo depuis l'instance locale
+- Livraison optimisée pour la proximité
+- Mise en avant de l'ancrage local
+
+#### Rayonnement Provincial
+- Intégration directe avec l'Odoo Enterprise Central (fédérateur)
+- Visibilité sur l'ensemble de la plateforme provinciale
+- Réception des documents Odoo depuis le fédérateur
+- Gestion des expéditions inter-régionales
+- Accès à un marché plus large
+
+### Personnalisation et Développement
+
+1. **Adaptations Sectorielles**
+ - Modules spécifiques par secteur d'activité
+ - Configurations pré-établies pour différents types de commerce
+ - Processus adaptés aux spécificités métiers
+
+2. **Intégrations Locales**
+ - Connecteurs avec services financiers québécois (Desjardins, etc.)
+ - Intégration avec transporteurs locaux
+ - Conformité fiscale québécoise (TPS/TVQ)
+ - Adaptation aux normes commerciales provinciales
+
+## Avantages et Limitations
+
+### Avantages
+
+- **Gestion intégrée complète** : Tous les processus d'entreprise dans un seul système
+- **Accès privilégié au marché québécois** via la fédération St-Laurent
+- **Commission exceptionnellement basse de 2%** pour les ventes en ligne (répartie équitablement : 1% pour BEMADE, 1% pour l'organisme local)
+- **Flexibilité stratégique de rayonnement** :
+ * Option locale : Bénéfice de l'ancrage régional et du support de proximité
+ * Option provinciale : Accès direct au marché québécois sans intermédiaire
+- **Réception complète des documents** : 100% des documents Odoo liés aux ventes
+- **Flexibilité de version** : Choix selon les besoins et ressources de l'entreprise
+- **Souveraineté numérique** : Contrôle total des données et processus
+- **Évolution progressive** : Possibilité de démarrer simple et d'évoluer
+- **Support adapté** : Accompagnement par l'équipe locale ou provinciale selon le mode de connexion
+
+### Limitations
+
+- **Courbe d'apprentissage** pour les petites entreprises sans expérience ERP
+- **Coût des licences Enterprise** pour les fonctionnalités avancées
+- **Ressources techniques** nécessaires pour la version Community
+- **Complexité d'intégration** pour les systèmes existants
+- **Maintenance régulière** requise pour les synchronisations
+
+## Cas d'Usage Typiques
+
+- **Fabricants québécois** souhaitant vendre directement aux consommateurs
+ * Connexion locale pour les fabricants à forte identité régionale
+ * Connexion provinciale pour les fabricants à ambition québécoise
+- **Détaillants multi-canaux** combinant vente physique et en ligne
+ * Généralement via connexion provinciale pour une stratégie omnicanale cohérente
+- **Artisans et producteurs** cherchant à étendre leur marché au-delà de leur région
+ * Souvent via connexion locale pour bénéficier du support de proximité
+- **PME en croissance** ayant besoin d'une gestion intégrée
+ * Évolution possible de la connexion locale vers provinciale avec la croissance
+- **Entreprises de distribution** cherchant un canal de vente additionnel
+ * Principalement via connexion provinciale pour une couverture maximale
+- **Commerçants existants** souhaitant migrer d'une autre plateforme e-commerce
+- **Entreprises avec processus métiers spécifiques** nécessitant personnalisation
+
+## Approche BEMADE pour St-Laurent
+
+### Notre Valeur Ajoutée
+
+- **Expertise Odoo 18.0** : Connaissance approfondie de la plateforme
+- **Partenariat officiel Odoo** : Accès aux ressources et support de l'éditeur
+- **Approche pragmatique** : Recommandation de la version adaptée aux besoins réels
+- **Conseil stratégique** : Accompagnement dans le choix du mode de connexion optimal (local ou provincial)
+- **Intégration St-Laurent** : Développement et maintenance des connecteurs
+- **Flexibilité d'intégration** : Connexion aux instances locales autonomes ou directement au fédérateur selon la stratégie commerciale
+- **Synchronisation complète** : Garantie de réception de 100% des documents Odoo
+- **Accompagnement complet** : De l'analyse des besoins au support continu
+- **Expertise locale** : Connaissance du marché et des spécificités québécoises
+
+### Parcours Recommandé pour les Commerçants
+
+1. **Évaluation initiale** des besoins et de la maturité numérique
+2. **Choix de la version** (Community ou Enterprise) selon les besoins et ressources
+3. **Déploiement de base** avec les modules essentiels
+4. **Analyse stratégique du rayonnement commercial** :
+ - Option locale : Pour un ancrage régional et une relation de proximité
+ - Option provinciale : Pour un rayonnement québécois immédiat
+5. **Intégration St-Laurent** avec configuration du connecteur selon l'option choisie
+6. **Configuration de la réception des documents** pour garantir 100% des documents Odoo
+7. **Optimisation des descriptions produits** pour le moteur de recherche Elasticsearch
+8. **Enrichissement du dictionnaire de synonymes** spécifiques au secteur d'activité
+9. **Formation des utilisateurs** et accompagnement au changement
+10. **Évolution progressive** vers des fonctionnalités plus avancées
+11. **Optimisation continue** basée sur les performances de vente et les statistiques de recherche
+
+### Témoignages de Succès
+
+*"En tant que fabricant provincial, nous avons choisi de connecter notre Odoo directement au fédérateur St-Laurent. Cette stratégie nous a permis d'atteindre immédiatement l'ensemble du marché québécois. La commission de 2% est exceptionnellement compétitive et nous apprécions la transparence de sa répartition entre BEMADE et les organismes locaux."* - Manufacturier québécois
+
+*"Notre entreprise artisanale a d'abord connecté son Odoo à l'instance locale St-Laurent de notre région. Le support de proximité a été précieux pour notre démarrage. Avec notre croissance, nous envisageons maintenant de nous connecter directement au fédérateur pour étendre notre rayonnement à l'ensemble du Québec."* - Artisan de la région de Québec
+
+*"Le passage à Odoo nous a permis d'unifier notre gestion d'entreprise tout en bénéficiant d'une vitrine provinciale via St-Laurent. L'accompagnement de BEMADE a été déterminant dans notre succès."* - PME manufacturière montréalaise
diff --git a/st_laurent_portal_vendor/doc/Odoo-Federateur.md b/st_laurent_portal_vendor/doc/Odoo-Federateur.md
new file mode 100644
index 0000000..3f558cc
--- /dev/null
+++ b/st_laurent_portal_vendor/doc/Odoo-Federateur.md
@@ -0,0 +1,228 @@
+# Odoo Enterprise Central : L'Instance Provinciale
+
+## Définition et Objectif
+
+L'Odoo Enterprise Central est l'instance provinciale qui agrège et unifie l'ensemble des plateformes Odoo Enterprise Locales (régionales/municipales) autonomes de la fédération St-Laurent. Il agit comme le hub principal qui offre une expérience d'achat unifiée aux consommateurs québécois, tout en permettant une visibilité provinciale aux vendeurs locaux avec une commission exceptionnellement basse de 2%. Bien que chaque plateforme locale conserve son autonomie complète, le fédérateur s'approvisionne en données auprès des instances locales et maintient une copie synchronisée de toutes les informations, tout en transférant 100% des documents Odoo aux instances locales concernées.
+
+## Caractéristiques Principales
+
+- **Plateforme e-commerce provinciale** : Vitrine unifiée pour tous les produits québécois
+- **Fédération d'instances autonomes** : Agrégation des produits de toutes les régions tout en préservant l'autonomie locale
+- **Synchronisation bidirectionnelle** : Approvisionnement en données depuis les locaux et transfert des documents Odoo vers les instances concernées
+- **Commission équitable de 2%** : Répartition équitable avec 1% pour BEMADE (gestion du fédérateur) et 1% pour l'organisme local
+- **Expérience d'achat unifiée** : Panier d'achat multi-régions et paiement centralisé
+- **Gouvernance québécoise** : Gestion centralisée par une équipe 100% locale
+- **Alternative souveraine** : Solution québécoise face aux géants internationaux
+
+## Utilisateurs et Accès
+
+### Qui s'y connecte ?
+
+- **Consommateurs québécois** : Acheteurs cherchant des produits locaux
+- **Vendeurs de toutes les régions** : Via la synchronisation des instances locales
+- **Vendeurs provinciaux directs** : Acteurs provinciaux avec leur propre Odoo ERP
+- **Équipe St-Laurent provinciale** : Administration et animation de la plateforme
+- **Responsables régionaux** : Pour la coordination inter-régionale
+- **Partenaires stratégiques** : Organismes de promotion du commerce québécois
+- **Médias et influenceurs** : Pour la promotion des produits québécois
+
+### Types d'accès
+
+- **Acheteurs** : Accès à la boutique en ligne provinciale
+- **Utilisateurs portal vendeur** : Interface simplifiée pour les vendeurs provinciaux directs
+- **Administrateurs provinciaux** : Gestion globale de la plateforme
+- **Modérateurs provinciaux** : Approbation des vendeurs et produits au niveau provincial
+- **Équipe marketing** : Outils de promotion et campagnes provinciales
+- **Équipe technique** : Configuration et maintenance de l'architecture fédérée
+- **API fédération** : Accès programmatique pour les instances régionales et les ERP commerçants
+
+## Gestion et Administration
+
+### Gouvernance
+
+- **Comité de pilotage St-Laurent** : Direction et représentants régionaux
+- **Équipe d'architecture fédérée** : Définition des standards et protocoles d'intégration
+- **Centre d'expertise Odoo 18.0** : Expertise technique et fonctionnelle centralisée
+- **Comité des vendeurs** : Représentants des vendeurs pour orienter les évolutions
+- **Partenariat Odoo** : Collaboration officielle avec Odoo SA
+
+### Infrastructure et Déploiement
+
+- **Infrastructure cloud québécoise** haute disponibilité (OVH, CloudWatt) ou chez BEMADE
+- **Architecture évolutive** avec auto-scaling basé sur la charge (kubernetes)
+- **Environnements multiples** (développement, test, production, disaster recovery)
+- **Monitoring avancé** pour garantir disponibilité et performances 24/7
+- **Souveraineté des données** garantie sur territoire québécois
+
+### Cycle de vie
+
+- **Planification stratégique** alignée avec les objectifs de souveraineté numérique québécoise
+- **Roadmap d'évolution** coordonnée avec les instances régionales
+- **Gestion des changements** avec impact minimal sur les vendeurs
+- **Mises à jour majeures** planifiées en dehors des périodes de forte activité commerciale
+- **Évolution continue** des fonctionnalités selon les besoins des vendeurs et acheteurs
+
+## Modules à Développer
+
+### Modules d'Orchestration
+
+- **st_laurent_central_core** : Fonctionnalités de la plateforme centrale
+ - Gestion du modèle de commission (2% réparti équitablement : 1% BEMADE, 1% organisme local)
+ - Administration centralisée de la fédération
+ - Tableaux de bord provinciaux
+ - Rapports de distribution des commissions
+- **st_laurent_federation_server** : Gestion de la fédération
+ - Enregistrement et gestion des instances régionales autonomes
+ - Approvisionnement en données depuis les instances locales
+ - Maintien d'une copie synchronisée de toutes les données locales
+ - Routage intelligent des commandes
+ - Transfert 100% des documents Odoo aux instances locales concernées
+ - Synchronisation et indexation des produits
+ - Monitoring de santé de la fédération
+- **st_laurent_marketplace** : Gestion multi-vendeurs et multi-régions
+ - Ventilation des commandes multi-vendeurs
+ - Système de messagerie vendeur-client-admin
+ - Gestion des avis et évaluations
+ - Portail vendeur provincial pour acteurs avec leur propre Odoo
+ - Interface de modération des vendeurs et produits provinciaux
+ - Moteur de recherche avancé basé sur Elasticsearch
+ - Gestion des synonymes et termes connexes en français québécois
+ - Outils de filtrage avancés et facettes de recherche
+ - Comparaison de produits inter-régions
+
+- **st_laurent_ai_product** : Assistant IA pour l'ajout de produits
+ - Génération de descriptions optimisées pour le référencement
+ - Extraction automatique d'attributs depuis photos et descriptions
+ - Suggestions de catégorisation adaptées au marché québécois
+ - Enrichissement automatique du dictionnaire de synonymes provincial
+ - Adaptation aux spécificités linguistiques du français québécois
+ - Optimisation pour le moteur de recherche Elasticsearch
+ - Analyse de tendances et suggestions de mots-clés
+
+### Modules Fonctionnels Transversaux
+
+1. **Fédération des plateformes régionales autonomes**
+ - Agrégation des produits de toutes les instances régionales
+ - Approvisionnement continu en données depuis les instances locales
+ - Maintien d'une copie synchronisée de toutes les données locales
+ - Recherche unifiée à travers toutes les régions via Elasticsearch
+ - Gestion avancée des synonymes et régionalismes québécois
+ - Indexation intelligente des produits et descriptions
+ - Filtrage par région, distance, disponibilité
+ - Mise en avant des spécificités régionales
+ - Respect de l'autonomie de chaque instance locale
+
+2. **Expérience d'achat unifiée**
+ - Panier d'achat multi-régions
+ - Processus de commande unifié
+ - Paiement centralisé (Stripe, PayPal, Desjardins)
+ - Transfert 100% des documents Odoo (commandes, factures, etc.) aux instances locales concernées
+ - Suivi de commande consolidé avec données provenant des instances locales
+ - Gestion des retours coordonnée
+
+3. **Portail vendeur provincial**
+ - Interface simplifiée pour vendeurs provinciaux directs
+ - Assistant IA pour l'ajout de produits
+ - Génération automatique de descriptions optimisées pour le référencement
+ - Extraction intelligente d'attributs depuis photos et descriptions
+ - Suggestions de catégorisation adaptées au marché québécois
+ - Enrichissement automatique du dictionnaire de synonymes provincial
+ - Processus d'approbation des vendeurs provinciaux
+ - Modération des produits au niveau provincial
+ - Intégration avec les ERP propres des vendeurs
+ - Tableau de bord de gestion des ventes provinciales
+ - Outils de promotion pour vendeurs provinciaux
+
+4. **Marketing et Promotion**
+ - Campagnes marketing provinciales
+ - Programmes de fidélité unifiés
+ - Mise en avant des produits québécois
+ - Intégration avec réseaux sociaux
+ - Système de recommandation intelligent
+
+5. **Gestion financière centralisée**
+ - Gestion des commissions (2% au total)
+ - Répartition équitable des commissions (1% BEMADE, 1% organisme local)
+ - Distribution automatique des parts de commission
+ - Répartition des paiements aux vendeurs
+ - Reporting financier consolidé
+ - Facturation automatisée
+ - Conformité fiscale québécoise
+
+### Modules Techniques Spécifiques
+
+- **Frontend client provincial**
+ - Interface adaptée à l'identité québécoise
+ - Approche mobile-first responsive
+ - Multilingue (français et anglais)
+ - Optimisation SEO et vitesse de chargement
+- **Moteur de recherche Elasticsearch**
+ - Intégration complète avec Odoo 18.0
+ - Dictionnaire de synonymes adapté au français québécois
+ - Gestion des régionalismes et termes spécifiques
+ - Recherche prédictive et suggestions intelligentes
+ - Correction orthographique automatique
+ - Pondération personnalisée des résultats
+ - Facettes de recherche dynamiques
+- **API externe**
+ - Spécifications OpenAPI
+ - Endpoints standardisés pour tous les niveaux
+ - Webhooks pour notifications événementielles
+ - Authentification OAuth2 avec clés API
+- **Analytique avancée**
+ - Tableaux de bord interactifs
+ - Analyse prédictive des tendances
+ - Segmentation des vendeurs et acheteurs
+ - Intelligence artificielle pour recommandations
+- **Sécurité et conformité**
+ - Authentification multi-facteurs
+ - Chiffrement des données sensibles
+ - Conformité RGPD et lois québécoises
+ - Audit et traçabilité des transactions
+
+## Avantages et Limitations
+
+### Avantages
+
+- **Alternative québécoise souveraine** face aux géants du e-commerce
+- **Commission exceptionnellement basse de 2%** (vs 15% Amazon, 5-10% autres plateformes)
+- **Répartition équitable des revenus** : 1% pour BEMADE (gestion du fédérateur) et 1% pour l'organisme local
+- **Visibilité provinciale** pour tous les vendeurs locaux
+- **Respect de l'autonomie locale** tout en bénéficiant d'une plateforme unifiée
+- **Expérience d'achat unifiée** pour les consommateurs québécois
+- **Valorisation des identités régionales** au sein d'une plateforme commune
+- **Souveraineté des données** sur infrastructure québécoise
+- **Transfert complet des documents** aux instances locales concernées
+- **Retombées économiques locales** et création d'emplois technologiques
+
+### Limitations
+
+- **Complexité technique** de l'architecture fédérée
+- **Défi de notoriété** face aux plateformes établies
+- **Nécessité d'atteindre** une masse critique de vendeurs et acheteurs
+- **Coût d'infrastructure** pour supporter la croissance à l'échelle provinciale
+- **Coordination requise** entre les différentes instances régionales
+- **Défi logistique** pour les livraisons inter-régionales
+
+## Cas d'Usage Typiques
+
+- **Marketplace provinciale** agrégeant tous les produits québécois
+- **Alternative souveraine** à Amazon suite à la fermeture du Panier Bleu
+- **Vitrine unifiée** pour l'artisanat et les produits du terroir québécois
+- **Plateforme fédérée** respectant les identités régionales
+- **Hub e-commerce** pour les PME québécoises
+- **Tremplin vers l'adoption** d'Odoo Enterprise pour les entreprises
+
+## Recommandations BEMADE pour St-Laurent
+
+- Utiliser Odoo Enterprise 18.0 pour bénéficier des modules e-commerce avancés
+- Mettre en place une architecture de fédération robuste respectant l'autonomie des instances locales
+- Développer un système de synchronisation bidirectionnelle performant et fiable
+- Implémenter un mécanisme de transfert complet des documents Odoo aux instances locales
+- Intégrer Elasticsearch avec un dictionnaire de synonymes adapté au français québécois
+- Développer une expérience utilisateur exceptionnelle pour les acheteurs québécois
+- Établir des partenariats stratégiques avec les acteurs économiques régionaux
+- Mettre en avant la commission de 2% et sa répartition équitable (1% BEMADE, 1% organisme local) comme avantage concurrentiel majeur
+- Constituer une équipe dédiée pour la gestion de l'Odoo Enterprise Central
+- Planifier une stratégie de marketing ciblée pour atteindre rapidement une masse critique
+- Devenir partenaire officiel Odoo pour le Québec et accompagner les entreprises vers Odoo Enterprise 18.0
diff --git a/st_laurent_portal_vendor/doc/Odoo-Local.md b/st_laurent_portal_vendor/doc/Odoo-Local.md
new file mode 100644
index 0000000..0432a7f
--- /dev/null
+++ b/st_laurent_portal_vendor/doc/Odoo-Local.md
@@ -0,0 +1,204 @@
+# Odoo Enterprise Local : L'Instance Régionale/Municipale
+
+## Définition et Objectif
+
+L'Odoo Enterprise Local est une instance Odoo Enterprise 18.0 autonome dédiée à une région ou municipalité spécifique au sein de la fédération St-Laurent. Cette installation est conçue pour fournir une micro-boutique en ligne aux vendeurs locaux, tout en s'intégrant dans l'écosystème fédéré de la plateforme e-commerce québécoise. Bien que complètement autonome dans sa gestion et son administration par un organisme local, elle fournit des données au fédérateur et reçoit 100% des documents Odoo (commandes, factures, etc.) qui lui sont attachés.
+
+## Caractéristiques Principales
+
+- **Identité régionale** : Configuré pour refléter l'identité visuelle et les spécificités d'une région/municipalité
+- **Autonomie complète** : Plateforme entièrement autonome gérée par un organisme local
+- **Intégration fédérée** : Fournit des données au fédérateur tout en conservant son indépendance
+- **Réception des documents** : Reçoit 100% des documents Odoo liés à ses vendeurs
+- **Données localisées** : Stockage et traitement des données propres à la région
+- **Interface simplifiée** : Adaptée aux micro-entreprises et vendeurs locaux via portal user
+- **Commission équitable** : Structure de commission exceptionnellement basse de 2% (1% pour l'organisme local, 1% pour BEMADE)
+- **Modules ciblés** : Installation des modules e-commerce essentiels pour les vendeurs locaux
+
+## Utilisateurs et Accès
+
+### Qui s'y connecte ?
+
+- **Micro-entreprises locales** : Artisans, producteurs et commerçants approuvés de la région
+- **Organismes locaux** : Chambres de commerce, centres de développement local, administrateurs de la plateforme
+- **Modérateurs** : Personnel de l'organisme local chargé de l'approbation des vendeurs et produits
+- **Acheteurs locaux** : Consommateurs de la région cherchant des produits locaux
+- **Équipe technique St-Laurent** : Support technique et maintenance de la plateforme
+
+### Types d'accès
+
+- **Utilisateurs portal vendeur** : Interface simplifiée pour micro-entreprises locales approuvées
+- **Acheteurs** : Accès à la boutique en ligne régionale
+- **Administrateurs de l'organisme local** : Droits de modération et approbation des vendeurs/produits
+- **Modérateurs** : Accès limité aux fonctions de validation des produits
+- **Équipe technique St-Laurent** : Accès pour maintenance et développement
+- **Connecteur fédération** : Accès API pour synchronisation avec l'Odoo Central
+
+## Gestion et Administration
+
+### Gouvernance
+
+- **Propriétaire et administrateur** : Organisme local (chambre de commerce, centre de développement local, etc.)
+- **Comité de modération** : Représentants de l'organisme local pour l'approbation des vendeurs et produits
+- **Support de premier niveau** : Assuré par l'organisme local pour les vendeurs de sa région
+- **Escalade technique** : Vers l'équipe centrale St-Laurent
+- **Coordination provinciale** : Liaison avec l'instance centrale St-Laurent
+
+### Infrastructure et Déploiement
+
+- **Hébergement régional** : Possibilité d'hébergement par partenaires locaux, ou hébergement par BEMADE
+- **Base de données indépendante** avec synchronisation vers l'Odoo Central
+- **Architecture multi-tenant** avec isolation des données par région
+- **Sauvegarde quotidienne** avec rétention de 30 jours
+- **Mises à jour coordonnées** avec l'écosystème St-Laurent global
+- **Support technique** : Disponible par l'équipe St-Laurent
+
+### Cycle de vie
+
+- **Idenfication des organismes locaux qui ont pour mission de valoriser le marché local**
+- **Évaluation des besoins** spécifiques à la région avec l'organisme local
+- **Déploiement initial** avec configuration de base et thème régional
+- **Formation de l'organisme local** sur l'administration de la plateforme
+- **Établissement des processus de modération** et critères d'approbation avec l'organisme local
+- **Recrutement des vendeurs locaux** et formation à l'interface portal avec l'organisme local
+- **Personnalisation progressive** selon l'évolution des besoins régionaux
+- **Maintenance continue** et mises à jour régulières
+- **Synchronisation permanente** avec l'Odoo Enterprise Central
+
+## Modules à Développer
+
+### Modules d'Intégration
+
+- **st_laurent_local_core** : Fonctionnalités de base de la plateforme locale
+ - Personnalisation régionale (identité visuelle, contenu local)
+ - Configuration des règles de marketplace locale
+ - Interface d'administration pour les organismes locaux
+ - Workflows de modération et approbation
+ - Tableaux de bord pour les administrateurs locaux
+- **st_laurent_portal_vendor** : Interface simplifiée pour utilisateurs portal
+ - Demande d'inscription et processus d'approbation
+ - Formulaires simplifiés d'ajout de produits avec soumission à modération
+ - Gestion des commandes par vendeur
+ - Notifications et alertes
+ - Suivi des statuts de modération
+- **st_laurent_ai_product** : Assistant IA pour l'ajout de produits
+ - Génération de descriptions optimisées pour le référencement
+ - Extraction automatique d'attributs depuis photos et descriptions
+ - Suggestions de catégorisation adaptées au marché local
+ - Enrichissement automatique du dictionnaire de synonymes
+ - Adaptation aux spécificités linguistiques régionales
+ - Optimisation pour le moteur de recherche Elasticsearch
+- **st_laurent_federation_client** : Connecteur vers l'Odoo Central
+ - Fourniture des données produits, vendeurs et stocks au fédérateur
+ - Partage des index Elasticsearch et dictionnaires de synonymes locaux
+ - Réception 100% des documents Odoo (commandes, factures, etc.) liés aux vendeurs locaux
+ - Synchronisation bidirectionnelle tout en préservant l'autonomie locale
+ - Statut de synchronisation et diagnostics
+ - Gestion des conflits et réconciliation des données
+
+### Modules Fonctionnels Spécifiques
+
+1. **Gestion des vendeurs locaux**
+ - Processus de demande d'inscription pour les vendeurs potentiels
+ - Workflow d'approbation par l'organisme local administrateur
+ - Vérification d'éligibilité (entreprises de la région/ville)
+ - Tableau de bord d'administration pour l'organisme local
+ - Tutoriel interactif d'intégration pour vendeurs approuvés
+ - Configuration guidée du profil vendeur local
+
+2. **Interface portal vendeur**
+ - Vue d'ensemble simplifiée des ventes et performances
+ - Alertes et notifications essentielles
+ - Gestion basique des produits et commandes
+ - Interface adaptée aux utilisateurs non-techniques
+
+3. **Gestion des produits et modération**
+ - Formulaires simplifiés d'ajout de produits
+ - Assistant IA pour la création de fiches produits complètes
+ - Génération automatique de descriptions optimisées
+ - Extraction intelligente d'attributs depuis photos et textes
+ - Suggestions de catégorisation basées sur le marché local
+ - Enrichissement automatique des termes de recherche et synonymes
+ - Workflow de soumission et modération des produits
+ - Interface d'approbation pour les modérateurs de l'organisme local
+ - Support pour attributs et variantes de base
+ - Gestion des images (multi-vues)
+ - Mise en avant de l'origine locale des produits
+ - Historique des modérations et commentaires
+
+4. **Gestion des commandes locales**
+ - Notifications de nouvelles commandes
+ - Processus simplifié de traitement des commandes
+ - Suivi de livraison basique
+ - Support client de proximité
+
+### Modules Techniques
+
+- **Frontend client régional**
+ - Design adapté à l'identité québécoise avec déclinaison régionale
+ - Approche mobile-first responsive
+ - Multilingue (français et anglais)
+ - Optimisation SEO et vitesse de chargement
+- **Moteur de recherche local**
+ - Intégration avec Elasticsearch
+ - Dictionnaire de synonymes adapté aux spécificités régionales
+ - Gestion des régionalismes et termes locaux
+ - Recherche prédictive et suggestions contextuelles
+ - Synchronisation des index avec le fédérateur
+- **Système de paiement local**
+ - Intégration des méthodes de paiement préférées régionalement
+ - Gestion de la commission de 2% (1% pour l'organisme local, 1% pour BEMADE)
+ - Rapports de revenus de commission pour l'organisme local
+ - Traitement sécurisé des transactions
+- **Tableau de bord pour organismes locaux**
+ - Interface de modération et approbation
+ - Suivi des demandes d'inscription vendeur
+ - Queue de modération des produits
+ - KPIs spécifiques à la performance de la région
+ - Suivi des vendeurs et produits populaires
+ - Statistiques de vente par catégorie
+
+## Avantages et Limitations
+
+### Avantages
+
+- **Valorisation de l'identité régionale** et des produits locaux
+- **Facilité d'accès** pour les micro-entreprises sans expertise technique
+- **Commission minimale de 2%** (vs 15% Amazon, 5-10% autres plateformes)
+- **Source de revenus pour l'organisme local** : 1% de commission sur toutes les ventes
+- **Performances optimisées** pour les utilisateurs de la région
+- **Autonomie complète** de la plateforme gérée par un organisme local
+- **Contrôle total** sur les processus d'approbation et de modération
+- **Réception de 100% des documents** liés aux vendeurs locaux
+- **Visibilité provinciale** via la synchronisation avec l'Odoo Central
+
+### Limitations
+
+- **Dépendance à la synchronisation** avec l'Odoo Central
+- **Fonctionnalités limitées** pour les vendeurs en portal user
+- **Besoin d'implication active** de l'organisme local pour la modération
+- **Délais potentiels** liés au processus d'approbation des vendeurs et produits
+- **Coûts d'infrastructure régionale** à financer par l'organisme local (possibilité d'hébergement par BEMADE)
+- **Nécessité de formation** des administrateurs locaux et des vendeurs
+
+## Cas d'Usage Typiques
+
+- **Marketplace régionale** pour artisans et producteurs locaux
+- **Regroupement de commerçants** d'une même ville ou région
+- **Vitrine numérique** pour une chambre de commerce régionale
+- **Plateforme de vente** pour produits du terroir québécois
+- **Hub e-commerce** pour une zone touristique
+
+## Recommandations BEMADE pour St-Laurent
+
+- Utiliser Odoo Enterprise 18.0 pour bénéficier des modules e-commerce avancés
+- Développer des interfaces de modération efficaces pour les organismes locaux
+- Créer des workflows d'approbation configurables selon les besoins de chaque région
+- Mettre en place une gouvernance claire entre organismes locaux et l'Odoo Central
+- Intégrer Elasticsearch avec dictionnaires de synonymes adaptés aux spécificités régionales
+- Développer un connecteur robuste pour la fourniture des données au fédérateur
+- Implémenter un système fiable de réception des documents Odoo depuis le fédérateur
+- Créer des thèmes visuels adaptables à chaque identité régionale
+- Former les équipes des organismes locaux à l'administration et la modération
+- Établir un processus de synchronisation bidirectionnelle avec l'Odoo Enterprise Central
+- Valoriser l'autonomie locale et la commission de 2% (dont 1% pour l'organisme local) comme avantages concurrentiels majeurs
diff --git a/st_laurent_portal_vendor/doc/Odoo-Module.md b/st_laurent_portal_vendor/doc/Odoo-Module.md
new file mode 100644
index 0000000..ff58b2f
--- /dev/null
+++ b/st_laurent_portal_vendor/doc/Odoo-Module.md
@@ -0,0 +1,115 @@
+# Modules Odoo à Développer pour le Projet St-Laurent
+
+Ce document présente les différents modules à développer pour chaque type d'installation Odoo dans le cadre du projet St-Laurent, une fédération e-commerce québécoise basée sur Odoo 18.0.
+
+## Tableau des Modules par Type d'Installation
+
+| Module | Odoo Enterprise Local | Odoo Enterprise Central | Odoo ERP Commerçant | Description |
+|--------|:---------------------:|:-----------------------:|:-------------------:|-------------|
+| **st_laurent_local_core** | ✅ | ❌ | ❌ | Fonctionnalités de base de la plateforme locale, personnalisation régionale, interface d'administration pour les organismes locaux, workflows de modération et approbation |
+| **st_laurent_portal_vendor** | ✅ | ✅ | ❌ | Interface simplifiée pour utilisateurs portal, demande d'inscription et processus d'approbation, formulaires simplifiés d'ajout de produits avec soumission à modération |
+| **st_laurent_ai_product** | ✅ | ✅ | ❌ | Assistant IA pour l'ajout de produits depuis le portail vendeur, génération de descriptions optimisées, extraction automatique d'attributs, suggestions de catégorisation, enrichissement des synonymes |
+| **st_laurent_federation_client** | ✅ | ❌ | ❌ | Connecteur vers l'Odoo Central, fourniture des données produits au fédérateur, partage des index Elasticsearch et dictionnaires de synonymes, réception des documents Odoo |
+| **st_laurent_central_core** | ❌ | ✅ | ❌ | Fonctionnalités de la plateforme centrale, gestion du modèle de commission (1% BEMADE, 1% organisme local), administration centralisée de la fédération |
+| **st_laurent_federation_server** | ❌ | ✅ | ❌ | Gestion de la fédération, enregistrement des instances régionales autonomes, approvisionnement en données, transfert des documents Odoo aux instances locales |
+| **st_laurent_marketplace** | ❌ | ✅ | ❌ | Gestion multi-vendeurs et multi-régions, ventilation des commandes, système de messagerie, portail vendeur provincial, moteur de recherche Elasticsearch, gestion des synonymes |
+| **st_laurent_connector** | ❌ | ❌ | ✅ | Connecteur ERP vers les plateformes St-Laurent, synchronisation bidirectionnelle, choix du rayonnement (local ou provincial), intégration avec Elasticsearch |
+| **st_laurent_vendor_dashboard** | ❌ | ❌ | ✅ | Tableau de bord vendeur, suivi des ventes, analyse des performances, analyse des termes de recherche et synonymes, suggestions d'optimisation |
+| **st_laurent_logistics** | ❌ | ❌ | ✅ | Gestion logistique intégrée, expédition multi-commandes, étiquetage standardisé, intégration transporteurs québécois |
+| **st_laurent_elasticsearch** | ✅ | ✅ | ✅ | Intégration avec Elasticsearch, dictionnaires de synonymes adaptés au français québécois et régionalismes, recherche prédictive |
+| **st_laurent_payment** | ✅ | ✅ | ❌ | Intégration des méthodes de paiement québécoises, gestion des commissions (1% BEMADE, 1% organisme local), traitement sécurisé des transactions |
+| **st_laurent_theme** | ✅ | ✅ | ❌ | Thèmes visuels adaptés à l'identité québécoise avec déclinaisons régionales, approche mobile-first, multilingue |
+
+## Détails des Modules Transversaux
+
+### Modules de Portail Vendeur
+
+| Fonctionnalité | Odoo Enterprise Local | Odoo Enterprise Central | Odoo ERP Commerçant |
+|----------------|:---------------------:|:-----------------------:|:-------------------:|
+| Interface simplifiée portal | ✅ | ✅ | ❌ |
+| Demande d'inscription vendeur | ✅ | ✅ | ❌ |
+| Formulaires d'ajout de produits | ✅ | ✅ | ❌ |
+| Assistant IA pour ajout de produits | ✅ | ✅ | ❌ |
+| Génération de descriptions par IA | ✅ | ✅ | ❌ |
+| Extraction automatique d'attributs | ✅ | ✅ | ❌ |
+| Suggestions de catégorisation | ✅ | ✅ | ❌ |
+| Enrichissement automatique de synonymes | ✅ | ✅ | ❌ |
+| Gestion des commandes vendeur | ✅ | ✅ | ❌ |
+| Soumission à modération | ✅ | ✅ | ❌ |
+| Intégration avec ERP propre | ❌ | ✅ | ✅ |
+
+### Modules de Modération et Approbation
+
+| Fonctionnalité | Odoo Enterprise Local | Odoo Enterprise Central | Odoo ERP Commerçant |
+|----------------|:---------------------:|:-----------------------:|:-------------------:|
+| Approbation des vendeurs | ✅ | ✅ | ❌ |
+| Modération des produits | ✅ | ✅ | ❌ |
+| Tableau de bord de modération | ✅ | ✅ | ❌ |
+| Workflows configurables | ✅ | ✅ | ❌ |
+| Historique des modérations | ✅ | ✅ | ❌ |
+
+### Modules de Recherche et Indexation
+
+| Fonctionnalité | Odoo Enterprise Local | Odoo Enterprise Central | Odoo ERP Commerçant |
+|----------------|:---------------------:|:-----------------------:|:-------------------:|
+| Intégration Elasticsearch | ✅ | ✅ | ✅ |
+| Dictionnaire de synonymes | ✅ | ✅ | ✅ |
+| Gestion des régionalismes | ✅ | ✅ | ✅ |
+| Recherche prédictive | ✅ | ✅ | ✅ |
+| Facettes de recherche | ✅ | ✅ | ❌ |
+| Correction orthographique | ❌ | ✅ | ❌ |
+| Analyse des termes de recherche | ❌ | ✅ | ✅ |
+
+### Modules de Synchronisation et Fédération
+
+| Fonctionnalité | Odoo Enterprise Local | Odoo Enterprise Central | Odoo ERP Commerçant |
+|----------------|:---------------------:|:-----------------------:|:-------------------:|
+| Fourniture de données au fédérateur | ✅ | ❌ | ✅ |
+| Réception des documents Odoo | ✅ | ❌ | ✅ |
+| Gestion des conflits | ✅ | ✅ | ✅ |
+| Monitoring de santé | ✅ | ✅ | ✅ |
+| Routage des commandes | ❌ | ✅ | ❌ |
+| Choix du rayonnement | ❌ | ❌ | ✅ |
+
+### Modules Financiers
+
+| Fonctionnalité | Odoo Enterprise Local | Odoo Enterprise Central | Odoo ERP Commerçant |
+|----------------|:---------------------:|:-----------------------:|:-------------------:|
+| Gestion des commissions (2%) | ✅ | ✅ | ❌ |
+| Répartition (1% BEMADE, 1% organisme) | ✅ | ✅ | ❌ |
+| Rapports de revenus | ✅ | ✅ | ✅ |
+| Paiement centralisé | ❌ | ✅ | ❌ |
+| Facturation automatisée | ❌ | ✅ | ✅ |
+| Conformité fiscale québécoise | ✅ | ✅ | ✅ |
+
+## Priorités de Développement
+
+1. **Phase 1 : Modules fondamentaux**
+ - st_laurent_local_core
+ - st_laurent_central_core
+ - st_laurent_federation_client/server
+ - st_laurent_portal_vendor (local et central)
+ - st_laurent_ai_product (assistant IA pour produits)
+ - st_laurent_elasticsearch
+ - st_laurent_payment
+
+2. **Phase 2 : Modules d'expérience utilisateur**
+ - st_laurent_portal_vendor
+ - st_laurent_marketplace
+ - st_laurent_theme
+ - st_laurent_connector (version de base)
+
+3. **Phase 3 : Modules avancés**
+ - st_laurent_vendor_dashboard
+ - st_laurent_logistics
+ - st_laurent_connector (fonctionnalités avancées)
+
+## Notes d'Implémentation
+
+- Tous les modules doivent être développés pour Odoo 18.0
+- Les modules doivent respecter les standards de développement Odoo
+- La documentation technique complète doit être fournie pour chaque module
+- Les tests unitaires et d'intégration sont obligatoires
+- L'approche de développement doit être modulaire pour faciliter la maintenance
+- Les modules doivent supporter le multilingue (français et anglais)
+- La sécurité et la protection des données doivent être prioritaires
diff --git a/st_laurent_portal_vendor/doc/analyse.md b/st_laurent_portal_vendor/doc/analyse.md
new file mode 100644
index 0000000..c25afa7
--- /dev/null
+++ b/st_laurent_portal_vendor/doc/analyse.md
@@ -0,0 +1,278 @@
+# Analyse du module st_laurent_portal_vendor
+
+## Introduction
+
+Le module `st_laurent_portal_vendor` est une extension des modules `vendor_portal_management` et `vendor_product_management` qui ajoute des fonctionnalités e-commerce aux produits fournisseurs. Ce module permet aux vendeurs de gérer leurs produits, leurs images et leurs informations e-commerce via un portail dédié.
+
+## Structure de données
+
+### Modèles principaux
+
+#### 1. vendor.product (extension)
+
+Le modèle `vendor.product` est étendu pour inclure des fonctionnalités e-commerce :
+
+- **Champs d'image** : `image_1920`, `image_1024`, `image_512`, `image_256`, `image_128`
+- **Champs e-commerce** :
+ - `website_published` : Indique si le produit est publié sur le site web
+ - `website_description` : Description HTML pour le site web
+ - `website_url` : URL du produit sur le site web
+ - `public_categ_ids` : Catégories du site web
+ - `product_tag_ids` : Tags pour le filtrage et la catégorisation
+- **Prix et disponibilité** :
+ - `website_price` : Prix affiché sur le site web
+ - `website_ribbon` : Texte affiché dans un ruban sur le produit
+ - `availability` : État de disponibilité du produit
+ - `availability_date` : Date de disponibilité
+- **SEO et métadonnées** :
+ - `website_meta_title` : Titre Meta
+ - `website_meta_description` : Description Meta
+ - `website_meta_keywords` : Mots-clés Meta
+ - `website_meta_og_img` : Image Open Graph
+
+#### 2. vendor.request
+
+Nouveau modèle pour gérer les demandes pour devenir vendeur :
+
+- **Informations de base** :
+ - `name` : Référence de la demande
+ - `partner_id` : Contact associé
+ - `user_id` : Utilisateur associé
+- **Informations de l'entreprise** :
+ - `company_name` : Nom de l'entreprise
+ - `company_street`, `company_street2`, `company_zip`, `company_city` : Adresse
+ - `company_state_id`, `company_country_id` : État/Province et Pays
+ - `company_email`, `company_phone`, `company_website` : Coordonnées
+ - `company_vat` : Numéro de TVA/TPS
+- **Gestion de la demande** :
+ - `state` : État de la demande (brouillon, en attente, approuvée, rejetée)
+ - `rejection_reason` : Motif de rejet
+ - `approved_date` : Date d'approbation
+ - `attachment_ids` : Documents joints
+
+#### 3. res.partner (extension)
+
+Le modèle `res.partner` est étendu pour gérer le statut vendeur :
+
+- `vendor_status` : Statut vendeur (oui/non)
+- `is_vendor` : Champ calculé indiquant si le partenaire est un vendeur
+- `vendor_request_ids` : Relation avec les demandes de vendeur
+- `has_pending_vendor_request` : Indique si le partenaire a une demande en attente
+
+#### 4. res.users (extension)
+
+Le modèle `res.users` est étendu pour accéder facilement aux informations vendeur :
+
+- `is_vendor` : Champ lié au partenaire
+- `vendor_status` : Champ lié au partenaire
+- `has_pending_vendor_request` : Champ lié au partenaire
+- `vendor_request_ids` : Champ lié au partenaire
+
+### Relations entre les modèles
+
+- `vendor.product` est lié à `res.partner` via le champ `partner_id`
+- `vendor.request` est lié à `res.partner` via le champ `partner_id`
+- `vendor.request` est lié à `res.users` via le champ `user_id`
+- `vendor.product` peut être lié à `product.template` et `product.product` via les champs `product_tmpl_id` et `product_id`
+
+### Analyse comparative : vendor.product vs extension de product.product
+
+Une question architecturale importante concerne le choix entre maintenir un modèle `vendor.product` séparé ou simplement étendre le modèle `product.product` existant. Voici une analyse des avantages et inconvénients de chaque approche :
+
+#### Approche 1 : Modèle vendor.product séparé (approche actuelle)
+
+**Avantages :**
+
+1. **Séparation claire des préoccupations** : Les produits vendeurs et les produits standard sont gérés séparément, ce qui simplifie la logique métier.
+2. **Contrôle du workflow** : Permet un processus de validation avant qu'un produit vendeur ne devienne un produit standard.
+3. **Sécurité et permissions** : Facilite la gestion des droits d'accès, les vendeurs n'ayant accès qu'à leurs propres produits sans risque d'altérer les produits standard.
+4. **Données spécifiques aux vendeurs** : Permet de stocker des informations propres aux vendeurs sans surcharger le modèle `product.product`.
+5. **Évolutivité** : Facilite l'ajout de fonctionnalités spécifiques aux vendeurs sans impacter le catalogue principal.
+
+**Inconvénients :**
+
+1. **Duplication potentielle** : Certaines données sont dupliquées entre `vendor.product` et `product.product`.
+2. **Complexité de synchronisation** : Nécessite un mécanisme pour maintenir la cohérence lors de la conversion d'un produit vendeur en produit standard.
+3. **Requêtes plus complexes** : Les recherches impliquant à la fois des produits vendeurs et standard nécessitent des jointures ou des unions.
+
+#### Approche 2 : Extension du modèle product.product
+
+**Avantages :**
+
+1. **Modèle de données unifié** : Un seul modèle pour tous les produits, simplifiant les requêtes et les rapports.
+2. **Pas de duplication** : Évite la redondance des données et les problèmes de synchronisation.
+3. **Intégration native** : Fonctionne naturellement avec toutes les fonctionnalités existantes d'Odoo (inventaire, ventes, achats).
+4. **Maintenance simplifiée** : Moins de code à maintenir et à tester.
+
+**Inconvénients :**
+
+1. **Confusion potentielle** : Mélange des produits vendeurs et standard dans la même table, ce qui peut compliquer la gestion.
+2. **Risques de sécurité** : Plus difficile de restreindre l'accès des vendeurs uniquement à leurs produits.
+3. **Surcharge du modèle** : Ajout de nombreux champs qui ne sont pertinents que pour les produits vendeurs.
+4. **Workflow moins flexible** : Plus difficile d'implémenter un processus de validation avant qu'un produit ne soit disponible dans le catalogue principal.
+5. **Impact sur les performances** : L'ajout de nombreux produits vendeurs peut ralentir les opérations sur la table `product.product`.
+
+**Comment l'approche actuelle résout ces problèmes :**
+
+1. **Séparation claire** : Avec `vendor.product`, il n'y a pas de confusion possible entre les produits vendeurs et les produits standard, chacun étant dans sa propre table.
+2. **Sécurité renforcée** : Les règles d'accès peuvent être définies précisément sur le modèle `vendor.product` sans affecter l'accès aux produits standard.
+3. **Modèles spécialisés** : Chaque modèle ne contient que les champs pertinents pour son usage, évitant la surcharge et améliorant la lisibilité.
+4. **Workflow de validation** : Un produit vendeur peut suivre son propre cycle de vie et de validation avant d'être converti en produit standard.
+
+#### Conclusion
+
+L'approche actuelle avec un modèle `vendor.product` séparé est justifiée par :
+
+1. **La nécessité d'un workflow de validation** : Les produits vendeurs doivent être vérifiés avant d'être intégrés au catalogue principal.
+2. **Les exigences de sécurité** : Les vendeurs ne doivent avoir accès qu'à leurs propres produits.
+3. **La spécificité des données vendeur** : De nombreux champs sont spécifiques aux produits vendeurs et n'ont pas leur place dans le modèle standard.
+4. **L'évolutivité future** : La séparation facilite l'ajout de fonctionnalités spécifiques aux vendeurs dans des modules complémentaires.
+
+Cependant, pour des cas d'utilisation plus simples où ces considérations sont moins importantes, l'extension du modèle `product.product` pourrait être une solution plus légère et plus facile à maintenir.
+
+#### Recommandations spécifiques pour la gestion des prix et commissions
+
+Considérant que les listes de prix des vendeurs sont des prix de vente et que la plateforme prélève un pourcentage en commission, voici des recommandations supplémentaires :
+
+1. **Modèle `vendor.product` séparé (recommandé)**
+ - **Gestion des prix** : Permet de stocker à la fois le prix vendeur original et le prix final (incluant la commission) sans confusion.
+ - **Calcul des commissions** : Facilite l'implémentation de règles de commission variables par vendeur, par catégorie ou par produit.
+ - **Transparence pour les vendeurs** : Les vendeurs peuvent voir clairement leur prix de vente et la commission prélevée.
+ - **Rapports financiers** : Simplifie la génération de rapports sur les ventes et les commissions par vendeur.
+ - **Flexibilité des promotions** : Permet aux vendeurs de créer des promotions sans affecter la structure de commission.
+
+2. **Extension de `product.product` (non recommandée pour ce cas d'usage)**
+ - **Complexité accrue** : Nécessiterait des champs supplémentaires pour gérer les prix vendeurs, les commissions et les prix finaux.
+ - **Risque de confusion** : Les prix standards et les prix vendeurs pourraient être confondus dans les processus de vente.
+ - **Difficultés comptables** : La séparation des revenus (part vendeur vs commission) serait plus complexe à gérer.
+
+**Recommandation finale** : Dans un modèle d'affaires basé sur des commissions prélevées sur les ventes des vendeurs, l'approche avec un modèle `vendor.product` séparé est fortement recommandée. Elle offre une séparation claire des flux financiers, une meilleure traçabilité des transactions, et une plus grande flexibilité pour adapter les règles de commission selon différents critères.
+
+#### Stratégies de synchronisation entre vendor.product et product.product
+
+L'utilisation de deux modèles séparés nécessite une stratégie de synchronisation efficace, notamment pour les changements de prix et autres mises à jour importantes :
+
+1. **Synchronisation des prix**
+ - **Changements de prix vendeur** : Lorsqu'un vendeur modifie son prix, un mécanisme de recalcul automatique du prix final (incluant la commission) doit être déclenché.
+ - **Règles de propagation** : Définir clairement quand et comment les changements de prix sont propagés au produit standard correspondant.
+ - **Historique des prix** : Conserver un historique des changements de prix pour l'audit et l'analyse.
+
+2. **Synchronisation des attributs produit**
+ - **Attributs à synchroniser** : Identifier clairement quels attributs doivent être synchronisés (ex: nom, description, catégories) et lesquels restent spécifiques à chaque modèle.
+ - **Direction de la synchronisation** : Déterminer si la synchronisation est unidirectionnelle (vendor.product → product.product) ou bidirectionnelle selon les attributs.
+ - **Règles de priorité** : Établir des règles de priorité en cas de conflit (ex: qui du vendeur ou de l'administrateur a le dernier mot sur certains attributs).
+
+3. **Mécanismes techniques de synchronisation**
+ - **Triggers automatiques** : Utiliser des déclencheurs sur les méthodes `write` et `create` pour propager automatiquement les changements.
+ - **Jobs planifiés** : Pour les synchronisations non critiques, utiliser des tâches planifiées pour réduire la charge sur le système.
+ - **Verrouillage optimiste** : Implémenter un mécanisme de verrouillage optimiste pour éviter les problèmes de concurrence lors des mises à jour.
+
+4. **Interface utilisateur et expérience vendeur**
+ - **Transparence** : Informer clairement les vendeurs des règles de synchronisation et des délais potentiels.
+ - **Validation** : Mettre en place des processus de validation pour certaines modifications critiques avant leur propagation.
+ - **Notifications** : Alerter les vendeurs lorsque leurs modifications ont été appliquées ou si des problèmes sont survenus.
+
+Cette stratégie de synchronisation bien définie permet de maintenir la cohérence des données tout en préservant les avantages d'avoir deux modèles séparés.
+
+## Fonctionnalités
+
+### 1. Portail vendeur
+
+- **Page d'accueil vendeur** : Interface personnalisée pour les vendeurs
+- **Gestion des produits** : Ajout, modification et suppression de produits
+- **Gestion des images** : Upload et gestion des images pour les produits
+- **Publication sur le site web** : Contrôle de la visibilité des produits sur le site web
+
+### 2. Processus de demande vendeur
+
+- **Formulaire de demande** : Interface pour soumettre une demande pour devenir vendeur
+- **Workflow d'approbation** : Processus de validation des demandes par les administrateurs
+- **Notifications** : Alertes par email lors des changements d'état des demandes
+
+### 3. Intégration e-commerce
+
+- **SEO** : Gestion des métadonnées pour le référencement
+- **Catégorisation** : Association des produits vendeur aux catégories du site web
+- **Prix et disponibilité** : Gestion des informations de prix et de stock pour le site web
+
+### 4. Conversion de produits vendeur en produits standard
+
+- Fonctionnalité pour créer des produits standard (`product.template`) à partir des produits vendeur
+- Association automatique du fournisseur au produit créé
+
+## Intégration avec les modules existants
+
+### vendor_product_management
+
+Le module `vendor_product_management` fournit les fonctionnalités de base pour la gestion des produits vendeur :
+
+- Modèle `vendor.product` de base
+- Gestion des prix et des stocks
+- Import de données vendeur
+
+Le module `st_laurent_portal_vendor` étend ces fonctionnalités en ajoutant des capacités e-commerce et une meilleure intégration avec le site web.
+
+### vendor_portal_management
+
+Le module `vendor_portal_management` fournit le portail de base pour les vendeurs :
+
+- Interface de portail pour les vendeurs
+- Gestion des produits via le portail
+- Import de données via le portail
+
+Le module `st_laurent_portal_vendor` étend ces fonctionnalités en ajoutant un processus de demande pour devenir vendeur et des fonctionnalités e-commerce avancées.
+
+## Tâches restantes
+
+### Répartition des tâches par module
+
+Les tâches restantes ont été analysées pour déterminer lesquelles devraient être intégrées au module `st_laurent_portal_vendor` actuel et lesquelles devraient être développées dans des modules distincts.
+
+#### Tâches à intégrer dans le module `st_laurent_portal_vendor` actuel
+
+1. **Améliorations du portail vendeur** ✅
+ - ✅ Ajouter une interface pour gérer les catégories et les tags des produits
+ - ✅ Améliorer l'interface d'upload d'images avec prévisualisation et recadrage
+
+2. **Optimisations techniques**
+ - Améliorer les performances du portail pour gérer un grand nombre de produits
+ - Optimiser le stockage et le traitement des images
+ - Renforcer la sécurité des accès et des permissions
+
+#### Tâches à développer dans des modules distincts
+
+1. **Module "st_laurent_vendor_analytics"**
+ - Ajouter des statistiques de vente et de visite pour les produits vendeur
+ - Créer des rapports de vente spécifiques aux vendeurs
+ - Ajouter des tableaux de bord avec des indicateurs de performance
+ - Implémenter des analyses de tendances pour aider les vendeurs à optimiser leurs offres
+
+2. **Module "st_laurent_vendor_reviews"**
+ - Implémenter un système de notation et d'avis pour les produits vendeur
+ - Intégrer un système de questions/réponses pour les produits vendeur
+
+3. **Module "st_laurent_vendor_promotions"**
+ - Ajouter la possibilité de créer des promotions spécifiques aux produits vendeur
+
+4. **Module "st_laurent_vendor_orders"**
+ - Ajouter une interface pour que les vendeurs puissent voir les commandes de leurs produits
+ - Implémenter un système de notification pour les nouvelles commandes
+ - Permettre aux vendeurs de gérer les expéditions de leurs produits
+
+### Justification de la modularisation
+
+1. **Cohésion fonctionnelle** : Chaque module a une responsabilité claire et cohérente. Le module `st_laurent_portal_vendor` actuel se concentre sur la gestion des produits vendeur et leur intégration e-commerce de base.
+
+2. **Complexité** : Les fonctionnalités comme les analyses, les avis, les promotions et la gestion des commandes sont suffisamment complexes pour justifier leurs propres modules.
+
+3. **Dépendances** : Les modules distincts peuvent avoir leurs propres dépendances sans alourdir le module principal.
+
+4. **Maintenance** : Des modules plus petits et plus ciblés sont plus faciles à maintenir et à faire évoluer.
+
+5. **Déploiement progressif** : La modularisation permet un déploiement progressif des fonctionnalités, en fonction des priorités du projet.
+
+## Conclusion
+
+Le module `st_laurent_portal_vendor` étend les fonctionnalités des modules `vendor_portal_management` et `vendor_product_management` en ajoutant des capacités e-commerce avancées et un processus de demande pour devenir vendeur. Il offre une solution complète pour permettre aux vendeurs de gérer leurs produits et leur présence sur le site web e-commerce.
+
+Les tâches restantes se concentrent sur l'amélioration de l'expérience utilisateur, l'ajout de fonctionnalités e-commerce avancées, la gestion des commandes et l'optimisation des performances et de la sécurité.
\ No newline at end of file
diff --git a/st_laurent_portal_vendor/doc/portal_vendor_modules_analysis.md b/st_laurent_portal_vendor/doc/portal_vendor_modules_analysis.md
new file mode 100644
index 0000000..4eaa8e0
--- /dev/null
+++ b/st_laurent_portal_vendor/doc/portal_vendor_modules_analysis.md
@@ -0,0 +1,71 @@
+# Analyse des modules portail vendeur Odoo
+
+## 1. portal_partner_manager
+- **Rôle** : Extension du portail Odoo pour permettre aux utilisateurs de gérer leur société parente et leurs contacts via le portail.
+- **Points clés** :
+ - Ajoute/modifie les templates du portail (cartes société, contacts, vendors).
+ - Utilise des templates hérités de `portal.portal_my_home`.
+ - Les blocs vendors/clients sont insérés via des xpaths, mais il faut que les IDs ciblés existent dans la vue parente.
+ - Les modèles Python gèrent les droits et la logique de modification côté portail.
+ - Contrôleurs spécifiques pour la gestion des sociétés/contacts via le portail.
+ - Sécurité renforcée par des règles d'accès et des contrôles dans les méthodes.
+
+## 2. st_laurent_portal_vendor
+- **Rôle** : Fournit l'espace vendeur, la gestion des demandes pour devenir vendeur, l'affichage des produits vendeurs, etc.
+- **Points clés** :
+ - Nombreux templates pour l'espace vendeur, les demandes, la gestion des produits et des catégories.
+ - Contrôleurs dédiés pour les routes `/my/vendor`, `/my/vendor/request`, etc.
+ - Les boutons d'accès aux produits, locations, etc., dépendent de la structure des templates et de la logique de visibilité.
+ - Les vues utilisent parfois des conditions (`t-if`) pour afficher ou non certains boutons.
+ - Les modèles Python gèrent la logique des demandes, les droits d'accès, et l'affichage des informations vendeur.
+ - Les modules peuvent se marcher sur les pieds si plusieurs héritent ou modifient la même vue parent.
+
+## 3. vendor_product_management
+- **Rôle** : Gestion des produits et des emplacements (locations) côté vendeur.
+- **Points clés** :
+ - Modèles `res.partner` enrichis avec des One2many vers produits et locations.
+ - Champs calculés pour compter produits/locations.
+ - Vues pour la gestion des produits, locations, supplierinfo, etc.
+ - Si les boutons n'apparaissent pas dans le portail, vérifier que les champs sont bien passés au template et que les routes existent côté contrôleur.
+ - Peut nécessiter une intégration explicite dans les templates du portail vendeur.
+
+## 4. vendor_portal_management
+- **Rôle** : Fournit une surcouche portail vendeur, cartes d'accès rapide (produits, locations), et personnalisation de l'UI.
+- **Points clés** :
+ - Ajoute/étend le template `portal_my_home_vendor` avec des entrées pour produits et locations (cartes avec icônes, liens, compteurs).
+ - Utilise des xpaths pour insérer les blocs dans la vue parente.
+ - Les templates attendent que les variables `products_count`, `locations_count` soient passées au contexte.
+ - Breadcrumbs personnalisés pour navigation produits/locations.
+ - Si les boutons n'apparaissent pas, vérifier l'ordre de chargement des modules, l'héritage des vues et la présence des variables dans le contexte.
+
+## 5. st_laurent_vendor_orders
+- **Rôle** : Gestion des commandes vendeurs dans le portail (affichage, suivi, expédition, notifications).
+- **Points clés** :
+ - Ajoute des vues portail pour afficher la liste et le détail des commandes vendeurs (`vendor_order_portal_templates.xml`).
+ - Les vendeurs voient les commandes associées à leurs produits, avec statuts, montants, et actions d’expédition.
+ - Système de notification pour nouvelles commandes et suivi d’expédition.
+ - Dépend des modules : `vendor_product_management`, `vendor_portal_management`, `st_laurent_portal_vendor`.
+ - Les templates attendent que la variable `vendor_orders` soit passée au contexte par le contrôleur.
+ - Ajoute des menus spécifiques pour accéder aux commandes vendeur dans le portail.
+ - Les droits d’accès sont gérés via les security et les règles d’accès Odoo.
+ - Si la liste n’apparaît pas, vérifier le contexte, les droits, et la route du contrôleur.
+
+## 6. Interactions et points de vigilance
+- Plusieurs modules héritent ou modifient les mêmes templates portail (ex: `portal.portal_my_home`).
+- Les IDs ou classes ciblés dans les xpaths doivent exister dans la vue héritée, sinon Odoo lève une erreur et/ou le bloc n'est pas inséré.
+- Les variables de contexte (ex: `products_count`, `locations_count`) doivent être injectées par le contrôleur pour que les widgets/cartes s'affichent correctement.
+- L'ordre d'installation/chargement des modules peut impacter l'affichage (un module peut écraser la vue d'un autre).
+- Les droits d'accès (groupes, security) peuvent masquer des boutons ou sections si non configurés pour l'utilisateur courant.
+- Le cache Odoo peut empêcher la prise en compte immédiate des modifications de vues/templates.
+
+## 6. Recommandations
+- Toujours vérifier l'existence des IDs/classes dans les vues héritées avant d'utiliser un xpath.
+- S'assurer que les variables nécessaires sont bien passées au contexte du template.
+- Contrôler l'ordre d'installation des modules et l'héritage des vues lors de l'ajout de nouvelles fonctionnalités portail.
+- Si un bouton ou une carte n'apparaît pas, vérifier : le template, le contrôleur, les droits, l'ordre des modules, et le cache.
+
+---
+
+**Dernière analyse générée automatiquement le 20/04/2025 à 09:09**
+
+Pour toute question ou besoin de diagnostic sur un point précis, se référer à ce document ou demander une analyse ciblée.
diff --git a/st_laurent_portal_vendor/doc/projet-st-laurent.md b/st_laurent_portal_vendor/doc/projet-st-laurent.md
new file mode 100644
index 0000000..d4eb3f2
--- /dev/null
+++ b/st_laurent_portal_vendor/doc/projet-st-laurent.md
@@ -0,0 +1,895 @@
+# Projet St-Laurent
+## Une fédération e-commerce québécoise basée sur Odoo
+
+---
+
+## Sommaire exécutif
+
+Le projet St-Laurent vise à créer une alternative québécoise à Amazon suite à la fermeture du Panier Bleu, en développant une fédération de plateformes e-commerce basées sur Odoo 18.0. Cette architecture à trois niveaux permettra aux entreprises québécoises de vendre leurs produits en ligne avec une commission exceptionnellement basse de 2%, positionnant St-Laurent comme la solution la plus économique et la plus adaptée au contexte québécois.
+
+La fédération St-Laurent s'articule autour de trois niveaux complémentaires :
+
+1. **Odoo Enterprise Local** (régional/municipal) : Permettant aux micro-entreprises d'accéder facilement à une boutique en ligne via une interface simplifiée de type portal user.
+
+2. **Odoo Enterprise Central** (provincial) : Agrégeant l'ensemble des Odoo régionaux pour offrir une expérience d'achat unifiée aux consommateurs à travers tout le Québec.
+
+3. **Odoo ERP Commerçant** (Community ou Enterprise) : Offrant aux entreprises une solution complète de gestion intégrée à l'écosystème St-Laurent, avec un choix de rayonnement local ou provincial.
+
+St-Laurent deviendra partenaire officiel Odoo pour le Québec, avec l'objectif explicite d'accompagner les entreprises dans leur migration vers Odoo Enterprise 18.0, générant ainsi un modèle d'affaires à triple valeur ajoutée : les plateformes e-commerce régionales, la plateforme provinciale unifiée, et les services d'intégration Odoo.
+
+---
+
+## Vision et objectifs
+
+### Vision
+Devenir la première destination en ligne pour l'achat de produits québécois, en offrant une alternative locale, économique et technologiquement avancée aux géants du commerce électronique international, tout en respectant les spécificités régionales à travers une architecture fédérée.
+
+### Objectifs
+1. Créer un écosystème e-commerce québécois autonome, décentralisé et compétitif
+2. Proposer la commission la plus basse du marché (2%)
+3. Simplifier la présence en ligne des entreprises québécoises de toutes tailles
+4. Fédérer les acteurs économiques locaux à travers une structure régionale et provinciale
+5. Offrir une évolution progressive des solutions, du simple portal user à l'ERP complet
+6. Valoriser les identités régionales tout en offrant une visibilité provinciale
+7. Faciliter l'intégration complète pour les utilisateurs d'Odoo Enterprise et Community
+
+---
+
+## Architecture technique
+
+### 1. Architecture à trois niveaux
+
+#### Niveau 1: Odoo Enterprise Local (Régional/Municipal)
+- **Base**: Odoo Enterprise Edition 18.0
+- **Objectif**: Fournir une micro-boutique en ligne pour les vendeurs locaux
+- **Modules principaux**:
+ - Website (base)
+ - E-commerce
+ - Paiement en ligne
+ - Gestion des contacts simplifiée
+- **Modules personnalisés**:
+ - **st_laurent_local_core**: Fonctionnalités de base de la plateforme locale
+ - Personnalisation régionale (identité visuelle, contenu local)
+ - Configuration des règles de marketplace locale
+ - Administration des vendeurs locaux
+ - **st_laurent_portal_vendor**: Interface simplifiée pour utilisateurs portal
+ - Inscription et création autonome de compte vendeur
+ - Formulaires simplifiés d'ajout de produits
+ - Gestion des commandes par vendeur
+ - Notifications et alertes
+ - **st_laurent_federation_client**: Connecteur vers l'Odoo Central
+ - Synchronisation des produits vers la plateforme centrale
+ - Réception des commandes de la plateforme centrale
+ - Statut de synchronisation et diagnostics
+
+#### Niveau 2: Odoo Enterprise Central (Provincial)
+- **Base**: Odoo Enterprise Edition 18.0
+- **Objectif**: Agréger tous les Odoo régionaux et offrir une expérience unifiée
+- **Modules principaux**:
+ - Website (base)
+ - E-commerce avancé
+ - Paiement en ligne multi-méthodes
+ - CRM
+ - Marketing automation
+ - Voicemail
+ - Signature électronique
+- **Modules personnalisés**:
+ - **st_laurent_central_core**: Fonctionnalités de la plateforme centrale
+ - Gestion du modèle de commission (2%)
+ - Administration centralisée de la fédération
+ - Tableaux de bord provinciaux
+ - **st_laurent_federation_server**: Gestion de la fédération
+ - Enregistrement et gestion des instances régionales
+ - Routage intelligent des commandes
+ - Synchronisation et indexation des produits
+ - Monitoring de santé de la fédération
+ - **st_laurent_marketplace**: Gestion multi-vendeurs et multi-régions
+ - Ventilation des commandes multi-vendeurs
+ - Système de messagerie vendeur-client-admin
+ - Gestion des avis et évaluations
+ - Outils de recherche et filtrage avancés
+ - Comparaison de produits inter-régions
+
+#### Niveau 3: Odoo ERP Commerçant (Community ou Enterprise)
+- **Base**: Odoo Community Edition 18.0 ou Enterprise Edition 18.0 (au choix)
+- **Objectif**: Fournir une solution ERP complète aux commerçants
+- **Modules principaux**: Tous les modules standards Odoo selon les besoins
+ - Inventaire
+ - Comptabilité
+ - Achats
+ - Ventes
+ - Fabrication
+ - RH
+ - etc.
+- **Modules personnalisés**:
+ - **st_laurent_connector**: Connecteur vers les plateformes St-Laurent
+ - Synchronisation bidirectionnelle
+ - Mapping flexible des champs et modèles
+ - Choix du rayonnement (local ou provincial)
+ - Gestion des erreurs et conflits
+ - Tableau de bord de santé des connecteurs
+
+### 2. Infrastructure serveur
+
+#### Hébergement fédéré
+- **Niveau 1 (Local)**: Serveurs régionaux (possibilité d'hébergement par partenaires locaux)
+- **Niveau 2 (Central)**: Infrastructure cloud québécoise haute disponibilité (OVH, CloudWatt)
+- **Niveau 3 (Commerçant)**: Au choix du commerçant (on-premise ou cloud)
+
+#### Architecture technique
+- **Fédération**: Architecture distribuée avec registre central
+- **Isolation**: Multi-tenant avec isolation des données par région
+- **Scaling**: Auto-scaling basé sur la charge (niveau central)
+- **Redondance**: Configuration active-passive avec basculement automatique
+- **Sauvegarde**: Quotidienne avec rétention de 30 jours
+
+### 3. Interfaces utilisateur
+
+#### Frontend client (Niveau 1 et 2)
+- **Design**: Interface adaptée à l'identité québécoise avec déclinaisons régionales
+- **Responsivité**: Mobile-first approach
+- **Multilingue**: Français et anglais
+- **Personnalisation**: Thème St-Laurent avec variantes régionales
+- **Performance**: Optimisation SEO et vitesse de chargement
+
+#### Backend vendeur
+- **Niveau 1**: Interface portal simplifiée pour micro-entreprises
+- **Niveau 2**: Dashboard provincial avec vue globale
+- **Niveau 3**: Interface Odoo standard avec connecteurs St-Laurent
+
+### 4. Système de connecteurs
+
+#### Connecteurs inter-niveaux
+- **Protocole**: API REST bidirectionnelle sécurisée
+- **Authentification**: OAuth2 avec clés API
+- **Synchronisation**:
+ - Produits et catalogues (Local → Central)
+ - Commandes (Central → Local)
+ - Inventaire en temps réel
+ - Prix et promotions
+
+#### API externe
+- **Documentation**: Spécifications OpenAPI
+- **Endpoints standardisés** pour tous les niveaux:
+ - /products: Gestion des produits
+ - /orders: Gestion des commandes
+ - /inventory: Mise à jour des stocks
+ - /webhooks: Notifications événementielles
+ - /federation: Gestion de la fédération (niveau 2 uniquement)
+
+---
+
+## Fonctionnalités principales
+
+### 1. Niveau 1: Odoo Enterprise Local (Régional/Municipal)
+
+#### Gestion des vendeurs locaux
+- Processus d'inscription simplifié pour micro-entreprises locales
+- Vérification d'éligibilité (entreprises de la région/ville)
+- Tutoriel interactif d'intégration pour utilisateurs non-techniques
+- Configuration guidée du profil vendeur local
+
+#### Interface portal vendeur
+- Vue d'ensemble simplifiée des ventes et performances
+- Alertes et notifications essentielles
+- Gestion basique des produits et commandes
+- Interface adaptée aux utilisateurs non-techniques
+
+#### Gestion des produits
+- Formulaires simplifiés d'ajout de produits
+- Support pour attributs et variantes de base
+- Gestion des images (multi-vues)
+- Mise en avant de l'origine locale des produits
+
+#### Gestion des commandes locales
+- Notifications de nouvelles commandes
+- Processus simplifié de traitement des commandes
+- Suivi de livraison basique
+- Support client de proximité
+
+### 2. Niveau 2: Odoo Enterprise Central (Provincial)
+
+#### Fédération des plateformes régionales
+- Agrégation des produits de toutes les instances régionales
+- Recherche unifiée à travers toutes les régions
+- Filtrage par région, distance, disponibilité
+- Mise en avant des spécificités régionales
+
+#### Expérience d'achat unifiée
+- Panier d'achat multi-régions
+- Processus de commande unifié
+- Paiement centralisé (Stripe, PayPal, Desjardins)
+- Suivi de commandes multi-vendeurs
+
+#### Gestion des commandes provinciales
+1. Client passe commande sur la plateforme centrale
+2. Paiement traité par la plateforme centrale
+3. Commande ventilée vers les plateformes régionales concernées
+4. Confirmation de traitement par chaque vendeur régional
+5. Suivi de livraison consolidé pour le client
+
+#### Marketing et promotion provinciale
+- Campagnes marketing à l'échelle du Québec
+- Mise en avant des produits régionaux
+- Programmes de fidélité provinciaux
+- Événements promotionnels saisonniers
+
+#### Administration centrale
+- Tableau de bord de la fédération
+- Monitoring de santé des instances régionales
+- Rapports consolidés de ventes et performances
+- Gestion des commissions (2% standard)
+
+### 3. Niveau 3: Odoo ERP Commerçant (Community ou Enterprise)
+
+#### Intégration complète
+- Synchronisation bidirectionnelle avec les plateformes St-Laurent
+- Choix du rayonnement (local ou provincial)
+- Gestion avancée des produits et variantes
+- Automatisation des flux de travail
+
+#### Fonctionnalités ERP complètes
+- Gestion d'inventaire avancée
+- Comptabilité intégrée
+- CRM et gestion de la relation client
+- Fabrication et gestion de production
+- Ressources humaines
+- Point de vente physique
+
+#### Tableau de bord vendeur avancé
+- Analyse détaillée des ventes par canal
+- Prévisions et tendances
+- KPIs personnalisables
+- Business intelligence
+
+#### Gestion multi-canal
+- Intégration St-Laurent (local et/ou provincial)
+- Possibilité d'intégration avec d'autres marketplaces
+- Synchronisation avec boutique physique
+- Gestion omnicanal complète
+
+### 4. Fonctionnalités transversales
+
+#### Paiement et facturation
+- Paiement centralisé au niveau provincial
+- Commission de 2% retenue automatiquement
+- Transfert des fonds aux vendeurs (délai J+3)
+- Facturation mensuelle des services additionnels
+
+#### Service client multi-niveau
+- Support de proximité au niveau régional
+- Support centralisé pour questions transversales
+- Système d'évaluation des vendeurs harmonisé
+- Centre d'aide et FAQ à chaque niveau
+
+#### Identité et personnalisation
+- Thème commun St-Laurent avec déclinaisons régionales
+- Personnalisation des boutiques vendeurs
+- Badges et certifications (produits locaux, artisanaux, etc.)
+- Mise en avant des spécificités culturelles régionales
+
+---
+
+## Modèle économique
+
+### 1. Structure de revenus
+
+#### Commission base
+- **Taux fixe**: 2% sur toutes les ventes (niveau provincial)
+- **Positionnement**: Le plus bas du marché
+
+#### Services additionnels par niveau
+- **Niveau 1 (Local)**:
+ - Visibilité locale premium (25-100$/mois)
+ - Support technique de proximité (tarifs variables selon régions)
+
+- **Niveau 2 (Provincial)**:
+ - Visibilité premium en page d'accueil provinciale (50-200$/mois)
+ - Outils marketing avancés: Campagnes email, analytics avancées (30-100$/mois)
+ - Mise en avant dans les résultats de recherche provinciaux (tarifs variables)
+
+- **Niveau 3 (ERP Commerçant)**:
+ - Services d'implémentation et personnalisation Odoo
+ - Formation et support technique
+ - Développements spécifiques
+ - Migration depuis d'autres systèmes
+- **Support dédié**: Assistance prioritaire (25$/mois)
+- **Formation**: Sessions personnalisées (75$/heure)
+
+#### Partenariat Odoo
+- **Statut de partenaire officiel Odoo** pour le Québec
+- Commissions sur nouveaux déploiements Odoo Enterprise (15-25% des contrats)
+- Services d'intégration et personnalisation à valeur ajoutée
+- Formation et support Odoo certifiés
+- Stratégie de migration proactive des vendeurs vers Odoo Enterprise
+- Développement de modules verticaux spécifiques aux industries québécoises
+
+### 2. Structure de coûts
+
+#### Développement initial
+- Développement plateforme: 200 000$ - 300 000$
+- Design et UX: 50 000$ - 75 000$
+- Tests et assurance qualité: 25 000$ - 50 000$
+
+#### Opérations continues
+- Infrastructure cloud: 5 000$ - 10 000$/mois
+- Support technique: 10 000$ - 15 000$/mois
+- Marketing et acquisition: 10 000$ - 20 000$/mois
+- Équipe produit: 20 000$ - 40 000$/mois
+
+#### Mise à l'échelle
+- Prévision d'investissement par palier de 1000 vendeurs
+- Réserve opérationnelle de 6 mois minimum
+
+---
+
+## Modifications techniques Odoo 18.0 Enterprise
+
+### 1. Extension portal user pour création de comptes et gestion produits
+
+```python
+# Modèle de sécurité étendu (st_laurent_portal_vendor/security/ir.model.access.csv)
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_product_template_sl_vendor,product.template.sl.vendor,product.model_product_template,st_laurent_portal_vendor.group_sl_vendor,1,1,1,0
+access_product_product_sl_vendor,product.product.sl.vendor,product.model_product_product,st_laurent_portal_vendor.group_sl_vendor,1,1,1,0
+access_product_image_sl_vendor,product.image.sl.vendor,product.model_product_image,st_laurent_portal_vendor.group_sl_vendor,1,1,1,1
+access_product_attribute_sl_vendor,product.attribute.sl.vendor,product.model_product_attribute,st_laurent_portal_vendor.group_sl_vendor,1,1,0,0
+access_product_attribute_value_sl_vendor,product.attribute.value.sl.vendor,product.model_product_attribute_value,st_laurent_portal_vendor.group_sl_vendor,1,1,1,0
+access_product_category_sl_vendor,product.category.sl.vendor,product.model_product_category,st_laurent_portal_vendor.group_sl_vendor,1,0,0,0
+```
+
+```python
+# Modèle étendu de partenaire pour gestion vendeur
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ is_st_laurent_vendor = fields.Boolean('Vendeur St-Laurent', default=False)
+ vendor_status = fields.Selection([
+ ('pending', 'En attente d\'approbation'),
+ ('approved', 'Approuvé'),
+ ('suspended', 'Suspendu')
+ ], string='Statut vendeur', default='pending')
+ vendor_commission_rate = fields.Float('Taux de commission', default=2.0)
+ vendor_category_ids = fields.Many2many('product.public.category', string='Catégories autorisées')
+ vendor_products_count = fields.Integer('Nombre de produits', compute='_compute_vendor_products_count')
+ vendor_sales_count = fields.Integer('Nombre de ventes', compute='_compute_vendor_sales_count')
+ vendor_registration_date = fields.Datetime('Date d\'inscription vendeur')
+ vendor_description = fields.Html('Description boutique')
+
+ # Méthodes de calcul et validation...
+```
+
+```python
+# Règle de sécurité avancée pour isolation multi-vendeurs
+class ProductTemplateVendorRule(models.Model):
+ _name = 'st_laurent.product.template.rule'
+ _description = 'Règle de sécurité pour produits vendeur'
+
+ @api.model
+ def _apply_ir_rules(self, query, mode='read'):
+ if self.env.user.has_group('st_laurent_portal_vendor.group_sl_vendor'):
+ # Accès limité aux produits du vendeur uniquement
+ query.where_clause += ["product_template.vendor_id = %s"]
+ query.where_clause_params += [self.env.user.partner_id.id]
+
+ # Restrictions additionnelles selon statut vendeur
+ partner = self.env.user.partner_id
+ if partner.vendor_status != 'approved':
+ # Vendeur en attente ou suspendu - lecture seule
+ if mode in ('write', 'create', 'unlink'):
+ query.where_clause += ["1=0"] # Bloquer toute modification
+ return super()._apply_ir_rules(query, mode)
+```
+
+### 2. Interface portail vendeur avancée
+
+```python
+# Contrôleur web complet pour portail vendeur avec auto-inscription
+class StLaurentVendorPortal(PortalController):
+ @http.route(['/vendor/register'], type='http', auth="public", website=True)
+ def vendor_register(self, **kw):
+ """Page d'inscription vendeur accessible sans connexion"""
+ return request.render("st_laurent_portal_vendor.vendor_register_form")
+
+ @http.route(['/vendor/register/submit'], type='http', auth="public", website=True, methods=['POST'])
+ def vendor_register_submit(self, **kw):
+ """Traitement inscription vendeur et création compte portal"""
+ # Validation des données
+ required_fields = ['name', 'email', 'company_name', 'phone', 'business_number']
+ for field in required_fields:
+ if not kw.get(field):
+ return request.render("st_laurent_portal_vendor.vendor_register_form", {
+ 'error': f"Le champ {field} est obligatoire",
+ 'data': kw
+ })
+
+ # Vérification email unique
+ if request.env['res.partner'].sudo().search([('email', '=', kw.get('email'))]):
+ return request.render("st_laurent_portal_vendor.vendor_register_form", {
+ 'error': "Cet email est déjà utilisé",
+ 'data': kw
+ })
+
+ # Création du partenaire
+ vendor_data = {
+ 'name': kw.get('company_name'),
+ 'email': kw.get('email'),
+ 'phone': kw.get('phone'),
+ 'is_company': True,
+ 'is_st_laurent_vendor': True,
+ 'vendor_status': 'pending',
+ 'vendor_registration_date': fields.Datetime.now(),
+ 'vendor_description': kw.get('description', ''),
+ 'company_id': request.website.company_id.id,
+ }
+
+ partner = request.env['res.partner'].sudo().create(vendor_data)
+
+ # Création utilisateur portal
+ user_data = {
+ 'name': kw.get('name'),
+ 'login': kw.get('email'),
+ 'partner_id': partner.id,
+ 'groups_id': [(6, 0, [
+ request.env.ref('base.group_portal').id,
+ request.env.ref('st_laurent_portal_vendor.group_sl_vendor').id
+ ])]
+ }
+
+ # Générer mot de passe aléatoire et envoyer par email
+ user = request.env['res.users'].sudo().create(user_data)
+ user.action_reset_password()
+
+ # Notification administrateurs
+ admin_users = request.env['res.users'].sudo().search([
+ ('groups_id', 'in', request.env.ref('st_laurent_core.group_sl_admin').id)
+ ])
+ request.env['mail.mail'].sudo().create({
+ 'subject': f"Nouvelle inscription vendeur: {partner.name}",
+ 'body_html': f"""
+
Un nouveau vendeur s'est inscrit sur la plateforme St-Laurent:
+
+
Entreprise: {partner.name}
+
Contact: {user.name}
+
Email: {user.login}
+
+
Veuillez vérifier et approuver ce vendeur dans l'administration.
+ """,
+ 'email_to': ','.join(admin_users.mapped('email')),
+ 'auto_delete': True,
+ }).send()
+
+ return request.render("st_laurent_portal_vendor.vendor_register_success")
+
+ @http.route(['/vendor/dashboard'], type='http', auth="user", website=True)
+ def vendor_dashboard(self, **kw):
+ """Tableau de bord principal vendeur"""
+ if not request.env.user.partner_id.is_st_laurent_vendor:
+ return request.redirect('/')
+
+ partner = request.env.user.partner_id
+ products = request.env['product.template'].search([
+ ('vendor_id', '=', partner.id)
+ ])
+
+ # Statistiques
+ sales_data = self._get_vendor_sales_data(partner)
+
+ values = {
+ 'partner': partner,
+ 'products_count': len(products),
+ 'pending_orders': sales_data['pending_count'],
+ 'monthly_sales': sales_data['monthly_total'],
+ 'status': partner.vendor_status,
+ }
+
+ return request.render("st_laurent_portal_vendor.vendor_dashboard", values)
+
+ @http.route(['/vendor/products'], type='http', auth="user", website=True)
+ def vendor_products(self, **kw):
+ """Liste des produits du vendeur avec gestion"""
+ if not request.env.user.partner_id.is_st_laurent_vendor:
+ return request.redirect('/')
+
+ products = request.env['product.template'].search([
+ ('vendor_id', '=', request.env.user.partner_id.id)
+ ])
+
+ values = {
+ 'products': products,
+ 'categories': request.env['product.public.category'].search([]),
+ }
+
+ return request.render("st_laurent_portal_vendor.vendor_products", values)
+
+ @http.route(['/vendor/product/new', '/vendor/product//edit'], type='http', auth="user", website=True)
+ def vendor_product_form(self, product_id=None, **kw):
+ """Formulaire création/édition produit pour vendeurs portal"""
+ if not request.env.user.partner_id.is_st_laurent_vendor:
+ return request.redirect('/')
+
+ product = False
+ if product_id:
+ product = request.env['product.template'].browse(product_id)
+ # Vérification propriétaire
+ if product.vendor_id.id != request.env.user.partner_id.id:
+ return request.redirect('/vendor/products')
+
+ # Récupération des catégories et attributs autorisés
+ categories = request.env['product.public.category'].search([])
+ attributes = request.env['product.attribute'].search([])
+
+ values = {
+ 'product': product,
+ 'categories': categories,
+ 'attributes': attributes,
+ 'error': {},
+ 'partner': request.env.user.partner_id,
+ }
+
+ return request.render("st_laurent_portal_vendor.vendor_product_form", values)
+
+ @http.route(['/vendor/product/save'], type='http', auth="user", website=True, methods=['POST'])
+ def vendor_product_save(self, **kw):
+ """Traitement sauvegarde produit vendeur"""
+ if not request.env.user.partner_id.is_st_laurent_vendor:
+ return request.redirect('/')
+
+ # Logique de validation et sauvegarde du produit...
+ # (code détaillé pour validation, création images, attributs, etc.)
+
+ return request.redirect('/vendor/products')
+```
+
+### 3. Connecteur avancé Odoo 18.0 à Odoo 18.0 Enterprise
+
+```python
+# Module connecteur complet avec support de toutes les fonctionnalités Odoo 18.0
+class StLaurentOdooConnector(models.Model):
+ _name = 'st_laurent.odoo.connector'
+ _description = 'Connecteur St-Laurent Odoo à Odoo Enterprise'
+ _inherit = ['mail.thread', 'mail.activity.mixin']
+
+ name = fields.Char('Nom du connecteur', required=True)
+ vendor_id = fields.Many2one('res.partner', string='Vendeur', required=True, domain=[('is_st_laurent_vendor', '=', True)])
+ api_key = fields.Char('Clé API', readonly=True, copy=False)
+ url_endpoint = fields.Char('URL de l\'instance Odoo', required=True)
+ state = fields.Selection([
+ ('draft', 'Brouillon'),
+ ('test', 'Test de connexion'),
+ ('ready', 'Prêt'),
+ ('active', 'Actif'),
+ ('error', 'Erreur'),
+ ('disabled', 'Désactivé')
+ ], string='État', default='draft', tracking=True)
+
+ # Options de synchronisation
+ sync_products = fields.Boolean('Synchroniser produits', default=True)
+ sync_inventory = fields.Boolean('Synchroniser inventaire', default=True)
+ sync_orders = fields.Boolean('Synchroniser commandes', default=True)
+ sync_customers = fields.Boolean('Synchroniser clients', default=False)
+
+ # Paramètres avancés
+ sync_interval = fields.Integer('Intervalle (minutes)', default=15, help="Intervalle de synchronisation automatique")
+ webhook_url = fields.Char('URL Webhook', compute='_compute_webhook_url', readonly=True)
+ webhook_token = fields.Char('Token Webhook', readonly=True, copy=False)
+ log_level = fields.Selection([
+ ('debug', 'Debug - Tous les détails'),
+ ('info', 'Info - Événements importants'),
+ ('warning', 'Warning - Erreurs non critiques'),
+ ('error', 'Error - Erreurs critiques uniquement')
+ ], string='Niveau de log', default='info')
+
+ # Statistiques
+ last_sync = fields.Datetime('Dernière synchronisation', readonly=True)
+ last_sync_status = fields.Selection([
+ ('success', 'Succès'),
+ ('partial', 'Succès partiel'),
+ ('failed', 'Échec')
+ ], string='Statut dernière sync', readonly=True)
+ sync_count = fields.Integer('Nombre de synchronisations', readonly=True, default=0)
+ error_count = fields.Integer('Nombre d\'erreurs', readonly=True, default=0)
+ product_count = fields.Integer('Produits synchronisés', readonly=True, default=0)
+ error_message = fields.Text('Message d\'erreur', readonly=True)
+
+ # Journal d'activité
+ log_ids = fields.One2many('st_laurent.connector.log', 'connector_id', string='Journal de synchronisation')
+
+ # Mapping des champs
+ field_mapping_ids = fields.One2many('st_laurent.connector.field.mapping', 'connector_id', string='Mapping des champs')
+
+ @api.model
+ def create(self, vals):
+ """Génération de clés sécurisées à la création"""
+ vals['api_key'] = self._generate_secure_key(64)
+ vals['webhook_token'] = self._generate_secure_key(32)
+ return super().create(vals)
+
+ def _generate_secure_key(self, length):
+ """Génère une clé sécurisée aléatoire"""
+ chars = string.ascii_letters + string.digits
+ return ''.join(random.choice(chars) for _ in range(length))
+
+ def _compute_webhook_url(self):
+ """Calcule l'URL de webhook pour ce connecteur"""
+ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url')
+ for record in self:
+ record.webhook_url = f"{base_url}/st_laurent/webhook/{record.id}/{record.webhook_token}"
+
+ def action_test_connection(self):
+ """Test de connexion à l'instance Odoo du vendeur"""
+ self.ensure_one()
+ try:
+ # Configuration de la connexion
+ headers = {
+ 'Content-Type': 'application/json',
+ 'Authorization': f'Bearer {self.api_key}'
+ }
+
+ # Requête simple pour vérifier la connexion
+ response = requests.get(
+ f"{self.url_endpoint}/api/v1/version",
+ headers=headers,
+ timeout=10
+ )
+ response.raise_for_status()
+
+ # Vérification version Odoo
+ version_info = response.json()
+ if not version_info.get('version', '').startswith('18.'):
+ raise ValidationError("L'instance distante n'est pas en version Odoo 18")
+
+ self.write({
+ 'state': 'ready',
+ 'error_message': False
+ })
+
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': 'Connexion réussie',
+ 'message': f"Connexion établie avec l'instance Odoo {version_info.get('version')}",
+ 'type': 'success',
+ 'sticky': False,
+ }
+ }
+
+ except Exception as e:
+ self.write({
+ 'state': 'error',
+ 'error_message': str(e)
+ })
+
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': 'Erreur de connexion',
+ 'message': str(e),
+ 'type': 'danger',
+ 'sticky': True,
+ }
+ }
+
+ def action_sync_now(self):
+ """Déclenche une synchronisation manuelle complète"""
+ self.ensure_one()
+ if self.state not in ['ready', 'active', 'error']:
+ raise UserError("Le connecteur n'est pas prêt pour la synchronisation")
+
+ # Démarrage synchronisation dans une tâche asynchrone
+ self.env['st_laurent.connector.job'].create({
+ 'connector_id': self.id,
+ 'job_type': 'full_sync',
+ 'state': 'pending'
+ })
+
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': 'Synchronisation lancée',
+ 'message': "La synchronisation a été programmée et sera exécutée en arrière-plan",
+ 'type': 'info',
+ 'sticky': False,
+ }
+ }
+
+ def sync_products(self):
+ """Synchronise les produits depuis l'Odoo du vendeur"""
+ self.ensure_one()
+
+ # Préparation du log de synchronisation
+ sync_log = self.env['st_laurent.connector.log'].create({
+ 'connector_id': self.id,
+ 'operation': 'sync_products',
+ 'start_time': fields.Datetime.now()
+ })
+
+ try:
+ # Configuration de la connexion
+ headers = {
+ 'Content-Type': 'application/json',
+ 'Authorization': f'Bearer {self.api_key}'
+ }
+
+ # Récupération de la date de dernière synchronisation pour sync incrémentale
+ last_sync_date = self.last_sync or fields.Datetime.subtract(fields.Datetime.now(), days=30)
+ last_sync_str = fields.Datetime.to_string(last_sync_date)
+
+ # Paramètres de pagination
+ page = 1
+ per_page = 50
+ total_synced = 0
+ has_more = True
+
+ while has_more:
+ # Requête paginée pour les produits modifiés depuis la dernière sync
+ response = requests.get(
+ f"{self.url_endpoint}/api/v1/products",
+ headers=headers,
+ params={
+ 'modified_since': last_sync_str,
+ 'page': page,
+ 'per_page': per_page
+ },
+ timeout=60
+ )
+ response.raise_for_status()
+
+ data = response.json()
+ products_data = data.get('data', [])
+
+ if not products_data:
+ has_more = False
+ continue
+
+ # Traitement par lots de produits
+ for product_data in products_data:
+ result = self._process_vendor_product(product_data)
+ if result.get('success'):
+ total_synced += 1
+
+ # Logging détaillé en mode debug
+ if self.log_level == 'debug':
+ sync_log.add_detail(
+ product_id=product_data.get('id'),
+ status='success' if result.get('success') else 'error',
+ message=result.get('message', '')
+ )
+
+ # Pagination
+ page += 1
+ has_more = data.get('has_more', False)
+
+ # Mise à jour des statistiques
+ self.write({
+ 'last_sync': fields.Datetime.now(),
+ 'last_sync_status': 'success',
+ 'sync_count': self.sync_count + 1,
+ 'product_count': self.product_count + total_synced,
+ })
+
+ # Finalisation du log
+ sync_log.write({
+ 'end_time': fields.Datetime.now(),
+ 'status': 'success',
+ 'message': f"Synchronisation réussie de {total_synced} produits"
+ })
+
+ return {
+ 'status': 'success',
+ 'count': total_synce
+
+---
+
+## Feuille de route du projet
+
+### Phase 1: Développement initial - Pilote régional (4 mois)
+- Étude des besoins et spécifications détaillées
+- Développement du core de la plateforme
+- Construction de l'interface vendeur portail
+- Tests utilisateurs avec panel d'entreprises québécoises
+
+### Phase 2: MVP et lancement pilote (3 mois)
+- Sélection de 20-30 vendeurs pilotes
+- Déploiement en environnement de production
+- Optimisation des processus
+- Premier connecteur Odoo-à-Odoo
+
+### Phase 3: Lancement public (3 mois)
+- Campagne marketing de lancement
+- Onboarding des premiers 100 vendeurs
+- Développement des fonctionnalités additionnelles
+- Optimisation de la performance
+
+### Phase 4: Croissance et expansion (12+ mois)
+- Élargissement de la base vendeurs
+- Développement de l'application mobile
+- Intégration de nouveaux services
+- Expansion potentielle au-delà du Québec
+
+---
+
+## Stratégie marketing
+
+### 1. Positionnement
+- **Slogan proposé**: "St-Laurent, l'avenir du commerce québécois"
+- **Proposition de valeur**: La plateforme e-commerce la plus économique pour les entreprises québécoises
+- **Différenciateurs clés**:
+ - Commission fixe de 2% (vs. 15% moyenne Amazon)
+ - 100% québécois
+ - Intégration native avec Odoo Enterprise
+
+### 2. Stratégie d'acquisition vendeurs
+- Partenariats avec associations d'entreprises québécoises
+- Webinaires et événements de présentation
+- Programme de référencement (prime pour recommandation)
+- Campagne ciblée sur les vendeurs Amazon/Shopify existants
+
+### 3. Stratégie d'acquisition clients
+- Mise en avant du "Fait au Québec"
+- Campagnes de sensibilisation économie locale
+- Partenariats influenceurs québécois
+- Stratégie SEO/SEM locale
+
+---
+
+## Gouvernance et organisation
+
+### Structure proposée
+- Entreprise à but lucratif avec mission sociale
+- Conseil d'administration incluant des représentants des vendeurs
+- Comité consultatif avec acteurs économiques québécois
+
+### Équipe initiale
+- Direction générale (1)
+- Développement technique (4-6)
+- Expérience utilisateur (2)
+- Acquisition vendeurs (2-3)
+- Support client (2-4)
+- Marketing (2)
+
+---
+
+## Analyse des risques
+
+### 1. Risques techniques
+- **Intégration Odoo complexe**: Mitigation par phase de tests extensifs
+- **Scalabilité plateforme**: Architecture cloud évolutive
+- **Sécurité données**: Audits réguliers et conformité RGPD/PIPEDA
+
+### 2. Risques commerciaux
+- **Adoption limitée**: Stratégie d'acquisition aggressive et commission ultra-basse
+- **Rétention vendeurs**: Services à valeur ajoutée et support premium
+- **Concurrence future**: Premier entrant avec avantage établi
+
+### 3. Risques financiers
+- **Viabilité modèle 2%**: Diversification revenus et services additionnels
+- **Coûts infrastructure**: Optimisation continue et scaling progressif
+- **Délai rentabilité**: Plan financier sur 3 ans avec objectifs précis
+
+---
+
+## Conclusion
+
+Le projet St-Laurent représente une opportunité unique de créer une infrastructure e-commerce nationale pour le Québec, en s'appuyant sur la puissance et la flexibilité d'Odoo. Avec sa commission disruptive de 2%, la plateforme offre un avantage compétitif significatif aux entreprises québécoises face aux géants internationaux.
+
+L'approche technique proposée, combinant une plateforme centrale et des connecteurs Odoo-à-Odoo avancés, permet d'offrir une solution flexible qui s'adapte aux besoins variés des entreprises, des plus petits artisans aux plus grandes entreprises utilisant déjà Odoo Enterprise.
+
+St-Laurent a le potentiel de devenir le carrefour incontournable du commerce électronique québécois, stimulant l'économie locale tout en offrant une alternative viable et économique aux plateformes dominantes.
+
+---
+
+## Annexes
+
+### Annexe A: Glossaire technique
+### Annexe B: Comparatif détaillé des commissions
+### Annexe C: Maquettes d'interface
+### Annexe D: Architecture technique détaillée
+### Annexe E: Plan financier prévisionnel
+
+---
+
+*Document confidentiel - Projet St-Laurent - Avril 2025*
diff --git a/st_laurent_portal_vendor/models/__init__.py b/st_laurent_portal_vendor/models/__init__.py
new file mode 100644
index 0000000..1634175
--- /dev/null
+++ b/st_laurent_portal_vendor/models/__init__.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+
+from . import vendor_product
+from . import res_partner
+from . import res_users
+from . import vendor_request
+from . import res_config_settings
+from . import vendor_shop
diff --git a/st_laurent_portal_vendor/models/res_config_settings.py b/st_laurent_portal_vendor/models/res_config_settings.py
new file mode 100644
index 0000000..18c7ca0
--- /dev/null
+++ b/st_laurent_portal_vendor/models/res_config_settings.py
@@ -0,0 +1,95 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ # Utilisation de champs many2one pour sélectionner un pays à la fois
+ vendor_request_default_country_id = fields.Many2one(
+ 'res.country',
+ string="Pays par défaut",
+ config_parameter='st_laurent_portal_vendor.default_country',
+ help="Pays par défaut dans le formulaire de demande de vendeur."
+ )
+
+ vendor_request_restrict_countries = fields.Boolean(
+ string="Restreindre les pays disponibles",
+ config_parameter='st_laurent_portal_vendor.restrict_countries',
+ help="Si activé, seuls les pays spécifiés seront disponibles dans le formulaire de demande de vendeur."
+ )
+
+ vendor_request_restrict_states = fields.Boolean(
+ string="Restreindre les états/provinces disponibles",
+ config_parameter='st_laurent_portal_vendor.restrict_states',
+ help="Si activé, seuls les états/provinces spécifiés seront disponibles dans le formulaire de demande de vendeur."
+ )
+
+ # Pays et états autorisés (stockés comme des paramètres de configuration)
+ vendor_request_north_america = fields.Boolean(
+ string="Amérique du Nord",
+ config_parameter='st_laurent_portal_vendor.north_america',
+ help="Inclure les pays d'Amérique du Nord (Canada, États-Unis, Mexique)"
+ )
+
+ vendor_request_europe = fields.Boolean(
+ string="Europe",
+ config_parameter='st_laurent_portal_vendor.europe',
+ help="Inclure les pays d'Europe"
+ )
+
+ vendor_request_asia = fields.Boolean(
+ string="Asie",
+ config_parameter='st_laurent_portal_vendor.asia',
+ help="Inclure les pays d'Asie"
+ )
+
+ vendor_request_other_regions = fields.Boolean(
+ string="Autres régions",
+ config_parameter='st_laurent_portal_vendor.other_regions',
+ help="Inclure les pays des autres régions"
+ )
+
+ @api.model
+ def get_allowed_countries(self):
+ """Récupère les pays autorisés pour les demandes de vendeur"""
+ # Vérifier si la restriction est activée
+ restrict = self.env['ir.config_parameter'].sudo().get_param('st_laurent_portal_vendor.restrict_countries', 'false')
+ if restrict != 'true':
+ return self.env['res.country'].search([])
+
+ # Récupérer les régions activées
+ regions = []
+ if self.env['ir.config_parameter'].sudo().get_param('st_laurent_portal_vendor.north_america', False) == 'true':
+ regions.append('north_america')
+ if self.env['ir.config_parameter'].sudo().get_param('st_laurent_portal_vendor.europe', False) == 'true':
+ regions.append('europe')
+ if self.env['ir.config_parameter'].sudo().get_param('st_laurent_portal_vendor.asia', False) == 'true':
+ regions.append('asia')
+ if self.env['ir.config_parameter'].sudo().get_param('st_laurent_portal_vendor.other_regions', False) == 'true':
+ regions.append('other')
+
+ # Si aucune région n'est sélectionnée, retourner tous les pays
+ if not regions:
+ return self.env['res.country'].search([])
+
+ # Définir les pays par région
+ country_codes = []
+ if 'north_america' in regions:
+ country_codes.extend(['CA', 'US', 'MX'])
+ if 'europe' in regions:
+ country_codes.extend(['FR', 'DE', 'GB', 'IT', 'ES', 'PT', 'BE', 'NL', 'LU', 'CH', 'AT', 'SE', 'NO', 'DK', 'FI', 'IE', 'PL'])
+ if 'asia' in regions:
+ country_codes.extend(['CN', 'JP', 'KR', 'IN', 'SG', 'MY', 'TH', 'VN', 'ID', 'PH'])
+
+ # Retourner les pays correspondants aux codes
+ if country_codes:
+ return self.env['res.country'].search([('code', 'in', country_codes)])
+ return self.env['res.country'].search([])
+
+ @api.model
+ def get_allowed_states(self):
+ """Toujours retourner tous les états/provinces pour tous les pays autorisés"""
+ countries = self.get_allowed_countries()
+ return self.env['res.country.state'].search([('country_id', 'in', countries.ids)])
diff --git a/st_laurent_portal_vendor/models/res_partner.py b/st_laurent_portal_vendor/models/res_partner.py
new file mode 100644
index 0000000..d762bd1
--- /dev/null
+++ b/st_laurent_portal_vendor/models/res_partner.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ is_vendor = fields.Boolean(
+ string="Est un vendeur",
+ compute='_compute_is_vendor',
+ search='_search_is_vendor',
+ store=False,
+ help="Si coché, cet utilisateur est un vendeur et a accès au portail vendeur"
+ )
+
+ vendor_status = fields.Selection([
+ ('no', 'Non vendeur'),
+ ('yes', 'Vendeur')
+ ], string="Statut vendeur", default='no')
+
+ # Relation avec les demandes de vendeur
+ vendor_request_ids = fields.One2many(
+ 'vendor.request',
+ 'partner_id', # Nous allons modifier le modèle vendor.request pour utiliser partner_id
+ string="Demandes de vendeur"
+ )
+
+ # Champs calculé pour savoir si l'utilisateur a une demande en cours
+ has_pending_vendor_request = fields.Boolean(
+ string="Demande en cours",
+ compute='_compute_has_pending_vendor_request',
+ store=False
+ )
+
+ @api.depends('vendor_request_ids', 'vendor_request_ids.state')
+ def _compute_has_pending_vendor_request(self):
+ """Vérifie si le partenaire a une demande de vendeur en attente"""
+ for partner in self:
+ # Valeur par défaut à False
+ partner.has_pending_vendor_request = False
+ if hasattr(partner, 'vendor_request_ids'):
+ pending_requests = partner.vendor_request_ids.filtered(lambda r: r.state == 'pending')
+ partner.has_pending_vendor_request = bool(pending_requests)
+
+ @api.depends('vendor_status', 'parent_id', 'parent_id.vendor_status')
+ def _compute_is_vendor(self):
+ """Calcule si le partenaire est un vendeur
+ Un partenaire est considéré comme vendeur si:
+ 1. Son propre statut est 'yes', OU
+ 2. Son partenaire parent a un statut 'yes'
+ """
+ for partner in self:
+ # Valeur par défaut à False
+ partner.is_vendor = False
+
+ # Vérifier le statut du partenaire lui-même
+ if hasattr(partner, 'vendor_status') and partner.vendor_status == 'yes':
+ partner.is_vendor = True
+ continue
+
+ # Vérifier le statut du partenaire parent
+ if partner.parent_id and hasattr(partner.parent_id, 'vendor_status'):
+ partner.is_vendor = partner.parent_id.vendor_status == 'yes'
+
+ def _search_is_vendor(self, operator, value):
+ """Recherche les partenaires qui sont vendeurs"""
+ if operator == '=' and value:
+ return [('vendor_status', '=', 'yes')]
+ elif operator == '=' and not value:
+ return [('vendor_status', '!=', 'yes')]
+ elif operator == '!=' and value:
+ return [('vendor_status', '!=', 'yes')]
+ elif operator == '!=' and not value:
+ return [('vendor_status', '=', 'yes')]
+ return []
+
+ def action_approve_as_vendor(self):
+ """Approuve le partenaire comme vendeur"""
+ self.ensure_one()
+ self.write({'vendor_status': 'yes'})
+ # Approuver également la demande en attente si elle existe
+ pending_requests = self.vendor_request_ids.filtered(lambda r: r.state == 'pending')
+ if pending_requests:
+ pending_requests.write({'state': 'approved'})
+ return True
+
+ def action_revoke_vendor_status(self):
+ """Révoque le statut de vendeur"""
+ self.ensure_one()
+ self.write({'vendor_status': 'no'})
+ return True
diff --git a/st_laurent_portal_vendor/models/res_users.py b/st_laurent_portal_vendor/models/res_users.py
new file mode 100644
index 0000000..74314f2
--- /dev/null
+++ b/st_laurent_portal_vendor/models/res_users.py
@@ -0,0 +1,43 @@
+# -*- coding: utf-8 -*-
+
+from odoo import fields, models
+
+
+class ResUsers(models.Model):
+ _inherit = 'res.users'
+
+ # Champs liés au partenaire associé
+ is_vendor = fields.Boolean(
+ string="Est un vendeur",
+ related='partner_id.is_vendor',
+ readonly=True,
+ help="Si coché, cet utilisateur est un vendeur et a accès au portail vendeur"
+ )
+
+ vendor_status = fields.Selection(
+ related='partner_id.vendor_status',
+ readonly=False,
+ string="Statut vendeur"
+ )
+
+ has_pending_vendor_request = fields.Boolean(
+ string="Demande en cours",
+ related='partner_id.has_pending_vendor_request',
+ readonly=True
+ )
+
+ # Relation avec les demandes de vendeur
+ vendor_request_ids = fields.One2many(
+ related='partner_id.vendor_request_ids',
+ readonly=True
+ )
+
+ def action_approve_as_vendor(self):
+ """Approuve l'utilisateur comme vendeur"""
+ self.ensure_one()
+ return self.partner_id.action_approve_as_vendor()
+
+ def action_revoke_vendor_status(self):
+ """Révoque le statut de vendeur"""
+ self.ensure_one()
+ return self.partner_id.action_revoke_vendor_status()
diff --git a/st_laurent_portal_vendor/models/vendor_config.py b/st_laurent_portal_vendor/models/vendor_config.py
new file mode 100644
index 0000000..129d7a5
--- /dev/null
+++ b/st_laurent_portal_vendor/models/vendor_config.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+
+
+class VendorConfig(models.Model):
+ _name = 'vendor.config'
+ _description = 'Configuration des paramètres vendeur'
+
+ name = fields.Char(string="Nom", required=True, default="Configuration par défaut")
+ active = fields.Boolean(string="Actif", default=True)
+
+ # Pays autorisés
+ country_ids = fields.Many2many(
+ 'res.country',
+ string="Pays autorisés",
+ help="Pays autorisés dans le formulaire de demande de vendeur. Si vide, tous les pays sont autorisés."
+ )
+
+ # États/provinces autorisés
+ state_ids = fields.Many2many(
+ 'res.country.state',
+ string="États/Provinces autorisés",
+ help="États/Provinces autorisés dans le formulaire de demande de vendeur. Si vide, tous les états sont autorisés."
+ )
+
+ # Champ pour définir cette configuration comme la configuration par défaut
+ is_default = fields.Boolean(
+ string="Configuration par défaut",
+ default=False,
+ help="Si coché, cette configuration sera utilisée comme configuration par défaut."
+ )
+
+ @api.model
+ def get_default_config(self):
+ """Récupère la configuration par défaut"""
+ default_config = self.search([('is_default', '=', True)], limit=1)
+ if not default_config:
+ default_config = self.search([], limit=1)
+ return default_config
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Assure qu'il n'y a qu'une seule configuration par défaut"""
+ for vals in vals_list:
+ if vals.get('is_default'):
+ self.search([('is_default', '=', True)]).write({'is_default': False})
+ return super(VendorConfig, self).create(vals_list)
+
+ def write(self, vals):
+ """Assure qu'il n'y a qu'une seule configuration par défaut"""
+ if vals.get('is_default'):
+ self.search([('is_default', '=', True), ('id', '!=', self.id)]).write({'is_default': False})
+ return super(VendorConfig, self).write(vals)
+
+ def get_allowed_countries(self):
+ """Récupère les pays autorisés"""
+ self.ensure_one()
+ if not self.country_ids:
+ return self.env['res.country'].search([])
+ return self.country_ids
+
+ def get_allowed_states(self):
+ """Récupère les états/provinces autorisés"""
+ self.ensure_one()
+ if not self.state_ids:
+ return self.env['res.country.state'].search([])
+ return self.state_ids
diff --git a/st_laurent_portal_vendor/models/vendor_product.py b/st_laurent_portal_vendor/models/vendor_product.py
new file mode 100644
index 0000000..03664f8
--- /dev/null
+++ b/st_laurent_portal_vendor/models/vendor_product.py
@@ -0,0 +1,296 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, tools, _
+from odoo.tools.image import is_image_size_above
+from odoo.exceptions import UserError
+
+
+class VendorProduct(models.Model):
+ _inherit = 'vendor.product'
+ _description = 'Produit vendeur avec fonctionnalités e-commerce'
+
+ # Champ de base pour l'archivage
+ active = fields.Boolean('Actif', default=True, tracking=True)
+
+ # Champs d'image (repris de vendor_product_image)
+ image_1920 = fields.Image("Image", max_width=1920, max_height=1920, attachment=True)
+ image_1024 = fields.Image("Image 1024", related="image_1920", max_width=1024, max_height=1024, store=True)
+ image_512 = fields.Image("Image 512", related="image_1920", max_width=512, max_height=512, store=True)
+ image_256 = fields.Image("Image 256", related="image_1920", max_width=256, max_height=256, store=True)
+ image_128 = fields.Image("Image 128", related="image_1920", max_width=128, max_height=128, store=True)
+ can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed', store=True)
+
+ # Champs e-commerce de base
+ website_published = fields.Boolean('Publié sur le site web', default=False, copy=False)
+ website_description = fields.Html('Description du site web', translate=True, sanitize_attributes=False)
+ website_url = fields.Char('URL du site web', compute='_compute_website_url')
+
+ # Catégories et tags
+ public_categ_ids = fields.Many2many(
+ 'product.public.category', string='Catégories du site web',
+ help="Les catégories pour l'affichage sur le site web")
+ product_tag_ids = fields.Many2many(
+ 'product.tag', string='Tags',
+ help="Tags pour le filtrage et la catégorisation")
+
+ # Prix et disponibilité
+ website_price = fields.Float('Prix sur le site web', digits='Product Price')
+ website_ribbon = fields.Char('Ruban du site web', help="Texte affiché dans un ruban sur le produit (ex: 'Nouveau', 'Promotion')")
+ availability = fields.Selection([
+ ('in_stock', 'En stock'),
+ ('out_of_stock', 'Épuisé'),
+ ('preorder', 'Précommande'),
+ ('discontinued', 'Abandonné')
+ ], string='Disponibilité', default='in_stock')
+ availability_date = fields.Date('Date de disponibilité')
+
+ # SEO et métadonnées
+ website_meta_title = fields.Char('Titre Meta', translate=True)
+ website_meta_description = fields.Text('Description Meta', translate=True)
+ website_meta_keywords = fields.Char('Mots-clés Meta', translate=True)
+ website_meta_og_img = fields.Binary('Image Open Graph')
+
+ # Autres informations utiles
+ barcode = fields.Char('Code-barres', copy=False)
+ default_code = fields.Char('Référence interne', copy=False)
+ website_sequence = fields.Integer('Séquence sur le site web', default=50, help="Détermine l'ordre d'affichage sur le site web")
+
+ # Champs pour les messages de succès/erreur
+ success = fields.Char(string="Message de succès", readonly=True, copy=False)
+ error = fields.Char(string="Message d'erreur", readonly=True, copy=False)
+
+ # Champs manquants nécessaires pour le fonctionnement du module
+ partner_id = fields.Many2one('res.partner', string='Partenaire', tracking=True)
+ product_name = fields.Char('Nom du produit', required=True, tracking=True)
+ product_code = fields.Char('Code produit', tracking=True)
+ description = fields.Text('Description', tracking=True)
+ product_tmpl_id = fields.Many2one('product.template', string='Produit associé', copy=False)
+ product_id = fields.Many2one('product.product', string='Variante de produit', copy=False)
+
+ # Gestion des quantités
+ vendor_quantity = fields.Float('Quantité disponible', default=0.0, tracking=True)
+ zero_qty = fields.Float('Quantité nulle', compute='_compute_zero_qty', store=True)
+
+ # Prix
+ price = fields.Float('Prix', digits='Product Price', default=0.0, tracking=True)
+
+ # Champs pour la synchronisation avec product.product
+ commission_rate = fields.Float('Taux de commission (%)', default=10.0)
+ auto_sync_price = fields.Boolean('Synchronisation auto. des prix', default=True)
+ auto_sync_images = fields.Boolean('Synchronisation auto. des images', default=True)
+ last_sync_date = fields.Datetime('Dernière synchronisation', readonly=True)
+
+ # Champs pour l'historique des prix
+ old_price = fields.Float('Ancien prix', digits='Product Price', readonly=True)
+ price_change_date = fields.Datetime('Date changement prix', readonly=True)
+ price_change_user_id = fields.Many2one('res.users', string='Modifié par', readonly=True)
+ final_price = fields.Float(string='Montant net vendeur', compute='_compute_final_price', digits='Product Price', help='Montant que le vendeur recevra après déduction de la commission')
+
+ @api.depends('image_1920', 'image_1024')
+ def _compute_can_image_1024_be_zoomed(self):
+ for record in self:
+ record.can_image_1024_be_zoomed = record.image_1920 and is_image_size_above(record.image_1920, record.image_1024)
+
+ @api.depends('vendor_quantity')
+ def _compute_zero_qty(self):
+ for record in self:
+ record.zero_qty = 1.0 if record.vendor_quantity <= 0 else 0.0
+
+ @api.depends('price', 'commission_rate')
+ def _compute_final_price(self):
+ """Calcule le prix net pour le vendeur après commission"""
+ for record in self:
+ # Le prix final est le montant que le vendeur recevra après déduction de la commission
+ record.final_price = record.price * (1 - record.commission_rate / 100)
+
+ @api.onchange('price')
+ def _onchange_price(self):
+ """Avertissement lors d'un changement de prix"""
+ if self.price and self.old_price and self.price != self.old_price:
+ # Calcul du montant net que le vendeur recevra après commission
+ net_price = self.price * (1 - self.commission_rate / 100)
+ old_net_price = self.old_price * (1 - self.commission_rate / 100)
+ return {
+ 'warning': {
+ 'title': _('Changement de prix'),
+ 'message': _(
+ 'Le prix de vente va changer de %.2f à %.2f.\n'
+ 'Votre rémunération (après commission de %.1f%%) passera de %.2f à %.2f.\n'
+ 'Si vous confirmez, ce changement sera propagé au produit standard associé.'
+ ) % (self.old_price, self.price, self.commission_rate, old_net_price, net_price)
+ }
+ }
+
+ def _compute_website_url(self):
+ for product in self:
+ product.website_url = f'/shop/vendor-product/{product.id}'
+
+ def action_publish_website(self):
+ self.ensure_one()
+ self.website_published = True
+ return True
+
+ def action_unpublish_website(self):
+ self.ensure_one()
+ self.website_published = False
+ return True
+
+ def write(self, vals):
+ """Surcharge pour gérer les changements de prix et la synchronisation"""
+ # Enregistrement des anciennes valeurs pour l'historique
+ for record in self:
+ if 'price' in vals and record.price != vals['price']:
+ record.old_price = record.price
+ record.price_change_date = fields.Datetime.now()
+ record.price_change_user_id = self.env.user.id
+
+ result = super(VendorProduct, self).write(vals)
+
+ # Synchronisation avec le produit standard si nécessaire
+ for record in self:
+ if record.product_tmpl_id:
+ sync_vals = {}
+
+ # Synchronisation du prix si modifié et auto-sync activé
+ if 'price' in vals and record.auto_sync_price:
+ # Le prix de vente est directement le prix entré par le vendeur
+ sync_vals['list_price'] = record.price
+
+ # Synchronisation des images si modifiées et auto-sync activé
+ if 'image_1920' in vals and record.auto_sync_images:
+ sync_vals['image_1920'] = record.image_1920
+
+ # Mise à jour du produit standard si des valeurs à synchroniser
+ if sync_vals:
+ record.product_tmpl_id.write(sync_vals)
+ record.last_sync_date = fields.Datetime.now()
+
+ return result
+
+ def action_create_product(self):
+ """Créer un produit standard (product.product) à partir du produit vendeur"""
+ self.ensure_one()
+ ProductTemplate = self.env['product.template']
+
+ # Vérifier si un produit existe déjà avec le même code
+ existing_product = ProductTemplate.search([('default_code', '=', self.default_code)], limit=1)
+ if existing_product:
+ # Si un produit existe déjà, afficher un message d'avertissement
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': 'Produit existant',
+ 'message': f'Un produit avec la référence {self.default_code} existe déjà.',
+ 'sticky': False,
+ 'type': 'warning',
+ }
+ }
+
+ # Le prix de vente est directement le prix entré par le vendeur
+ # La commission sera retenue lors du paiement au vendeur
+ final_price = self.price # Utiliser directement le prix du vendeur
+
+ # Créer le produit
+ vals = {
+ 'name': self.product_name,
+ 'default_code': self.default_code,
+ 'barcode': self.barcode,
+ 'description': self.description,
+ 'description_sale': self.website_description,
+ 'list_price': final_price, # Utilisation du prix avec commission
+ 'standard_price': self.price, # Prix d'achat = prix du vendeur sans commission
+ 'image_1920': self.image_1920,
+ 'type': 'consu', # Consommable (valeur par défaut sécuritaire)
+ 'sale_ok': True,
+ 'purchase_ok': True,
+ 'invoice_policy': 'order',
+ 'purchase_method': 'purchase',
+ 'categ_id': self.env.ref('product.product_category_all').id,
+ 'taxes_id': [(6, 0, [])], # Pas de taxes par défaut
+ 'supplier_taxes_id': [(6, 0, [])], # Pas de taxes fournisseur par défaut
+ }
+
+ # Ajouter les métadonnées SEO si disponibles
+ if self.website_meta_title:
+ vals['website_meta_title'] = self.website_meta_title
+ if self.website_meta_description:
+ vals['website_meta_description'] = self.website_meta_description
+ if self.website_meta_keywords:
+ vals['website_meta_keywords'] = self.website_meta_keywords
+
+ # Créer le template de produit
+ product_tmpl = ProductTemplate.create(vals)
+
+ # Ajouter le fournisseur
+ if self.partner_id:
+ self.env['product.supplierinfo'].create({
+ 'product_tmpl_id': product_tmpl.id,
+ 'partner_id': self.partner_id.id,
+ 'product_name': self.product_name,
+ 'product_code': self.product_code,
+ 'min_qty': 1.0,
+ 'price': self.price,
+ })
+
+ # Lier le produit vendeur au produit standard
+ self.write({
+ 'product_tmpl_id': product_tmpl.id,
+ 'last_sync_date': fields.Datetime.now(),
+ 'success': f'Produit {product_tmpl.name} créé avec succès!'
+ })
+
+ # Rediriger vers le produit créé
+ return {
+ 'type': 'ir.actions.act_window',
+ 'name': 'Produit créé',
+ 'res_model': 'product.template',
+ 'res_id': product_tmpl.id,
+ 'view_mode': 'form',
+ 'target': 'current',
+ }
+
+ def sync_to_product(self):
+ """Synchronise manuellement toutes les données vers le produit standard"""
+ self.ensure_one()
+
+ if not self.product_tmpl_id:
+ raise UserError(_('Ce produit vendeur n\'est pas lié à un produit standard.'))
+
+ # Le prix de vente est directement le prix entré par le vendeur
+ # La commission sera retenue lors du paiement au vendeur
+ final_price = self.price # Utiliser directement le prix du vendeur
+
+ # Préparation des valeurs à synchroniser
+ vals = {
+ 'name': self.product_name,
+ 'description': self.description,
+ 'description_sale': self.website_description,
+ 'list_price': final_price,
+ 'standard_price': self.price, # Prix d'achat = prix du vendeur sans commission
+ 'default_code': self.default_code,
+ 'barcode': self.barcode,
+ }
+
+ # Ajout de l'image si disponible
+ if self.image_1920:
+ vals['image_1920'] = self.image_1920
+
+ # Mise à jour du produit standard
+ self.product_tmpl_id.write(vals)
+
+ # Mise à jour de la date de synchronisation
+ self.write({
+ 'last_sync_date': fields.Datetime.now(),
+ })
+
+ return {
+ 'type': 'ir.actions.client',
+ 'tag': 'display_notification',
+ 'params': {
+ 'title': _('Synchronisation réussie'),
+ 'message': _('Le produit a été synchronisé avec succès.'),
+ 'sticky': False,
+ 'type': 'success',
+ }
+ }
diff --git a/st_laurent_portal_vendor/models/vendor_request.py b/st_laurent_portal_vendor/models/vendor_request.py
new file mode 100644
index 0000000..5dd8fbe
--- /dev/null
+++ b/st_laurent_portal_vendor/models/vendor_request.py
@@ -0,0 +1,259 @@
+# -*- coding: utf-8 -*-
+
+from odoo import api, fields, models, _
+from odoo.exceptions import UserError
+from odoo.addons.portal.models.portal_mixin import PortalMixin
+
+
+class VendorRequest(models.Model):
+ _name = 'vendor.request'
+ _description = 'Request to become a vendor'
+ _inherit = ['mail.thread', 'mail.activity.mixin', 'portal.mixin', 'website.published.mixin']
+
+ # Temporary solution to add is_frontend_multilang attribute
+ is_frontend_multilang = fields.Boolean(default=False)
+ _order = 'create_date desc'
+
+ name = fields.Char(
+ string="Reference",
+ required=True,
+ copy=False,
+ readonly=True,
+ default=lambda self: _('New Request')
+ )
+ partner_id = fields.Many2one(
+ 'res.partner',
+ string="Contact",
+ required=True,
+ readonly=True,
+ default=lambda self: self.env.user.partner_id,
+ index=True
+ )
+ user_id = fields.Many2one(
+ 'res.users',
+ string="User",
+ compute='_compute_user_id',
+ store=True,
+ readonly=True
+ )
+
+ @api.depends('partner_id')
+ def _compute_user_id(self):
+ for request in self:
+ # Find the user associated with this partner (if exists)
+ user = self.env['res.users'].search([('partner_id', '=', request.partner_id.id)], limit=1)
+ request.user_id = user.id if user else False
+
+ # Company information
+ company_name = fields.Char(
+ string="Company Name",
+ required=True
+ )
+ company_street = fields.Char(string="Street")
+ company_street2 = fields.Char(string="Address Complement")
+ company_zip = fields.Char(string="Zip Code")
+ company_city = fields.Char(string="City")
+ company_state_id = fields.Many2one('res.country.state', string="State/Province")
+ company_country_id = fields.Many2one(
+ 'res.country',
+ string="Country",
+ default=lambda self: self.env.ref('base.ca').id if self.env.ref('base.ca', False) else False
+ ) # Canada by default but modifiable if other countries are proposed
+ company_email = fields.Char(string="Company Email")
+ company_phone = fields.Char(string="Company Phone")
+ company_website = fields.Char(string="Company Website")
+ company_vat = fields.Char(string="VAT/Tax ID")
+
+ # Additional information
+ description = fields.Text(
+ string="Description",
+ help="Describe your company and the products you want to sell"
+ )
+ company_partner_id = fields.Many2one(
+ 'res.partner',
+ string="Created Company",
+ readonly=True,
+ help="Company created when the request is approved"
+ )
+ state = fields.Selection([
+ ('draft', 'Draft'),
+ ('pending', 'Pending'),
+ ('approved', 'Approved'),
+ ('rejected', 'Rejected')
+ ], string="Status", default='draft', tracking=True)
+ rejection_reason = fields.Text(
+ string="Rejection Reason",
+ tracking=True
+ )
+ approved_date = fields.Datetime(
+ string="Approval Date",
+ readonly=True
+ )
+
+ # Additional documents (attachments)
+ attachment_ids = fields.Many2many(
+ 'ir.attachment',
+ 'vendor_request_attachment_rel',
+ 'request_id',
+ 'attachment_id',
+ string="Documents"
+ )
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """Prevents creating vendor requests from the backend (outside the portal) and automatically submits the request upon creation."""
+ # If the user is not in portal mode, block
+ if not self.env.context.get('from_portal') and not self.env.user.has_group('base.group_portal'):
+ raise UserError(_('Creating vendor requests is only allowed from the customer portal.'))
+ for vals in vals_list:
+ if vals.get('name', _('New Request')) == _('New Request'):
+ vals['name'] = self.env['ir.sequence'].next_by_code('vendor.request') or _('New Request')
+ # All new requests are directly pending
+ vals['state'] = 'pending'
+ requests = super(VendorRequest, self).create(vals_list)
+ # For each created request, trigger the notification logic
+ for request in requests:
+ # Notify administrators
+ admin_group = request.env.ref('base.group_system')
+ admin_partners = admin_group.users.mapped('partner_id')
+ if admin_partners:
+ request.message_subscribe(partner_ids=admin_partners.ids)
+ request.message_post(
+ body=_("A new request to become a vendor has been submitted by %s") % request.user_id.name,
+ partner_ids=admin_partners.ids,
+ subtype_xmlid='mail.mt_note'
+ )
+ # Send an acknowledgement to the user
+ template = request.env.ref('st_laurent_portal_vendor.mail_template_vendor_request_ack', raise_if_not_found=False)
+ if template:
+ template.send_mail(request.id, force_send=True)
+ return requests
+
+ def action_approve(self):
+ """
+ Approves the request, sets the user as a vendor, and creates the company.
+ Automatically adds the user to the St-Laurent vendor group, creates the user if necessary,
+ logs the reviewer, and sends an enriched notification.
+ """
+ self.ensure_one()
+ if self.state != 'pending':
+ raise UserError(_("Only pending requests can be approved."))
+
+ # If the partner already has a parent, use this parent as the company
+ existing_parent = self.partner_id.parent_id if self.partner_id.parent_id and self.partner_id.parent_id != self.partner_id else None
+ if existing_parent:
+ company_partner = existing_parent
+ # Optional: update the existing company's information with the request data
+ company_partner.write({
+ 'name': self.company_name,
+ 'street': self.company_street,
+ 'street2': self.company_street2,
+ 'zip': self.company_zip,
+ 'city': self.company_city,
+ 'state_id': self.company_state_id.id if self.company_state_id else False,
+ 'country_id': self.company_country_id.id if self.company_country_id else False,
+ 'email': self.company_email,
+ 'phone': self.company_phone,
+ 'website': self.company_website,
+ 'vat': self.company_vat,
+ 'vendor_status': 'yes',
+ 'is_company': True,
+ })
+ else:
+ # Create the company (partner of type company)
+ company_partner = self.env['res.partner'].create({
+ 'name': self.company_name,
+ 'company_type': 'company',
+ 'street': self.company_street,
+ 'street2': self.company_street2,
+ 'zip': self.company_zip,
+ 'city': self.company_city,
+ 'state_id': self.company_state_id.id if self.company_state_id else False,
+ 'country_id': self.company_country_id.id if self.company_country_id else False,
+ 'email': self.company_email,
+ 'phone': self.company_phone,
+ 'website': self.company_website,
+ 'vat': self.company_vat,
+ 'vendor_status': 'yes',
+ 'is_company': True,
+ })
+ # Associate the user's partner with the company
+ self.partner_id.write({
+ 'parent_id': company_partner.id,
+ })
+ # Update the request
+ self.write({
+ 'state': 'approved',
+ 'approved_date': fields.Datetime.now(),
+ 'company_partner_id': company_partner.id,
+ })
+
+ # Automatically create the shop upon approval
+ VendorShop = self.env['vendor.shop'].sudo()
+ VendorShop.create_shop_for_vendor(company_partner)
+
+ # Automatically add the user to the vendor group
+ user = self.user_id
+ if not user:
+ # Create the user if non-existent
+ user = self.env['res.users'].sudo().create({
+ 'login': self.partner_id.email,
+ 'name': self.partner_id.name,
+ 'partner_id': self.partner_id.id,
+ 'email': self.partner_id.email,
+ })
+ self.user_id = user.id
+ seller_group = self.env.ref('st_laurent_portal_vendor.group_seller', raise_if_not_found=False)
+ if seller_group and user and user not in seller_group.users:
+ seller_group.sudo().write({'users': [(4, user.id)]})
+
+ # Notify the user
+ self.message_post(
+ body=_("Your request to become a vendor has been approved. Your company %s was created by %s.") % (self.company_name, self.env.user.display_name),
+ partner_ids=[self.partner_id.id],
+ subtype_xmlid='mail.mt_note'
+ )
+ # Traceability: log the action in the chatter
+ self.message_post(
+ body=_("Request approved by %s.") % self.env.user.display_name,
+ subtype_xmlid='mail.mt_note'
+ )
+ # Custom email notification (example)
+ template = self.env.ref('st_laurent_portal_vendor.mail_template_vendor_request_approved', raise_if_not_found=False)
+ if template:
+ template.send_mail(self.id, force_send=True)
+
+ return True
+
+ def action_reject(self):
+ """
+ Rejects the request, logs the reviewer, and sends an enriched notification.
+ """
+ self.ensure_one()
+ if self.state != 'pending':
+ raise UserError(_("Only pending requests can be rejected."))
+ # Traceability: log the action in the chatter
+ self.message_post(
+ body=_("Request rejected by %s.") % self.env.user.display_name,
+ subtype_xmlid='mail.mt_note'
+ )
+ # Custom email notification (rejection)
+ template = self.env.ref('st_laurent_portal_vendor.mail_template_vendor_request_rejected', raise_if_not_found=False)
+ if template:
+ template.send_mail(self.id, force_send=True)
+ # Open a wizard to enter the rejection reason
+ return {
+ 'name': _('Rejection Reason'),
+ 'type': 'ir.actions.act_window',
+ 'res_model': 'vendor.request.reject.wizard',
+ 'view_mode': 'form',
+ 'target': 'new',
+ 'context': {'default_request_id': self.id}
+ }
+
+ def action_reset_to_draft(self):
+ """Resets the request to draft state."""
+ self.ensure_one()
+ if self.state in ['approved', 'rejected']:
+ self.state = 'draft'
+ return True
diff --git a/st_laurent_portal_vendor/models/vendor_shop.py b/st_laurent_portal_vendor/models/vendor_shop.py
new file mode 100644
index 0000000..3184cc5
--- /dev/null
+++ b/st_laurent_portal_vendor/models/vendor_shop.py
@@ -0,0 +1,34 @@
+from odoo import models, fields, api
+
+class VendorShop(models.Model):
+ _name = 'vendor.shop'
+ _description = 'Boutique Vendeur'
+ _sql_constraints = [
+ ('slug_unique', 'unique(slug)', "L'URL de la boutique doit être unique.")
+ ]
+
+ name = fields.Char('Nom de la boutique', required=True)
+ slug = fields.Char('Slug URL', required=True, help="Utilisé pour l'URL /shop/slug")
+ vendor_id = fields.Many2one('res.partner', string='Vendeur', required=True, domain=[('is_company', '=', True)])
+ product_ids = fields.One2many('product.template', 'vendor_shop_id', string='Produits')
+
+ @api.model
+ def create_shop_for_vendor(self, vendor):
+ # Génère un slug unique basé sur le nom du vendeur
+ slug = vendor.name.lower().replace(' ', '-')
+ existing = self.search([('slug', '=', slug)])
+ if existing:
+ slug = f"{slug}-{vendor.id}"
+ return self.create({
+ 'name': vendor.name,
+ 'slug': slug,
+ 'vendor_id': vendor.id,
+ })
+
+# Extension du modèle product.template
+from odoo import models, fields
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ vendor_shop_id = fields.Many2one('vendor.shop', string='Boutique du vendeur')
diff --git a/st_laurent_portal_vendor/security/ir.model.access.csv b/st_laurent_portal_vendor/security/ir.model.access.csv
new file mode 100644
index 0000000..e4a09a9
--- /dev/null
+++ b/st_laurent_portal_vendor/security/ir.model.access.csv
@@ -0,0 +1,11 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_st_laurent_portal_vendor_user,vendor.product.portal.user,model_vendor_product,base.group_user,1,1,1,1
+access_st_laurent_portal_vendor_portal,vendor.product.portal.portal,model_vendor_product,base.group_portal,1,0,0,0
+access_st_laurent_portal_vendor_public,vendor.product.portal.public,model_vendor_product,base.group_public,1,0,0,0
+access_vendor_request_user,vendor.request.user,model_vendor_request,base.group_user,1,1,1,1
+access_vendor_request_portal,vendor.request.portal,model_vendor_request,base.group_portal,1,1,1,0
+access_vendor_request_public,vendor.request.public,model_vendor_request,base.group_public,0,0,0,0
+access_vendor_request_reject_wizard,vendor.request.reject.wizard,model_vendor_request_reject_wizard,base.group_user,1,1,1,1
+access_vendor_shop_user,vendor.shop.user,model_vendor_shop,base.group_user,1,1,1,1
+access_vendor_shop_portal,vendor.shop.portal,model_vendor_shop,base.group_portal,1,0,0,0
+access_vendor_shop_public,vendor.shop.public,model_vendor_shop,base.group_public,1,0,0,0
diff --git a/st_laurent_portal_vendor/security/security.xml b/st_laurent_portal_vendor/security/security.xml
new file mode 100644
index 0000000..a1f1b05
--- /dev/null
+++ b/st_laurent_portal_vendor/security/security.xml
@@ -0,0 +1,52 @@
+
+
+
+
+ Vendeurs
+ Gestion des vendeurs et de leurs produits
+ 20
+
+
+
+
+ Vendeur
+
+
+
+
+
+
+ Demandes de vendeur: utilisateur ne voit que ses propres demandes
+
+ [('user_id', '=', user.id)]
+
+
+
+
+
+
+
+
+
+ Demandes de vendeur: administrateurs voient toutes les demandes
+
+ [(1, '=', 1)]
+
+
+
+
+
+
+
+
+
+ Produits vendeur: vendeur ne voit que ses propres produits
+
+ [('partner_id', '=', user.partner_id.commercial_partner_id.id)]
+
+
+
+
+
+
+
diff --git a/st_laurent_portal_vendor/static/description/icon.png b/st_laurent_portal_vendor/static/description/icon.png
new file mode 100644
index 0000000000000000000000000000000000000000..a77ffa7ad225a6d6e473f6cae2f1c07bc30ecce5
GIT binary patch
literal 72686
zcmXt9cOaGT`+gl;*2xy)PzecH*>Q|gwzA2{5wfz^LBz2|WIOhjbda5lRAgrpva)4`
ze$Vmw{{A@socDR(XWjSp-1mK5PlT3+DisAQ1q4A}(_G~6Z;@%{;wdD5Xs=9jGYxWl~8-6X`H+R*G!}TT=6iTcU1V8iC=rDAg
zewfioE_EI*?H;~B@tMa|gykfHfvC|ZGFZ(n0q3BTqddmckguL#*qk+U`R=`ygg;;X
z`o79+j(%Zsbp4gC8nG_3JB8H0lStL}VPqz9^{>McCj!yKB@69S`XpISWRH@05UMF`
z(as7;Z*de`YbFQoYFl|0K9m@8kLBd=pEz_R&HkvRJ>eP@US&-Shf`^~%mk8LO_5xX
zE_wDe_8~47LDwHAi6hhsqFsu_af2rC?6`y83MYZL86i8DBS~x~iQ~;L=X~dP#CMHQ
zK(~87{aomIrZNFP|8i#{{n{cFQqRQwiq=X4cQe+enep}NaE@BxW*syg3Lk-M=I2c8V^
zhdlbH0!YnAs2XmgZ-ghPZhT8VgNb7o_-Xw*8(64Bmx&WzDgOu0dtYniK#70+
zZ?35JyVzsha6c&3>O+)6Na)M#Pm78=p~LgBV#~bXt<;b%&0$P`S=!UvlzYeZlJ`yn6t!vO8%TlVfsrnT!r3sjTS
zulG|~V=g~5vE0yaV>!13?TR>k#PQL7XUPNHtGL=K6fJsMw%BAGCGna_pFT+b0$6-e<|JTFsG`GP2@vUKEqf4X+^EUsRth#%
z+CY@T#QPL8$Z#0;-%efNv6CiPq-KnUc}$-$(6P_|y@7svK85%LmJVAaC(cdl*{tOj
zObj5%)`s=5T+r95|0WQgy*(t`iyay1sR09YWb5Zu7<`%*yKy)5pVemoN!Z7_Lyml6
zRMpR~&!`Td4+(WFf-e}y9H{q)Km~>|ig=GIy;^72^T_cfr_=mUG0Zi9$0Lm9kR7e-
zh&NyCWI^jlg5c5%RU`feU?fgI!&QP(%--$z%gt(^qh|aCB1swH&LR97bikuwv?Y~oR4H^Nlg3b&b>Sv|C$|}iO1d2;B9Wj_vMd1rd(@Hz@G=x
zu4S0B)@x6a1WbDlUe_>h>Nq3gPYRiFBV%g>-Q&7@`-lVFcGDTSN
zhbzpxwC7XhO)mQVmOZF3gqxK^=k4J>dOvZBvd#9~J`k*e+{X!Ef!PIEIp8IcFSBht
zVUm643y6h($G)g|%O2bqqTF7M={ShGbC4NGx_4o=g$9p)rvDC>G4+)b)OV)aByfzO
zt48fwr<%~K|BW=1L;Li@I_<&@0anKQu{DX8x?>4U{zLG7-h5^4!4bW0cDj4w0<4C5
z+AXdaRznK4E704Fmw32cgfl+s3d@ad~k+s&k5eQB81N
z-!%|bvGXxqla5{uBusg7Zui=A|n@nrDNjE2`ksI@tTkzD|PK&}3=t-V9
zBj-{hu+G>yX439ZC}yvCgTVRSy265742n`$lI;KhLl(5<2+=vJ`SlhMW5$1W80Wjt7|LrQq3i&+gVkm`7^GH8?dh5k@cwPsZn=N89KY%2Ps*Vz01
zz-FYvo)skd6u8Dhvn)j4Y5+olHI8Le$%m+y2MiU#X;NZtZ9Cg{yZ)KM-;@$8@hJcw
zz|T{2=P6%CI-J=Ur{CZaNF5wV+S=lIq5Vq${Wsgsybj*xqQ|4ghfzn_it=PxfsAM<
za3dl-TtrVB;dKo8fcULr~ONoiG$(I#<
zG9l=vp;#bXzB=S9Da;gNfN{17Ej<`MeKo-?a!%?kMnh6))23H&kZ=Ef>pCklBmzf9}M?E;edP%izBwoGhgevi#*J4T!
zX!q}!_jo?d*NSx@;l7|(Vedro@1qB0PiDFirI|L9w0=-z$Lz(77hl@m3F41Ehca`R
zJh7I0(S^*W#cB??2~%105j13blhr2c?zo1+*y3lb3;x`TBs_aZwZ|srN}atFVZn-y
zfU+%ba{^bRihmIj|06Ys+EF(&ti88OJSkNKy8PKvvIjALEAVYbQ1zG9q2jT3m-jq1
zeEv9u(*;|mV$yGvETJc+!Rw2!63Gf`#2z}bE@KCs>MFfXVlr9?_hiQ?R19~meQQ=t
zCb)PTs>QKz>MsAZd%8=!ehe@w0y4{s1q2#)|Kb@Fa@Umcn4%Dn>qCW3Bl)mAW-Pe&
z<~^_3*sCEiNaKr!Xb(x~$+!nePjeJQwB7q}R)jh_&*B0K6$A%#zFw4kd4Z;f8-d`H
zL0E;kj&Zk?O&V#p?A#OU&4qeI(lOPagnep3WZ+-H7>zkj(jAg~r0AS)juF&jZprq%
zL!7J+4pIi8j+QVVJLC3ATgCVvFsS7YN5+wUtJyR2hwrJs;jFZ9^*e#nJE^Y?bH;yI
zJxy;RsAIu+w&h;<2F$M;7Eje)-zaP39t)ZG>9O@7+Kluy?5-m#=&4lmSb|rcO-6zl
z?r8G8@v*F{v~w!?K#noDLD1O5$1gdma!OCK#L!P}_V|NFyqjbT`CZ9<6XKJj#fteu
zhGet#l%KEy+QKg8np7UmS-!-Q{{;g&{;bFW;JujIAeK3te8uGL<
zfGDL4#JXck_xe!{<*qO9O1nY7pU(&2Uy-L@X8pD{DyCQQvWM!~VvfTr?i1eQly!Qr
zLhB3P-`-YEZ4?ChA660wl&1;?IitcaMvg^J@`@*4Yy4Ht~v7U8l(KCIEQZif#
z_TYdTC$&29bl}G-jm28#)Q@9pdz+6Q&O5j$`e=OE&c5*mKuhRI{%Fsx=xs1!&>Tq&i*m3Kd3$J50_Zg!3cdkV~_$bj9E=6k_
z%Y8?YBO($-J(ldwu35bn?9BfWW7AB#R^B
z5&zeA2=;7}PT^v!V9@V}D4Jt+f+|vlYeeMC&p)$XtTvHC>%zsaNIm{FwwlZZz}!bGDF*x<=$Vuabig(
z*da4oYsOPk@J8o#NId3Oi~J(TL1dV)EaKO`*);4HsJiFF)R!wB68%1IK;UXtLd=c#zbj_r3aCs*8*UEYUC
zzj2$31^kcGH?H-1dAt-fw4Cf=1UTDAlraBFwy8z_lB5Ou*CFRdUZIiubRpbak`x&3
z9G^V07=my%d#_0?5L!kQ%nFby5y|)rbM}v=rexTdAOF?XnT7*N{lE0ZT?CU4
z;dUA2trAd1dGB(sPR``Q@lY`UV%5L-%kGby?twI?h^0q@>F>ohL4|~N?`W7Q#V+o;
z2sY+p|2nCefG_gB?Pl6jv5CiizS*Cru4lX56Z!646*?30M)vQWz@>;vGs>>pDx_r?!RI6w!fF8RtVXJt~|_E=Op2qh+e)FBY89W-;(yFuy-7-jtr5TEUzY;=C>5G
zQCRJaq7ph~_eM#b5==WprJ#Kk5y0E?y=7WKn<6`JQ=IPa84LQS5QMg%oR;FxqYTlw
zHQ0hvEwu$@xr;4Yac2%Qg60*0P&)h0<@NrpQJOzfcpnOUK6cXUa24Uy-EFz0#j{U>
z&NXQf1}9!r;?m%`Q%WLf690m3Tw4W!bwf(+4u9Ok75`?`c%f8n`5f`lai8G9JB&W7*IEZz3A?V?Up#Gld5u52(6m_;2(_Mlauh
zG`{WaM$IQy+83F~53SjbO1yraI&hiLAid4t+2L~#DLtIv>h@J>y0(AEX?6M4o4T98
z)~L-nhyUGu_)uP~U@pPav+%e>aUrzxFdyYran&luy2vW
zz1E(aC(SfCCv!mmf-T)PJ0+{{(W~pM=sDv{cKy?Sjx|0R!pB@*%k3rvnW>FkEY3mF
z3>8rVBw1#o#~QiERcjJg5!iI$vrsFT^z`;GmY=sY$9ff&_AT$x**c<1SVk_5X*vED
zB$Ja$A0=Cmb?o0-o$L6Lj!CuYDyH)yX#Wwfi!O|seZp|+=32X%gf(wF%nCO_zSk9SE%%nc|!1JxzYshwe)4B?Lf){<$&u(
zWv%96yR4x{ht65A?mB+EU`D)E`nYkSenZgqiK>GywXbZd+w7FWEo5hV*7o^L2hx1yN_DGQLOw3QU^CYHOIC3xeedFy
zJ>H-pg2UZqSobTlv4WIFex(b0>0P(n%)xfS{j)Y)C%&-ptR8O2IGtf4+P$@B9+r(N
zrL#aAcQ(m42(Rn3m-IBbDkzuKs42l^|i2%L{TjdQ6QV^O$|HmUSMAezR>58hBGD0UQYS0EP~K
z{X;{sz3@97j9{iuWoLZ9vuA%FHJk(-y0DcAI@w)+q!wKgK%>BlxTTskVyb-iR3r=+
z{kym$|JSekOSkDsU3<7%ihrcvE1aHjv0Y3foXTx_ym6}Y(tyqiTibtoSrKc>L(;JCK{WDx3bM
zD`D=xHorh_+UfFy>HxWR8le#UzI=vY4-1x~VTvO8E(o@sK<491>UM0_YXQW#s~^%|
zRX3S&>(R+m#q>T}7N(91?k=S`^7pl}`HR}PbY9^;gS9#r^s>NDO^mJn+{pD7U(5oT
z8b-}8;iC)<#}E>!7oO=D`!EAu67A0z{I8llOvUDv3xt~&87?mDHI9fC@}oD_8|~ZUTqs(kJew(Lt2_^&-&u#
zzoR~v#1w1|mvm32k?bU%*U!DkTI(J+?~TNK?(@5vg<*;|QBl1#RCxK31u=S5obKE+!)Ra?5kdP5R2x!BH<1gaAeppvT+04!7yEE3_j@JP{FRInqlsyQ
z;kdV2iMrL*0hWJO)pZ>Eori)1zjp3|z)^6_*W$9O=niI9b@FjhRhfNBGYe*dv`K4g
zB2j9|Q&gX3h3z6ArJ-?=DZVPl=a~aCW6A+vgEZbql>oT5C|H6>2hyD1TA?@z{Tqz^
zHy^j`D*M=$EZ^kRQ1-PN*1wd>msLyuN`{YLITtY?H6e^56cW4C2QuunV@;B!q2iNy
z-YphSdLP>i*n?*^#;ZDKJGS#&6y>mma0eNX{>msE
zW5VtUe{yAX%_y37lqR&9Sq=N}3=&`vwKinwUtV#225r%yCw_utoM=!UOAv!v$YM%R
zbLA6-Q-QI}e;(;U0?c`b_2Hs#fqf73d2+Ee65@bc$fDD4e*$GmlG$vhXp%Y=?v^~g
zyeCS!-=0m}3Ge<}E#h8cZmj!<6&i&Uyicd-MkLIdtY+E+1&!*Snml^i7fVnto>mXm
z*7PGh+AmQrUQN~Nun!kPtNpBdyh8V{@u>5k{Zmb5|HGJ44-8r1BY-NBgDoncV0&ai
ziY5tnd#q~ZmA@{8Oq8yc!V0HonL5bP%h-p6^LG8o%FQY1_&7X!TOD1Purfp{A&~Yj
zVO;>3=vx+z`hAn$(0e+L`UMvPYZ6BU1v#%KO1Q(k9OLY#@3$R6pgtd+cnuRqhU992
zggWsBLRaN~tW+8qJDE70z!h?Uzw@)Ricl=uZv?~9*JIS;7sFl^r1vK}<=n=r0dEza
z{J@i)f^bHvJ{?Ct`~+YMzDmQng&wz=N_%cNHK=UueL&T9+>=A}T*~BSh3P)SQ=#=Q
z8#KI=_chYSOW(g2nd+xMqtufp=A?qKI}@i84qTuvtw~QMxr13J13554HNn5-XLwkb
zWQ^O#w&v++z0;YP9)0L6Ur5#FHY0qIOOrl&yV{n)hh~Etf%R*JQ&oD+wccDQe)+-6
zQTNa@LDcq6M)Vs>Wn^d@ow604cESj>8b^{wg!8^+(XQyP((K@|g&k@_Pd1qtUQ+F0
zaSU$%hNtP9Zx7Ce^ZxV30(Zx6n$}n1xO3V=MHT%Cq&;LD$G!#Md8?|+z1vE`Vg-g+
zmhirY>V|`+t)(x)8A!DMj2Kl~;f5QK5);^oBz{)kaP*4*r)
zk8VE7Tf_6L*nag$`8oB%dKK&&4@ea&|n
zLE$L#X+BnsW3CA}o)19AL5a%Tr3=A$9^!3AaClMHHO3IlQb#!{&W>b)=3*0K4P+vz
zXp-I^hM>~6cf~}q4NnN`Mm=Z|h*oap_M6Iq+Roj{;k@SIAukH*nJ0t!Td{YL9FP51
z^*}A1fcG7qpq-qT>?zh|Eb0jcq_YXV1~Sv42~~r8STJb$gz%6YEf1*~#ejlG?$BT^
zIkBp!fTTdYhy-9jkVFu6wOkCIlX__9{PiBhfM5UqZlZ)=KVFzm-$XpXy`x8T^^e(K
zD!Gj3_9@c-z@ty!OFWVrH55NXK>x8w-0xIH0W29$StBbc!DACeqq)PKUYqg$W2`{QL
zyvYjl24o#@*b?NXJex*FwHoD<@m>XF=r^B!sP7vmqKE%OEecRqSK%2p00tPuOO=&~
zu+U472*BL`K7}Hb+Y!VCW9P4o@8fQ*&Uca6e=VQIIw7!)h;eyrn`xnd5X3=JbvHY(
ziaiXh<9lqp$Vxcb2WX7M0$UEK^MwyY5U&;w?iKyTwn3q~f~MkCCtNNqoPaS>MYZPm
zDnvH&K0r@zvpNZExXI`_%#OV?eYNsM`gPym!gWq0^0e$GiRZv^<9~DEfg~rV#qISL
zUJ9@v7sN&)!w&O?JV*dk>TeERf2quEpHTb+Z>9UTO{ly96-Qf8VS4mOBSQpmp;R!L
zzJ5x?4q{EYU83`V-p3QvpRh97rwtl_dgRx$$;EdR~-$u>SelN+XXkB%>(n
zm$?(5e2b7`##bPT4iV7Vox8|Hro7Eoxw)ZY0j1
z>uFLQVyuQalDU>HjbA+|5!h4j0+b2u(I0q;Yc!tm7JW(%(K1FmY}?1yfAk;&4ZzN~
zeR!>|6#pZW{^CE_o-A$`SpV|xDR&APRc?EFbF!E7a(J}ZPx^xu_T3LTtMzZ|)`q;K
zB;nebLY*tJEa}{=V|r&M#YZvgv;Y@+eEOHu_r@KopRY_WVOGE3O#=DmU0*m!qzWVX
zRuZU|DB#1*wj+LxYCsuw%O75O_G&jctx$%s#LoduVy#ZiCJ)FP{mrhH(L!_1cmKyG7K1(WUCT$hJ)OQVm{W`}=)pyuk
zp!4pk-X-ZT0D<`KkKk$@?MuuEhn>cK$isM
zcw%yRFAk^_C}RKQ=n
z`01|QuuaXZ-W&r67TV#{Pis+lZu1;>xgS${rth$7`*G8(se
z9zGWyHd-`!v*g;381QEM*+@t*yA~K?c=rvvJBpMTPTVUk`Jf61`;t^!pb27NT;pa%
zJJe=?_|$|)t;lsXF`3so1N5cm^_z)GUy$S)qURdl-Ei=raCrqsN2@)zt=K;sP6F5F
zfCC~tPLFu0Qu4D01%giiIAH7JegG!i2(`mgz+2xkJ85otv1+-iT;~%OUfu4~FC^#P
zf9|2Nd2tFD-9njVb}fMmBe-co|{XpNK+C!XXvB9
zls6jC8Bc}i5!HU*QtQBb@h?C6&G|XIoIjU7D;HR=ojw6_ccAj^+0WE&97}Alb_^9J)
zzgEY4@hnXwvO*n#3N;qlT2eTAg*Xw94Eb4aEtSiVw6YoFZ8%D8|13ne&^212*#}SV
z0f8L$`3oW#>wkOkT-5+NgK*cZB&@bc
zl$E0_$Zkbc_cbzpR_NB>kFIy|adhBQ6%R*=o``ddzb$js6}lLyq-{5U1l*Bj{E;Y|
za{M^8QtmdK`;hFzmxLTO2kvamQc2D5{=&iFYkkYE;(r{i?jxV8J;a3Z)gZ9fLw|V5
z06)a+#iCG9kNo_u0@SLfP=I;L3_FAz_{2=XCkWBv3{AdM*DpOPw@DmHswH6{5Y1+Q&jwFv(N=|w3B6ePvF;RhY;pEYb
z(~w0|-#$eImo=SfgQX6?y)(__$#@0k{=KXg>`L>8(8L-$bOnU%^=mHUMDak8Pq=@Fj}VmQ2~dZ85!fD?M{OnO}o2F=ay<+jfPOSoQIlxiZhb
z=_`-3S}ViFK>)<@rX#f}rcK8Qi3)&rP6L>R6qVB0Of*oaQ^{3e&u@+GupPGzoj-x%
zi&_DaL*E(8%?OhE@I4}Pb3eXnBiCw+q1)$}RzrsN&?(PjLrXge4$IDuf_qQ0)*Xv2
z$hY(}-`znz96{}+;bmNUPc304ur}s2Ltx0n)d#u`Kpl0ZWX3M?p#(l**f+evs(fXI
zFUaKCEqpdmQVDj!gvu;DU}Qw^^Z@{p?n@4u8B)VLyAk2t%GI?fvQZR$W^{(lyu6b%
z5#0ObFqSLW^xTp+z%i>|h1|34n|3vf1*2MEe+ay2PeRK9hxdfZ`J%kzfX`TMi>Tuf
zd^9p`=^ovA+*z*~Fl+QVjB*fE?ET8S!3hXte~A9ab)em)xwxJ>h3UpsrA3jtOiyXANbL)>-Qv
zv!rChxFLeN&v=<9Ys49xDwEO-U7(Q1btU355S}2XXN$4I^W(4SxLl>{t|8#s&jvuF
z_F~=8HL?i;unIEzq661WD5z`cKmd;KNpl;Y6D?3>^07Me+&%=M+Jb(MX(2@>EU*?t
z&5dow%I#`}__RsXH3qGm+jvpA)p42p_39j?)$Xm5a)0VSKUtW)b2|+W
zZ^|Xb+wX?vaUe3G~)7$`L6p9m$qgN&X(|Sp*P(!O*QNfsC>fhex^LyVduUvx3&pqdxnmaf$ugM5i5g<=8j;>rbUSqA84k(HQ(#~sjM^VMJGxXXS0x@zQ
za5=|q0>5|=vAdrtI7@b-!mCjr2a-y%c6m#muNz{1FSACFO0Uw1g2sn24De##8?J_|
ziG(j_muY6eAl2vRah<4-oRv;0ne(b%%*ADz5!F(!Sq^ypM>^$pAZLUxlD6=0g(-8Z
z7Uvp+pb>jA7lDocF>g$m-F0p+>|)LINJCNA#3G>Y{O|F$wf&+XMDyPU=B?!M)z*H~
zr|<>}Re&ur+Mv6J5LmryGM@$zW`ys&w8exee8ESw*#3S6kNPuQdUy_bRIvlzoO0qpDxT00w-;zAzHa8rDRI>QPxU5mLS1MyhH1vt@Ke
zq3rUv$J2nx_ymfN2J)B~x^7e4E#TbW8Qkqs?L}SFFqeeSq%O(|bsNCQQiYEa&C8
zl9X83q2oT?#eNGJX>7kHrt6Hg$zB|I$b2Umcv(unzSScPXs9(@ipMfFzFdgeJ~gG+
zhDc5G1mo0rFU}iLA!v5|fDB_DBa4=?z=tzbf?_xU;&u=mq+0CYC4>)e6}hoPGeTzD
z`515h*spqQ2v(LpCMt+_*$ST{^k@5wKWngzl>!;d+CXZiy%^eJob!VS-HLr_{+X{vya-l!Uo5J7AU}pr5XkRAHSOy6Y3ySI(Vf=f#lEaRVwY&-Dhs?R
zNixOX=Z9S3-b@^=O=pYx6wtPg*^OL^R9UXSzI36g``j~9_(llPTh?2RoFx)rh_~jU
zh#L}_cxlti4}yA3H1+jzSkXm!hdA|^G`yVdnQZLuwD-ILbynX6S`nAOn)9vBFOByi
zH@5uxwcz5$2!xZb36bX^Y$cEsTHMDSeI%XbMhmdCeRQ8*%!TO0
zYSIT)(#=waQY%q=I_bkPrfb&nb5AR8u`@Hzn!c
zyvfQpV26hQaNFS2F?wzjFK_h=2u!=1wszdyj*U;=lk7?v&PjdkNuaWj=aPI{8L$x^!r_Dt}up6Kiyfb=Urs9-bR
z)4Lf(rqgha4}=lj;Db3-SjkSc%}b;&bqjaKh$MGF7p}P_0S*5mV~#bP*9d;T;>x&R
zft{fk?(B74t6!)no;!TprnctG|I|ubtbh>1FWmS}`#6c9&iiD)w@~T*4fl7p@+oxE
z1v)o3|9uhB`E@D<>iq`v#|FMNJ9An*9>nMa2u;=l?SarUZJYPqy}tzwzmpih5^Xi~
zPa6*&y~?o@2x*T=l~JC#auiABtoV&UYc&VAxp-P$u=+WRDV
zIlG@mTUbi$itU{$0=o01bxFKiP-MM;B@7Rr)zk-}&GI
z+U!#;hk(O=l(SO-sIf`AzAP6;bfLVZMeZimBJq90
zy$4;Fs9SnOt562Fz^0d(W2V!Bj9bVp2szhsjz?=G?MGDk&3jwjctd1NMHSO}kJB+>
z=k>RyOy?-Nkt^!=t8g~c6Y@=o%@Gxo?j@Yp1B|pU2R4@H>_p5pi`H=l;0~R4yEFMe
z!<%AEv=`{Job*(8=Yk2U;NLwIIGEZesksle*a%<%qxA-mV@DDsugON
z()ru7Zv2U&*a+=8wK}pl`(!N?U{ahBW*Eq@c_r
zN8Y+Z+JcE}yYS;N+V6h0*78TdjWXb=OZH`ArW+<=8fc}YkvrW@j^E~D;+tMia$=Qa
zJN`02Uj{9)YnPT>ON@iI9QslA6#r;jX$UMpj!^`x
zuQ#bIuEHlh{kJ&^pW+o)m#gn{zM|fDXK1X53uP~qaeD^%SDVkg_cPi1Q@|A>;!cvz
zGW$9oX-HpTPX*UYpxsP7ZJ
z74-4^Gskb^?3LnQBNSptgdV3Hj5k))AH$RB2FOgRwxejVcu(69>f9D@bYH3c4SVsu1vj((gzPs9EfJZr+X!Ty`Bv|9D|^ELeGmjJjVeY
z#kz^YSf9Lg$|3dpJ~K3Bp0A10oeVB0WiEWi<^=PfCu4+-mjiSOAQB_jR?kr7km{e4
zcx@6$-_SQRriro-C@JF$&acZnL+r5jF+}6_UKD;npNLoA60T`@KQYAfLexGRK~Mix
zfP6*y&B8%$U9SfjjKfsCMVIq3WA}sn<^&L!D^1VWi?L=DJcr}2K!EtDmTb#UyY&r|
z-`Q_h)X!QdMtU~2x%~uC96dsCQ?l776Sv7wkztPr4IHIOyiw}ct!@d*VBY~KmSLtnD!fX>kj{52I9&&*JBod#|n@dn#cVu|CJ+hJp++hPu
z#=lU@^*cAcOHue2(u+0~LF9PM=2^-5Rby8vhMV)ZAN_6a^GLq11Q$g)CFk~i7+U?HDwC%I^(VasSGNjLmdkeQZE
zsINt_moJF5kN|l;KH5TZkU*qfOLtoM)ei4{FcjtG*Q@yf0!-GFVXQD-7QRvg2OXCsB%=0PvS2&4HC~qeaz&c4-%~4dO<3S>fSf
zO0`4@K1wq|CFb%AHo-5{$iYRsm$MENZ~%L>uXf5Q1RdrKQ8kZ7B0Fn{zi2MsbC)IR
zZg_VJY8|X{LA#-AFC1fk&Q&3DYp3oUd7XyLv