st-laurent
This commit is contained in:
parent
2e15f18285
commit
9de2654b0f
129 changed files with 20584 additions and 4054 deletions
118
portal_partner_manager/README.md
Normal file
118
portal_partner_manager/README.md
Normal file
|
|
@ -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.
|
||||
2
portal_partner_manager/__init__.py
Normal file
2
portal_partner_manager/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import models
|
||||
from . import controllers
|
||||
46
portal_partner_manager/__manifest__.py
Normal file
46
portal_partner_manager/__manifest__.py
Normal file
|
|
@ -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",
|
||||
}
|
||||
106
portal_partner_manager/architecture.md
Normal file
106
portal_partner_manager/architecture.md
Normal file
|
|
@ -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
|
||||
1
portal_partner_manager/controllers/__init__.py
Normal file
1
portal_partner_manager/controllers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import portal
|
||||
903
portal_partner_manager/controllers/portal.py
Normal file
903
portal_partner_manager/controllers/portal.py
Normal file
|
|
@ -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/<int: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/<int:contact_id>'], 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/<int:contact_id>'], 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/<int:contact_id>'], 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/<int:contact_id>'], 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/<int:contact_id>'], 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))
|
||||
302
portal_partner_manager/doc/Spécifications.md
Normal file
302
portal_partner_manager/doc/Spécifications.md
Normal file
|
|
@ -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/<int:contact_id>` : É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/<int:contact_id>` : Attribution d'un accès portail
|
||||
- `/my/contacts/set_password_form/<int:contact_id>` : Formulaire de définition de mot de passe
|
||||
- `/my/contacts/set_password` : Traitement du mot de passe
|
||||
- `/my/contacts/change_status/<int:contact_id>` : Changement de statut d'un contact (archivage, accès)
|
||||
- `/my/contacts/archive/<int:contact_id>` : 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
|
||||
498
portal_partner_manager/doc/js.md
Normal file
498
portal_partner_manager/doc/js.md
Normal file
|
|
@ -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
|
||||
<!-- Réutiliser le template portal_my_details avec des modifications minimales -->
|
||||
<template id="portal_my_details" inherit_id="portal.portal_my_details">
|
||||
<!-- Ajouter uniquement les champs spécifiques aux partenaires parent/frères -->
|
||||
<xpath expr="//div[hasclass('o_portal_details')]" position="attributes">
|
||||
<attribute name="class" add="o_partner_manager_portal_details" separator=" "/>
|
||||
</xpath>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 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
|
||||
132
portal_partner_manager/doc/models.md
Normal file
132
portal_partner_manager/doc/models.md
Normal file
|
|
@ -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.
|
||||
5
portal_partner_manager/models/__init__.py
Normal file
5
portal_partner_manager/models/__init__.py
Normal file
|
|
@ -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
|
||||
126
portal_partner_manager/models/portal_access.py
Normal file
126
portal_partner_manager/models/portal_access.py
Normal file
|
|
@ -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
|
||||
118
portal_partner_manager/models/portal_logging_mixin.py
Normal file
118
portal_partner_manager/models/portal_logging_mixin.py
Normal file
|
|
@ -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,
|
||||
})
|
||||
127
portal_partner_manager/models/portal_mixin.py
Normal file
127
portal_partner_manager/models/portal_mixin.py
Normal file
|
|
@ -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 []
|
||||
195
portal_partner_manager/models/res_partner.py
Normal file
195
portal_partner_manager/models/res_partner.py
Normal file
|
|
@ -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)
|
||||
88
portal_partner_manager/models/res_users.py
Normal file
88
portal_partner_manager/models/res_users.py
Normal file
|
|
@ -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
|
||||
7
portal_partner_manager/security/ir.model.access.csv
Normal file
7
portal_partner_manager/security/ir.model.access.csv
Normal file
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="portal_partner_manager_rule_partner_child_of_company" model="ir.rule">
|
||||
<field name="name">Portal: Read contacts of own company</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="domain_force">['|', ('id', '=', user.partner_id.id), ('parent_id', 'child_of', user.partner_id.id)]</field>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
103
portal_partner_manager/security/portal_security.xml
Normal file
103
portal_partner_manager/security/portal_security.xml
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Groupe de sécurité pour la gestion des accès portail -->
|
||||
<record id="group_portal_manager" model="res.groups">
|
||||
<field name="name">Gestionnaire des accès portail</field>
|
||||
<field name="category_id" ref="base.module_category_hidden"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Règles de sécurité pour les utilisateurs du portail -->
|
||||
<record id="portal_partner_rule" model="ir.rule">
|
||||
<field name="name">Portal User: access to parent company</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="domain_force">[
|
||||
'|',
|
||||
('id', '=', user.partner_id.id),
|
||||
'|',
|
||||
('id', '=', user.partner_id.parent_id.id),
|
||||
('parent_id', '=', user.partner_id.parent_id.id)
|
||||
]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Règle pour permettre aux utilisateurs du portail de modifier leur société parente -->
|
||||
<record id="portal_partner_write_rule" model="ir.rule">
|
||||
<field name="name">Portal User: write access to parent company</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="domain_force">[
|
||||
('id', '=', user.partner_id.parent_id.id),
|
||||
('allow_portal_parent_edit', '=', True)
|
||||
]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="False"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Règle pour permettre aux utilisateurs du portail de modifier leur propre profil -->
|
||||
<record id="portal_partner_self_write_rule" model="ir.rule">
|
||||
<field name="name">Portal User: write access to own profile</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="domain_force">[('id', '=', user.partner_id.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="False"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Règle pour permettre aux utilisateurs du portail de créer des contacts pour leur société parente -->
|
||||
<record id="portal_partner_create_rule" model="ir.rule">
|
||||
<field name="name">Portal User: create contacts for parent company</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="domain_force">[
|
||||
('parent_id', '=', user.partner_id.parent_id.id),
|
||||
('parent_id.allow_portal_parent_edit', '=', True)
|
||||
]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="False"/>
|
||||
<field name="perm_write" eval="False"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Règle pour permettre aux utilisateurs du portail de modifier les contacts associés à leur société parente -->
|
||||
<record id="portal_partner_contact_write_rule" model="ir.rule">
|
||||
<field name="name">Portal User: write access to parent company contacts</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="domain_force">[
|
||||
('parent_id', '=', user.partner_id.parent_id.id),
|
||||
('parent_id.allow_portal_parent_edit', '=', True)
|
||||
]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="False"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="False"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Règles pour le modèle portal.access -->
|
||||
<record id="portal_access_user_rule" model="ir.rule">
|
||||
<field name="name">Portal Access: employees only</field>
|
||||
<field name="model_id" ref="model_portal_access"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Les règles pour l'ancien modèle portal.access.log ont été supprimées car elles ont été remplacées
|
||||
par le nouveau système de journalisation basé sur portal.activity.log -->
|
||||
<!-- Règles pour le modèle portal.activity.log -->
|
||||
<record id="portal_activity_log_user_rule" model="ir.rule">
|
||||
<field name="name">Portal Activity Log: employees only</field>
|
||||
<field name="model_id" ref="model_portal_activity_log"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
</record>
|
||||
</odoo>
|
||||
40
portal_partner_manager/static/src/img/company.svg
Normal file
40
portal_partner_manager/static/src/img/company.svg
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<?xml version="1.0" ?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 64 64" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="_x32_5_attachment"/>
|
||||
<g id="_x32_4_office">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M31.5273,34.4512h22.5313c0.5527,0,1-0.4478,1-1v-6.1519c0-0.5522-0.4473-1-1-1H31.5273c-0.5527,0-1,0.4478-1,1v6.1519 C30.5273,34.0034,30.9746,34.4512,31.5273,34.4512z M47.5479,28.2993h5.5107v4.1519h-5.5107V28.2993z M40.0381,28.2993h5.5098 v4.1519h-5.5098V28.2993z M32.5273,28.2993h5.5107v4.1519h-5.5107V28.2993z"/>
|
||||
<path d="M62,60.8833h-1.125V11.1647c1.1713-0.2427,2.0547-1.2807,2.0547-2.5216V7.3232c0-1.4204-1.1572-2.5757-2.5791-2.5757 H25.2354c-1.4209,0-2.5762,1.1553-2.5762,2.5757v1.3198c0,0.1985,0.0275,0.3899,0.0702,0.5757H11.792V5.5513 c0-0.5522-0.4473-1-1-1s-1,0.4478-1,1v3.6675H6.5361V2.1167c0-0.5522-0.4473-1-1-1s-1,0.4478-1,1v7.1021H3.2119 C1.9922,9.2188,1,10.2124,1,11.4341v2.8887c0,1.2192,0.9922,2.2114,2.2119,2.2114h0.1904v44.3491H2c-0.5527,0-1,0.4478-1,1 s0.4473,1,1,1h60c0.5527,0,1-0.4478,1-1S62.5527,60.8833,62,60.8833z M11.9395,60.8833v-9.8662h5.9795v9.8662H11.9395z M24.7119,60.8833h-4.793V50.0171c0-0.5522-0.4473-1-1-1h-7.9795c-0.5527,0-1,0.4478-1,1v10.8662H5.4023V16.5342h19.3096V60.8833 z M4.4023,14.5342H3.2119C3.0986,14.5342,3,14.4355,3,14.3228v-2.8887c0-0.1167,0.0967-0.2153,0.2119-0.2153h21.5v3.3154H4.4023z M41.7949,60.8833h-4.2393v-9.8662h4.2393V60.8833z M48.0313,60.8833h-4.2363v-9.8662h4.2363V60.8833z M58.875,60.8833h-8.8438 V50.0171c0-0.5522-0.4473-1-1-1H36.5557c-0.5527,0-1,0.4478-1,1v10.8662h-8.8438V11.2188H58.875V60.8833z M60.3506,9.2188H59.875 H25.7119h-0.4766c-0.3174,0-0.5762-0.2583-0.5762-0.5757V7.3232c0-0.312,0.2637-0.5757,0.5762-0.5757h35.1152 c0.3135,0,0.5791,0.2637,0.5791,0.5757v1.3198C60.9297,8.9604,60.6699,9.2188,60.3506,9.2188z"/>
|
||||
<path d="M9.8926,29.1709h10.2031c0.5527,0,1-0.4478,1-1v-6.2256c0-0.5522-0.4473-1-1-1H9.8926c-0.5527,0-1,0.4478-1,1v6.2256 C8.8926,28.7231,9.3398,29.1709,9.8926,29.1709z M15.9941,22.9453h3.1016v4.2256h-3.1016V22.9453z M10.8926,22.9453h3.1016 v4.2256h-3.1016V22.9453z"/>
|
||||
<path d="M9.8926,41.6436h10.2031c0.5527,0,1-0.4478,1-1v-6.2295c0-0.5522-0.4473-1-1-1H9.8926c-0.5527,0-1,0.4478-1,1v6.2295 C8.8926,41.1958,9.3398,41.6436,9.8926,41.6436z M15.9941,35.4141h3.1016v4.2295h-3.1016V35.4141z M10.8926,35.4141h3.1016 v4.2295h-3.1016V35.4141z"/>
|
||||
<path d="M31.5273,45.4775h22.5313c0.5527,0,1-0.4478,1-1v-6.1519c0-0.5522-0.4473-1-1-1H31.5273c-0.5527,0-1,0.4478-1,1v6.1519 C30.5273,45.0298,30.9746,45.4775,31.5273,45.4775z M47.5479,39.3257h5.5107v4.1519h-5.5107V39.3257z M40.0381,39.3257h5.5098 v4.1519h-5.5098V39.3257z M32.5273,39.3257h5.5107v4.1519h-5.5107V39.3257z"/>
|
||||
<path d="M31.5273,23.4287h22.5313c0.5527,0,1-0.4478,1-1v-6.1523c0-0.5522-0.4473-1-1-1H31.5273c-0.5527,0-1,0.4478-1,1v6.1523 C30.5273,22.981,30.9746,23.4287,31.5273,23.4287z M47.5479,17.2764h5.5107v4.1523h-5.5107V17.2764z M40.0381,17.2764h5.5098 v4.1523h-5.5098V17.2764z M32.5273,17.2764h5.5107v4.1523h-5.5107V17.2764z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="_x32_3_pin"/>
|
||||
<g id="_x32_2_business_card"/>
|
||||
<g id="_x32_1_form"/>
|
||||
<g id="_x32_0_headset"/>
|
||||
<g id="_x31_9_video_call"/>
|
||||
<g id="_x31_8_letter_box"/>
|
||||
<g id="_x31_7_papperplane"/>
|
||||
<g id="_x31_6_laptop"/>
|
||||
<g id="_x31_5_connection"/>
|
||||
<g id="_x31_4_phonebook"/>
|
||||
<g id="_x31_3_classic_telephone"/>
|
||||
<g id="_x31_2_sending_mail"/>
|
||||
<g id="_x31_1_man_talking"/>
|
||||
<g id="_x31_0_date"/>
|
||||
<g id="_x30_9_review"/>
|
||||
<g id="_x30_8_email"/>
|
||||
<g id="_x30_7_information"/>
|
||||
<g id="_x30_6_phone_talking"/>
|
||||
<g id="_x30_5_women_talking"/>
|
||||
<g id="_x30_4_calling"/>
|
||||
<g id="_x30_3_women"/>
|
||||
<g id="_x30_2_writing"/>
|
||||
<g id="_x30_1_chatting"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
45
portal_partner_manager/static/src/img/contacts.svg
Normal file
45
portal_partner_manager/static/src/img/contacts.svg
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
<?xml version="1.0" ?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 64 64" version="1.1" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="_x32_5_attachment"/>
|
||||
<g id="_x32_4_office"/>
|
||||
<g id="_x32_3_pin"/>
|
||||
<g id="_x32_2_business_card">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M61.0112,9.9204H2.9888C1.8921,9.9204,1,10.8125,1,11.9092v40.1816c0,1.0967,0.8921,1.9888,1.9888,1.9888h58.0225 c1.0967,0,1.9888-0.8921,1.9888-1.9888V11.9092C63,10.8125,62.1079,9.9204,61.0112,9.9204z M2.9983,16.8672h58.0031 l0.0084,30.2656H2.9998L2.9983,16.8672z M61,11.9092l0.0009,2.958H2.9982L2.998,11.9204L61,11.9092z M3,52.0908l-0.0001-2.958 h58.0106l0.0008,2.9468L3,52.0908z"/>
|
||||
<path d="M45.9526,19.5654c-6.6436,0-12.0488,5.4053-12.0488,12.0493c0,6.6436,5.4053,12.0488,12.0488,12.0488 c6.6416,0,12.0449-5.4053,12.0449-12.0488C57.9976,24.9707,52.5942,19.5654,45.9526,19.5654z M45.9526,41.6636 c-2.1734,0-4.1822-0.7008-5.8279-1.8782c0.9388-2.3649,3.2468-3.9661,5.8279-3.9661c2.5791,0,4.8859,1.6016,5.825,3.9665 C50.1325,40.963,48.1248,41.6636,45.9526,41.6636z M53.3403,38.3998c-1.3822-2.757-4.2274-4.5805-7.3876-4.5805 c-3.1621,0-6.0085,1.8232-7.3909,4.5803c-1.6445-1.7899-2.658-4.1683-2.658-6.7849c0-5.541,4.5078-10.0493,10.0488-10.0493 c5.5386,0,10.0449,4.5083,10.0449,10.0493C55.9976,34.2314,54.9843,36.6099,53.3403,38.3998z"/>
|
||||
<path d="M45.9526,24.105c-2.3052,0-4.1807,1.875-4.1807,4.1802s1.8755,4.1807,4.1807,4.1807s4.1802-1.8755,4.1802-4.1807 S48.2578,24.105,45.9526,24.105z M45.9526,30.4658c-1.2026,0-2.1807-0.978-2.1807-2.1807c0-1.2021,0.978-2.1802,2.1807-2.1802 c1.2021,0,2.1802,0.978,2.1802,2.1802C48.1328,29.4878,47.1548,30.4658,45.9526,30.4658z"/>
|
||||
<path d="M26.8872,31.897H14.0479c-0.5522,0-1,0.4478-1,1s0.4478,1,1,1h12.8394c0.5522,0,1-0.4478,1-1 S27.4395,31.897,26.8872,31.897z"/>
|
||||
<path d="M26.8872,36.2563H9.9893c-0.5522,0-1,0.4478-1,1s0.4478,1,1,1h16.8979c0.5522,0,1-0.4478,1-1 S27.4395,36.2563,26.8872,36.2563z"/>
|
||||
<path d="M26.8872,40.6123H9.9893c-0.5522,0-1,0.4478-1,1s0.4478,1,1,1h16.8979c0.5522,0,1-0.4478,1-1 S27.4395,40.6123,26.8872,40.6123z"/>
|
||||
</g>
|
||||
<g>
|
||||
<g>
|
||||
<path d="M27.0459,28H9.9507C8.875,28,8,27.125,8,26.0493v-4.0986C8,20.875,8.875,20,9.9507,20h17.106 C28.1284,20,29,20.9097,29,22.0278v4.0181C29,27.1235,28.1235,28,27.0459,28z M9.9794,22v4h17.0665v-4H9.9794z"/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="_x32_1_form"/>
|
||||
<g id="_x32_0_headset"/>
|
||||
<g id="_x31_9_video_call"/>
|
||||
<g id="_x31_8_letter_box"/>
|
||||
<g id="_x31_7_papperplane"/>
|
||||
<g id="_x31_6_laptop"/>
|
||||
<g id="_x31_5_connection"/>
|
||||
<g id="_x31_4_phonebook"/>
|
||||
<g id="_x31_3_classic_telephone"/>
|
||||
<g id="_x31_2_sending_mail"/>
|
||||
<g id="_x31_1_man_talking"/>
|
||||
<g id="_x31_0_date"/>
|
||||
<g id="_x30_9_review"/>
|
||||
<g id="_x30_8_email"/>
|
||||
<g id="_x30_7_information"/>
|
||||
<g id="_x30_6_phone_talking"/>
|
||||
<g id="_x30_5_women_talking"/>
|
||||
<g id="_x30_4_calling"/>
|
||||
<g id="_x30_3_women"/>
|
||||
<g id="_x30_2_writing"/>
|
||||
<g id="_x30_1_chatting"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3 KiB |
247
portal_partner_manager/static/src/js/debug_tools.js
Normal file
247
portal_partner_manager/static/src/js/debug_tools.js
Normal file
|
|
@ -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;');
|
||||
95
portal_partner_manager/static/src/js/jquery_early_fix.js
vendored
Normal file
95
portal_partner_manager/static/src/js/jquery_early_fix.js
vendored
Normal file
|
|
@ -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é');
|
||||
})();
|
||||
210
portal_partner_manager/static/src/js/jquery_safety.js
vendored
Normal file
210
portal_partner_manager/static/src/js/jquery_safety.js
vendored
Normal file
|
|
@ -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);
|
||||
}
|
||||
})();
|
||||
50
portal_partner_manager/static/src/js/portal_fix.js
Normal file
50
portal_partner_manager/static/src/js/portal_fix.js
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
})();
|
||||
330
portal_partner_manager/static/src/js/portal_partner.js
Normal file
330
portal_partner_manager/static/src/js/portal_partner.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
69
portal_partner_manager/static/src/js/portal_partner_utils.js
Normal file
69
portal_partner_manager/static/src/js/portal_partner_utils.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
65
portal_partner_manager/static/src/scss/portal_partner.scss
Normal file
65
portal_partner_manager/static/src/scss/portal_partner.scss
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
portal_partner_manager/tests/__init__.py
Normal file
1
portal_partner_manager/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_email_login_sync
|
||||
41
portal_partner_manager/tests/test_email_login_sync.py
Normal file
41
portal_partner_manager/tests/test_email_login_sync.py
Normal file
|
|
@ -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")
|
||||
147
portal_partner_manager/tests/test_portal_partner.py
Normal file
147
portal_partner_manager/tests/test_portal_partner.py
Normal file
|
|
@ -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',
|
||||
})
|
||||
19
portal_partner_manager/todo.md
Normal file
19
portal_partner_manager/todo.md
Normal file
|
|
@ -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
|
||||
115
portal_partner_manager/views/portal_activity_log_views.xml
Normal file
115
portal_partner_manager/views/portal_activity_log_views.xml
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vue arborescente pour les logs d'activité du portail -->
|
||||
<record id="view_portal_activity_log_tree" model="ir.ui.view">
|
||||
<field name="name">portal.activity.log.tree</field>
|
||||
<field name="model">portal.activity.log</field>
|
||||
<field name="type">list</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Portal Activity Logs" create="false" edit="false" delete="false">
|
||||
<field name="create_date" string="Date"/>
|
||||
<field name="user_id"/>
|
||||
<field name="ip"/>
|
||||
<field name="model"/>
|
||||
<field name="resource_name"/>
|
||||
<field name="action"/>
|
||||
<field name="details"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue formulaire pour les logs d'activité du portail -->
|
||||
<record id="view_portal_activity_log_form" model="ir.ui.view">
|
||||
<field name="name">portal.activity.log.form</field>
|
||||
<field name="model">portal.activity.log</field>
|
||||
<field name="type">form</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Portal Activity Log" create="false" edit="false" delete="false">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="user_id"/>
|
||||
<field name="create_date" string="Date"/>
|
||||
<field name="action"/>
|
||||
<field name="ip"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="model"/>
|
||||
<field name="res_id"/>
|
||||
<field name="resource_name"/>
|
||||
</group>
|
||||
</group>
|
||||
<separator string="Details"/>
|
||||
<field name="details"/>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue de recherche pour les logs d'activité du portail -->
|
||||
<record id="view_portal_activity_log_search" model="ir.ui.view">
|
||||
<field name="name">portal.activity.log.search</field>
|
||||
<field name="model">portal.activity.log</field>
|
||||
<field name="type">search</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Portal Activity Logs">
|
||||
<field name="user_id"/>
|
||||
<field name="ip"/>
|
||||
<field name="model"/>
|
||||
<field name="resource_name"/>
|
||||
<field name="action"/>
|
||||
<field name="details"/>
|
||||
<separator/>
|
||||
<filter string="View Actions" name="view_actions" domain="[('action', '=', 'view')]"/>
|
||||
<filter string="Edit Actions" name="edit_actions" domain="[('action', '=', 'edit')]"/>
|
||||
<filter string="Create Actions" name="create_actions" domain="[('action', '=', 'create')]"/>
|
||||
<separator/>
|
||||
<filter string="Partners" name="partners" domain="[('model', '=', 'res.partner')]"/>
|
||||
<filter string="Users" name="users" domain="[('model', '=', 'res.users')]"/>
|
||||
<separator/>
|
||||
<filter string="This Month" name="this_month" domain="[('create_date', '>=', (context_today() + relativedelta(day=1)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last Month" name="last_month" domain="[
|
||||
('create_date', '>=', (context_today() + relativedelta(months=-1, day=1)).strftime('%Y-%m-%d')),
|
||||
('create_date', '<', (context_today() + relativedelta(day=1)).strftime('%Y-%m-%d'))
|
||||
]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="User" name="group_by_user" context="{'group_by': 'user_id'}"/>
|
||||
<filter string="Model" name="group_by_model" context="{'group_by': 'model'}"/>
|
||||
<filter string="Action" name="group_by_action" context="{'group_by': 'action'}"/>
|
||||
<filter string="Date" name="group_by_date" context="{'group_by': 'create_date:day'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour les logs d'activité du portail -->
|
||||
<record id="action_portal_activity_logs" model="ir.actions.act_window">
|
||||
<field name="name">Portal Activity Logs</field>
|
||||
<field name="res_model">portal.activity.log</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="view_portal_activity_log_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No activity logs found!
|
||||
</p>
|
||||
<p>
|
||||
Portal activity logs are automatically created when users interact with the portal.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu racine pour le module -->
|
||||
<menuitem id="menu_portal_partner_root"
|
||||
name="Portal Partner"
|
||||
parent="base.menu_administration"
|
||||
sequence="40"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<!-- Menu pour les logs d'activité du portail -->
|
||||
<menuitem id="menu_portal_activity_logs"
|
||||
name="Portal Activity Logs"
|
||||
parent="portal_partner_manager.menu_portal_partner_root"
|
||||
action="action_portal_activity_logs"
|
||||
sequence="20"
|
||||
groups="base.group_system"/>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="portal_archive_contact_confirm" name="Confirm Contact Archive">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning">
|
||||
<h4 class="alert-heading">Warning!</h4>
|
||||
<p>
|
||||
The contact <strong t-esc="contact.name"/> has active portal users associated with it.
|
||||
You cannot archive a contact with active users.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Active users:</strong>
|
||||
<ul>
|
||||
<t t-foreach="active_users" t-as="user">
|
||||
<li t-esc="user.name"/>
|
||||
</t>
|
||||
</ul>
|
||||
</p>
|
||||
<p>Would you like to archive both the contact and their associated portal users?</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between">
|
||||
<a t-att-href="'/my/contacts'" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left"/> Cancel
|
||||
</a>
|
||||
<a t-att-href="'/my/contacts/archive/%s?archive_user=1' % contact.id" class="btn btn-primary">
|
||||
<i class="fa fa-archive"/> Archive Contact and Portal Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
392
portal_partner_manager/views/portal_company_templates.xml
Normal file
392
portal_partner_manager/views/portal_company_templates.xml
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Template for the 'My Company' page -->
|
||||
<template id="portal_my_company" name="My Company">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_home">
|
||||
<div class="oe_structure" />
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="o_portal_my_details">
|
||||
<h3 class="page-header">
|
||||
<t t-if="company">
|
||||
My Company: <span t-field="company.name"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
My Company
|
||||
</t>
|
||||
</h3>
|
||||
|
||||
<!-- Success message after update -->
|
||||
<t t-if="request.params.get('update') == 'success'">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check mr-2"></i> Company information has been successfully updated.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Display company information -->
|
||||
<t t-if="has_company">
|
||||
<t t-if="company">
|
||||
<div class="row mt-4">
|
||||
<div class="col-lg-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-building mr-2"></i> General Information
|
||||
<t t-if="can_edit">
|
||||
<a href="/my/company/edit" class="btn btn-sm btn-light float-right">
|
||||
<i class="fa fa-pencil"></i> Edit
|
||||
</a>
|
||||
</t>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-3">
|
||||
<strong>Name:</strong> <span t-field="company.name"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Address:</strong>
|
||||
<div t-field="company.contact_address"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Phone:</strong> <span t-field="company.phone"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Mobile:</strong> <span t-field="company.mobile"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-3">
|
||||
<strong>Email:</strong> <span t-field="company.email"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Website:</strong> <span t-field="company.website"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>VAT:</strong> <span t-field="company.vat"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts Section -->
|
||||
<div class="mt-5">
|
||||
<h4 class="mb-3">
|
||||
<i class="fa fa-users mr-2"></i> Contacts
|
||||
<t t-if="can_edit">
|
||||
<a href="/my/contacts/add" class="btn btn-sm btn-primary float-right">
|
||||
<i class="fa fa-plus"></i> Add Contact/Address
|
||||
</a>
|
||||
</t>
|
||||
</h4>
|
||||
|
||||
<t t-if="contacts">
|
||||
<div class="o_portal_contact_list">
|
||||
<div class="row">
|
||||
<!-- Boucle sur les contacts (type=contact) -->
|
||||
<t t-foreach="contacts" t-as="contact">
|
||||
<!-- Conteneur pour chaque contact -->
|
||||
<div class="col-lg-4 col-md-6 mb-4" t-att-data-contact-id="contact.id">
|
||||
<div class="card h-100" t-att-class="'text-muted' if not contact.active else ''">
|
||||
<div class="card-header" t-att-class="'bg-danger text-white' if not contact.active else 'bg-primary text-white'">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-user mr-2"></i> <t t-esc="contact.name"/>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="mb-3">
|
||||
<strong>Email:</strong> <span t-field="contact.email"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Phone:</strong> <span t-field="contact.phone"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Mobile:</strong> <span t-field="contact.mobile"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Status:</strong>
|
||||
<span t-if="contact.user_ids and contact.active" class="badge badge-success">
|
||||
<i class="fa fa-check mr-1"></i> Portal Access
|
||||
</span>
|
||||
<span t-if="not contact.user_ids and contact.active" class="badge badge-secondary">
|
||||
<i class="fa fa-user-o mr-1"></i> No Access
|
||||
</span>
|
||||
<span t-if="not contact.active" class="badge badge-danger">
|
||||
<i class="fa fa-archive mr-1"></i> Archived
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-auto">
|
||||
<div class="d-flex justify-content-center">
|
||||
<t t-if="can_edit">
|
||||
<a t-att-href="'/my/contacts/edit/%s' % contact.id" class="btn btn-outline-primary mr-2">
|
||||
<i class="fa fa-pencil mr-1"></i> Edit
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="can_archive and contact.active">
|
||||
<a t-att-href="'/my/contacts/archive/%s' % contact.id" class="btn btn-outline-warning mr-2">
|
||||
<i class="fa fa-archive mr-1"></i> Archive
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="can_archive and not contact.active">
|
||||
<a t-att-href="'/my/contacts/unarchive/%s' % contact.id" class="btn btn-outline-success mr-2">
|
||||
<i class="fa fa-undo mr-1"></i> Unarchive
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="contact.type == 'contact' and contact.email and contact.active">
|
||||
<a t-att-href="'/my/contacts/grant_access/%s' % contact.id" class="btn btn-outline-info">
|
||||
<t t-if="contact.user_ids">
|
||||
<i class="fa fa-key mr-1"></i> Reset Password
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-user-plus mr-1"></i> Grant Access
|
||||
</t>
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-info">
|
||||
<p>No contacts found.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<!-- Addresses Section -->
|
||||
<div class="mt-5">
|
||||
<h4 class="mb-3">
|
||||
<i class="fa fa-map-marker mr-2"></i> Other Addresses
|
||||
<t t-if="can_edit">
|
||||
<a href="/my/contacts/add" class="btn btn-sm btn-primary float-right">
|
||||
<i class="fa fa-plus"></i> Add Contact/Address
|
||||
</a>
|
||||
</t>
|
||||
</h4>
|
||||
|
||||
<t t-if="addresses">
|
||||
<div class="o_portal_address_list">
|
||||
<div class="row">
|
||||
<!-- Boucle sur les adresses (type!=contact) -->
|
||||
<t t-foreach="addresses" t-as="address">
|
||||
<!-- Conteneur pour chaque adresse -->
|
||||
<div class="col-lg-4 col-md-6 mb-4" t-att-data-contact-id="address.id">
|
||||
<div class="card h-100" t-att-class="'text-muted' if not address.active else ''">
|
||||
<div class="card-header" t-att-class="'bg-danger text-white' if not address.active else 'bg-success text-white'">
|
||||
<h5 class="mb-0">
|
||||
<i t-if="address.type == 'invoice'" class="fa fa-file-text-o mr-2"></i>
|
||||
<i t-elif="address.type == 'delivery'" class="fa fa-truck mr-2"></i>
|
||||
<i t-else="" class="fa fa-map-marker mr-2"></i>
|
||||
<t t-esc="address.name"/>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="mb-3">
|
||||
<strong>Type:</strong>
|
||||
<span t-if="address.type == 'invoice'" class="badge badge-info">Invoice</span>
|
||||
<span t-elif="address.type == 'delivery'" class="badge badge-info">Delivery</span>
|
||||
<span t-else="" class="badge badge-info"><t t-esc="address.type"/></span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Address:</strong>
|
||||
<div t-field="address.contact_address"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Email:</strong> <span t-field="address.email"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Phone:</strong> <span t-field="address.phone"/>
|
||||
</div>
|
||||
<div class="mt-auto">
|
||||
<div class="d-flex justify-content-center">
|
||||
<t t-if="can_edit">
|
||||
<a t-att-href="'/my/contacts/edit/%s' % address.id" class="btn btn-outline-primary mr-2">
|
||||
<i class="fa fa-pencil mr-1"></i> Edit
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="can_archive and address.active">
|
||||
<a t-att-href="'/my/contacts/archive/%s' % address.id" class="btn btn-outline-warning">
|
||||
<i class="fa fa-archive mr-1"></i> Archive
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="can_archive and not address.active">
|
||||
<a t-att-href="'/my/contacts/unarchive/%s' % address.id" class="btn btn-outline-success">
|
||||
<i class="fa fa-undo mr-1"></i> Unarchive
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-info">
|
||||
<p>No additional addresses found.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-info">
|
||||
<p>You are not associated with a parent company.</p>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-warning">
|
||||
<p>You are not attached to a company. Information and contacts are inaccessible.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="oe_structure" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template for the company edit page -->
|
||||
<template id="portal_my_company_edit" name="Edit My Company">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_home">
|
||||
<div class="oe_structure" />
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="o_portal_my_details">
|
||||
<h3 class="page-header">
|
||||
Edit My Company: <span t-field="company.name"/>
|
||||
</h3>
|
||||
|
||||
<!-- Error message -->
|
||||
<t t-if="request.params.get('error')">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> An error occurred while updating the information.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Edit form -->
|
||||
<form id="bemade_company_edit_form" action="/my/company/update" method="post" class="mt-4">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-building mr-2"></i> General Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" class="form-control" name="name" id="name" t-att-value="company.name" required="required"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="street">Address</label>
|
||||
<input type="text" class="form-control" name="street" id="street" t-att-value="company.street"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="street2">Address (2nd line)</label>
|
||||
<input type="text" class="form-control" name="street2" id="street2" t-att-value="company.street2"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="zip">Zip Code</label>
|
||||
<input type="text" class="form-control" name="zip" id="zip" t-att-value="company.zip"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="city">City</label>
|
||||
<input type="text" class="form-control" name="city" id="city" t-att-value="company.city"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="country_id">Country</label>
|
||||
<select class="form-control" name="country_id" id="country_id">
|
||||
<option value="">-- Select a country --</option>
|
||||
<t t-foreach="countries" t-as="country">
|
||||
<option t-att-value="country.id" t-att-selected="country.id == company.country_id.id">
|
||||
<t t-esc="country.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" id="states_container">
|
||||
<label for="state_id">State / Province</label>
|
||||
<select class="form-control" name="state_id" id="state_id">
|
||||
<option value="">-- Select a state --</option>
|
||||
<t t-foreach="states" t-as="state">
|
||||
<option t-att-value="state.id"
|
||||
class="d-none"
|
||||
t-att-data-country_id="state.country_id.id"
|
||||
t-att-selected="state.id == company.state_id.id"
|
||||
>
|
||||
<t t-esc="state.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" class="form-control" name="phone" id="phone" t-att-value="company.phone"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mobile">Mobile</label>
|
||||
<input type="tel" class="form-control" name="mobile" id="mobile" t-att-value="company.mobile"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" class="form-control" name="email" id="email" t-att-value="company.email"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="website">Website</label>
|
||||
<input type="url" class="form-control" name="website" id="website" t-att-value="company.website"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vat">VAT</label>
|
||||
<input type="text" class="form-control" name="vat" id="vat" t-att-value="company.vat"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="comment">Comment</label>
|
||||
<textarea class="form-control" name="comment" id="comment" rows="4"><t t-esc="company.comment"/></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/my/company" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left mr-1"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save mr-1"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Le script a été supprimé et remplacé par un widget JavaScript -->
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="oe_structure" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
577
portal_partner_manager/views/portal_contact_templates.xml
Normal file
577
portal_partner_manager/views/portal_contact_templates.xml
Normal file
|
|
@ -0,0 +1,577 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Template for the contact list -->
|
||||
<template id="portal_my_contacts" name="My Contacts">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_home">
|
||||
<div class="oe_structure" />
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="o_portal_my_details">
|
||||
<h3 class="page-header">
|
||||
My Contacts
|
||||
</h3>
|
||||
|
||||
<!-- Success messages -->
|
||||
<t t-if="request.params.get('create') == 'success'">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check mr-2"></i> The contact has been successfully created.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('update') == 'success'">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check mr-2"></i> The contact has been successfully updated.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('access_granted') == 'success'">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check mr-2"></i> Portal access has been granted to the contact. An invitation email has been sent.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('password_set') == 'success'">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check mr-2"></i> The password has been set successfully for the contact.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('archive') == 'success' or request.params.get('status_change') == 'archived'">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check mr-2"></i> The contact has been archived successfully.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="request.params.get('status_change') == 'portal_granted'">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check mr-2"></i> Portal access has been granted to the contact.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="request.params.get('status_change') == 'access_removed'">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check mr-2"></i> Portal access has been removed and the contact has been unarchived.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-if="request.params.get('success') == 'invitation_sent'">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check-circle mr-2"></i> Invitation email successfully sent.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Error messages -->
|
||||
<t t-if="request.params.get('error') == 'email_required'">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> An email address is required to grant portal access.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'user_exists'">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> A user with this email already exists.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'grant_access'">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> An error occurred while granting portal access.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'archive' or request.params.get('error') == 'status_change'">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> An error occurred while updating the contact status.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Special alert for contacts with active users -->
|
||||
<t t-if="request.params.get('error') == 'has_users'">
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
<h5><i class="fa fa-exclamation-triangle mr-2"></i> Cannot archive contact with active users</h5>
|
||||
<p>
|
||||
The contact has active portal users associated with it: <strong t-esc="request.params.get('user_list')"></strong>
|
||||
</p>
|
||||
<p>
|
||||
You need to archive both the contact and its associated users.
|
||||
</p>
|
||||
<div class="mt-3">
|
||||
<a t-att-href="request.params.get('archive_url')" class="btn btn-primary">
|
||||
<i class="fa fa-archive mr-1"></i> Archive Contact and Portal Users
|
||||
</a>
|
||||
</div>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Bouton d'ajout de contact -->
|
||||
<t t-if="can_add_contact">
|
||||
<div class="mb-4">
|
||||
<a href="/my/contacts/add" class="btn btn-primary">
|
||||
<i class="fa fa-plus mr-1"></i> Add a contact
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Liste des contacts -->
|
||||
<t t-if="contacts">
|
||||
<div class="o_portal_contact_list">
|
||||
<div class="row">
|
||||
<!-- Boucle sur les contacts -->
|
||||
<t t-foreach="contacts" t-as="contact">
|
||||
<!-- Conteneur pour chaque contact -->
|
||||
<div class="col-lg-4 col-md-6 mb-4" t-att-data-contact-id="contact.id">
|
||||
<div class="card h-100" t-att-class="'text-muted' if not contact.active else ''">
|
||||
<div class="card-header" t-att-class="'bg-danger text-white' if not contact.active else 'bg-primary text-white'">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-user mr-2"></i> <t t-esc="contact.name"/>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="mb-3">
|
||||
<strong>Email:</strong> <span t-field="contact.email"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Phone:</strong> <span t-field="contact.phone"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Mobile:</strong> <span t-field="contact.mobile"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Status:</strong>
|
||||
<span t-if="contact.user_ids and contact.active" class="badge badge-success">
|
||||
<i class="fa fa-check mr-1"></i> Portal Access
|
||||
</span>
|
||||
<span t-if="not contact.user_ids and contact.active" class="badge badge-secondary">
|
||||
<i class="fa fa-user-o mr-1"></i> No Access
|
||||
</span>
|
||||
<span t-if="not contact.active" class="badge badge-danger">
|
||||
<i class="fa fa-archive mr-1"></i> Archived
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-auto">
|
||||
<div class="d-flex justify-content-center">
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-outline-secondary dropdown-toggle" data-toggle="dropdown" aria-expanded="false" data-display="static" data-boundary="window">
|
||||
<i class="fa fa-cog mr-1"></i> Actions
|
||||
</button>
|
||||
<div class="dropdown-menu">
|
||||
<t t-if="can_add_contact">
|
||||
<a t-att-href="'/my/contacts/edit/%s' % contact.id" class="dropdown-item">
|
||||
<i class="fa fa-pencil text-primary mr-2"></i> Edit
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="contact.email and not contact.user_ids and contact.active and can_add_contact">
|
||||
<a t-att-href="'/my/contacts/grant_access/%s' % contact.id" class="dropdown-item">
|
||||
<i class="fa fa-key text-success mr-2"></i> Grant Portal Access
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="not (contact.user_ids and contact.active)">
|
||||
<a t-att-href="'/my/contacts/change_status/%s?status=portal' % contact.id" class="dropdown-item">
|
||||
<i class="fa fa-check text-success mr-2"></i> Portal Access
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="not (not contact.user_ids and contact.active)">
|
||||
<a t-att-href="'/my/contacts/change_status/%s?status=no_access' % contact.id" class="dropdown-item">
|
||||
<i class="fa fa-user-o text-secondary mr-2"></i> No Access
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="contact.active">
|
||||
<a t-att-href="'/my/contacts/change_status/%s?status=archived' % contact.id" class="dropdown-item">
|
||||
<i class="fa fa-archive text-danger mr-2"></i> Archive
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="not contact.active">
|
||||
<a t-att-href="'/my/contacts/change_status/%s?status=no_access' % contact.id" class="dropdown-item">
|
||||
<i class="fa fa-undo text-success mr-2"></i> Unarchive
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="o_portal_pager mt-3">
|
||||
<t t-call="portal.pager"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-info">
|
||||
<p>No contacts found.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="oe_structure" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template pour l'ajout d'un contact -->
|
||||
<template id="portal_my_contacts_add" name="Add Contact">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_home">
|
||||
<div class="oe_structure" />
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="o_portal_my_details">
|
||||
<h3 class="page-header">
|
||||
Add a Contact
|
||||
</h3>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<t t-if="request.params.get('error') == 'email_required'">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> Email is required.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="request.params.get('error')">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> An error occurred while creating the contact.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Formulaire d'ajout -->
|
||||
<form action="/my/contacts/create" method="post" class="mt-4 o_portal_details">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-user mr-2"></i> New Contact
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="form-group">
|
||||
<label for="name">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="name" id="name" required="required"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email <span class="text-danger email-required">*</span></label>
|
||||
<input type="email" class="form-control" name="email" id="email"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" class="form-control" name="phone" id="phone"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mobile">Mobile</label>
|
||||
<input type="tel" class="form-control" name="mobile" id="mobile"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="type">Contact Type</label>
|
||||
<select class="form-control" name="type" id="type" onchange="toggleContactFields(this.value)">
|
||||
<option value="contact" selected="selected">Contact</option>
|
||||
<option value="invoice">Billing Address</option>
|
||||
<option value="delivery">Shipping Address</option>
|
||||
<option value="origin">Origin Address</option>
|
||||
<option value="other">Other Address</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="form-group address-field">
|
||||
<label for="street">Address</label>
|
||||
<input type="text" class="form-control" name="street" id="street"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="city">City</label>
|
||||
<input type="text" class="form-control" name="city" id="city"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="zip">Zip Code</label>
|
||||
<input type="text" class="form-control" name="zip" id="zip"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="country_id">Country</label>
|
||||
<select class="form-control" name="country_id" id="country_id" disabled="disabled">
|
||||
<t t-foreach="countries" t-as="country">
|
||||
<option t-att-value="country.id" t-att-selected="country.code == 'CA'">
|
||||
<t t-esc="country.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<!-- Champ caché pour s'assurer que la valeur est envoyée même si le champ est désactivé -->
|
||||
<input type="hidden" name="country_id" t-att-value="canada and canada.id or ''"/>
|
||||
</div>
|
||||
<div class="form-group" id="states_container">
|
||||
<label for="state_id">Province</label>
|
||||
<select class="form-control" name="state_id" id="state_id">
|
||||
<t t-foreach="canadian_provinces" t-as="province">
|
||||
<option t-att-value="province.id" t-att-selected="province.code == 'QC'">
|
||||
<t t-esc="province.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/my/company" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left mr-1"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save mr-1"></i> Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="oe_structure" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template for editing a contact -->
|
||||
<template id="portal_my_contacts_edit" name="Edit Contact">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_home">
|
||||
<div class="oe_structure" />
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="o_portal_my_details">
|
||||
<h3 class="page-header">
|
||||
Edit Contact: <span t-field="contact.name"/>
|
||||
</h3>
|
||||
|
||||
<!-- Message d'erreur -->
|
||||
<t t-if="request.params.get('error')">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> An error occurred while updating the contact.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Formulaire d'édition -->
|
||||
<form action="/my/contacts/update" method="post" class="mt-4 o_portal_details">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="contact_id" t-att-value="contact.id"/>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-user mr-2"></i> Contact Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="form-group">
|
||||
<label for="name">Name <span class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control" name="name" id="name" t-att-value="contact.name" required="required"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email <span class="text-danger email-required">*</span></label>
|
||||
<input type="email" class="form-control" name="email" id="email" t-att-value="contact.email"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" class="form-control" name="phone" id="phone" t-att-value="contact.phone"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mobile">Mobile</label>
|
||||
<input type="tel" class="form-control" name="mobile" id="mobile" t-att-value="contact.mobile"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="type">Contact Type</label>
|
||||
<select class="form-control" name="type" id="type" onchange="toggleContactFields(this.value)">
|
||||
<option value="contact" t-att-selected="contact.type == 'contact' or not contact.type">Contact</option>
|
||||
<option value="invoice" t-att-selected="contact.type == 'invoice'">Billing Address</option>
|
||||
<option value="delivery" t-att-selected="contact.type == 'delivery'">Shipping Address</option>
|
||||
<option value="origin" t-att-selected="contact.type == 'origin'">Origin Address</option>
|
||||
<option value="other" t-att-selected="contact.type == 'other'">Other Address</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="form-group address-field">
|
||||
<label for="street">Address</label>
|
||||
<input type="text" class="form-control" name="street" id="street" t-att-value="contact.street"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="city">City</label>
|
||||
<input type="text" class="form-control" name="city" id="city" t-att-value="contact.city"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="zip">Zip Code</label>
|
||||
<input type="text" class="form-control" name="zip" id="zip" t-att-value="contact.zip"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="country_id">Country</label>
|
||||
<select class="form-control" name="country_id" id="country_id" disabled="disabled">
|
||||
<t t-foreach="countries" t-as="country">
|
||||
<option t-att-value="country.id" t-att-selected="country.id == contact.country_id.id or (not contact.country_id and country.code == 'CA')">
|
||||
<t t-esc="country.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<!-- Champ caché pour s'assurer que la valeur est envoyée même si le champ est désactivé -->
|
||||
<input type="hidden" name="country_id" t-att-value="canada and canada.id or ''"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="state_id">Province</label>
|
||||
<select class="form-control" name="state_id" id="state_id" t-att-data-value="contact.state_id.id">
|
||||
<t t-foreach="canadian_provinces" t-as="province">
|
||||
<option t-att-value="province.id" t-att-selected="province.id == contact.state_id.id or (not contact.state_id and province.code == 'QC')">
|
||||
<t t-esc="province.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/my/company" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left mr-1"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save mr-1"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="oe_structure" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
<template id="contact_form_scripts" name="Contact Form Scripts" inherit_id="portal_my_contacts_add">
|
||||
<xpath expr="//div[hasclass('oe_structure')][last()]" position="after">
|
||||
<script type="text/javascript">
|
||||
function toggleContactFields(type) {
|
||||
// Handle email required field
|
||||
var emailInput = document.getElementById('email');
|
||||
var emailRequired = document.querySelector('.email-required');
|
||||
|
||||
if (type === 'contact') {
|
||||
// Make email required for contact type
|
||||
emailInput.setAttribute('required', 'required');
|
||||
emailRequired.style.display = '';
|
||||
|
||||
// Hide address fields for contact type
|
||||
var addressFields = document.querySelectorAll('.address-field');
|
||||
addressFields.forEach(function(field) {
|
||||
field.style.display = 'none';
|
||||
});
|
||||
} else {
|
||||
// Make email optional for other types
|
||||
// Force remove the required attribute
|
||||
emailInput.required = false;
|
||||
emailInput.removeAttribute('required');
|
||||
emailRequired.style.display = 'none';
|
||||
|
||||
// Show address fields for non-contact types
|
||||
var addressFields = document.querySelectorAll('.address-field');
|
||||
addressFields.forEach(function(field) {
|
||||
field.style.display = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var typeSelect = document.getElementById('type');
|
||||
if (typeSelect) {
|
||||
toggleContactFields(typeSelect.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<template id="contact_edit_form_scripts" name="Contact Edit Form Scripts" inherit_id="portal_my_contacts_edit">
|
||||
<xpath expr="//div[hasclass('oe_structure')][last()]" position="after">
|
||||
<script type="text/javascript">
|
||||
function toggleContactFields(type) {
|
||||
// Handle email required field
|
||||
var emailInput = document.getElementById('email');
|
||||
var emailRequired = document.querySelector('.email-required');
|
||||
|
||||
if (type === 'contact') {
|
||||
// Make email required for contact type
|
||||
emailInput.setAttribute('required', 'required');
|
||||
emailRequired.style.display = '';
|
||||
|
||||
// Hide address fields for contact type
|
||||
var addressFields = document.querySelectorAll('.address-field');
|
||||
addressFields.forEach(function(field) {
|
||||
field.style.display = 'none';
|
||||
});
|
||||
} else {
|
||||
// Make email optional for other types
|
||||
// Force remove the required attribute
|
||||
emailInput.required = false;
|
||||
emailInput.removeAttribute('required');
|
||||
emailRequired.style.display = 'none';
|
||||
|
||||
// Show address fields for non-contact types
|
||||
var addressFields = document.querySelectorAll('.address-field');
|
||||
addressFields.forEach(function(field) {
|
||||
field.style.display = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Run on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var typeSelect = document.getElementById('type');
|
||||
if (typeSelect) {
|
||||
toggleContactFields(typeSelect.value);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
49
portal_partner_manager/views/portal_menu_templates.xml
Normal file
49
portal_partner_manager/views/portal_menu_templates.xml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Adding a link in the portal menu -->
|
||||
<template id="portal_my_home_menu_company" name="Portal My Home : company menu entry" inherit_id="portal.portal_breadcrumbs" priority="30">
|
||||
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
|
||||
<li t-if="page_name == 'company' or page_name == 'contacts' or page_name == 'siblings'" class="breadcrumb-item">
|
||||
<a t-if="page_name != 'company'" t-attf-href="/my/company">My Company</a>
|
||||
<t t-else="">My Company</t>
|
||||
</li>
|
||||
<li t-if="page_name == 'contacts'" class="breadcrumb-item active">
|
||||
Contacts
|
||||
</li>
|
||||
<li t-if="page_name == 'siblings'" class="breadcrumb-item active">
|
||||
Related Companies
|
||||
</li>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Ajout d'une carte dans le tableau de bord du portail -->
|
||||
<template id="portal_my_home_company" name="Portal My Home : company entry" inherit_id="portal.portal_my_home" priority="30">
|
||||
<xpath expr="//div[@id='portal_client_category']" position="before">
|
||||
<xpath expr="//div[@id='portal_vendor_category']" position="move"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@id='portal_vendor_category']" position="inside">
|
||||
<!-- Bloc clients (infos société + contacts) -->
|
||||
<div class="o_portal_category row g-2 mt-3" id="portal_client_category">
|
||||
<div t-if="has_company" class="o_portal_index_card col-md-6 order-2">
|
||||
<a href="/my/company" title="Company Information" class="d-flex gap-2 gap-md-3 py-3 pe-2 px-md-3 h-100 rounded text-decoration-none bg-100">
|
||||
<div class="o_portal_icon d-block align-self-start">
|
||||
<img src="/portal_partner_manager/static/src/img/company.svg" loading="lazy" style="width: 48px; height: 48px; object-fit: contain;"/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mt-0 mb-1 fs-5 fw-normal lh-1 d-flex gap-2">
|
||||
<span>Information</span>
|
||||
</div>
|
||||
<div class="opacity-75">
|
||||
View or edit your company information
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- XPath pour déplacer vendor avant client si besoin -->
|
||||
<xpath expr="//div[@id='portal_client_category']" position="before">
|
||||
<xpath expr="//div[@id='portal_vendor_category']" position="move"/>
|
||||
</xpath>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
80
portal_partner_manager/views/portal_set_password.xml
Normal file
80
portal_partner_manager/views/portal_set_password.xml
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Template for setting a password for a contact -->
|
||||
<template id="portal_set_password" name="Set Contact Password">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_home">
|
||||
<div class="oe_structure" />
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="o_portal_my_details">
|
||||
<h3 class="page-header">
|
||||
Set Password for: <span t-field="contact.name"/>
|
||||
</h3>
|
||||
|
||||
<!-- Error message -->
|
||||
<t t-if="request.params.get('error') == 'password_mismatch'">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> The passwords do not match.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'password_too_short'">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> The password must be at least 8 characters long.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="request.params.get('error') == 'general'">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> An error occurred while setting the password.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Password form -->
|
||||
<form action="/my/contacts/set_password" method="post" class="mt-4">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="contact_id" t-att-value="contact.id"/>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-key mr-2"></i> Set Password
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group">
|
||||
<label for="password">New Password <span class="text-danger">*</span></label>
|
||||
<input type="password" class="form-control" name="password" id="password" required="required"/>
|
||||
<small class="form-text text-muted">Password must be at least 8 characters long.</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_password">Confirm Password <span class="text-danger">*</span></label>
|
||||
<input type="password" class="form-control" name="confirm_password" id="confirm_password" required="required"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/my/contacts" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left mr-1"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save mr-1"></i> Set Password
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="oe_structure" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
538
portal_partner_manager/views/portal_sibling_templates.xml
Normal file
538
portal_partner_manager/views/portal_sibling_templates.xml
Normal file
|
|
@ -0,0 +1,538 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Template pour la liste des partenaires frères -->
|
||||
<template id="portal_my_siblings" name="My Related Partners">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_home">
|
||||
<div class="oe_structure" />
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="o_portal_my_details">
|
||||
<h3 class="page-header">
|
||||
Related Partners
|
||||
</h3>
|
||||
|
||||
<!-- Success message after update -->
|
||||
<t t-if="request.params.get('update') == 'success'">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check mr-2"></i> Related company information has been successfully updated.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Display sibling partners information -->
|
||||
<t t-if="sibling_partners">
|
||||
<div class="o_portal_sibling_partners">
|
||||
<div class="row">
|
||||
<!-- Boucle sur les partenaires frères -->
|
||||
<t t-foreach="sibling_partners" t-as="sibling">
|
||||
<!-- Conteneur pour chaque partenaire frère avec data-sibling-id -->
|
||||
<div class="col-lg-4 col-md-6 mb-4" t-att-data-sibling-id="sibling.id">
|
||||
<div class="card h-100">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-building mr-2"></i> <t t-esc="sibling.name"/>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body d-flex flex-column">
|
||||
<div class="mb-3">
|
||||
<strong>Type:</strong> <span t-field="sibling.type" t-options="{'widget': 'selection'}"/>
|
||||
</div>
|
||||
<div t-if="sibling.type != 'contact'" class="mb-3">
|
||||
<strong>Address:</strong>
|
||||
<div t-field="sibling.contact_address"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Phone:</strong> <span t-field="sibling.phone"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Email:</strong> <span t-field="sibling.email"/>
|
||||
</div>
|
||||
<div t-if="sibling.type != 'contact'" class="mb-3">
|
||||
<strong>Website:</strong> <span t-field="sibling.website"/>
|
||||
</div>
|
||||
<div class="mt-auto text-center">
|
||||
<t t-if="can_edit">
|
||||
<a t-att-href="'/my/siblings/edit/%s' % sibling.id" class="btn btn-primary">
|
||||
<i class="fa fa-pencil mr-1"></i> Edit
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-info">
|
||||
<p>No related partners found.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="oe_structure" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template pour l'édition d'un partenaire frère -->
|
||||
<template id="portal_my_siblings_edit" name="Edit Related Partner">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_home">
|
||||
<div class="oe_structure" />
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="o_portal_my_details">
|
||||
<h3 class="page-header">
|
||||
Edit Related Partner: <span t-field="sibling.name"/>
|
||||
</h3>
|
||||
|
||||
<!-- Error message -->
|
||||
<t t-if="error">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> <t t-esc="error"/>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Form for editing sibling partner -->
|
||||
<form action="/my/siblings/update" method="post" class="mt-4 o_portal_details">
|
||||
<input type="hidden" name="sibling_id" t-att-value="sibling.id"/>
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-building mr-2"></i> Related Company Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="form-group">
|
||||
<label for="type">Contact Type</label>
|
||||
<select class="form-control" name="type" id="type" onchange="toggleAddressFields(this.value)">
|
||||
<option value="contact" t-att-selected="sibling.type == 'contact'">Contact</option>
|
||||
<option value="invoice" t-att-selected="sibling.type == 'invoice'">Invoice Address</option>
|
||||
<option value="delivery" t-att-selected="sibling.type == 'delivery'">Delivery Address</option>
|
||||
<option value="other" t-att-selected="sibling.type == 'other'">Other Address</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" class="form-control" name="name" id="name" t-att-value="sibling.name" required="required"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="street">Street</label>
|
||||
<input type="text" class="form-control" name="street" id="street" t-att-value="sibling.street"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="street2">Street 2</label>
|
||||
<input type="text" class="form-control" name="street2" id="street2" t-att-value="sibling.street2"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="city">City</label>
|
||||
<input type="text" class="form-control" name="city" id="city" t-att-value="sibling.city"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="zip">ZIP</label>
|
||||
<input type="text" class="form-control" name="zip" id="zip" t-att-value="sibling.zip"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<!-- Champ pays avec data-sibling-id pour le JS -->
|
||||
<div class="form-group address-field">
|
||||
<label for="sibling_country_id">Country</label>
|
||||
<select class="form-control" t-att-name="'sibling_country_id_' + str(sibling.id)" id="sibling_country_id">
|
||||
<option value="">Country...</option>
|
||||
<t t-foreach="countries" t-as="country">
|
||||
<option t-att-value="country.id" t-att-selected="country.id == sibling.country_id.id">
|
||||
<t t-esc="country.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Champ état/province avec data-sibling-id pour le JS -->
|
||||
<div class="form-group address-field">
|
||||
<label for="sibling_state_id">State/Province</label>
|
||||
<select class="form-control" t-att-name="'sibling_state_id_' + str(sibling.id)" id="sibling_state_id">
|
||||
<option value="">State/Province...</option>
|
||||
<t t-foreach="states" t-as="state">
|
||||
<option t-att-value="state.id"
|
||||
t-att-data-country_id="state.country_id.id"
|
||||
t-att-selected="state.id == sibling.state_id.id">
|
||||
<t t-esc="state.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" class="form-control" name="phone" id="phone" t-att-value="sibling.phone"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" class="form-control" name="email" id="email" t-att-value="sibling.email"/>
|
||||
</div>
|
||||
<div class="form-group website-field">
|
||||
<label for="website">Website</label>
|
||||
<input type="url" class="form-control" name="website" id="website" t-att-value="sibling.website"/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/my/siblings" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left mr-1"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save mr-1"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="oe_structure" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template pour l'édition du partenaire parent -->
|
||||
<template id="portal_my_parent_company" name="My Parent Company">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_home">
|
||||
<div class="oe_structure" />
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="o_portal_my_details">
|
||||
<h3 class="page-header">
|
||||
<t t-if="parent_company">
|
||||
Parent Company: <span t-field="parent_company.name"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
Parent Company
|
||||
</t>
|
||||
</h3>
|
||||
|
||||
<!-- Success message after update -->
|
||||
<t t-if="request.params.get('update') == 'success'">
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-check mr-2"></i> Parent company information has been successfully updated.
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Display parent company information -->
|
||||
<t t-if="parent_company">
|
||||
<div class="row mt-4">
|
||||
<div class="col-lg-12">
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-building mr-2"></i> Parent Company Information
|
||||
<t t-if="can_edit">
|
||||
<a href="/my/parent/edit" class="btn btn-sm btn-light float-right">
|
||||
<i class="fa fa-pencil"></i> Edit
|
||||
</a>
|
||||
</t>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-3">
|
||||
<strong>Name:</strong> <span t-field="parent_company.name"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Address:</strong>
|
||||
<div t-field="parent_company.contact_address"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Phone:</strong> <span t-field="parent_company.phone"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="mb-3">
|
||||
<strong>Email:</strong> <span t-field="parent_company.email"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Website:</strong> <span t-field="parent_company.website"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Relationship:</strong> Parent Company
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="alert alert-info">
|
||||
<p>No parent company found.</p>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="oe_structure" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template pour l'édition du partenaire parent -->
|
||||
<template id="portal_my_parent_edit" name="Edit Parent Company">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_home">
|
||||
<div class="oe_structure" />
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="o_portal_my_details">
|
||||
<h3 class="page-header">
|
||||
Edit Parent Company: <span t-field="parent_company.name"/>
|
||||
</h3>
|
||||
|
||||
<!-- Error message -->
|
||||
<t t-if="error">
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fa fa-exclamation-triangle mr-2"></i> <t t-esc="error"/>
|
||||
<button type="button" class="close" data-dismiss="alert" aria-label="Close">
|
||||
<span aria-hidden="true">&times;</span>
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Form for editing parent company -->
|
||||
<form action="/my/parent/update" method="post" class="mt-4 o_portal_parent_company">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-building mr-2"></i> Parent Company Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" class="form-control" name="name" id="name" t-att-value="parent_company.name" required="required"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="street">Street</label>
|
||||
<input type="text" class="form-control" name="street" id="street" t-att-value="parent_company.street"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="street2">Street 2</label>
|
||||
<input type="text" class="form-control" name="street2" id="street2" t-att-value="parent_company.street2"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="city">City</label>
|
||||
<input type="text" class="form-control" name="city" id="city" t-att-value="parent_company.city"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="zip">ZIP</label>
|
||||
<input type="text" class="form-control" name="zip" id="zip" t-att-value="parent_company.zip"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<!-- Champ pays pour le partenaire parent -->
|
||||
<div class="form-group">
|
||||
<label for="parent_country_id">Country</label>
|
||||
<select class="form-control" name="parent_country_id" id="parent_country_id">
|
||||
<option value="">Country...</option>
|
||||
<t t-foreach="countries" t-as="country">
|
||||
<option t-att-value="country.id" t-att-selected="country.id == parent_company.country_id.id">
|
||||
<t t-esc="country.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<!-- Champ état/province pour le partenaire parent -->
|
||||
<div class="form-group">
|
||||
<label for="parent_state_id">State/Province</label>
|
||||
<select class="form-control" name="parent_state_id" id="parent_state_id">
|
||||
<option value="">State/Province...</option>
|
||||
<t t-foreach="states" t-as="state">
|
||||
<option t-att-value="state.id"
|
||||
t-att-data-country_id="state.country_id.id"
|
||||
t-att-selected="state.id == parent_company.state_id.id">
|
||||
<t t-esc="state.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" class="form-control" name="phone" id="phone" t-att-value="parent_company.phone"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" class="form-control" name="email" id="email" t-att-value="parent_company.email"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="website">Website</label>
|
||||
<input type="url" class="form-control" name="website" id="website" t-att-value="parent_company.website"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/my/parent" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left mr-1"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save mr-1"></i> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="oe_structure" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Adding links in the portal menu -->
|
||||
<template id="portal_my_home_menu_siblings" name="Portal Menu: siblings entry" inherit_id="portal.portal_breadcrumbs" priority="35">
|
||||
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
|
||||
<li t-if="page_name == 'siblings' or page_name == 'sibling_edit'" class="breadcrumb-item">
|
||||
<a t-if="page_name != 'siblings'" t-attf-href="/my/siblings">Related Partners</a>
|
||||
<t t-else="">Related Partners</t>
|
||||
</li>
|
||||
<li t-if="page_name == 'sibling_edit'" class="breadcrumb-item active">
|
||||
Edit
|
||||
</li>
|
||||
<li t-if="page_name == 'parent' or page_name == 'parent_edit'" class="breadcrumb-item">
|
||||
<a t-if="page_name != 'parent'" t-attf-href="/my/parent">Parent Company</a>
|
||||
<t t-else="">Parent Company</t>
|
||||
</li>
|
||||
<li t-if="page_name == 'parent_edit'" class="breadcrumb-item active">
|
||||
Edit
|
||||
</li>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Template for adding a new sibling partner -->
|
||||
<template id="portal_my_siblings_add" name="Add Related Partner">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="o_portal_my_home">
|
||||
<div class="oe_structure" />
|
||||
|
||||
<!-- Page title -->
|
||||
<div class="o_portal_my_details">
|
||||
<h3 class="page-header">
|
||||
Add Related Partner
|
||||
</h3>
|
||||
|
||||
<form action="/my/siblings/create" method="post" class="mt-4 o_portal_details">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-building-o mr-2"></i> New Related Partner Information
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="form-group">
|
||||
<label for="type">Contact Type</label>
|
||||
<select class="form-control" name="type" id="type" onchange="toggleAddressFields(this.value)">
|
||||
<option value="contact" selected="selected">Contact</option>
|
||||
<option value="invoice">Invoice Address</option>
|
||||
<option value="delivery">Delivery Address</option>
|
||||
<option value="other">Other Address</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input type="text" class="form-control" name="name" id="name" required="required"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="phone">Phone</label>
|
||||
<input type="tel" class="form-control" name="phone" id="phone"/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mobile">Mobile</label>
|
||||
<input type="tel" class="form-control" name="mobile" id="mobile"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="street">Street</label>
|
||||
<input type="text" class="form-control" name="street" id="street"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="street2">Street 2</label>
|
||||
<input type="text" class="form-control" name="street2" id="street2"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6">
|
||||
<div class="form-group address-field">
|
||||
<label for="city">City</label>
|
||||
<input type="text" class="form-control" name="city" id="city"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="zip">ZIP</label>
|
||||
<input type="text" class="form-control" name="zip" id="zip"/>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="country_id">Country</label>
|
||||
<select class="form-control" name="country_id" id="country_id">
|
||||
<option value="">Select a country...</option>
|
||||
<t t-foreach="countries" t-as="country">
|
||||
<option t-att-value="country.id"><t t-esc="country.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group address-field">
|
||||
<label for="state_id">State/Province</label>
|
||||
<select class="form-control" name="state_id" id="state_id">
|
||||
<option value="">Select a state...</option>
|
||||
<t t-foreach="states" t-as="state">
|
||||
<option t-att-value="state.id" t-att-data-country-id="state.country_id.id"><t t-esc="state.name"/></option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input type="email" class="form-control" name="email" id="email"/>
|
||||
</div>
|
||||
<div class="form-group website-field">
|
||||
<label for="website">Website</label>
|
||||
<input type="url" class="form-control" name="website" id="website"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="d-flex justify-content-between">
|
||||
<a href="/my/siblings" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left mr-1"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save mr-1"></i> Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="oe_structure" />
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
155
portal_partner_manager/views/res_partner_views.xml
Normal file
155
portal_partner_manager/views/res_partner_views.xml
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Form view for the portal.access model -->
|
||||
<record id="view_portal_access_form" model="ir.ui.view">
|
||||
<field name="name">portal.access.form</field>
|
||||
<field name="model">portal.access</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Portal Access Configuration">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Configuration Name"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id" options="{'no_create': True}"/>
|
||||
<field name="allow_edit"/>
|
||||
<field name="allow_add_contacts"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="portal_user_ids" widget="many2many_tags"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Allowed Fields" name="allowed_fields">
|
||||
<field name="allowed_fields_ids" domain="[('model', '=', 'res.partner')]">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="ttype"/>
|
||||
</list>
|
||||
</field>
|
||||
<div class="alert alert-info" role="alert">
|
||||
<p>If no field is selected, the default fields will be used.</p>
|
||||
</div>
|
||||
</page>
|
||||
<page string="Access Log" name="logs">
|
||||
<div class="alert alert-info" role="alert">
|
||||
<p>Les journaux d'accès sont maintenant disponibles dans le menu Administration > Portal Partner > Portal Activity Logs.</p>
|
||||
<p>Le nouveau système de journalisation centralise tous les journaux d'activité pour une meilleure gestion.</p>
|
||||
</div>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree view for the portal.access model -->
|
||||
<record id="view_portal_access_tree" model="ir.ui.view">
|
||||
<field name="name">portal.access.tree</field>
|
||||
<field name="model">portal.access</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Portal Access Configurations">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="allow_edit"/>
|
||||
<field name="allow_add_contacts"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search view for the portal.access model -->
|
||||
<record id="view_portal_access_search" model="ir.ui.view">
|
||||
<field name="name">portal.access.search</field>
|
||||
<field name="model">portal.access</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search a portal access configuration">
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<filter string="Edit Allowed" name="allow_edit" domain="[('allow_edit', '=', True)]"/>
|
||||
<filter string="Contact Addition Allowed" name="allow_add_contacts" domain="[('allow_add_contacts', '=', True)]"/>
|
||||
<group expand="0" string="Group by">
|
||||
<filter string="Company" name="group_by_partner" context="{'group_by': 'partner_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Les vues pour l'ancien modèle portal.access.log ont été supprimées car elles ont été remplacées par
|
||||
le nouveau système de journalisation basé sur portal.activity.log -->
|
||||
|
||||
|
||||
<!-- Action for the portal.access model -->
|
||||
<record id="action_portal_access" model="ir.actions.act_window">
|
||||
<field name="name">Portal Access Configurations</field>
|
||||
<field name="res_model">portal.access</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create a new portal access configuration
|
||||
</p>
|
||||
<p>
|
||||
Portal access configurations allow you to define which portal users
|
||||
can edit which companies' information and which fields they can edit.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- L'action pour l'ancien modèle portal.access.log a été supprimée car elle a été remplacée par
|
||||
le nouveau système de journalisation basé sur portal.activity.log -->
|
||||
|
||||
|
||||
<!-- Inherited form view for res.partner -->
|
||||
<record id="view_partner_form_inherit_portal_manager" model="ir.ui.view">
|
||||
<field name="name">res.partner.form.inherit.portal.manager</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='sales_purchases']" position="after">
|
||||
<page string="Portal Access" name="portal_access" invisible="is_company == False">
|
||||
<group>
|
||||
<group>
|
||||
<field name="allow_portal_parent_edit"/>
|
||||
<field name="portal_last_update" readonly="1"/>
|
||||
<field name="portal_updated_by" readonly="1" options="{'no_create': True, 'no_open': True}"/>
|
||||
</group>
|
||||
</group>
|
||||
<div class="oe_clear"/>
|
||||
<div class="oe_button_box" name="portal_button_box">
|
||||
<button name="%(portal_partner_manager.action_portal_access)d" type="action"
|
||||
string="Access Configurations" class="oe_stat_button" icon="fa-cogs"
|
||||
context="{'default_partner_id': id}"/>
|
||||
</div>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu items -->
|
||||
<menuitem id="menu_portal_access_root" name="Portal Access"
|
||||
parent="contacts.menu_contacts"
|
||||
groups="base.group_no_one"
|
||||
sequence="20"/>
|
||||
|
||||
<menuitem id="menu_portal_access" name="Access Configurations"
|
||||
parent="menu_portal_access_root"
|
||||
action="action_portal_access"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Le menu pour les anciens logs d'accès a été supprimé car il a été remplacé par
|
||||
le nouveau système de journalisation accessible via Administration > Portal Partner > Portal Activity Logs -->
|
||||
</odoo>
|
||||
5
st_laurent_portal_vendor/__init__.py
Normal file
5
st_laurent_portal_vendor/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
72
st_laurent_portal_vendor/__manifest__.py
Normal file
72
st_laurent_portal_vendor/__manifest__.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
"name": "Vendor Product E-commerce",
|
||||
"version": "18.0.1.0.0",
|
||||
"category": "Purchases",
|
||||
"author": "Bemade",
|
||||
"website": "https://bemade.org",
|
||||
"license": "LGPL-3",
|
||||
"summary": "Ajoute la gestion des images et des fonctionnalités e-commerce aux produits fournisseurs",
|
||||
"description": """
|
||||
Ce module étend les fonctionnalités du module Vendor Product Management en ajoutant
|
||||
la possibilité de gérer des images pour les produits fournisseurs ainsi que des champs
|
||||
spécifiques pour l'e-commerce.
|
||||
|
||||
Fonctionnalités:
|
||||
- Ajout de champs d'images au modèle vendor.product
|
||||
- Ajout de champs pour le référencement (SEO)
|
||||
- Ajout de champs pour la gestion des prix et de la disponibilité sur le site web
|
||||
- Ajout de champs pour les catégories et les tags
|
||||
- Intégration avec le site web e-commerce
|
||||
""",
|
||||
"depends": [
|
||||
"base",
|
||||
"mail",
|
||||
"portal",
|
||||
"website_sale",
|
||||
"product",
|
||||
"purchase",
|
||||
"base_import",
|
||||
"vendor_product_management",
|
||||
"vendor_portal_management",
|
||||
],
|
||||
"data": [
|
||||
"data/mail_template_vendor_request_approved.xml",
|
||||
"data/mail_template_vendor_request_rejected.xml",
|
||||
"data/mail_template_vendor_request_ack.xml",
|
||||
"security/security.xml",
|
||||
"security/ir.model.access.csv",
|
||||
"wizards/vendor_request_reject_wizard.xml",
|
||||
"views/vendor_menu.xml",
|
||||
"views/vendor_product_action.xml",
|
||||
"views/vendor_product_views.xml",
|
||||
"views/vendor_product_portal_templates.xml",
|
||||
"views/vendor_product_categories_templates.xml",
|
||||
"views/vendor_portal_templates.xml",
|
||||
"views/portal_templates.xml",
|
||||
"views/portal_menu_templates.xml",
|
||||
"views/portal_home_vendor_banner.xml",
|
||||
"views/res_users_views.xml",
|
||||
"views/res_partner_views.xml",
|
||||
"views/vendor_request_views.xml",
|
||||
"views/portal_vendor_request_templates.xml",
|
||||
"views/portal_vendor_home_template.xml",
|
||||
"views/res_config_settings_views.xml",
|
||||
"views/vendor_shop_templates.xml",
|
||||
"data/vendor_request_sequence.xml"
|
||||
],
|
||||
"assets": {
|
||||
"web.assets_backend": [
|
||||
"st_laurent_portal_vendor/static/src/scss/st_laurent_portal_vendor.scss",
|
||||
],
|
||||
"web.assets_frontend": [
|
||||
"st_laurent_portal_vendor/static/lib/cropperjs/cropper.min.css",
|
||||
"st_laurent_portal_vendor/static/lib/cropperjs/cropper.min.js",
|
||||
"st_laurent_portal_vendor/static/src/scss/image_cropper.scss",
|
||||
"st_laurent_portal_vendor/static/src/js/image_cropper_simple.js",
|
||||
],
|
||||
},
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"auto_install": False,
|
||||
}
|
||||
3
st_laurent_portal_vendor/controllers/__init__.py
Normal file
3
st_laurent_portal_vendor/controllers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from . import portal # Import du module portal
|
||||
from . import vendor_categories # Import du module de gestion des catégories
|
||||
from . import vendor_shop_controller
|
||||
441
st_laurent_portal_vendor/controllers/portal.py
Normal file
441
st_laurent_portal_vendor/controllers/portal.py
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import base64
|
||||
import werkzeug
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
from odoo.exceptions import AccessError, ValidationError, MissingError
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager, get_records_pager
|
||||
|
||||
|
||||
class VendorRequestPortal(CustomerPortal):
|
||||
"""
|
||||
Contrôleur pour gérer les demandes de vendeur dans le portail
|
||||
"""
|
||||
|
||||
@http.route(['/my/vendor/request/new'], type='http', auth="user", website=True)
|
||||
def vendor_request_form(self, **kw):
|
||||
"""
|
||||
Affiche le formulaire de demande pour devenir vendeur
|
||||
"""
|
||||
user = request.env.user
|
||||
partner = user.partner_id
|
||||
|
||||
# Vérifier que l'utilisateur n'est pas déjà un vendeur
|
||||
if hasattr(partner, 'is_vendor') and partner.is_vendor:
|
||||
return request.redirect('/my/home')
|
||||
|
||||
# Vérifier qu'il n'y a pas déjà une demande en attente
|
||||
if hasattr(partner, 'has_pending_vendor_request') and partner.has_pending_vendor_request:
|
||||
return request.redirect('/my/vendor/requests')
|
||||
|
||||
# Récupérer les pays et états autorisés selon la configuration
|
||||
config_settings = request.env['res.config.settings'].sudo()
|
||||
countries = config_settings.get_allowed_countries()
|
||||
states = config_settings.get_allowed_states()
|
||||
|
||||
# Déterminer le pays par défaut (Canada si dispo)
|
||||
default_country = None
|
||||
for country in countries:
|
||||
if country.code == 'CA':
|
||||
default_country = country
|
||||
break
|
||||
# Si l'utilisateur a déjà sélectionné un pays, le garder, sinon mettre Canada par défaut
|
||||
company_country_id = int(kw.get('company_country_id')) if kw.get('company_country_id') and str(kw.get('company_country_id')).isdigit() else (default_country.id if default_country else False)
|
||||
company_state_id = int(kw.get('company_state_id')) if kw.get('company_state_id') and str(kw.get('company_state_id')).isdigit() else False
|
||||
# Préremplir les champs société avec le parent si présent
|
||||
parent = partner.parent_id or partner.commercial_partner_id if partner.commercial_partner_id != partner else None
|
||||
if parent:
|
||||
if not kw.get('company_name'):
|
||||
kw['company_name'] = parent.name
|
||||
if not kw.get('company_street'):
|
||||
kw['company_street'] = parent.street
|
||||
if not kw.get('company_street2'):
|
||||
kw['company_street2'] = parent.street2
|
||||
if not kw.get('company_zip'):
|
||||
kw['company_zip'] = parent.zip
|
||||
if not kw.get('company_city'):
|
||||
kw['company_city'] = parent.city
|
||||
if not kw.get('company_state_id'):
|
||||
kw['company_state_id'] = parent.state_id.id if parent.state_id else False
|
||||
if not kw.get('company_country_id'):
|
||||
kw['company_country_id'] = parent.country_id.id if parent.country_id else False
|
||||
if not kw.get('company_email'):
|
||||
kw['company_email'] = parent.email
|
||||
if not kw.get('company_phone'):
|
||||
kw['company_phone'] = parent.phone
|
||||
if not kw.get('company_website'):
|
||||
kw['company_website'] = parent.website
|
||||
if not kw.get('company_vat'):
|
||||
kw['company_vat'] = parent.vat
|
||||
values = {
|
||||
'page_name': 'vendor_request_new',
|
||||
'countries': countries,
|
||||
'states': states,
|
||||
'partner': partner,
|
||||
'company_country_id': company_country_id,
|
||||
'company_state_id': company_state_id,
|
||||
'error': kw.get('error'),
|
||||
'error_message': kw.get('error_message'),
|
||||
# Champs préremplis
|
||||
'company_name': kw.get('company_name'),
|
||||
'company_street': kw.get('company_street'),
|
||||
'company_street2': kw.get('company_street2'),
|
||||
'company_zip': kw.get('company_zip'),
|
||||
'company_city': kw.get('company_city'),
|
||||
'company_state_id': kw.get('company_state_id'),
|
||||
'company_country_id': kw.get('company_country_id'),
|
||||
'company_email': kw.get('company_email'),
|
||||
'company_phone': kw.get('company_phone'),
|
||||
'company_website': kw.get('company_website'),
|
||||
'company_vat': kw.get('company_vat'),
|
||||
'description': kw.get('description'),
|
||||
}
|
||||
return request.render("st_laurent_portal_vendor.portal_vendor_request_form", values)
|
||||
|
||||
@http.route(['/my/vendor/request/submit'], type='http', auth="user", website=True, methods=['POST'], csrf=True)
|
||||
def vendor_request_submit(self, **kw):
|
||||
"""
|
||||
Traite la soumission du formulaire de demande pour devenir vendeur
|
||||
"""
|
||||
user = request.env.user
|
||||
partner = user.partner_id
|
||||
|
||||
# Vérifier que l'utilisateur n'est pas déjà un vendeur
|
||||
if hasattr(partner, 'is_vendor') and partner.is_vendor:
|
||||
return request.redirect('/my/home')
|
||||
|
||||
# Vérifier qu'il n'y a pas déjà une demande en attente
|
||||
if hasattr(partner, 'has_pending_vendor_request') and partner.has_pending_vendor_request:
|
||||
return request.redirect('/my/vendor/requests')
|
||||
|
||||
# Valider les données du formulaire
|
||||
if not kw.get('company_name'):
|
||||
return self.vendor_request_form(error="missing", error_message=_("Le nom de l'entreprise est obligatoire."))
|
||||
|
||||
# Créer la demande
|
||||
try:
|
||||
vals = {
|
||||
'company_name': kw.get('company_name', ''),
|
||||
'company_street': kw.get('company_street', ''),
|
||||
'company_street2': kw.get('company_street2', ''),
|
||||
'company_zip': kw.get('company_zip', ''),
|
||||
'company_city': kw.get('company_city', ''),
|
||||
'company_state_id': int(kw.get('company_state_id', '0')) if kw.get('company_state_id') and str(kw.get('company_state_id')).isdigit() else False,
|
||||
'company_country_id': int(kw.get('company_country_id', '0')) if kw.get('company_country_id') and str(kw.get('company_country_id')).isdigit() else False,
|
||||
'company_email': kw.get('company_email', ''),
|
||||
'company_phone': kw.get('company_phone', ''),
|
||||
'company_website': kw.get('company_website', ''),
|
||||
'company_vat': kw.get('company_vat', ''),
|
||||
'description': kw.get('description', ''),
|
||||
}
|
||||
|
||||
vendor_request = request.env['vendor.request'].sudo().create(vals)
|
||||
|
||||
# Soumettre la demande
|
||||
vendor_request.action_submit()
|
||||
|
||||
return request.redirect('/my/vendor/requests')
|
||||
|
||||
except Exception as e:
|
||||
return self.vendor_request_form(error="error", error_message=str(e))
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
"""
|
||||
Ajoute le compteur de demandes de vendeur aux valeurs du portail
|
||||
"""
|
||||
values = super(VendorRequestPortal, self)._prepare_home_portal_values(counters)
|
||||
|
||||
if 'vendor_request_count' in counters:
|
||||
partner = request.env.user.partner_id
|
||||
vendor_request_count = request.env['vendor.request'].sudo().search_count([
|
||||
('partner_id', '=', partner.id)
|
||||
])
|
||||
values['vendor_request_count'] = vendor_request_count
|
||||
|
||||
return values
|
||||
|
||||
@http.route(['/my/vendor/requests'], type='http', auth="user", website=True)
|
||||
def vendor_requests(self, page=1, date_begin=None, date_end=None, sortby=None, **kw):
|
||||
"""
|
||||
Affiche la liste des demandes de vendeur de l'utilisateur
|
||||
"""
|
||||
values = self._prepare_portal_layout_values()
|
||||
partner = request.env.user.partner_id
|
||||
VendorRequest = request.env['vendor.request'].sudo()
|
||||
|
||||
# Domaine de recherche
|
||||
domain = [('partner_id', '=', partner.id)]
|
||||
|
||||
# Tri par défaut
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
sort_order = 'create_date desc'
|
||||
|
||||
# Comptage pour la pagination
|
||||
request_count = VendorRequest.search_count(domain)
|
||||
|
||||
# Pager
|
||||
pager = portal_pager(
|
||||
url="/my/vendor/requests",
|
||||
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby},
|
||||
total=request_count,
|
||||
page=page,
|
||||
step=self._items_per_page
|
||||
)
|
||||
|
||||
# Récupérer les demandes avec pagination
|
||||
vendor_requests = VendorRequest.search(
|
||||
domain,
|
||||
order=sort_order,
|
||||
limit=self._items_per_page,
|
||||
offset=pager['offset']
|
||||
)
|
||||
|
||||
values.update({
|
||||
'page_name': 'vendor_requests',
|
||||
'pager': pager,
|
||||
'vendor_requests': vendor_requests,
|
||||
'default_url': '/my/vendor/requests',
|
||||
})
|
||||
return request.render("st_laurent_portal_vendor.portal_vendor_requests", values)
|
||||
|
||||
@http.route(['/my/vendor/request/<int:request_id>'], type='http', auth="user", website=True)
|
||||
def vendor_request_detail(self, request_id, access_token=None, **kw):
|
||||
"""
|
||||
Affiche le détail d'une demande de vendeur
|
||||
"""
|
||||
try:
|
||||
# Utiliser la méthode standard de CustomerPortal pour vérifier l'accès
|
||||
vendor_request_sudo = self._document_check_access('vendor.request', request_id, access_token)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/vendor/requests')
|
||||
|
||||
# Préparer les valeurs pour le template
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'page_name': 'vendor_request_detail',
|
||||
'vendor_request': vendor_request_sudo,
|
||||
'default_url': f'/my/vendor/request/{request_id}',
|
||||
})
|
||||
|
||||
# Ajouter les valeurs pour la navigation entre demandes
|
||||
history = request.session.get('my_vendor_requests_history', [()])
|
||||
values.update(get_records_pager(history, vendor_request_sudo))
|
||||
|
||||
return request.render("st_laurent_portal_vendor.portal_vendor_request_detail", values)
|
||||
|
||||
@http.route(['/my/vendor/request/<int:request_id>/edit'], type='http', auth="user", website=True)
|
||||
def vendor_request_edit(self, request_id, access_token=None, **kw):
|
||||
"""
|
||||
Affiche le formulaire d'édition d'une demande de vendeur
|
||||
"""
|
||||
try:
|
||||
# Utiliser la méthode standard de CustomerPortal pour vérifier l'accès
|
||||
vendor_request_sudo = self._document_check_access('vendor.request', request_id, access_token)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/vendor/requests')
|
||||
|
||||
# Vérifier que la demande est en état 'pending' (en attente)
|
||||
if hasattr(vendor_request_sudo, 'state') and vendor_request_sudo.state != 'pending':
|
||||
return request.redirect(f'/my/vendor/request/{request_id}')
|
||||
|
||||
# Récupérer les pays et états autorisés selon la configuration
|
||||
config_settings = request.env['res.config.settings'].sudo()
|
||||
countries = config_settings.get_allowed_countries()
|
||||
states = config_settings.get_allowed_states()
|
||||
|
||||
# Préparer les valeurs pour le template
|
||||
values = self._prepare_portal_layout_values()
|
||||
values.update({
|
||||
'page_name': 'vendor_request_edit',
|
||||
'vendor_request': vendor_request_sudo,
|
||||
'countries': countries,
|
||||
'states': states,
|
||||
'error': kw.get('error'),
|
||||
'error_message': kw.get('error_message'),
|
||||
})
|
||||
|
||||
return request.render("st_laurent_portal_vendor.portal_vendor_request_edit", values)
|
||||
|
||||
@http.route(['/my/vendor/request/<int:request_id>/update'], type='http', auth="user", website=True, methods=['POST'], csrf=True)
|
||||
def vendor_request_update(self, request_id, **kw):
|
||||
"""
|
||||
Traite la mise à jour d'une demande de vendeur
|
||||
"""
|
||||
try:
|
||||
# Utiliser la méthode standard de CustomerPortal pour vérifier l'accès
|
||||
vendor_request_sudo = self._document_check_access('vendor.request', request_id)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/vendor/requests')
|
||||
|
||||
# Vérifier que la demande est en état 'pending' (en attente)
|
||||
if hasattr(vendor_request_sudo, 'state') and vendor_request_sudo.state != 'pending':
|
||||
return request.redirect(f'/my/vendor/request/{request_id}')
|
||||
|
||||
# Valider les données du formulaire
|
||||
if not kw.get('company_name'):
|
||||
return self.vendor_request_edit(request_id, error="missing", error_message="Le nom de l'entreprise est obligatoire.")
|
||||
|
||||
# Mettre à jour la demande
|
||||
try:
|
||||
vals = {
|
||||
'company_name': kw.get('company_name', ''),
|
||||
'company_street': kw.get('company_street', ''),
|
||||
'company_street2': kw.get('company_street2', ''),
|
||||
'company_zip': kw.get('company_zip', ''),
|
||||
'company_city': kw.get('company_city', ''),
|
||||
'company_state_id': int(kw.get('company_state_id', '0')) if kw.get('company_state_id') and str(kw.get('company_state_id')).isdigit() else False,
|
||||
'company_country_id': int(kw.get('company_country_id', '0')) if kw.get('company_country_id') and str(kw.get('company_country_id')).isdigit() else False,
|
||||
'company_email': kw.get('company_email', ''),
|
||||
'company_phone': kw.get('company_phone', ''),
|
||||
'company_website': kw.get('company_website', ''),
|
||||
'company_vat': kw.get('company_vat', ''),
|
||||
'description': kw.get('description', ''),
|
||||
}
|
||||
|
||||
vendor_request_sudo.write(vals)
|
||||
|
||||
# Soumettre à nouveau la demande si nécessaire
|
||||
if kw.get('submit', False) and hasattr(vendor_request_sudo, 'action_submit'):
|
||||
vendor_request_sudo.action_submit()
|
||||
|
||||
return request.redirect(f'/my/vendor/request/{request_id}')
|
||||
|
||||
except Exception as e:
|
||||
return self.vendor_request_edit(request_id, error="error", error_message=str(e))
|
||||
|
||||
|
||||
class VendorProductEcommercePortal(http.Controller):
|
||||
"""
|
||||
Contrôleur pour gérer les fonctionnalités e-commerce du portail vendeur
|
||||
"""
|
||||
|
||||
@http.route(['/my/vendor'], type='http', auth="user", website=True)
|
||||
def vendor_portal_home(self, **kw):
|
||||
"""
|
||||
Affiche la page d'accueil de l'espace vendeur
|
||||
"""
|
||||
# Vérifier que l'utilisateur est un vendeur
|
||||
partner = request.env.user.partner_id
|
||||
if not partner.is_vendor:
|
||||
return request.redirect('/my/home')
|
||||
|
||||
# Récupérer les produits du vendeur
|
||||
vendor_products = request.env['vendor.product'].sudo().search(
|
||||
[('partner_id', '=', partner.commercial_partner_id.id)]
|
||||
)
|
||||
|
||||
values = {
|
||||
'page_name': 'vendor_home',
|
||||
'vendor_products': vendor_products,
|
||||
}
|
||||
|
||||
return request.render("st_laurent_portal_vendor.portal_vendor_home", values)
|
||||
|
||||
@http.route(['/my/products/<model("vendor.product"):product_id>/image'], type='http', auth="user", website=True)
|
||||
def vendor_product_image_form(self, product_id=None, **kw):
|
||||
"""
|
||||
Affiche le formulaire d'upload d'image pour un produit vendeur
|
||||
"""
|
||||
try:
|
||||
if not product_id:
|
||||
return request.redirect('/my/products')
|
||||
|
||||
product_sudo = product_id.sudo()
|
||||
# Vérifier que l'utilisateur a accès à ce produit
|
||||
if product_sudo.partner_id.id != request.env.user.partner_id.commercial_partner_id.id:
|
||||
return request.redirect('/my/products')
|
||||
|
||||
values = {
|
||||
'vendor_product': product_sudo,
|
||||
'page_name': _('Upload Product Image'),
|
||||
'error': kw.get('error'),
|
||||
'success': kw.get('success'),
|
||||
}
|
||||
return request.render("st_laurent_portal_vendor.vendor_product_image_form", values)
|
||||
except AccessError:
|
||||
return request.redirect('/my/products')
|
||||
|
||||
@http.route(['/my/products/update_image'], type='http', auth="user", website=True, methods=['POST'], csrf=True)
|
||||
def vendor_product_update_image(self, **kw):
|
||||
"""
|
||||
Traite l'upload d'image pour un produit vendeur
|
||||
"""
|
||||
product_id = kw.get('product_id')
|
||||
if not product_id:
|
||||
return request.redirect('/my/products')
|
||||
|
||||
try:
|
||||
product = request.env['vendor.product'].sudo().browse(int(product_id))
|
||||
# Vérifier que l'utilisateur a accès à ce produit
|
||||
if product.partner_id.id != request.env.user.partner_id.commercial_partner_id.id:
|
||||
return request.redirect('/my/products')
|
||||
|
||||
# Vérifier si une image recadrée a été fournie
|
||||
cropped_image = kw.get('cropped_image')
|
||||
if cropped_image and cropped_image.startswith('data:image/'):
|
||||
# Traiter l'image recadrée (format base64 data URL)
|
||||
try:
|
||||
# Extraire les données base64 de l'URL data
|
||||
image_format, image_data = cropped_image.split(';base64,')
|
||||
image_data = base64.b64decode(image_data)
|
||||
|
||||
# Mettre à jour l'image du produit
|
||||
product.write({
|
||||
'image_1920': base64.b64encode(image_data),
|
||||
'website_published': True, # Publier automatiquement le produit
|
||||
'success': _("L'image recadrée a été mise à jour avec succès.")
|
||||
})
|
||||
|
||||
# Rediriger vers la page du produit
|
||||
return request.redirect('/my/products/%s' % product.id)
|
||||
|
||||
except Exception as e:
|
||||
return self.vendor_product_image_form(
|
||||
product_id=product,
|
||||
error=_("Une erreur est survenue lors du traitement de l'image recadrée: %s") % str(e)
|
||||
)
|
||||
|
||||
# Si pas d'image recadrée, utiliser l'image uploadée normalement
|
||||
image_data = kw.get('product_image')
|
||||
if not image_data and not cropped_image:
|
||||
return self.vendor_product_image_form(product_id=product, error=_("Aucune image n'a été fournie."))
|
||||
|
||||
# Traiter l'image normale
|
||||
if image_data:
|
||||
try:
|
||||
image_data = image_data.read()
|
||||
if len(image_data) > 5 * 1024 * 1024: # 5 MB max
|
||||
return self.vendor_product_image_form(
|
||||
product_id=product,
|
||||
error=_("L'image est trop volumineuse. La taille maximale est de 5 Mo.")
|
||||
)
|
||||
|
||||
# Mettre à jour l'image du produit
|
||||
product.write({
|
||||
'image_1920': base64.b64encode(image_data),
|
||||
'website_published': True, # Publier automatiquement le produit
|
||||
'success': _("L'image a été mise à jour avec succès.")
|
||||
})
|
||||
|
||||
# Rediriger vers la page du produit
|
||||
return request.redirect('/my/products/%s' % product.id)
|
||||
|
||||
except Exception as e:
|
||||
return self.vendor_product_image_form(
|
||||
product_id=product,
|
||||
error=_("Une erreur est survenue lors du traitement de l'image: %s") % str(e)
|
||||
)
|
||||
|
||||
# Si on arrive ici, c'est qu'il y a eu un problème
|
||||
return self.vendor_product_image_form(
|
||||
product_id=product,
|
||||
error=_("Aucune image valide n'a été fournie.")
|
||||
)
|
||||
|
||||
except (AccessError, ValidationError) as e:
|
||||
return request.redirect('/my/products')
|
||||
except Exception as e:
|
||||
return request.redirect('/my/products')
|
||||
95
st_laurent_portal_vendor/controllers/vendor_categories.py
Normal file
95
st_laurent_portal_vendor/controllers/vendor_categories.py
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import base64
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
|
||||
class VendorProductCategoriesController(http.Controller):
|
||||
"""
|
||||
Contrôleur pour gérer les catégories et tags des produits vendeur
|
||||
"""
|
||||
|
||||
@http.route(['/my/products/<model("vendor.product"):product_id>/categories'], type='http', auth="user", website=True)
|
||||
def vendor_product_categories_form(self, product_id=None, **kw):
|
||||
"""
|
||||
Affiche le formulaire de gestion des catégories et tags pour un produit vendeur
|
||||
"""
|
||||
try:
|
||||
if not product_id:
|
||||
return request.redirect('/my/products')
|
||||
|
||||
product_sudo = product_id.sudo()
|
||||
# Vérifier que l'utilisateur a accès à ce produit
|
||||
if product_sudo.partner_id.id != request.env.user.partner_id.commercial_partner_id.id:
|
||||
return request.redirect('/my/products')
|
||||
|
||||
# Récupérer toutes les catégories et tags disponibles
|
||||
categories = request.env['product.public.category'].sudo().search([])
|
||||
tags = request.env['product.tag'].sudo().search([])
|
||||
|
||||
# Récupérer les catégories et tags sélectionnés pour ce produit
|
||||
selected_category_ids = product_sudo.public_categ_ids.ids
|
||||
selected_tag_ids = product_sudo.product_tag_ids.ids
|
||||
|
||||
values = {
|
||||
'vendor_product': product_sudo,
|
||||
'page_name': _('Gérer les catégories et tags'),
|
||||
'categories': categories,
|
||||
'tags': tags,
|
||||
'selected_category_ids': selected_category_ids,
|
||||
'selected_tag_ids': selected_tag_ids,
|
||||
'error': kw.get('error'),
|
||||
'success': kw.get('success'),
|
||||
}
|
||||
return request.render("st_laurent_portal_vendor.vendor_product_categories_form", values)
|
||||
except AccessError:
|
||||
return request.redirect('/my/products')
|
||||
|
||||
@http.route(['/my/products/update_categories'], type='http', auth="user", website=True, methods=['POST'], csrf=True)
|
||||
def vendor_product_update_categories(self, **kw):
|
||||
"""
|
||||
Traite la mise à jour des catégories et tags pour un produit vendeur
|
||||
"""
|
||||
product_id = kw.get('product_id')
|
||||
if not product_id:
|
||||
return request.redirect('/my/products')
|
||||
|
||||
try:
|
||||
product = request.env['vendor.product'].sudo().browse(int(product_id))
|
||||
# Vérifier que l'utilisateur a accès à ce produit
|
||||
if product.partner_id.id != request.env.user.partner_id.commercial_partner_id.id:
|
||||
return request.redirect('/my/products')
|
||||
|
||||
# Récupérer les catégories et tags sélectionnés
|
||||
category_ids = request.httprequest.form.getlist('category_ids')
|
||||
tag_ids = request.httprequest.form.getlist('tag_ids')
|
||||
|
||||
# Si les valeurs ne sont pas des listes, les convertir
|
||||
if not isinstance(category_ids, list):
|
||||
category_ids = [category_ids] if category_ids else []
|
||||
if not isinstance(tag_ids, list):
|
||||
tag_ids = [tag_ids] if tag_ids else []
|
||||
|
||||
# Convertir en entiers
|
||||
category_ids = [int(id) for id in category_ids if id and str(id).isdigit()]
|
||||
tag_ids = [int(id) for id in tag_ids if id and str(id).isdigit()]
|
||||
|
||||
# Mettre à jour les catégories et tags du produit
|
||||
product.write({
|
||||
'public_categ_ids': [(6, 0, category_ids)],
|
||||
'product_tag_ids': [(6, 0, tag_ids)],
|
||||
})
|
||||
|
||||
# Rediriger vers la page du produit avec un message de succès
|
||||
return request.redirect('/my/products/%s?success=%s' % (
|
||||
product.id,
|
||||
_('Les catégories et tags ont été mis à jour avec succès.')
|
||||
))
|
||||
|
||||
except Exception as e:
|
||||
# En cas d'erreur, rediriger vers le formulaire avec un message d'erreur
|
||||
return request.redirect('/my/products/%s/categories?error=%s' % (
|
||||
product_id,
|
||||
_("Une erreur est survenue lors de la mise à jour des catégories et tags: %s") % str(e)
|
||||
))
|
||||
218
st_laurent_portal_vendor/controllers/vendor_portal.py
Normal file
218
st_laurent_portal_vendor/controllers/vendor_portal.py
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import base64
|
||||
import werkzeug
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
|
||||
|
||||
class VendorPortalController(http.Controller):
|
||||
"""
|
||||
Contrôleur pour gérer les fonctionnalités du portail vendeur
|
||||
"""
|
||||
|
||||
@http.route(['/my/vendor'], type='http', auth="user", website=True)
|
||||
def vendor_portal_home(self, **kw):
|
||||
"""
|
||||
Page d'accueil du portail vendeur
|
||||
"""
|
||||
# Vérifier si l'utilisateur est un vendeur
|
||||
if not request.env.user.is_vendor:
|
||||
return request.redirect('/my')
|
||||
|
||||
# Récupérer les produits du vendeur
|
||||
vendor_products = request.env['vendor.product'].sudo().search([
|
||||
('partner_id', '=', request.env.user.partner_id.commercial_partner_id.id)
|
||||
])
|
||||
|
||||
values = {
|
||||
'page_name': _('Portail Vendeur'),
|
||||
'vendor_products': vendor_products,
|
||||
}
|
||||
return request.render("st_laurent_portal_vendor.vendor_portal_home", values)
|
||||
|
||||
@http.route(['/my/vendor/requests'], type='http', auth="user", website=True)
|
||||
def vendor_requests(self, **kw):
|
||||
"""
|
||||
Liste des demandes de vendeur de l'utilisateur
|
||||
"""
|
||||
# Récupérer les demandes de l'utilisateur
|
||||
requests = request.env['vendor.request'].sudo().search([
|
||||
('user_id', '=', request.env.user.id)
|
||||
])
|
||||
|
||||
values = {
|
||||
'page_name': _('Mes demandes de vendeur'),
|
||||
'requests': requests,
|
||||
}
|
||||
return request.render("st_laurent_portal_vendor.portal_my_vendor_requests", values)
|
||||
|
||||
@http.route(['/my/vendor/request/new'], type='http', auth="user", website=True)
|
||||
def vendor_request_new(self, **kw):
|
||||
"""
|
||||
Formulaire de création d'une nouvelle demande de vendeur
|
||||
"""
|
||||
# Vérifier si l'utilisateur est déjà un vendeur
|
||||
if request.env.user.is_vendor:
|
||||
return request.redirect('/my')
|
||||
|
||||
# Vérifier s'il y a déjà une demande en attente
|
||||
pending_request = request.env['vendor.request'].sudo().search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
('state', 'in', ['pending', 'approved'])
|
||||
], limit=1)
|
||||
|
||||
if pending_request:
|
||||
return request.redirect('/my/vendor/request/%s' % pending_request.id)
|
||||
|
||||
# Récupérer le brouillon existant ou en créer un nouveau
|
||||
draft_request = request.env['vendor.request'].sudo().search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
('state', '=', 'draft')
|
||||
], limit=1)
|
||||
|
||||
values = {
|
||||
'page_name': _('Nouvelle demande de vendeur'),
|
||||
'vendor_request': draft_request,
|
||||
'error': kw.get('error'),
|
||||
}
|
||||
return request.render("st_laurent_portal_vendor.portal_vendor_request_form", values)
|
||||
|
||||
@http.route(['/my/vendor/request/edit/<int:request_id>'], type='http', auth="user", website=True)
|
||||
def vendor_request_edit(self, request_id, **kw):
|
||||
"""
|
||||
Formulaire d'édition d'une demande de vendeur existante
|
||||
"""
|
||||
try:
|
||||
# Récupérer la demande
|
||||
vendor_request = request.env['vendor.request'].sudo().browse(request_id)
|
||||
|
||||
# Vérifier que l'utilisateur a accès à cette demande
|
||||
if vendor_request.user_id.id != request.env.user.id:
|
||||
return request.redirect('/my')
|
||||
|
||||
# Vérifier que la demande est en brouillon
|
||||
if vendor_request.state != 'draft':
|
||||
return request.redirect('/my/vendor/request/%s' % vendor_request.id)
|
||||
|
||||
values = {
|
||||
'page_name': _('Modifier ma demande de vendeur'),
|
||||
'vendor_request': vendor_request,
|
||||
'error': kw.get('error'),
|
||||
}
|
||||
return request.render("st_laurent_portal_vendor.portal_vendor_request_form", values)
|
||||
except Exception as e:
|
||||
return request.redirect('/my')
|
||||
|
||||
@http.route(['/my/vendor/request/<int:request_id>'], type='http', auth="user", website=True)
|
||||
def vendor_request_detail(self, request_id, **kw):
|
||||
"""
|
||||
Vue détaillée d'une demande de vendeur
|
||||
"""
|
||||
try:
|
||||
# Récupérer la demande
|
||||
vendor_request = request.env['vendor.request'].sudo().browse(request_id)
|
||||
|
||||
# Vérifier que l'utilisateur a accès à cette demande
|
||||
if vendor_request.user_id.id != request.env.user.id:
|
||||
return request.redirect('/my')
|
||||
|
||||
values = {
|
||||
'page_name': _('Demande de vendeur'),
|
||||
'vendor_request': vendor_request,
|
||||
}
|
||||
return request.render("st_laurent_portal_vendor.portal_vendor_request_details", values)
|
||||
except Exception as e:
|
||||
return request.redirect('/my')
|
||||
|
||||
@http.route(['/my/vendor/request/submit'], type='http', auth="user", website=True, methods=['POST'], csrf=True)
|
||||
def vendor_request_submit(self, **kw):
|
||||
"""
|
||||
Traite la soumission d'une demande de vendeur
|
||||
"""
|
||||
# Récupérer les données du formulaire
|
||||
request_id = kw.get('request_id')
|
||||
company_name = kw.get('company_name')
|
||||
description = kw.get('description')
|
||||
attachments = request.httprequest.files.getlist('attachments')
|
||||
|
||||
if not company_name or not description:
|
||||
return request.redirect('/my/vendor/request/new?error=%s' % _("Tous les champs sont obligatoires."))
|
||||
|
||||
try:
|
||||
VendorRequest = request.env['vendor.request'].sudo()
|
||||
|
||||
# Créer ou mettre à jour la demande
|
||||
if request_id and request_id.isdigit():
|
||||
vendor_request = VendorRequest.browse(int(request_id))
|
||||
# Vérifier que l'utilisateur a accès à cette demande
|
||||
if vendor_request.user_id.id != request.env.user.id:
|
||||
return request.redirect('/my')
|
||||
|
||||
# Mettre à jour la demande
|
||||
vendor_request.write({
|
||||
'company_name': company_name,
|
||||
'description': description,
|
||||
})
|
||||
else:
|
||||
# Créer une nouvelle demande
|
||||
vendor_request = VendorRequest.create({
|
||||
'user_id': request.env.user.id,
|
||||
'company_name': company_name,
|
||||
'description': description,
|
||||
})
|
||||
|
||||
# Traiter les pièces jointes
|
||||
attachment_ids = []
|
||||
for attachment in attachments:
|
||||
if attachment.filename:
|
||||
attachment_data = {
|
||||
'name': attachment.filename,
|
||||
'datas': base64.b64encode(attachment.read()),
|
||||
'res_model': 'vendor.request',
|
||||
'res_id': vendor_request.id,
|
||||
}
|
||||
new_attachment = request.env['ir.attachment'].sudo().create(attachment_data)
|
||||
attachment_ids.append(new_attachment.id)
|
||||
|
||||
if attachment_ids:
|
||||
vendor_request.write({
|
||||
'attachment_ids': [(4, id) for id in attachment_ids]
|
||||
})
|
||||
|
||||
# Soumettre la demande
|
||||
vendor_request.action_submit()
|
||||
|
||||
return request.redirect('/my/vendor/request/%s' % vendor_request.id)
|
||||
except ValidationError as e:
|
||||
return request.redirect('/my/vendor/request/new?error=%s' % e)
|
||||
except Exception as e:
|
||||
return request.redirect('/my/vendor/request/new?error=%s' % _("Une erreur est survenue lors de la soumission de votre demande."))
|
||||
|
||||
@http.route(['/my/vendor/request/submit/<int:request_id>'], type='http', auth="user", website=True)
|
||||
def vendor_request_submit_direct(self, request_id, **kw):
|
||||
"""
|
||||
Soumet directement une demande de vendeur existante
|
||||
"""
|
||||
try:
|
||||
# Récupérer la demande
|
||||
vendor_request = request.env['vendor.request'].sudo().browse(request_id)
|
||||
|
||||
# Vérifier que l'utilisateur a accès à cette demande
|
||||
if vendor_request.user_id.id != request.env.user.id:
|
||||
return request.redirect('/my')
|
||||
|
||||
# Vérifier que la demande est en brouillon
|
||||
if vendor_request.state != 'draft':
|
||||
return request.redirect('/my/vendor/request/%s' % vendor_request.id)
|
||||
|
||||
# Soumettre la demande
|
||||
vendor_request.action_submit()
|
||||
|
||||
return request.redirect('/my/vendor/request/%s' % vendor_request.id)
|
||||
except ValidationError as e:
|
||||
return request.redirect('/my/vendor/request/%s?error=%s' % (request_id, e))
|
||||
except Exception as e:
|
||||
return request.redirect('/my/vendor/request/%s?error=%s' % (request_id, _("Une erreur est survenue lors de la soumission de votre demande.")))
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
class VendorShopController(http.Controller):
|
||||
@http.route(['/shop/<string:shop_slug>'], type='http', auth='public', website=True)
|
||||
def vendor_shop(self, shop_slug, **kwargs):
|
||||
# Extract the ID from the slug if it's in the format 'name-id'
|
||||
slug_parts = shop_slug.rsplit('-', 1)
|
||||
if len(slug_parts) == 2 and slug_parts[1].isdigit():
|
||||
shop_id = int(slug_parts[1])
|
||||
shop = request.env['vendor.shop'].sudo().browse(shop_id)
|
||||
if not shop.exists():
|
||||
shop = None
|
||||
else:
|
||||
# Fallback to searching by the full slug
|
||||
shop = request.env['vendor.shop'].sudo().search([('slug', '=', shop_slug)], limit=1)
|
||||
if not shop:
|
||||
return request.not_found()
|
||||
# Retrieve the products linked to the shop
|
||||
products = request.env['product.template'].sudo().search([
|
||||
('vendor_shop_id', '=', shop.id),
|
||||
('sale_ok', '=', True),
|
||||
('website_published', '=', True),
|
||||
])
|
||||
values = {
|
||||
'shop': shop,
|
||||
'products': products,
|
||||
}
|
||||
return request.render('st_laurent_portal_vendor.vendor_shop_page', values)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="mail_template_vendor_request_ack" model="mail.template">
|
||||
<field name="name">Accusé réception demande vendeur</field>
|
||||
<field name="model_id" ref="st_laurent_portal_vendor.model_vendor_request"/>
|
||||
<field name="subject">Votre demande pour devenir vendeur a bien été reçue</field>
|
||||
<field name="email_from">${(user.email or 'noreply@%s' % (object.company_partner_id and object.company_partner_id.name or 'st-laurent.quebec'))}</field>
|
||||
<field name="email_to">${object.partner_id.email}</field>
|
||||
<field name="body_html"><![CDATA[
|
||||
<p>Bonjour ${object.partner_id.name},</p>
|
||||
<p>Nous avons bien reçu votre demande pour devenir vendeur sur la plateforme St-Laurent. Notre équipe va l'examiner dans les plus brefs délais.</p>
|
||||
<p>Vous pouvez suivre l'état de votre demande depuis votre espace personnel.</p>
|
||||
<p>L'équipe St-Laurent</p>
|
||||
]]></field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="mail_template_vendor_request_approved" model="mail.template">
|
||||
<field name="name">Demande vendeur approuvée</field>
|
||||
<field name="model_id" ref="st_laurent_portal_vendor.model_vendor_request"/>
|
||||
<field name="subject">Votre demande pour devenir vendeur a été approuvée</field>
|
||||
<field name="email_from">${(user.email or 'noreply@%s' % (object.company_partner_id and object.company_partner_id.name or 'st-laurent.quebec'))}</field>
|
||||
<field name="email_to">${object.partner_id.email}</field>
|
||||
<field name="body_html"><![CDATA[
|
||||
<p>Bonjour ${object.partner_id.name},</p>
|
||||
<p>Votre demande pour devenir vendeur a été <b>approuvée</b> sur la plateforme St-Laurent.</p>
|
||||
<p>Votre entreprise <b>${object.company_name}</b> a été créée et rattachée à votre profil.</p>
|
||||
<p>Vous pouvez désormais accéder à votre espace vendeur pour gérer vos produits et commandes.</p>
|
||||
<p><a href="/my/vendor/products" style="background:#2c3e50;color:#fff;padding:10px 14px;text-decoration:none;border-radius:4px;">Accéder à mon espace vendeur</a></p>
|
||||
<p>Bienvenue dans la fédération St-Laurent!</p>
|
||||
<p>L'équipe St-Laurent</p>
|
||||
]]></field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="mail_template_vendor_request_rejected" model="mail.template">
|
||||
<field name="name">Demande vendeur rejetée</field>
|
||||
<field name="model_id" ref="st_laurent_portal_vendor.model_vendor_request"/>
|
||||
<field name="subject">Votre demande pour devenir vendeur a été rejetée</field>
|
||||
<field name="email_from">${(user.email or 'noreply@%s' % (object.company_partner_id and object.company_partner_id.name or 'st-laurent.quebec'))}</field>
|
||||
<field name="email_to">${object.partner_id.email}</field>
|
||||
<field name="body_html"><![CDATA[
|
||||
<p>Bonjour ${object.partner_id.name},</p>
|
||||
<p>Nous sommes au regret de vous informer que votre demande pour devenir vendeur sur la plateforme St-Laurent a été <b>rejetée</b>.</p>
|
||||
<t t-if="object.rejection_reason">
|
||||
<p><b>Motif du rejet :</b> ${object.rejection_reason}</p>
|
||||
</t>
|
||||
<p>Pour toute question ou pour déposer une nouvelle demande, contactez notre équipe ou rendez-vous sur votre espace personnel.</p>
|
||||
<p>L'équipe St-Laurent</p>
|
||||
]]></field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
13
st_laurent_portal_vendor/data/vendor_request_sequence.xml
Normal file
13
st_laurent_portal_vendor/data/vendor_request_sequence.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Séquence pour les demandes de vendeur -->
|
||||
<record id="seq_vendor_request" model="ir.sequence">
|
||||
<field name="name">Séquence des demandes de vendeur</field>
|
||||
<field name="code">vendor.request</field>
|
||||
<field name="prefix">VR/%(year)s/</field>
|
||||
<field name="padding">4</field>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
244
st_laurent_portal_vendor/doc/Odoo-Enterprise.md
Normal file
244
st_laurent_portal_vendor/doc/Odoo-Enterprise.md
Normal file
|
|
@ -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
|
||||
228
st_laurent_portal_vendor/doc/Odoo-Federateur.md
Normal file
228
st_laurent_portal_vendor/doc/Odoo-Federateur.md
Normal file
|
|
@ -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
|
||||
204
st_laurent_portal_vendor/doc/Odoo-Local.md
Normal file
204
st_laurent_portal_vendor/doc/Odoo-Local.md
Normal file
|
|
@ -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
|
||||
115
st_laurent_portal_vendor/doc/Odoo-Module.md
Normal file
115
st_laurent_portal_vendor/doc/Odoo-Module.md
Normal file
|
|
@ -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
|
||||
278
st_laurent_portal_vendor/doc/analyse.md
Normal file
278
st_laurent_portal_vendor/doc/analyse.md
Normal file
|
|
@ -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é.
|
||||
|
|
@ -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.
|
||||
895
st_laurent_portal_vendor/doc/projet-st-laurent.md
Normal file
895
st_laurent_portal_vendor/doc/projet-st-laurent.md
Normal file
|
|
@ -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"""
|
||||
<p>Un nouveau vendeur s'est inscrit sur la plateforme St-Laurent:</p>
|
||||
<ul>
|
||||
<li>Entreprise: {partner.name}</li>
|
||||
<li>Contact: {user.name}</li>
|
||||
<li>Email: {user.login}</li>
|
||||
</ul>
|
||||
<p>Veuillez vérifier et approuver ce vendeur dans l'administration.</p>
|
||||
""",
|
||||
'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/<int:product_id>/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*
|
||||
8
st_laurent_portal_vendor/models/__init__.py
Normal file
8
st_laurent_portal_vendor/models/__init__.py
Normal file
|
|
@ -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
|
||||
95
st_laurent_portal_vendor/models/res_config_settings.py
Normal file
95
st_laurent_portal_vendor/models/res_config_settings.py
Normal file
|
|
@ -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)])
|
||||
92
st_laurent_portal_vendor/models/res_partner.py
Normal file
92
st_laurent_portal_vendor/models/res_partner.py
Normal file
|
|
@ -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
|
||||
43
st_laurent_portal_vendor/models/res_users.py
Normal file
43
st_laurent_portal_vendor/models/res_users.py
Normal file
|
|
@ -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()
|
||||
68
st_laurent_portal_vendor/models/vendor_config.py
Normal file
68
st_laurent_portal_vendor/models/vendor_config.py
Normal file
|
|
@ -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
|
||||
296
st_laurent_portal_vendor/models/vendor_product.py
Normal file
296
st_laurent_portal_vendor/models/vendor_product.py
Normal file
|
|
@ -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',
|
||||
}
|
||||
}
|
||||
259
st_laurent_portal_vendor/models/vendor_request.py
Normal file
259
st_laurent_portal_vendor/models/vendor_request.py
Normal file
|
|
@ -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
|
||||
34
st_laurent_portal_vendor/models/vendor_shop.py
Normal file
34
st_laurent_portal_vendor/models/vendor_shop.py
Normal file
|
|
@ -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')
|
||||
11
st_laurent_portal_vendor/security/ir.model.access.csv
Normal file
11
st_laurent_portal_vendor/security/ir.model.access.csv
Normal file
|
|
@ -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
|
||||
|
52
st_laurent_portal_vendor/security/security.xml
Normal file
52
st_laurent_portal_vendor/security/security.xml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Catégorie de sécurité pour les vendeurs -->
|
||||
<record id="module_category_vendor" model="ir.module.category">
|
||||
<field name="name">Vendeurs</field>
|
||||
<field name="description">Gestion des vendeurs et de leurs produits</field>
|
||||
<field name="sequence">20</field>
|
||||
</record>
|
||||
|
||||
<!-- Groupe pour les vendeurs -->
|
||||
<record id="group_vendor" model="res.groups">
|
||||
<field name="name">Vendeur</field>
|
||||
<field name="category_id" ref="module_category_vendor"/>
|
||||
<field name="implied_ids" eval="[(4, ref('base.group_portal'))]"/>
|
||||
</record>
|
||||
|
||||
<!-- Règle de sécurité pour les demandes de vendeur -->
|
||||
<record id="vendor_request_rule_user" model="ir.rule">
|
||||
<field name="name">Demandes de vendeur: utilisateur ne voit que ses propres demandes</field>
|
||||
<field name="model_id" ref="model_vendor_request"/>
|
||||
<field name="domain_force">[('user_id', '=', user.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
|
||||
<!-- Règle de sécurité pour les administrateurs -->
|
||||
<record id="vendor_request_rule_admin" model="ir.rule">
|
||||
<field name="name">Demandes de vendeur: administrateurs voient toutes les demandes</field>
|
||||
<field name="model_id" ref="model_vendor_request"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_system'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Règle de sécurité pour les produits vendeur -->
|
||||
<record id="vendor_product_rule_vendor" model="ir.rule">
|
||||
<field name="name">Produits vendeur: vendeur ne voit que ses propres produits</field>
|
||||
<field name="model_id" ref="vendor_product_management.model_vendor_product"/>
|
||||
<field name="domain_force">[('partner_id', '=', user.partner_id.commercial_partner_id.id)]</field>
|
||||
<field name="groups" eval="[(4, ref('group_vendor'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
BIN
st_laurent_portal_vendor/static/description/icon.png
Normal file
BIN
st_laurent_portal_vendor/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 71 KiB |
45
st_laurent_portal_vendor/static/description/icon.svg
Normal file
45
st_laurent_portal_vendor/static/description/icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 80 KiB |
9
st_laurent_portal_vendor/static/lib/cropperjs/cropper.min.css
vendored
Normal file
9
st_laurent_portal_vendor/static/lib/cropperjs/cropper.min.css
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
/*!
|
||||
* Cropper.js v1.5.13
|
||||
* https://fengyuanchen.github.io/cropperjs
|
||||
*
|
||||
* Copyright 2015-present Chen Fengyuan
|
||||
* Released under the MIT license
|
||||
*
|
||||
* Date: 2022-11-20T05:30:43.444Z
|
||||
*/.cropper-container{direction:ltr;font-size:0;line-height:0;position:relative;-ms-touch-action:none;touch-action:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.cropper-container img{-webkit-backface-visibility:hidden;backface-visibility:hidden;display:block;height:100%;image-orientation:0deg;max-height:none!important;max-width:none!important;min-height:0!important;min-width:0!important;width:100%}.cropper-canvas,.cropper-crop-box,.cropper-drag-box,.cropper-modal,.cropper-wrap-box{bottom:0;left:0;position:absolute;right:0;top:0}.cropper-canvas,.cropper-wrap-box{overflow:hidden}.cropper-drag-box{background-color:#fff;opacity:0}.cropper-modal{background-color:#000;opacity:.5}.cropper-view-box{display:block;height:100%;outline:1px solid #39f;outline-color:rgba(51,153,255,.75);overflow:hidden;width:100%}.cropper-dashed{border:0 dashed #eee;display:block;opacity:.5;position:absolute}.cropper-dashed.dashed-h{border-bottom-width:1px;border-top-width:1px;height:33.33333%;left:0;top:33.33333%;width:100%}.cropper-dashed.dashed-v{border-left-width:1px;border-right-width:1px;height:100%;left:33.33333%;top:0;width:33.33333%}.cropper-center{display:block;height:0;left:50%;opacity:.75;position:absolute;top:50%;width:0}.cropper-center:after,.cropper-center:before{background-color:#eee;content:" ";display:block;position:absolute}.cropper-center:before{height:1px;left:-3px;top:0;width:7px}.cropper-center:after{height:7px;left:0;top:-3px;width:1px}.cropper-face,.cropper-line,.cropper-point{display:block;height:100%;opacity:.1;position:absolute;width:100%}.cropper-face{background-color:#fff;left:0;top:0}.cropper-line{background-color:#39f}.cropper-line.line-e{cursor:ew-resize;right:-3px;top:0;width:5px}.cropper-line.line-n{cursor:ns-resize;height:5px;left:0;top:-3px}.cropper-line.line-w{cursor:ew-resize;left:-3px;top:0;width:5px}.cropper-line.line-s{bottom:-3px;cursor:ns-resize;height:5px;left:0}.cropper-point{background-color:#39f;height:5px;opacity:.75;width:5px}.cropper-point.point-e{cursor:ew-resize;margin-top:-3px;right:-3px;top:50%}.cropper-point.point-n{cursor:ns-resize;left:50%;margin-left:-3px;top:-3px}.cropper-point.point-w{cursor:ew-resize;left:-3px;margin-top:-3px;top:50%}.cropper-point.point-s{bottom:-3px;cursor:s-resize;left:50%;margin-left:-3px}.cropper-point.point-ne{cursor:nesw-resize;right:-3px;top:-3px}.cropper-point.point-nw{cursor:nwse-resize;left:-3px;top:-3px}.cropper-point.point-sw{bottom:-3px;cursor:nesw-resize;left:-3px}.cropper-point.point-se{bottom:-3px;cursor:nwse-resize;height:20px;opacity:1;right:-3px;width:20px}@media (min-width:768px){.cropper-point.point-se{height:15px;width:15px}}@media (min-width:992px){.cropper-point.point-se{height:10px;width:10px}}@media (min-width:1200px){.cropper-point.point-se{height:5px;opacity:.75;width:5px}}.cropper-point.point-se:before{background-color:#39f;bottom:-50%;content:" ";display:block;height:200%;opacity:0;position:absolute;right:-50%;width:200%}.cropper-invisible{opacity:0}.cropper-bg{background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC")}.cropper-hide{display:block;height:0;position:absolute;width:0}.cropper-hidden{display:none!important}.cropper-move{cursor:move}.cropper-crop{cursor:crosshair}.cropper-disabled .cropper-drag-box,.cropper-disabled .cropper-face,.cropper-disabled .cropper-line,.cropper-disabled .cropper-point{cursor:not-allowed}
|
||||
10
st_laurent_portal_vendor/static/lib/cropperjs/cropper.min.js
vendored
Normal file
10
st_laurent_portal_vendor/static/lib/cropperjs/cropper.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
48
st_laurent_portal_vendor/static/src/img/icon.svg
Normal file
48
st_laurent_portal_vendor/static/src/img/icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 80 KiB |
148
st_laurent_portal_vendor/static/src/js/image_cropper.js
Normal file
148
st_laurent_portal_vendor/static/src/js/image_cropper.js
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/* Cropper.js integration for vendor product images */
|
||||
odoo.define('st_laurent_portal_vendor.image_cropper', [], function () {
|
||||
'use strict';
|
||||
|
||||
// Initialisation du cropper d'image
|
||||
$(document).ready(function () {
|
||||
var $image = $('#image-preview');
|
||||
var $inputFile = $('#product_image');
|
||||
var $cropButton = $('#crop-button');
|
||||
var $cropperContainer = $('#cropper-container');
|
||||
var $imageContainer = $('#image-container');
|
||||
var $croppedImageInput = $('#cropped_image');
|
||||
var $cropperControls = $('.cropper-controls');
|
||||
var $aspectRatioButtons = $('.aspect-ratio-button');
|
||||
var $zoomInButton = $('#zoom-in');
|
||||
var $zoomOutButton = $('#zoom-out');
|
||||
var $rotateLeftButton = $('#rotate-left');
|
||||
var $rotateRightButton = $('#rotate-right');
|
||||
var $resetButton = $('#reset-cropper');
|
||||
var cropper;
|
||||
|
||||
// Fonction pour initialiser le cropper
|
||||
function initCropper() {
|
||||
if (cropper) {
|
||||
cropper.destroy();
|
||||
}
|
||||
|
||||
// Initialiser le cropper avec les options par défaut
|
||||
cropper = new Cropper($image[0], {
|
||||
aspectRatio: NaN, // Aspect ratio libre par défaut
|
||||
viewMode: 1, // Restreint la zone de recadrage à l'image
|
||||
autoCropArea: 0.8, // 80% de la zone de l'image
|
||||
responsive: true,
|
||||
restore: false,
|
||||
guides: true,
|
||||
center: true,
|
||||
highlight: true,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: true,
|
||||
ready: function() {
|
||||
$cropperControls.removeClass('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Gestion de l'upload d'image
|
||||
$inputFile.on('change', function (e) {
|
||||
var files = e.target.files;
|
||||
var done = function (url) {
|
||||
$inputFile.val('');
|
||||
$image.attr('src', url);
|
||||
$cropperContainer.removeClass('d-none');
|
||||
$imageContainer.addClass('d-none');
|
||||
$cropButton.removeClass('d-none');
|
||||
initCropper();
|
||||
};
|
||||
|
||||
if (files && files.length > 0) {
|
||||
var file = files[0];
|
||||
|
||||
// Vérifier le type de fichier
|
||||
if (!/^image\/(jpeg|png|gif)$/.test(file.type)) {
|
||||
alert('Veuillez sélectionner une image valide (JPG, PNG ou GIF).');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier la taille du fichier (max 5 Mo)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert("L'image est trop volumineuse. La taille maximale est de 5 Mo.");
|
||||
return;
|
||||
}
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
done(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
// Gestion du recadrage
|
||||
$cropButton.on('click', function () {
|
||||
if (!cropper) {
|
||||
return;
|
||||
}
|
||||
|
||||
var canvas = cropper.getCroppedCanvas({
|
||||
width: 1920,
|
||||
height: 1920,
|
||||
fillColor: '#fff',
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'high',
|
||||
});
|
||||
|
||||
if (canvas) {
|
||||
// Convertir le canvas en base64
|
||||
var croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
|
||||
$croppedImageInput.val(croppedImageData);
|
||||
|
||||
// Soumettre le formulaire
|
||||
$('#image-upload-form').submit();
|
||||
}
|
||||
});
|
||||
|
||||
// Gestion des boutons de ratio d'aspect
|
||||
$aspectRatioButtons.on('click', function() {
|
||||
$aspectRatioButtons.removeClass('active');
|
||||
$(this).addClass('active');
|
||||
|
||||
var ratio = $(this).data('ratio');
|
||||
if (ratio === 'free') {
|
||||
cropper.setAspectRatio(NaN);
|
||||
} else if (ratio === 'square') {
|
||||
cropper.setAspectRatio(1);
|
||||
} else if (ratio === '4:3') {
|
||||
cropper.setAspectRatio(4/3);
|
||||
} else if (ratio === '16:9') {
|
||||
cropper.setAspectRatio(16/9);
|
||||
}
|
||||
});
|
||||
|
||||
// Zoom in
|
||||
$zoomInButton.on('click', function() {
|
||||
cropper.zoom(0.1);
|
||||
});
|
||||
|
||||
// Zoom out
|
||||
$zoomOutButton.on('click', function() {
|
||||
cropper.zoom(-0.1);
|
||||
});
|
||||
|
||||
// Rotation gauche
|
||||
$rotateLeftButton.on('click', function() {
|
||||
cropper.rotate(-90);
|
||||
});
|
||||
|
||||
// Rotation droite
|
||||
$rotateRightButton.on('click', function() {
|
||||
cropper.rotate(90);
|
||||
});
|
||||
|
||||
// Reset
|
||||
$resetButton.on('click', function() {
|
||||
cropper.reset();
|
||||
});
|
||||
});
|
||||
});
|
||||
143
st_laurent_portal_vendor/static/src/js/image_cropper_simple.js
Normal file
143
st_laurent_portal_vendor/static/src/js/image_cropper_simple.js
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
/* Cropper.js integration for vendor product images */
|
||||
$(document).ready(function() {
|
||||
var $image = $('#image-preview');
|
||||
var $inputFile = $('#product_image');
|
||||
var $cropButton = $('#crop-button');
|
||||
var $cropperContainer = $('#cropper-container');
|
||||
var $imageContainer = $('#image-container');
|
||||
var $croppedImageInput = $('#cropped_image');
|
||||
var $cropperControls = $('.cropper-controls');
|
||||
var $aspectRatioButtons = $('.aspect-ratio-button');
|
||||
var $zoomInButton = $('#zoom-in');
|
||||
var $zoomOutButton = $('#zoom-out');
|
||||
var $rotateLeftButton = $('#rotate-left');
|
||||
var $rotateRightButton = $('#rotate-right');
|
||||
var $resetButton = $('#reset-cropper');
|
||||
var cropper;
|
||||
|
||||
// Fonction pour initialiser le cropper
|
||||
function initCropper() {
|
||||
if (cropper) {
|
||||
cropper.destroy();
|
||||
}
|
||||
|
||||
// Initialiser le cropper avec les options par défaut
|
||||
cropper = new Cropper($image[0], {
|
||||
aspectRatio: NaN, // Aspect ratio libre par défaut
|
||||
viewMode: 1, // Restreint la zone de recadrage à l'image
|
||||
autoCropArea: 0.8, // 80% de la zone de l'image
|
||||
responsive: true,
|
||||
restore: false,
|
||||
guides: true,
|
||||
center: true,
|
||||
highlight: true,
|
||||
cropBoxMovable: true,
|
||||
cropBoxResizable: true,
|
||||
toggleDragModeOnDblclick: true,
|
||||
ready: function() {
|
||||
$cropperControls.removeClass('d-none');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Gestion de l'upload d'image
|
||||
$inputFile.on('change', function (e) {
|
||||
var files = e.target.files;
|
||||
var done = function (url) {
|
||||
$inputFile.val('');
|
||||
$image.attr('src', url);
|
||||
$cropperContainer.removeClass('d-none');
|
||||
$imageContainer.addClass('d-none');
|
||||
$cropButton.removeClass('d-none');
|
||||
initCropper();
|
||||
};
|
||||
|
||||
if (files && files.length > 0) {
|
||||
var file = files[0];
|
||||
|
||||
// Vérifier le type de fichier
|
||||
if (!/^image\/(jpeg|png|gif)$/.test(file.type)) {
|
||||
alert('Veuillez sélectionner une image valide (JPG, PNG ou GIF).');
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier la taille du fichier (max 5 Mo)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
alert("L'image est trop volumineuse. La taille maximale est de 5 Mo.");
|
||||
return;
|
||||
}
|
||||
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
done(reader.result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
// Gestion du recadrage
|
||||
$cropButton.on('click', function () {
|
||||
if (!cropper) {
|
||||
return;
|
||||
}
|
||||
|
||||
var canvas = cropper.getCroppedCanvas({
|
||||
width: 1920,
|
||||
height: 1920,
|
||||
fillColor: '#fff',
|
||||
imageSmoothingEnabled: true,
|
||||
imageSmoothingQuality: 'high',
|
||||
});
|
||||
|
||||
if (canvas) {
|
||||
// Convertir le canvas en base64
|
||||
var croppedImageData = canvas.toDataURL('image/jpeg', 0.9);
|
||||
$croppedImageInput.val(croppedImageData);
|
||||
|
||||
// Soumettre le formulaire
|
||||
$('#image-upload-form').submit();
|
||||
}
|
||||
});
|
||||
|
||||
// Gestion des boutons de ratio d'aspect
|
||||
$aspectRatioButtons.on('click', function() {
|
||||
$aspectRatioButtons.removeClass('active');
|
||||
$(this).addClass('active');
|
||||
|
||||
var ratio = $(this).data('ratio');
|
||||
if (ratio === 'free') {
|
||||
cropper.setAspectRatio(NaN);
|
||||
} else if (ratio === 'square') {
|
||||
cropper.setAspectRatio(1);
|
||||
} else if (ratio === '4:3') {
|
||||
cropper.setAspectRatio(4/3);
|
||||
} else if (ratio === '16:9') {
|
||||
cropper.setAspectRatio(16/9);
|
||||
}
|
||||
});
|
||||
|
||||
// Zoom in
|
||||
$zoomInButton.on('click', function() {
|
||||
cropper.zoom(0.1);
|
||||
});
|
||||
|
||||
// Zoom out
|
||||
$zoomOutButton.on('click', function() {
|
||||
cropper.zoom(-0.1);
|
||||
});
|
||||
|
||||
// Rotation gauche
|
||||
$rotateLeftButton.on('click', function() {
|
||||
cropper.rotate(-90);
|
||||
});
|
||||
|
||||
// Rotation droite
|
||||
$rotateRightButton.on('click', function() {
|
||||
cropper.rotate(90);
|
||||
});
|
||||
|
||||
// Reset
|
||||
$resetButton.on('click', function() {
|
||||
cropper.reset();
|
||||
});
|
||||
});
|
||||
56
st_laurent_portal_vendor/static/src/scss/image_cropper.scss
Normal file
56
st_laurent_portal_vendor/static/src/scss/image_cropper.scss
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
.cropper-container {
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.img-container {
|
||||
max-height: 500px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.img-preview {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cropper-controls {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.cropper-controls .btn {
|
||||
margin-right: 5px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.aspect-ratio-controls {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.cropper-view-box,
|
||||
.cropper-face {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.preview-container img {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
/* Ajustements pour le mode mobile */
|
||||
@media (max-width: 768px) {
|
||||
.img-container {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.cropper-controls .btn {
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
/* Styles pour le module st_laurent_portal_vendor */
|
||||
|
||||
.o_form_view {
|
||||
.oe_avatar > img {
|
||||
max-height: 90px;
|
||||
max-width: 90px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Styles pour les images dans la vue liste */
|
||||
.o_list_view .o_list_table tbody > tr > td.o_list_image {
|
||||
width: 50px;
|
||||
padding: 0;
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Styles pour la page e-commerce */
|
||||
.o_form_view .o_notebook .tab-pane[name="ecommerce"] {
|
||||
.o_field_widget.o_field_image {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.o_group.o_inner_group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Styles pour la page images */
|
||||
.o_form_view .o_notebook .tab-pane[name="images"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.o_field_widget.o_field_image {
|
||||
margin: 10px;
|
||||
border: 1px solid #ddd;
|
||||
padding: 5px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
47
st_laurent_portal_vendor/views/portal_home_vendor_banner.xml
Normal file
47
st_laurent_portal_vendor/views/portal_home_vendor_banner.xml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Add a 'Become a Vendor' banner to the portal home page -->
|
||||
<template id="portal_home_vendor_banner" name="Portal Home Vendor Banner" inherit_id="portal.portal_my_home">
|
||||
<xpath expr="//div[hasclass('o_portal_my_home')]" position="before">
|
||||
<!-- Banner to become a vendor - simplified conditions -->
|
||||
<t t-if="request.env.user.has_group('base.group_portal')">
|
||||
<t t-set="partner" t-value="request.env.user.partner_id" />
|
||||
<t t-set="user_is_vendor" t-value="False" />
|
||||
<t t-set="user_has_pending_request" t-value="False" />
|
||||
|
||||
<t t-if="'is_vendor' in partner">
|
||||
<t t-set="user_is_vendor" t-value="partner.is_vendor" />
|
||||
</t>
|
||||
<t t-if="'has_pending_vendor_request' in partner">
|
||||
<t t-set="user_has_pending_request" t-value="partner.has_pending_vendor_request" />
|
||||
</t>
|
||||
|
||||
<!-- Bannière pour devenir vendeur -->
|
||||
<div t-if="not user_is_vendor and not user_has_pending_request"
|
||||
class="alert alert-info d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fa fa-store fa-2x mr-3"></i>
|
||||
<span>Do you want to sell your products on our platform? Become a vendor today!</span>
|
||||
</div>
|
||||
<a href="/my/vendor/request/new" class="btn btn-primary">
|
||||
<i class="fa fa-handshake-o mr-1"></i>
|
||||
Become a Vendor
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Banner for pending request -->
|
||||
<div t-if="user_has_pending_request"
|
||||
class="alert alert-warning d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fa fa-clock-o fa-2x mr-3"></i>
|
||||
<span>Your request to become a vendor is under review. We will contact you as soon as a decision is made.</span>
|
||||
</div>
|
||||
<a href="/my/vendor/requests" class="btn btn-secondary">
|
||||
<i class="fa fa-eye mr-1"></i>
|
||||
View my request
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
65
st_laurent_portal_vendor/views/portal_menu_templates.xml
Normal file
65
st_laurent_portal_vendor/views/portal_menu_templates.xml
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Add 'Become a Vendor' option to the main portal menu -->
|
||||
<template id="portal_layout_vendor_option" name="Portal Layout Vendor Option" inherit_id="portal.portal_breadcrumbs">
|
||||
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
|
||||
<!-- Utilisation de t-set pour évaluer les conditions en toute sécurité -->
|
||||
<t t-set="is_portal_user" t-value="request.env.user.has_group('base.group_portal')" />
|
||||
<t t-set="partner" t-value="request.env.user.partner_id" />
|
||||
<t t-set="user_is_vendor" t-value="False" />
|
||||
<t t-set="user_has_pending_request" t-value="False" />
|
||||
|
||||
<t t-if="is_portal_user">
|
||||
<t t-if="'is_vendor' in partner">
|
||||
<t t-set="user_is_vendor" t-value="partner.is_vendor" />
|
||||
</t>
|
||||
<t t-if="'has_pending_vendor_request' in partner">
|
||||
<t t-set="user_has_pending_request" t-value="partner.has_pending_vendor_request" />
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<li t-if="is_portal_user and not user_is_vendor and not user_has_pending_request" class="breadcrumb-item">
|
||||
<a href="/my/vendor/request/new">Devenez un vendeur</a>
|
||||
</li>
|
||||
<li t-elif="is_portal_user and user_has_pending_request" class="breadcrumb-item">
|
||||
<a href="/my/vendor/requests">Demande vendeur en cours</a>
|
||||
</li>
|
||||
<li t-elif="is_portal_user and user_is_vendor" class="breadcrumb-item">
|
||||
<a href="/my/vendor">Espace vendeur</a>
|
||||
</li>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Add 'Become a Vendor' option to the portal dropdown menu -->
|
||||
<template id="portal_user_menu_vendor_option" name="Portal User Menu Vendor Option" inherit_id="portal.user_dropdown">
|
||||
<xpath expr="//div[@id='o_logout_divider']" position="before">
|
||||
<!-- Utilisation de t-set pour évaluer les conditions en toute sécurité -->
|
||||
<t t-set="is_portal_user" t-value="request.env.user.has_group('base.group_portal')" />
|
||||
<t t-set="partner" t-value="request.env.user.partner_id" />
|
||||
<t t-set="user_is_vendor" t-value="False" />
|
||||
<t t-set="user_has_pending_request" t-value="False" />
|
||||
|
||||
<t t-if="is_portal_user">
|
||||
<t t-if="'is_vendor' in partner">
|
||||
<t t-set="user_is_vendor" t-value="partner.is_vendor" />
|
||||
</t>
|
||||
<t t-if="'has_pending_vendor_request' in partner">
|
||||
<t t-set="user_has_pending_request" t-value="partner.has_pending_vendor_request" />
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<a t-if="is_portal_user and not user_is_vendor and not user_has_pending_request"
|
||||
href="/my/vendor/request/new" class="dropdown-item ps-3">
|
||||
<i class="fa fa-fw fa-shopping-cart me-1 small text-primary text-primary-emphasis"/> Devenez un vendeur
|
||||
</a>
|
||||
<a t-elif="is_portal_user and user_has_pending_request"
|
||||
href="/my/vendor/requests" class="dropdown-item ps-3">
|
||||
<i class="fa fa-fw fa-clock-o me-1 small text-primary text-primary-emphasis"/> Demande vendeur en cours
|
||||
</a>
|
||||
<a t-elif="is_portal_user and user_is_vendor"
|
||||
href="/my/vendor" class="dropdown-item ps-3">
|
||||
<i class="fa fa-fw fa-shopping-basket me-1 small text-primary text-primary-emphasis"/> Espace vendeur
|
||||
</a>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
335
st_laurent_portal_vendor/views/portal_templates.xml
Normal file
335
st_laurent_portal_vendor/views/portal_templates.xml
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Add a link to the vendor menu in the portal -->
|
||||
<template id="portal_my_home_vendor" name="Portal My Home Vendor" inherit_id="portal.portal_my_home">
|
||||
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
|
||||
<!-- Utilisation de t-set pour évaluer les conditions en toute sécurité -->
|
||||
<t t-set="is_portal_user" t-value="request.env.user.has_group('base.group_portal')" />
|
||||
<t t-set="partner" t-value="request.env.user.partner_id" />
|
||||
<t t-set="user_is_vendor" t-value="False" />
|
||||
<t t-set="user_has_pending_request" t-value="False" />
|
||||
|
||||
<t t-if="is_portal_user">
|
||||
<t t-if="'is_vendor' in partner">
|
||||
<t t-set="user_is_vendor" t-value="partner.is_vendor" />
|
||||
</t>
|
||||
<t t-if="'has_pending_vendor_request' in partner">
|
||||
<t t-set="user_has_pending_request" t-value="partner.has_pending_vendor_request" />
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Card to become a vendor -->
|
||||
<t t-if="is_portal_user and not user_is_vendor and not user_has_pending_request">
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h5 class="card-title">
|
||||
<i class="fa fa-shopping-cart mr-2"></i>
|
||||
Become a Vendor
|
||||
</h5>
|
||||
<p class="card-text">Request to become a vendor and offer your products on our platform.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col">
|
||||
<a href="/my/vendor/request/new" class="btn btn-primary btn-block">
|
||||
<i class="fa fa-arrow-right mr-1"></i>
|
||||
Make a Request
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Card for pending request -->
|
||||
<t t-elif="is_portal_user and user_has_pending_request">
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h5 class="card-title">
|
||||
<i class="fa fa-clock-o mr-2"></i>
|
||||
Pending Request
|
||||
</h5>
|
||||
<p class="card-text">Your request to become a vendor is under review.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col">
|
||||
<a href="/my/vendor/requests" class="btn btn-secondary btn-block">
|
||||
<i class="fa fa-eye mr-1"></i>
|
||||
View My Request
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<!-- Carte pour espace vendeur -->
|
||||
<t t-elif="is_portal_user and user_is_vendor">
|
||||
<div class="col-lg-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h5 class="card-title">
|
||||
<i class="fa fa-shopping-cart mr-2"></i>
|
||||
Vendor Area
|
||||
</h5>
|
||||
<p class="card-text">Manage your products and track your sales.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-3">
|
||||
<div class="col">
|
||||
<a href="/my/vendor" class="btn btn-primary btn-block">
|
||||
<i class="fa fa-arrow-right mr-1"></i>
|
||||
Access My Vendor Area
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Template for the list of vendor requests -->
|
||||
<template id="portal_my_vendor_requests" name="My Vendor Requests">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="breadcrumbs_searchbar" t-value="True"/>
|
||||
|
||||
<t t-call="portal.portal_searchbar">
|
||||
<t t-set="title">My Vendor Requests</t>
|
||||
</t>
|
||||
|
||||
<t t-if="not requests">
|
||||
<div class="alert alert-info text-center" role="status">
|
||||
You have not made any request to become a vendor yet.
|
||||
<a href="/my/vendor/request/new" class="btn btn-primary ml-2">
|
||||
<i class="fa fa-plus mr-1"></i>
|
||||
Make a Request
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="requests">
|
||||
<table class="table table-striped table-hover o_portal_my_doc_table">
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th>Reference</th>
|
||||
<th>Date</th>
|
||||
<th>Company</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="requests" t-as="request">
|
||||
<tr>
|
||||
<td>
|
||||
<a t-attf-href="/my/vendor/request/#{request.id}">
|
||||
<t t-out="request.name"/>
|
||||
</a>
|
||||
</td>
|
||||
<td><span t-field="request.create_date"/></td>
|
||||
<td><span t-field="request.company_name"/></td>
|
||||
<td>
|
||||
<t t-if="request.state == 'draft'">
|
||||
<span class="badge badge-info">Brouillon</span>
|
||||
</t>
|
||||
<t t-elif="request.state == 'pending'">
|
||||
<span class="badge badge-warning">En attente</span>
|
||||
</t>
|
||||
<t t-elif="request.state == 'approved'">
|
||||
<span class="badge badge-success">Approuvée</span>
|
||||
</t>
|
||||
<t t-elif="request.state == 'rejected'">
|
||||
<span class="badge badge-danger">Rejetée</span>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template for the vendor request form -->
|
||||
<template id="portal_vendor_request_form" name="Vendor Request Form">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Request to Become a Vendor</h1>
|
||||
|
||||
<div t-if="error" class="alert alert-danger" role="alert">
|
||||
<t t-out="error"/>
|
||||
</div>
|
||||
|
||||
<form action="/my/vendor/request/submit" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="request_id" t-att-value="vendor_request.id if vendor_request else ''"/>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="company_name">Company Name</label>
|
||||
<input type="text" class="form-control" id="company_name" name="company_name"
|
||||
t-att-value="vendor_request.company_name if vendor_request else ''" required="required"/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="description">Description</label>
|
||||
<textarea class="form-control" id="description" name="description" rows="5"
|
||||
placeholder="Describe your company and the products you want to sell..." required="required">
|
||||
<t t-if="vendor_request">
|
||||
<t t-out="vendor_request.description"/>
|
||||
</t>
|
||||
</textarea>
|
||||
<small class="form-text text-muted">
|
||||
Please describe your company, your products, and why you want to become a vendor on our platform.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="attachments">Additional Documents (optional)</label>
|
||||
<input type="file" class="form-control" id="attachments" name="attachments" multiple="multiple"/>
|
||||
<small class="form-text text-muted">
|
||||
You can attach additional documents to support your request (catalogs, certificates, etc.).
|
||||
</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group mt-4">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<t t-if="vendor_request and vendor_request.state == 'draft'">
|
||||
Submit my request
|
||||
</t>
|
||||
<t t-elif="not vendor_request">
|
||||
Submit my request
|
||||
</t>
|
||||
<t t-else="">
|
||||
Save changes
|
||||
</t>
|
||||
</button>
|
||||
<a href="/my" class="btn btn-secondary ml-2">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template for the detailed view of a request -->
|
||||
<template id="portal_vendor_request_details" name="Vendor Request Details">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<div class="d-flex justify-content-between">
|
||||
<h1>Request <t t-out="vendor_request.name"/></h1>
|
||||
<div>
|
||||
<a href="/my/vendor/requests" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left mr-1"></i>
|
||||
Retour
|
||||
</a>
|
||||
<t t-if="vendor_request.state == 'draft'">
|
||||
<a t-attf-href="/my/vendor/request/edit/#{vendor_request.id}" class="btn btn-primary ml-2">
|
||||
<i class="fa fa-edit mr-1"></i>
|
||||
Modifier
|
||||
</a>
|
||||
<a t-attf-href="/my/vendor/request/submit/#{vendor_request.id}" class="btn btn-success ml-2">
|
||||
<i class="fa fa-check mr-1"></i>
|
||||
Soumettre
|
||||
</a>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="alert alert-info" t-if="vendor_request.state == 'draft'">
|
||||
Cette demande est en brouillon. Vous pouvez la modifier avant de la soumettre.
|
||||
</div>
|
||||
<div class="alert alert-warning" t-if="vendor_request.state == 'pending'">
|
||||
Votre demande est en cours d'examen. Nous vous contacterons dès qu'une décision sera prise.
|
||||
</div>
|
||||
<div class="alert alert-success" t-if="vendor_request.state == 'approved'">
|
||||
Votre demande a été approuvée. Vous pouvez maintenant accéder à votre espace vendeur.
|
||||
</div>
|
||||
<div class="alert alert-danger" t-if="vendor_request.state == 'rejected'">
|
||||
Votre demande a été rejetée.
|
||||
<t t-if="vendor_request.rejection_reason">
|
||||
<br/>
|
||||
<strong>Motif du rejet:</strong> <t t-out="vendor_request.rejection_reason"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h4>Informations</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Date de demande:</strong> <span t-field="vendor_request.create_date"/></p>
|
||||
<p><strong>Entreprise:</strong> <span t-field="vendor_request.company_name"/></p>
|
||||
<p><strong>État:</strong>
|
||||
<t t-if="vendor_request.state == 'draft'">
|
||||
<span class="badge badge-info">Brouillon</span>
|
||||
</t>
|
||||
<t t-elif="vendor_request.state == 'pending'">
|
||||
<span class="badge badge-warning">En attente</span>
|
||||
</t>
|
||||
<t t-elif="vendor_request.state == 'approved'">
|
||||
<span class="badge badge-success">Approuvée</span>
|
||||
</t>
|
||||
<t t-elif="vendor_request.state == 'rejected'">
|
||||
<span class="badge badge-danger">Rejetée</span>
|
||||
</t>
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p t-if="vendor_request.approved_date"><strong>Date d'approbation:</strong> <span t-field="vendor_request.approved_date"/></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<h4>Description</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p t-field="vendor_request.description"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4" t-if="vendor_request.attachment_ids">
|
||||
<div class="card-header">
|
||||
<h4>Documents</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ul class="list-group">
|
||||
<t t-foreach="vendor_request.attachment_ids" t-as="attachment">
|
||||
<li class="list-group-item">
|
||||
<a t-att-href="'/web/content/%s?download=true' % attachment.id">
|
||||
<i class="fa fa-download mr-2"></i>
|
||||
<t t-out="attachment.name"/>
|
||||
</a>
|
||||
</li>
|
||||
</t>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
151
st_laurent_portal_vendor/views/portal_vendor_home_template.xml
Normal file
151
st_laurent_portal_vendor/views/portal_vendor_home_template.xml
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Template for the Vendor Portal Home page -->
|
||||
<template id="portal_vendor_home" name="Vendor Portal Home">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1 class="mt-4 mb-4">Vendor Portal</h1>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="card-title"><t t-out="len(vendor_products)"/></h3>
|
||||
<p class="card-text">Products</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="card-title">
|
||||
<t t-out="len(vendor_products.filtered(lambda p: p.website_published))"/>
|
||||
</h3>
|
||||
<p class="card-text">Published Products</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="card-title">
|
||||
<t t-out="len(request.env['sale.order.line'].sudo().search([
|
||||
('product_id', 'in', vendor_products.mapped('product_id').ids),
|
||||
('state', 'in', ['sale', 'done'])
|
||||
]))"/>
|
||||
</h3>
|
||||
<p class="card-text">Sales</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Quick Actions</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<a href="/my/products" class="btn btn-primary btn-block w-100">
|
||||
<i class="fa fa-list me-2"></i> Manage My Products
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<a href="/my/orders" class="btn btn-info btn-block w-100">
|
||||
<i class="fa fa-shopping-cart me-2"></i> View My Orders
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<a href="/shop" class="btn btn-success btn-block w-100">
|
||||
<i class="fa fa-external-link me-2"></i> View Shop
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latest products -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4 class="mb-0">My Latest Products</h4>
|
||||
<a href="/my/products" class="btn btn-sm btn-primary">
|
||||
View All <i class="fa fa-arrow-right ms-1"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Name</th>
|
||||
<th>Code</th>
|
||||
<th>Price</th>
|
||||
<th>Published</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-if="vendor_products">
|
||||
<t t-foreach="vendor_products[:5]" t-as="product">
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
<img t-if="product.image_128" t-att-src="image_data_uri(product.image_128)" class="img-thumbnail" style="max-height: 50px;"/>
|
||||
<img t-else="" src="/web/static/img/placeholder.png" class="img-thumbnail" style="max-height: 50px;"/>
|
||||
</td>
|
||||
<td class="align-middle"><t t-out="product.product_name"/></td>
|
||||
<td class="align-middle"><t t-out="product.product_code"/></td>
|
||||
<td class="align-middle"><t t-out="product.website_price"/></td>
|
||||
<td class="align-middle">
|
||||
<span t-if="product.website_published" class="badge bg-success">
|
||||
<i class="fa fa-check me-1"></i> Yes
|
||||
</span>
|
||||
<span t-else="" class="badge bg-secondary">
|
||||
<i class="fa fa-times me-1"></i> No
|
||||
</span>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="btn-group">
|
||||
<a t-att-href="'/my/products/%s' % product.id" class="btn btn-sm btn-primary">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
<a t-att-href="'/my/products/%s/image' % product.id" class="btn btn-sm btn-info">
|
||||
<i class="fa fa-image"></i>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</t>
|
||||
<tr t-if="not vendor_products">
|
||||
<td colspan="6" class="text-center">
|
||||
<p class="text-muted">You don't have any products yet.</p>
|
||||
<a href="/my/products/new" class="btn btn-primary">
|
||||
<i class="fa fa-plus me-1"></i> Add a Product
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,487 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Template for the vendor request form -->
|
||||
<template id="portal_vendor_request_form" name="Vendor Request Form">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-call-assets="portal.assets_frontend" t-js="true"/>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Request to Become a Vendor</h1>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<p>Fill out this form to request to become a vendor on our platform. Please provide accurate information about your company.</p>
|
||||
</div>
|
||||
|
||||
<!-- Affichage des erreurs -->
|
||||
<div t-if="error" class="alert alert-danger" role="alert">
|
||||
<t t-if="error == 'missing'">
|
||||
<p>Some required fields are missing.</p>
|
||||
</t>
|
||||
<t t-if="error == 'error'">
|
||||
<p>An error occurred while submitting your request.</p>
|
||||
</t>
|
||||
<p t-if="error_message"><t t-out="error_message"/></p>
|
||||
</div>
|
||||
|
||||
<!-- Vendor request form -->
|
||||
<form action="/my/vendor/request/submit" method="post" enctype="multipart/form-data" class="mt-4">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Company Information</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_name" class="form-label">Company Name *</label>
|
||||
<input type="text" name="company_name" id="company_name" class="form-control" required="required" t-att-value="company_name or ''"/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_vat" class="form-label">VAT/Tax Number</label>
|
||||
<input type="text" name="company_vat" id="company_vat" class="form-control" t-att-value="company_vat or ''"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_email" class="form-label">Company Email</label>
|
||||
<input type="email" name="company_email" id="company_email" class="form-control" t-att-value="company_email or ''"/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_phone" class="form-label">Company Phone</label>
|
||||
<input type="tel" name="company_phone" id="company_phone" class="form-control" t-att-value="company_phone or ''"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label for="company_website" class="form-label">Website</label>
|
||||
<input type="url" name="company_website" id="company_website" class="form-control" placeholder="https://" t-att-value="company_website or ''"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Company Address</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label for="company_street" class="form-label">Street</label>
|
||||
<input type="text" name="company_street" id="company_street" class="form-control" t-att-value="company_street or ''"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label for="company_street2" class="form-label">Address Line 2</label>
|
||||
<input type="text" name="company_street2" id="company_street2" class="form-control" t-att-value="company_street2 or ''"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="company_zip" class="form-label">Zip Code</label>
|
||||
<input type="text" name="company_zip" id="company_zip" class="form-control" t-att-value="company_zip or ''"/>
|
||||
</div>
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for="company_city" class="form-label">City</label>
|
||||
<input type="text" name="company_city" id="company_city" class="form-control" t-att-value="company_city or ''"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_country_id" class="form-label">Country</label>
|
||||
<select name="company_country_id" id="company_country_id" class="form-select">
|
||||
<option value="">-- Select a country --</option>
|
||||
<t t-foreach="countries or []" t-as="country">
|
||||
<option t-att-value="country.id" t-att-selected="country.id == int(company_country_id) if company_country_id else None">
|
||||
<t t-esc="country.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_state_id" class="form-label">State/Province</label>
|
||||
<select name="company_state_id" id="state_id" class="form-select">
|
||||
<option value="">-- Select a state/province --</option>
|
||||
<t t-foreach="states or []" t-as="state">
|
||||
<option t-att-value="state.id"
|
||||
class="d-none"
|
||||
t-att-data-country_id="state.country_id.id"
|
||||
t-att-selected="state.id == int(company_state_id) if company_state_id else None">
|
||||
<t t-esc="state.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Company Description</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label for="description" class="form-label">Description *</label>
|
||||
<textarea name="description" id="description" class="form-control" rows="5" required="required" placeholder="Describe your company and the products you want to sell..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="/my/home" class="btn btn-secondary me-md-2">Cancel</a>
|
||||
<button type="submit" class="btn btn-primary">Submit Request</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template for the list of vendor requests -->
|
||||
<template id="portal_vendor_requests" name="Vendor Requests">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>My Requests to Become a Vendor</h1>
|
||||
|
||||
<!-- Button to create a new request -->
|
||||
<div class="mb-4">
|
||||
<a href="/my/vendor/request/new" class="btn btn-primary">
|
||||
<i class="fa fa-plus"></i> Create a Request
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Message if no request -->
|
||||
<div t-if="not vendor_requests" class="alert alert-info" role="alert">
|
||||
<p>You have not made any vendor requests yet.</p>
|
||||
</div>
|
||||
|
||||
<!-- List of requests with pagination -->
|
||||
<div t-if="vendor_requests" class="mt-4">
|
||||
<!-- Top pagination -->
|
||||
<div class="o_portal_pager text-end mb-3">
|
||||
<t t-call="portal.pager"/>
|
||||
</div>
|
||||
|
||||
<!-- Table of requests -->
|
||||
<table class="table table-striped o_portal_my_doc_table">
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th>Reference</th>
|
||||
<th>Request Date</th>
|
||||
<th>Company</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="vendor_requests" t-as="request">
|
||||
<tr>
|
||||
<td><t t-out="request.name"/></td>
|
||||
<td><t t-out="request.create_date" t-options="{'widget': 'date'}"/></td>
|
||||
<td><t t-out="request.company_name"/></td>
|
||||
<td>
|
||||
<span t-if="request.state == 'draft'" class="badge bg-secondary">Draft</span>
|
||||
<span t-if="request.state == 'pending'" class="badge bg-warning">Pending</span>
|
||||
<span t-if="request.state == 'approved'" class="badge bg-success">Approved</span>
|
||||
<span t-if="request.state == 'rejected'" class="badge bg-danger">Rejected</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a t-att-href="'/my/vendor/request/%s%s' % (request.id, '/edit' if request.state == 'pending' else '')" class="btn btn-sm btn-primary">
|
||||
<t t-if="request.state == 'pending'">
|
||||
<i class="fa fa-edit"></i> Edit
|
||||
</t>
|
||||
<t t-else="">
|
||||
<i class="fa fa-eye"></i> View
|
||||
</t>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Bottom pagination -->
|
||||
<div class="o_portal_pager text-end mt-3">
|
||||
<t t-call="portal.pager"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Template for the vendor request detail -->
|
||||
<template id="portal_vendor_request_detail" name="Vendor Request Detail">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<!-- Breadcrumb and navigation -->
|
||||
<div class="d-flex justify-content-between mb-3 align-items-center">
|
||||
<div>
|
||||
<a href="/my/vendor/requests" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<t t-call="portal.record_pager"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Request <t t-out="vendor_request.name"/></h1>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h4>Request Information</h4>
|
||||
<div>
|
||||
<span t-if="vendor_request.state == 'draft'" class="badge bg-secondary">Draft</span>
|
||||
<span t-if="vendor_request.state == 'pending'" class="badge bg-warning">Pending</span>
|
||||
<span t-if="vendor_request.state == 'approved'" class="badge bg-success">Approved</span>
|
||||
<span t-if="vendor_request.state == 'rejected'" class="badge bg-danger">Rejected</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p><strong>Request Date:</strong> <t t-out="vendor_request.create_date" t-options="{'widget': 'date'}"/></p>
|
||||
<p><strong>Company:</strong> <t t-out="vendor_request.company_name"/></p>
|
||||
<p t-if="vendor_request.company_vat"><strong>VAT/Tax Number:</strong> <t t-out="vendor_request.company_vat"/></p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p t-if="vendor_request.approved_date"><strong>Approval Date:</strong> <t t-out="vendor_request.approved_date" t-options="{'widget': 'date'}"/></p>
|
||||
<p t-if="vendor_request.state == 'rejected'"><strong>Rejection Reason:</strong> <t t-out="vendor_request.rejection_reason"/></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Company Information</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p t-if="vendor_request.company_email"><strong>Email:</strong> <t t-out="vendor_request.company_email"/></p>
|
||||
<p t-if="vendor_request.company_phone"><strong>Phone:</strong> <t t-out="vendor_request.company_phone"/></p>
|
||||
<p t-if="vendor_request.company_website"><strong>Website:</strong> <a t-att-href="vendor_request.company_website" target="_blank"><t t-out="vendor_request.company_website"/></a></p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<p t-if="vendor_request.company_street"><strong>Address:</strong></p>
|
||||
<p t-if="vendor_request.company_street"><t t-out="vendor_request.company_street"/></p>
|
||||
<p t-if="vendor_request.company_street2"><t t-out="vendor_request.company_street2"/></p>
|
||||
<p t-if="vendor_request.company_zip or vendor_request.company_city">
|
||||
<t t-if="vendor_request.company_zip"><t t-out="vendor_request.company_zip"/></t>
|
||||
<t t-if="vendor_request.company_city"><t t-out="vendor_request.company_city"/></t>
|
||||
</p>
|
||||
<p t-if="vendor_request.company_state_id or vendor_request.company_country_id">
|
||||
<t t-if="vendor_request.company_state_id"><t t-out="vendor_request.company_state_id.name"/></t>
|
||||
<t t-if="vendor_request.company_country_id">, <t t-out="vendor_request.company_country_id.name"/></t>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Description</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p t-field="vendor_request.description"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div t-if="vendor_request.company_partner_id and vendor_request.state == 'approved'" class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Company Created</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Your company has been successfully created in our system.</p>
|
||||
<p><strong>Company Name:</strong> <t t-out="vendor_request.company_partner_id.name"/></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="/my/vendor/requests" class="btn btn-secondary">Back to List</a>
|
||||
<a t-if="vendor_request.state == 'draft'" href="#" class="btn btn-primary">Submit</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
<!-- Template for editing a vendor request -->
|
||||
<template id="portal_vendor_request_edit" name="Edit Vendor Request">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<!-- Breadcrumb and navigation -->
|
||||
<div class="d-flex justify-content-between mb-3 align-items-center">
|
||||
<div>
|
||||
<a t-att-href="'/my/vendor/request/%s' % vendor_request.id" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1>Edit Request <t t-out="vendor_request.name"/></h1>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<p>You can edit your request to become a vendor. Please provide accurate information about your company.</p>
|
||||
</div>
|
||||
|
||||
<!-- Affichage des erreurs -->
|
||||
<div t-if="error" class="alert alert-danger" role="alert">
|
||||
<t t-if="error == 'missing'">
|
||||
<p>Some required fields are missing.</p>
|
||||
</t>
|
||||
<t t-if="error == 'error'">
|
||||
<p>An error occurred while updating your request.</p>
|
||||
</t>
|
||||
<p t-if="error_message"><t t-out="error_message"/></p>
|
||||
</div>
|
||||
|
||||
<!-- Vendor request edit form -->
|
||||
<form t-att-action="'/my/vendor/request/%s/update' % vendor_request.id" method="post" enctype="multipart/form-data" class="mt-4">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Company Information</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_name" class="form-label">Company Name *</label>
|
||||
<input type="text" name="company_name" id="company_name" class="form-control" required="required" t-att-value="vendor_request.company_name"/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_vat" class="form-label">VAT/Tax Number</label>
|
||||
<input type="text" name="company_vat" id="company_vat" class="form-control" t-att-value="vendor_request.company_vat"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_email" class="form-label">Company Email</label>
|
||||
<input type="email" name="company_email" id="company_email" class="form-control" t-att-value="vendor_request.company_email"/>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_phone" class="form-label">Company Phone</label>
|
||||
<input type="tel" name="company_phone" id="company_phone" class="form-control" t-att-value="vendor_request.company_phone"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label for="company_website" class="form-label">Website</label>
|
||||
<input type="url" name="company_website" id="company_website" class="form-control" placeholder="https://" t-att-value="vendor_request.company_website"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Company Address</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label for="company_street" class="form-label">Street</label>
|
||||
<input type="text" name="company_street" id="company_street" class="form-control" t-att-value="vendor_request.company_street"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label for="company_street2" class="form-label">Address Line 2</label>
|
||||
<input type="text" name="company_street2" id="company_street2" class="form-control" t-att-value="vendor_request.company_street2"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="company_zip" class="form-label">Zip Code</label>
|
||||
<input type="text" name="company_zip" id="company_zip" class="form-control" t-att-value="vendor_request.company_zip"/>
|
||||
</div>
|
||||
<div class="col-md-8 mb-3">
|
||||
<label for="company_city" class="form-label">City</label>
|
||||
<input type="text" name="company_city" id="company_city" class="form-control" t-att-value="vendor_request.company_city"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_country_id" class="form-label">Country</label>
|
||||
<select name="company_country_id" id="company_country_id" class="form-select">
|
||||
<option value="">-- Select a country --</option>
|
||||
<t t-foreach="countries or []" t-as="country">
|
||||
<option t-att-value="country.id" t-att-selected="vendor_request.company_country_id and vendor_request.company_country_id.id == country.id">
|
||||
<t t-esc="country.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label for="company_state_id" class="form-label">State/Province</label>
|
||||
<select name="company_state_id" id="company_state_id" class="form-select">
|
||||
<option value="">-- Select a state/province --</option>
|
||||
<t t-foreach="states or []" t-as="state">
|
||||
<option t-att-value="state.id" class="d-none" t-att-data-country_id="state.country_id.id" t-att-selected="vendor_request.company_state_id and vendor_request.company_state_id.id == state.id">
|
||||
<t t-esc="state.name"/>
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Description</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-12 mb-3">
|
||||
<label for="description" class="form-label">Describe your company and the products you want to sell</label>
|
||||
<textarea name="description" id="description" class="form-control" rows="5"><t t-out="vendor_request.description"/></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a t-att-href="'/my/vendor/request/%s' % vendor_request.id" class="btn btn-secondary me-md-2">Cancel</a>
|
||||
<button type="submit" name="submit" value="1" class="btn btn-primary">Update and Submit</button>
|
||||
<button type="submit" class="btn btn-outline-primary">Save as Draft</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
89
st_laurent_portal_vendor/views/res_config_settings_views.xml
Normal file
89
st_laurent_portal_vendor/views/res_config_settings_views.xml
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="res_config_settings_view_form_vendor" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.vendor</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="priority">20</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<div class="app_settings_block" data-string="Vendor Management" string="Vendor Management" data-key="st_laurent_portal_vendor">
|
||||
<h2>Vendor Request Form</h2>
|
||||
<div class="row mt16 o_settings_container" id="vendor_request_settings">
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="vendor_request_restrict_countries"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="vendor_request_restrict_countries"/>
|
||||
<div class="text-muted">
|
||||
Restreindre les pays disponibles dans le formulaire de demande de vendeur
|
||||
</div>
|
||||
<div class="content-group" invisible="vendor_request_restrict_countries == False">
|
||||
<div class="mt16">
|
||||
<label for="vendor_request_north_america" string="Régions disponibles"/>
|
||||
<div class="d-flex flex-wrap">
|
||||
<div class="o_field_boolean o_field_widget me-3">
|
||||
<field name="vendor_request_north_america"/>
|
||||
<label for="vendor_request_north_america">Amérique du Nord</label>
|
||||
</div>
|
||||
<div class="o_field_boolean o_field_widget me-3">
|
||||
<field name="vendor_request_europe"/>
|
||||
<label for="vendor_request_europe">Europe</label>
|
||||
</div>
|
||||
<div class="o_field_boolean o_field_widget me-3">
|
||||
<field name="vendor_request_asia"/>
|
||||
<label for="vendor_request_asia">Asie</label>
|
||||
</div>
|
||||
<div class="o_field_boolean o_field_widget">
|
||||
<field name="vendor_request_other_regions"/>
|
||||
<label for="vendor_request_other_regions">Autres régions</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6 o_setting_box">
|
||||
<div class="o_setting_left_pane">
|
||||
<field name="vendor_request_restrict_states"/>
|
||||
</div>
|
||||
<div class="o_setting_right_pane">
|
||||
<label for="vendor_request_restrict_states"/>
|
||||
<div class="text-muted">
|
||||
Restreindre les états/provinces disponibles dans le formulaire de demande de vendeur
|
||||
</div>
|
||||
<div class="content-group" invisible="vendor_request_restrict_states == False">
|
||||
<div class="mt16">
|
||||
<label for="vendor_request_default_country_id">Pays par défaut</label>
|
||||
<field name="vendor_request_default_country_id" options="{'no_create': True, 'no_open': True}"/>
|
||||
<div class="text-muted">
|
||||
Seuls les états/provinces de ce pays seront disponibles
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action to open the configuration -->
|
||||
<record id="action_vendor_config_settings" model="ir.actions.act_window">
|
||||
<field name="name">Vendor Settings</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">res.config.settings</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">inline</field>
|
||||
<field name="context">{'module' : 'st_laurent_portal_vendor'}</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu item for the configuration -->
|
||||
<menuitem id="menu_vendor_config_settings"
|
||||
name="Settings"
|
||||
parent="st_laurent_portal_vendor.menu_vendor_product_root"
|
||||
action="action_vendor_config_settings"
|
||||
sequence="100"/>
|
||||
</odoo>
|
||||
53
st_laurent_portal_vendor/views/res_partner_views.xml
Normal file
53
st_laurent_portal_vendor/views/res_partner_views.xml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Ajouter un onglet "Vendeur" dans la vue formulaire du partenaire -->
|
||||
<record id="view_partner_form_vendor" model="ir.ui.view">
|
||||
<field name="name">res.partner.form.vendor</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Ajouter un nouvel onglet pour les informations de vendeur -->
|
||||
<notebook position="inside">
|
||||
<page string="Vendeur" name="vendor" invisible="not is_company or parent_id">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" readonly="1" class="oe_inline"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Statut">
|
||||
<field name="vendor_status"/>
|
||||
<field name="is_vendor" readonly="1"/>
|
||||
<field name="has_pending_vendor_request" readonly="1"/>
|
||||
</group>
|
||||
<group string="Actions">
|
||||
<button name="action_approve_as_vendor"
|
||||
string="Approuver comme vendeur"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
invisible="vendor_status == 'yes'"/>
|
||||
<button name="action_revoke_vendor_status"
|
||||
string="Révoquer le statut de vendeur"
|
||||
type="object"
|
||||
class="btn-secondary"
|
||||
invisible="vendor_status != 'yes'"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Demandes de vendeur" name="vendor_requests">
|
||||
<field name="vendor_request_ids" readonly="1">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="create_date"/>
|
||||
<field name="state"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</page>
|
||||
</notebook>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
31
st_laurent_portal_vendor/views/res_users_views.xml
Normal file
31
st_laurent_portal_vendor/views/res_users_views.xml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Étendre la vue formulaire des utilisateurs -->
|
||||
<record id="view_users_form_inherit_vendor" model="ir.ui.view">
|
||||
<field name="name">res.users.form.inherit.vendor</field>
|
||||
<field name="model">res.users</field>
|
||||
<field name="inherit_id" ref="base.view_users_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//page[@name='access_rights']" position="after">
|
||||
<page string="Statut vendeur" name="vendor_status" groups="base.group_system">
|
||||
<group>
|
||||
<field name="vendor_status"/>
|
||||
<field name="has_pending_vendor_request" readonly="1"/>
|
||||
<button name="action_approve_as_vendor" string="Approuver comme vendeur" type="object"
|
||||
class="oe_highlight" invisible="vendor_status == 'yes' or has_pending_vendor_request == False"/>
|
||||
<button name="action_revoke_vendor_status" string="Révoquer le statut de vendeur" type="object"
|
||||
invisible="vendor_status == 'no'"/>
|
||||
</group>
|
||||
<field name="vendor_request_ids" readonly="1" invisible="vendor_request_ids == []">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="company_name"/>
|
||||
<field name="create_date"/>
|
||||
<field name="state"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
89
st_laurent_portal_vendor/views/vendor_config_views.xml
Normal file
89
st_laurent_portal_vendor/views/vendor_config_views.xml
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vue formulaire pour la configuration vendeur -->
|
||||
<record id="vendor_config_view_form" model="ir.ui.view">
|
||||
<field name="name">vendor.config.form</field>
|
||||
<field name="model">vendor.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Configuration vendeur">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" placeholder="Nom de la configuration"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="active"/>
|
||||
<field name="is_default"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Pays autorisés" name="countries">
|
||||
<field name="country_ids" widget="many2many_tags" options="{'no_create': True}"/>
|
||||
<p class="text-muted mt-3">
|
||||
Si aucun pays n'est sélectionné, tous les pays seront disponibles dans le formulaire de demande.
|
||||
</p>
|
||||
</page>
|
||||
<page string="États/Provinces autorisés" name="states">
|
||||
<field name="state_ids" widget="many2many_tags" options="{'no_create': True}"/>
|
||||
<p class="text-muted mt-3">
|
||||
Si aucun état/province n'est sélectionné, tous les états/provinces seront disponibles dans le formulaire de demande.
|
||||
</p>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue liste pour la configuration vendeur -->
|
||||
<record id="vendor_config_view_list" model="ir.ui.view">
|
||||
<field name="name">vendor.config.list</field>
|
||||
<field name="model">vendor.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Configurations vendeur">
|
||||
<field name="name"/>
|
||||
<field name="is_default"/>
|
||||
<field name="active"/>
|
||||
<field name="country_ids" widget="many2many_tags"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue recherche pour la configuration vendeur -->
|
||||
<record id="vendor_config_view_search" model="ir.ui.view">
|
||||
<field name="name">vendor.config.search</field>
|
||||
<field name="model">vendor.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Rechercher des configurations vendeur">
|
||||
<field name="name"/>
|
||||
<filter string="Configuration par défaut" name="default" domain="[('is_default', '=', True)]"/>
|
||||
<filter string="Actif" name="active" domain="[('active', '=', True)]"/>
|
||||
<filter string="Inactif" name="inactive" domain="[('active', '=', False)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour la configuration vendeur -->
|
||||
<record id="action_vendor_config" model="ir.actions.act_window">
|
||||
<field name="name">Configuration vendeur</field>
|
||||
<field name="res_model">vendor.config</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Aucune configuration vendeur trouvée
|
||||
</p>
|
||||
<p>
|
||||
Créez votre première configuration pour définir les pays et états/provinces autorisés dans le formulaire de demande de vendeur.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu pour la configuration vendeur -->
|
||||
<menuitem id="menu_vendor_config"
|
||||
name="Configuration vendeur"
|
||||
parent="st_laurent_portal_vendor.menu_vendor_product_root"
|
||||
action="action_vendor_config"
|
||||
sequence="100"/>
|
||||
</odoo>
|
||||
11
st_laurent_portal_vendor/views/vendor_menu.xml
Normal file
11
st_laurent_portal_vendor/views/vendor_menu.xml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Menu racine pour la gestion des vendeurs -->
|
||||
<menuitem id="menu_vendor_product_root"
|
||||
name="Vendeurs"
|
||||
web_icon="st_laurent_portal_vendor,static/description/icon.png"
|
||||
sequence="50"/>
|
||||
|
||||
<!-- Menu pour les produits vendeur sera défini dans un autre fichier -->
|
||||
<!-- Pour l'instant, nous définissons uniquement le menu racine -->
|
||||
</odoo>
|
||||
152
st_laurent_portal_vendor/views/vendor_portal_templates.xml
Normal file
152
st_laurent_portal_vendor/views/vendor_portal_templates.xml
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Template for the vendor portal home page -->
|
||||
<template id="vendor_portal_home" name="Vendor Portal Home">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Vendor Portal</h1>
|
||||
|
||||
<div class="alert alert-info" role="alert">
|
||||
<p>Welcome to your vendor portal. Here, you can manage your products and track your sales.</p>
|
||||
</div>
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="card-title"><t t-out="len(vendor_products)"/></h3>
|
||||
<p class="card-text">Products</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="card-title">
|
||||
<t t-out="len(vendor_products.filtered(lambda p: p.website_published))"/>
|
||||
</h3>
|
||||
<p class="card-text">Published Products</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="card-title">0</h3>
|
||||
<p class="card-text">Sales</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Quick Actions</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<a href="/my/products" class="btn btn-primary btn-block">
|
||||
<i class="fa fa-list mr-2"></i>
|
||||
My Products
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<a href="/my/products/create" class="btn btn-success btn-block">
|
||||
<i class="fa fa-plus mr-2"></i>
|
||||
Ajouter un produit
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<a href="/shop" class="btn btn-info btn-block">
|
||||
<i class="fa fa-shopping-cart mr-2"></i>
|
||||
Voir la boutique
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Liste des produits récents -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h4>My Recent Products</h4>
|
||||
<a href="/my/products" class="btn btn-sm btn-secondary">
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<t t-if="not vendor_products">
|
||||
<div class="alert alert-info text-center">
|
||||
Vous n'avez pas encore de produits.
|
||||
<a href="/my/products/create" class="btn btn-primary ml-2">
|
||||
<i class="fa fa-plus mr-1"></i>
|
||||
Ajouter un produit
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="vendor_products">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Image</th>
|
||||
<th>Nom</th>
|
||||
<th>Code</th>
|
||||
<th>Prix</th>
|
||||
<th>Publié</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="vendor_products[:5]" t-as="product">
|
||||
<tr>
|
||||
<td>
|
||||
<img t-if="product.image_128" t-att-src="image_data_uri(product.image_128)" class="img-thumbnail" style="max-height: 50px;"/>
|
||||
<img t-else="" src="/web/static/img/placeholder.png" class="img-thumbnail" style="max-height: 50px;"/>
|
||||
</td>
|
||||
<td><t t-out="product.product_name"/></td>
|
||||
<td><t t-out="product.product_code"/></td>
|
||||
<td><t t-out="product.website_price"/></td>
|
||||
<td>
|
||||
<span t-if="product.website_published" class="badge badge-success">
|
||||
<i class="fa fa-check mr-1"></i> Oui
|
||||
</span>
|
||||
<span t-else="" class="badge badge-secondary">
|
||||
<i class="fa fa-times mr-1"></i> Non
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a t-att-href="'/my/products/%s' % product.id" class="btn btn-sm btn-primary">
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
<a t-att-href="'/my/products/%s/edit' % product.id" class="btn btn-sm btn-secondary">
|
||||
<i class="fa fa-edit"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
37
st_laurent_portal_vendor/views/vendor_product_action.xml
Normal file
37
st_laurent_portal_vendor/views/vendor_product_action.xml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Action pour les produits vendeur -->
|
||||
<record id="action_vendor_products" model="ir.actions.act_window">
|
||||
<field name="name">Produits vendeur</field>
|
||||
<field name="res_model">vendor.product</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Aucun produit vendeur trouvé
|
||||
</p>
|
||||
<p>
|
||||
Créez votre premier produit vendeur.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu pour les produits vendeur -->
|
||||
<menuitem id="menu_vendor_products"
|
||||
name="Produits vendeur"
|
||||
parent="st_laurent_portal_vendor.menu_vendor_product_root"
|
||||
action="action_vendor_products"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Action serveur pour créer un produit standard -->
|
||||
<record id="action_create_standard_product" model="ir.actions.server">
|
||||
<field name="name">Créer Produit Standard</field>
|
||||
<field name="model_id" ref="st_laurent_portal_vendor.model_vendor_product"/>
|
||||
<field name="binding_model_id" ref="st_laurent_portal_vendor.model_vendor_product"/>
|
||||
<field name="binding_view_types">form</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
if records:
|
||||
action = records.action_create_product()
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Template for managing product categories and tags -->
|
||||
<template id="vendor_product_categories_form" name="Manage Product Categories and Tags">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Manage Product Categories and Tags</h1>
|
||||
<div t-if="error" class="alert alert-danger" role="alert">
|
||||
<t t-out="error"/>
|
||||
</div>
|
||||
<div t-if="success" class="alert alert-success" role="alert">
|
||||
<t t-out="success"/>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4><t t-out="vendor_product.product_name"/></h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<img t-if="vendor_product.image_1920" t-att-src="image_data_uri(vendor_product.image_1920)" class="img-fluid mb-3"/>
|
||||
<img t-else="" src="/web/static/img/placeholder.png" class="img-fluid mb-3"/>
|
||||
</div>
|
||||
<div class="col-md-9">
|
||||
<p><strong>Code:</strong> <t t-out="vendor_product.product_code or '-'"/></p>
|
||||
<p><strong>Description:</strong> <t t-out="vendor_product.description or '-'"/></p>
|
||||
<p><strong>Price:</strong> <t t-out="vendor_product.price or '0.00'"/> €</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action="/my/products/update_categories" method="post">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="product_id" t-att-value="vendor_product.id"/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Catégories</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Select the categories in which this product should appear on the website.</p>
|
||||
<div class="form-group">
|
||||
<div t-foreach="categories" t-as="category" class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input"
|
||||
t-att-id="'category_' + str(category.id)"
|
||||
t-att-name="'category_ids'"
|
||||
t-att-value="category.id"
|
||||
t-att-checked="category.id in selected_category_ids"/>
|
||||
<label class="custom-control-label" t-att-for="'category_' + str(category.id)">
|
||||
<t t-out="category.name"/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h4>Tags</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted">Select the tags that apply to this product to make searching and filtering easier.</p>
|
||||
<div class="form-group">
|
||||
<div t-foreach="tags" t-as="tag" class="custom-control custom-checkbox">
|
||||
<input type="checkbox" class="custom-control-input"
|
||||
t-att-id="'tag_' + str(tag.id)"
|
||||
t-att-name="'tag_ids'"
|
||||
t-att-value="tag.id"
|
||||
t-att-checked="tag.id in selected_tag_ids"/>
|
||||
<label class="custom-control-label" t-att-for="'tag_' + str(tag.id)">
|
||||
<t t-out="tag.name"/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<a href="#" onclick="window.history.back(); return false;" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left"/> Back
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6 text-right">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save"/> Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<!-- Étendre le template du produit pour ajouter le bouton de gestion des catégories -->
|
||||
<template id="vendor_product_add_categories_button" name="Add Categories Button" inherit_id="vendor_portal_management.vendor_product">
|
||||
<xpath expr="//div[@id='informations']" position="before">
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Categories and Tags</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h5>Categories</h5>
|
||||
<div class="mb-3">
|
||||
<t t-if="vendor_product.public_categ_ids">
|
||||
<span t-foreach="vendor_product.public_categ_ids" t-as="category" class="badge badge-primary mr-2 mb-2">
|
||||
<t t-out="category.name"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted">Aucune catégorie sélectionnée</p>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<h5>Tags</h5>
|
||||
<div class="mb-3">
|
||||
<t t-if="vendor_product.product_tag_ids">
|
||||
<span t-foreach="vendor_product.product_tag_ids" t-as="tag" class="badge badge-secondary mr-2 mb-2">
|
||||
<t t-out="tag.name"/>
|
||||
</span>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<p class="text-muted">Aucun tag sélectionné</p>
|
||||
</t>
|
||||
</div>
|
||||
|
||||
<a role="button" class="btn btn-primary btn-block" t-att-href="'/my/products/%s/categories' % vendor_product.id">
|
||||
<i class="fa fa-tags"/> Gérer les catégories et tags
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Extend the product template to add the image upload button -->
|
||||
<template id="st_laurent_portal_vendor" name="Product E-commerce" inherit_id="vendor_portal_management.vendor_product">
|
||||
<xpath expr="//div[@id='informations']" position="before">
|
||||
<div class="row mb-4">
|
||||
<div class="col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>Product Image</h4>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<img t-if="vendor_product.image_1920" t-att-src="image_data_uri(vendor_product.image_1920)" class="img-fluid mb-3" style="max-height: 300px;"/>
|
||||
<img t-else="" src="/web/static/img/placeholder.png" class="img-fluid mb-3" style="max-height: 300px;"/>
|
||||
<a role="button" class="btn btn-primary btn-block" t-att-href="'/my/products/%s/image' % vendor_product.id">
|
||||
<i class="fa fa-upload"/> Change Image
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Image upload form with preview and cropping -->
|
||||
<template id="vendor_product_image_form" name="Upload Product Image">
|
||||
<t t-call="portal.portal_layout">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Change Product Image</h1>
|
||||
<div t-if="error" class="alert alert-danger" role="alert">
|
||||
<t t-out="error"/>
|
||||
</div>
|
||||
<div t-if="success" class="alert alert-success" role="alert">
|
||||
<t t-out="success"/>
|
||||
</div>
|
||||
|
||||
<!-- Current product image -->
|
||||
<div id="image-container" class="text-center mb-4">
|
||||
<h4 class="mb-3">Current Image</h4>
|
||||
<img t-if="vendor_product.image_1920" t-att-src="image_data_uri(vendor_product.image_1920)" class="img-fluid mb-3" style="max-height: 300px;"/>
|
||||
<img t-else="" src="/web/static/img/placeholder.png" class="img-fluid mb-3" style="max-height: 300px;"/>
|
||||
</div>
|
||||
|
||||
<form id="image-upload-form" action="/my/products/update_image" method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="product_id" t-att-value="vendor_product.id"/>
|
||||
<input type="hidden" id="cropped_image" name="cropped_image" value=""/>
|
||||
|
||||
<div class="form-group mb-4">
|
||||
<label for="product_image">Select a new image</label>
|
||||
<div class="custom-file">
|
||||
<input type="file" class="form-control" id="product_image" name="product_image" accept="image/jpeg,image/png,image/gif"/>
|
||||
</div>
|
||||
<small class="form-text text-muted">Accepted formats: JPG, PNG, GIF. Maximum size: 5MB.</small>
|
||||
</div>
|
||||
|
||||
<!-- Cropping area -->
|
||||
<div id="cropper-container" class="d-none mb-4">
|
||||
<h4 class="mb-3">Crop Image</h4>
|
||||
<div class="img-container">
|
||||
<img id="image-preview" src="" class="img-fluid" alt="Aperçu de l'image"/>
|
||||
</div>
|
||||
|
||||
<!-- Contrôles du cropper -->
|
||||
<div class="cropper-controls d-none">
|
||||
<div class="aspect-ratio-controls">
|
||||
<h5>Format</h5>
|
||||
<div class="btn-group" role="group">
|
||||
<button type="button" class="btn btn-outline-primary aspect-ratio-button active" data-ratio="free">Libre</button>
|
||||
<button type="button" class="btn btn-outline-primary aspect-ratio-button" data-ratio="square">Carré</button>
|
||||
<button type="button" class="btn btn-outline-primary aspect-ratio-button" data-ratio="4:3">4:3</button>
|
||||
<button type="button" class="btn btn-outline-primary aspect-ratio-button" data-ratio="16:9">16:9</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<h5>Outils</h5>
|
||||
<div class="btn-toolbar">
|
||||
<div class="btn-group mr-2">
|
||||
<button type="button" id="zoom-in" class="btn btn-outline-secondary" title="Zoom avant">
|
||||
<i class="fa fa-search-plus"/>
|
||||
</button>
|
||||
<button type="button" id="zoom-out" class="btn btn-outline-secondary" title="Zoom arrière">
|
||||
<i class="fa fa-search-minus"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group mr-2">
|
||||
<button type="button" id="rotate-left" class="btn btn-outline-secondary" title="Rotation gauche">
|
||||
<i class="fa fa-rotate-left"/>
|
||||
</button>
|
||||
<button type="button" id="rotate-right" class="btn btn-outline-secondary" title="Rotation droite">
|
||||
<i class="fa fa-rotate-right"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<button type="button" id="reset-cropper" class="btn btn-outline-secondary" title="Réinitialiser">
|
||||
<i class="fa fa-refresh"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-6">
|
||||
<a href="#" onclick="window.history.back(); return false;" class="btn btn-secondary">
|
||||
<i class="fa fa-arrow-left"/> Retour
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-md-6 text-right">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fa fa-save"/> Enregistrer sans recadrage
|
||||
</button>
|
||||
<button type="button" id="crop-button" class="btn btn-success d-none ml-2">
|
||||
<i class="fa fa-crop"/> Recadrer et enregistrer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Le script pour le cropper d'image est chargé via les assets frontend -->
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
158
st_laurent_portal_vendor/views/vendor_product_views.xml
Normal file
158
st_laurent_portal_vendor/views/vendor_product_views.xml
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- E-commerce form view for vendor products -->
|
||||
<record id="st_laurent_portal_vendor_form_view" model="ir.ui.view">
|
||||
<field name="name">vendor.product.ecommerce.form</field>
|
||||
<field name="model">vendor.product</field>
|
||||
<field name="inherit_id" ref="vendor_product_management.vendor_product_view_form"/>
|
||||
<field name="priority">20</field>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Add button to create a standard product in the header -->
|
||||
<xpath expr="//sheet" position="before">
|
||||
<header>
|
||||
<button name="action_create_product" string="Create Standard Product" type="object" class="oe_highlight"/>
|
||||
</header>
|
||||
</xpath>
|
||||
|
||||
<!-- Add action buttons to publish/unpublish in the button box -->
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button name="action_publish_website" type="object"
|
||||
icon="fa-globe"
|
||||
invisible="website_published">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Publish</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="action_unpublish_website" type="object"
|
||||
icon="fa-globe"
|
||||
invisible="not website_published">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Unpublish</span>
|
||||
</div>
|
||||
</button>
|
||||
<button name="%(st_laurent_portal_vendor.action_create_standard_product)d" type="action"
|
||||
icon="fa-cube"
|
||||
class="oe_stat_button"
|
||||
help="Create a standard product from this vendor product">
|
||||
<div class="o_stat_info">
|
||||
<span class="o_stat_text">Create Product</span>
|
||||
</div>
|
||||
</button>
|
||||
</xpath>
|
||||
|
||||
<!-- Add image next to the ribbon -->
|
||||
<xpath expr="//sheet" position="inside">
|
||||
<field name="image_1920" widget="image" class="oe_avatar" options="{'preview_image': 'image_128'}"/>
|
||||
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" invisible="active"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Add reference fields after the product code -->
|
||||
<xpath expr="//field[@name='product_code']" position="after">
|
||||
<field name="default_code" string="Internal Reference"/>
|
||||
<field name="barcode" string="Barcode"/>
|
||||
</xpath>
|
||||
|
||||
<!-- Add a tab for e-commerce -->
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="E-commerce" name="ecommerce">
|
||||
<group>
|
||||
<group string="Publication">
|
||||
<field name="website_published"/>
|
||||
<field name="website_url" widget="url" invisible="not website_published"/>
|
||||
<field name="website_sequence"/>
|
||||
<field name="website_ribbon"/>
|
||||
</group>
|
||||
<group string="Disponibilité">
|
||||
<field name="availability"/>
|
||||
<field name="availability_date" invisible="availability != 'preorder'"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Prix">
|
||||
<field name="website_price" widget="monetary"/>
|
||||
</group>
|
||||
<group string="Catégorisation">
|
||||
<field name="public_categ_ids" widget="many2many_tags"/>
|
||||
<field name="product_tag_ids" widget="many2many_tags"/>
|
||||
</group>
|
||||
<group string="SEO">
|
||||
<field name="website_meta_title"/>
|
||||
<field name="website_meta_description"/>
|
||||
<field name="website_meta_keywords"/>
|
||||
<field name="website_meta_og_img" widget="image"/>
|
||||
</group>
|
||||
<group string="Description">
|
||||
<field name="website_description" colspan="4"/>
|
||||
</group>
|
||||
</page>
|
||||
<page string="Images" name="images">
|
||||
<group>
|
||||
<field name="image_1920" widget="image" options="{'size': [800, 800]}"/>
|
||||
<field name="image_1024" widget="image" options="{'size': [500, 500]}"/>
|
||||
<field name="image_512" widget="image" options="{'size': [300, 300]}"/>
|
||||
<field name="image_256" widget="image" options="{'size': [150, 150]}"/>
|
||||
<field name="image_128" widget="image" options="{'size': [100, 100]}"/>
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue liste e-commerce pour les produits fournisseurs -->
|
||||
<record id="st_laurent_portal_vendor_tree_view" model="ir.ui.view">
|
||||
<field name="name">vendor.product.ecommerce.tree</field>
|
||||
<field name="model">vendor.product</field>
|
||||
<field name="inherit_id" ref="vendor_product_management.vendor_product_view_tree"/>
|
||||
<field name="priority">20</field>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Ajouter les champs e-commerce à la vue liste -->
|
||||
<xpath expr="//field[@name='product_code']" position="after">
|
||||
<field name="website_published" widget="boolean_toggle"/>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='vendor_quantity']" position="after">
|
||||
<field name="website_price"/>
|
||||
<field name="availability"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue recherche e-commerce pour les produits fournisseurs -->
|
||||
<record id="st_laurent_portal_vendor_search_view" model="ir.ui.view">
|
||||
<field name="name">vendor.product.ecommerce.search</field>
|
||||
<field name="model">vendor.product</field>
|
||||
<field name="inherit_id" ref="vendor_product_management.vendor_product_view_search"/>
|
||||
<field name="priority">20</field>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Ajouter les filtres e-commerce à la vue recherche -->
|
||||
<xpath expr="//filter[@name='inactive']" position="before">
|
||||
<filter string="Publié sur le site web" name="website_published" domain="[('website_published', '=', True)]"/>
|
||||
<filter string="Non publié" name="not_published" domain="[('website_published', '=', False)]"/>
|
||||
<separator/>
|
||||
</xpath>
|
||||
<xpath expr="//filter[@name='product_tmpl_id_group']" position="after">
|
||||
<filter name="availability_group" string="Disponibilité" context="{'group_by' : 'availability'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour les produits fournisseurs e-commerce -->
|
||||
<record id="st_laurent_portal_vendor_action" model="ir.actions.act_window">
|
||||
<field name="name">Produits Fournisseurs E-commerce</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">vendor.product</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_id" ref="st_laurent_portal_vendor_tree_view"/>
|
||||
<field name="search_view_id" ref="st_laurent_portal_vendor_search_view"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Créez votre premier produit fournisseur pour l'e-commerce
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu pour les produits fournisseurs e-commerce -->
|
||||
<menuitem id="menu_st_laurent_portal_vendor"
|
||||
name="Produits Fournisseurs E-commerce"
|
||||
action="st_laurent_portal_vendor_action"
|
||||
parent="website_sale.menu_catalog"
|
||||
sequence="20"/>
|
||||
</odoo>
|
||||
138
st_laurent_portal_vendor/views/vendor_request_views.xml
Normal file
138
st_laurent_portal_vendor/views/vendor_request_views.xml
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vue formulaire pour les demandes de vendeur -->
|
||||
<record id="vendor_request_view_form" model="ir.ui.view">
|
||||
<field name="name">vendor.request.form</field>
|
||||
<field name="model">vendor.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Demande de vendeur" create="false">
|
||||
<header>
|
||||
|
||||
<button name="action_approve" string="Approuver" type="object" class="btn-success"
|
||||
if="state == 'pending'" groups="base.group_system"/>
|
||||
<button name="action_reject" string="Rejeter" type="object" class="btn-danger"
|
||||
if="state == 'pending'" groups="base.group_system"/>
|
||||
<button name="action_reset_to_draft" string="Réinitialiser" type="object" class="btn-secondary"
|
||||
if="state not in ('draft', 'pending')" groups="base.group_system"/>
|
||||
<field name="state" widget="statusbar" statusbar_visible="draft,pending,approved,rejected"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<h1>
|
||||
<field name="name" readonly="1"/>
|
||||
</h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Informations utilisateur">
|
||||
<field name="user_id" options="{'no_create': True, 'no_open': True}"/>
|
||||
<field name="partner_id" options="{'no_create': True, 'no_open': True}"/>
|
||||
<field name="company_name"/>
|
||||
<field name="company_country_id"/>
|
||||
<field name="company_state_id"/>
|
||||
</group>
|
||||
<group string="Dates">
|
||||
<field name="create_date" string="Date de demande" readonly="1"/>
|
||||
<field name="approved_date" invisible="not approved_date"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Description">
|
||||
<field name="description" placeholder="Décrivez votre entreprise et les produits que vous souhaitez vendre..."/>
|
||||
</page>
|
||||
<page string="Documents" invisible="not attachment_ids">
|
||||
<field name="attachment_ids" widget="many2many_binary"/>
|
||||
</page>
|
||||
<page string="Motif de rejet" invisible="state != 'rejected'">
|
||||
<field name="rejection_reason" readonly="1"/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<chatter position="bottom">
|
||||
<field name="message_follower_ids" widget="mail_followers"/>
|
||||
<field name="activity_ids" widget="mail_activity"/>
|
||||
<field name="message_ids" widget="mail_thread"/>
|
||||
</chatter>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue liste pour les demandes de vendeur -->
|
||||
<record id="vendor_request_view_tree" model="ir.ui.view">
|
||||
<field name="name">vendor.request.tree</field>
|
||||
<field name="model">vendor.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Demandes de vendeur" create="false" decoration-info="state=='draft'" decoration-warning="state=='pending'" decoration-success="state=='approved'" decoration-danger="state=='rejected'">
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="company_name"/>
|
||||
<field name="create_date" string="Date de demande"/>
|
||||
<field name="state"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue recherche pour les demandes de vendeur -->
|
||||
<record id="vendor_request_view_search" model="ir.ui.view">
|
||||
<field name="name">vendor.request.search</field>
|
||||
<field name="model">vendor.request</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Rechercher des demandes de vendeur">
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="company_name"/>
|
||||
<separator/>
|
||||
<filter string="Brouillons" name="draft" domain="[('state', '=', 'draft')]"/>
|
||||
<filter string="En attente" name="pending" domain="[('state', '=', 'pending')]"/>
|
||||
<filter string="Approuvées" name="approved" domain="[('state', '=', 'approved')]"/>
|
||||
<filter string="Rejetées" name="rejected" domain="[('state', '=', 'rejected')]"/>
|
||||
<group expand="0" string="Regrouper par">
|
||||
<filter string="État" name="group_by_state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Utilisateur" name="group_by_user" context="{'group_by': 'user_id'}"/>
|
||||
<filter string="Date de création" name="group_by_create_date" context="{'group_by': 'create_date:month'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action pour les demandes de vendeur -->
|
||||
<record id="action_vendor_requests" model="ir.actions.act_window">
|
||||
<field name="name">Demandes de vendeur</field>
|
||||
<field name="res_model">vendor.request</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_pending': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Aucune demande de vendeur trouvée
|
||||
</p>
|
||||
<p>
|
||||
Les utilisateurs peuvent demander à devenir vendeur depuis leur portail client.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu pour les demandes de vendeur -->
|
||||
<menuitem id="menu_vendor_requests"
|
||||
name="Demandes de vendeur"
|
||||
parent="st_laurent_portal_vendor.menu_vendor_product_root"
|
||||
action="action_vendor_requests"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- Vue formulaire pour le wizard de rejet -->
|
||||
<record id="vendor_request_reject_wizard_view_form" model="ir.ui.view">
|
||||
<field name="name">vendor.request.reject.wizard.form</field>
|
||||
<field name="model">vendor.request.reject.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Rejeter la demande">
|
||||
<p>Veuillez indiquer le motif du rejet de cette demande.</p>
|
||||
<group>
|
||||
<field name="request_id" invisible="1"/>
|
||||
<field name="rejection_reason" placeholder="Expliquez pourquoi cette demande est rejetée..."/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_confirm_reject" string="Confirmer" type="object" class="btn-primary"/>
|
||||
<button string="Annuler" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
52
st_laurent_portal_vendor/views/vendor_shop_templates.xml
Normal file
52
st_laurent_portal_vendor/views/vendor_shop_templates.xml
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<odoo>
|
||||
<template id="vendor_shop_page" name="Vendor Shop">
|
||||
<t t-call="website.layout">
|
||||
<div class="container my-5">
|
||||
<div class="row mb-4">
|
||||
<div class="col-12 text-center">
|
||||
<h1 class="display-4">
|
||||
<i class="fa fa-store-alt text-primary me-2"></i>
|
||||
<t t-esc="shop.name"/>
|
||||
</h1>
|
||||
<p class="lead"><t t-translation="on">Discover this vendor's products</t></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<t t-if="products">
|
||||
<t t-foreach="products" t-as="product">
|
||||
<div class="col-md-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<a t-att-href="'/shop/product/%s' % product.id">
|
||||
<img t-att-src="'/web/image/product.template/%s/image_1920' % product.id" class="card-img-top" style="max-height:200px; object-fit:contain;" alt="Product Image"/>
|
||||
</a>
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">
|
||||
<a t-att-href="'/shop/product/%s' % product.id" class="text-decoration-none text-dark">
|
||||
<t t-esc="product.name"/>
|
||||
</a>
|
||||
</h5>
|
||||
<p class="card-text">
|
||||
<t t-esc="product.description_sale or ''"/>
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer bg-white border-0">
|
||||
<strong class="text-primary fs-5">
|
||||
<t t-esc="product.lst_price"/> €
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="col-12 text-center">
|
||||
<div class="alert alert-info my-5">
|
||||
<t t-translation="on">No products available for this shop.</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
3
st_laurent_portal_vendor/wizards/__init__.py
Normal file
3
st_laurent_portal_vendor/wizards/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import vendor_request_reject_wizard
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
|
||||
|
||||
class VendorRequestRejectWizard(models.TransientModel):
|
||||
_name = 'vendor.request.reject.wizard'
|
||||
_description = 'Assistant de rejet de demande de vendeur'
|
||||
|
||||
request_id = fields.Many2one(
|
||||
'vendor.request',
|
||||
string="Demande",
|
||||
required=True
|
||||
)
|
||||
rejection_reason = fields.Text(
|
||||
string="Motif du rejet",
|
||||
required=True
|
||||
)
|
||||
|
||||
def action_confirm_reject(self):
|
||||
"""Confirme le rejet de la demande"""
|
||||
self.ensure_one()
|
||||
|
||||
# Mettre à jour la demande
|
||||
self.request_id.write({
|
||||
'state': 'rejected',
|
||||
'rejection_reason': self.rejection_reason
|
||||
})
|
||||
|
||||
# Notifier l'utilisateur
|
||||
self.request_id.message_post(
|
||||
body=_("Votre demande pour devenir vendeur a été rejetée pour la raison suivante: %s") % self.rejection_reason,
|
||||
partner_ids=[self.request_id.partner_id.id],
|
||||
subtype_xmlid='mail.mt_note'
|
||||
)
|
||||
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Vue formulaire pour le wizard de rejet -->
|
||||
<record id="vendor_request_reject_wizard_view_form" model="ir.ui.view">
|
||||
<field name="name">vendor.request.reject.wizard.form</field>
|
||||
<field name="model">vendor.request.reject.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Rejeter la demande">
|
||||
<p>Veuillez indiquer le motif du rejet de cette demande.</p>
|
||||
<group>
|
||||
<field name="request_id" invisible="1"/>
|
||||
<field name="rejection_reason" placeholder="Expliquez pourquoi cette demande est rejetée..."/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_confirm_reject" string="Confirmer" type="object" class="btn-primary"/>
|
||||
<button string="Annuler" class="btn-secondary" special="cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
5
st_laurent_vendor_orders/__init__.py
Normal file
5
st_laurent_vendor_orders/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
48
st_laurent_vendor_orders/__manifest__.py
Normal file
48
st_laurent_vendor_orders/__manifest__.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
"name": "Vendor Orders Management",
|
||||
"version": "18.0.1.0.0",
|
||||
"category": "Sales",
|
||||
"author": "Bemade",
|
||||
"website": "https://bemade.org",
|
||||
"license": "LGPL-3",
|
||||
"summary": "Gestion des commandes pour les vendeurs dans le portail",
|
||||
"description": """
|
||||
Ce module étend les fonctionnalités du portail vendeur en ajoutant la gestion des commandes.
|
||||
|
||||
Fonctionnalités:
|
||||
- Interface pour que les vendeurs puissent voir les commandes de leurs produits
|
||||
- Système de notification pour les nouvelles commandes
|
||||
- Gestion des expéditions par les vendeurs
|
||||
- Suivi des commandes et des statuts d'expédition
|
||||
""",
|
||||
"depends": [
|
||||
"base",
|
||||
"mail",
|
||||
"portal",
|
||||
"website_sale",
|
||||
"product",
|
||||
"sale",
|
||||
"delivery",
|
||||
"vendor_product_management",
|
||||
"vendor_portal_management",
|
||||
"st_laurent_portal_vendor",
|
||||
],
|
||||
"data": [
|
||||
# "security/security.xml",
|
||||
"security/ir.model.access.csv",
|
||||
# "views/vendor_order_views.xml",
|
||||
# "views/vendor_order_portal_templates.xml",
|
||||
# "views/portal_menu_templates.xml",
|
||||
# "data/mail_templates.xml",
|
||||
],
|
||||
"assets": {
|
||||
"web.assets_frontend": [
|
||||
"st_laurent_vendor_orders/static/src/scss/vendor_orders.scss",
|
||||
"st_laurent_vendor_orders/static/src/js/vendor_orders.js",
|
||||
],
|
||||
},
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"auto_install": False,
|
||||
}
|
||||
3
st_laurent_vendor_orders/controllers/__init__.py
Normal file
3
st_laurent_vendor_orders/controllers/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import portal
|
||||
132
st_laurent_vendor_orders/controllers/portal.py
Normal file
132
st_laurent_vendor_orders/controllers/portal.py
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
# -*- 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, MissingError
|
||||
from odoo.osv.expression import OR
|
||||
|
||||
|
||||
class VendorOrderPortal(CustomerPortal):
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
values = super()._prepare_home_portal_values(counters)
|
||||
partner = request.env.user.partner_id
|
||||
|
||||
vendor_order_model = request.env['vendor.order']
|
||||
if 'vendor_order_count' in counters:
|
||||
values['vendor_order_count'] = vendor_order_model.search_count([
|
||||
('vendor_id', '=', partner.id)
|
||||
]) if vendor_order_model.check_access_rights('read', raise_exception=False) else 0
|
||||
|
||||
return values
|
||||
|
||||
def _get_vendor_order_domain(self, partner):
|
||||
return [
|
||||
('vendor_id', '=', partner.id),
|
||||
]
|
||||
|
||||
@http.route(['/my/vendor/orders', '/my/vendor/orders/page/<int:page>'], type='http', auth="user", website=True)
|
||||
def portal_my_vendor_orders(self, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw):
|
||||
values = self._prepare_portal_layout_values()
|
||||
partner = request.env.user.partner_id
|
||||
VendorOrder = request.env['vendor.order']
|
||||
|
||||
domain = self._get_vendor_order_domain(partner)
|
||||
|
||||
if date_begin and date_end:
|
||||
domain += [('date_order', '>', date_begin), ('date_order', '<=', date_end)]
|
||||
|
||||
searchbar_sortings = {
|
||||
'date': {'label': _('Date de commande'), 'order': 'date_order desc'},
|
||||
'name': {'label': _('Référence'), 'order': 'name'},
|
||||
'state': {'label': _('Statut'), 'order': 'state'},
|
||||
}
|
||||
|
||||
searchbar_filters = {
|
||||
'all': {'label': _('Toutes'), 'domain': []},
|
||||
'new': {'label': _('Nouvelles'), 'domain': [('state', '=', 'new')]},
|
||||
'processing': {'label': _('En traitement'), 'domain': [('state', '=', 'processing')]},
|
||||
'shipped': {'label': _('Expédiées'), 'domain': [('state', '=', 'shipped')]},
|
||||
'delivered': {'label': _('Livrées'), 'domain': [('state', '=', 'delivered')]},
|
||||
'cancelled': {'label': _('Annulées'), 'domain': [('state', '=', 'cancelled')]},
|
||||
}
|
||||
|
||||
# default sortby order
|
||||
if not sortby:
|
||||
sortby = 'date'
|
||||
sort_order = searchbar_sortings[sortby]['order']
|
||||
|
||||
# default filter by value
|
||||
if not filterby:
|
||||
filterby = 'all'
|
||||
domain += searchbar_filters[filterby]['domain']
|
||||
|
||||
# count for pager
|
||||
vendor_order_count = VendorOrder.search_count(domain)
|
||||
|
||||
# make pager
|
||||
pager = portal_pager(
|
||||
url="/my/vendor/orders",
|
||||
url_args={'date_begin': date_begin, 'date_end': date_end, 'sortby': sortby, 'filterby': filterby},
|
||||
total=vendor_order_count,
|
||||
page=page,
|
||||
step=self._items_per_page
|
||||
)
|
||||
|
||||
# search the count to display, according to the pager data
|
||||
vendor_orders = VendorOrder.search(domain, order=sort_order, limit=self._items_per_page, offset=pager['offset'])
|
||||
request.session['my_vendor_orders_history'] = vendor_orders.ids[:100]
|
||||
|
||||
values.update({
|
||||
'date': date_begin,
|
||||
'vendor_orders': vendor_orders,
|
||||
'page_name': 'vendor_order',
|
||||
'pager': pager,
|
||||
'default_url': '/my/vendor/orders',
|
||||
'searchbar_sortings': searchbar_sortings,
|
||||
'sortby': sortby,
|
||||
'searchbar_filters': searchbar_filters,
|
||||
'filterby': filterby,
|
||||
})
|
||||
return request.render("st_laurent_vendor_orders.portal_my_vendor_orders", values)
|
||||
|
||||
@http.route(['/my/vendor/orders/<int:order_id>'], type='http', auth="user", website=True)
|
||||
def portal_my_vendor_order_detail(self, order_id, **kw):
|
||||
try:
|
||||
order_sudo = self._document_check_access('vendor.order', order_id)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
values = self._vendor_order_get_page_view_values(order_sudo, **kw)
|
||||
return request.render("st_laurent_vendor_orders.portal_vendor_order_page", values)
|
||||
|
||||
def _vendor_order_get_page_view_values(self, order, **kwargs):
|
||||
values = {
|
||||
'order': order,
|
||||
'page_name': 'vendor_order',
|
||||
}
|
||||
return self._get_page_view_values(order, False, values, 'my_vendor_orders_history', False, **kwargs)
|
||||
|
||||
@http.route(['/my/vendor/orders/<int:order_id>/ship'], type='http', auth="user", website=True)
|
||||
def portal_vendor_order_ship(self, order_id, tracking_number=None, carrier_id=None, **kw):
|
||||
try:
|
||||
order_sudo = self._document_check_access('vendor.order', order_id)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my')
|
||||
|
||||
if tracking_number and carrier_id:
|
||||
order_sudo.write({
|
||||
'tracking_number': tracking_number,
|
||||
'carrier_id': int(carrier_id),
|
||||
})
|
||||
order_sudo.action_ship()
|
||||
return request.redirect('/my/vendor/orders/%s' % order_id)
|
||||
|
||||
carriers = request.env['delivery.carrier'].sudo().search([])
|
||||
values = {
|
||||
'order': order_sudo,
|
||||
'carriers': carriers,
|
||||
'page_name': 'vendor_order',
|
||||
}
|
||||
return request.render("st_laurent_vendor_orders.portal_vendor_order_ship", values)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue