Revert "Clean up repository for apps.odoo.com sharing: Remove non-18.0 addons and document them in README.md"
Addons were butchered by this commit.
This reverts commit b33f25c688.
This commit is contained in:
parent
1ef0102dd2
commit
4b2b53caa7
977 changed files with 49159 additions and 106 deletions
106
README.md
106
README.md
|
|
@ -1,108 +1,2 @@
|
|||
# bemade-addons
|
||||
Odoo Addons made by Bemade
|
||||
|
||||
## Addons Ported to 18.0
|
||||
|
||||
The following addons have been ported to Odoo 18.0 and are available in this repository:
|
||||
|
||||
- account_email_to_pdf (18.0.1.0.0)
|
||||
- batch_picking_create_one_bill (18.0.1.0.1)
|
||||
- caldav_sync (18.0.0.7.0)
|
||||
- customer_product_code_search (18.0.1.0.0)
|
||||
- delivery_carrier_partner_account (18.0.0.1.2)
|
||||
- incrementing_sequence_mixin (18.0.1.0.0)
|
||||
- portal_partner_manager (18.0.1.0.0)
|
||||
- preamble_on_quotation (18.0.0.1.0)
|
||||
- product_supplierinfo_tracking (18.0.2.0.5)
|
||||
- purchase_delivery_carrier (18.0.1.0.0)
|
||||
- reception_purchase_total (18.0.1.0.0)
|
||||
- sale_mandatory_customer_reference (18.0.1.0.0)
|
||||
- sale_order_show_delivery_address (18.0.1.0.0)
|
||||
- shipping_information_on_customer_invoice (18.0.0.1)
|
||||
- st_laurent_portal_vendor (18.0.1.0.0)
|
||||
- st_laurent_vendor_orders (18.0.1.0.0)
|
||||
- unifi_integration (18.0.1.0.1)
|
||||
|
||||
## Addons To Be Ported from 17.0 to 18.0
|
||||
|
||||
The following addons need to be ported from Odoo 17.0 to 18.0. They have been temporarily removed from this branch but can be reintegrated following the OCA methodology (creating a patch from 17.0 to bring the code in with its history):
|
||||
|
||||
- account_credit_hold (17.0.1.1.1)
|
||||
- aged_partner_balance_na
|
||||
- ai_integration (1.0)
|
||||
- ai_integration_ollama_api
|
||||
- ai_integration_openai_api (1.0)
|
||||
- bemade_add_follower_no_sendmail_default (17.0.0.0.1)
|
||||
- bemade_attachments_cleanup
|
||||
- bemade_documents_portal
|
||||
- bemade_fetchmail_only_production (17.0.0.0.1)
|
||||
- bemade_fix_quality_report
|
||||
- bemade_fsm (17.0.0.4.2)
|
||||
- bemade_full_formview_from_modal
|
||||
- bemade_git_repos_addons
|
||||
- bemade_helpdesk_mailcow_blacklist
|
||||
- bemade_helpdesk_one_ticket_per_email
|
||||
- bemade_hide_decimal_on_unit (17.0.0.1.1)
|
||||
- bemade_l10n_ca_payroll
|
||||
- bemade_mailcow_integration
|
||||
- bemade_margin_vendor_pricelist
|
||||
- bemade_module_linker
|
||||
- bemade_multiple_billing_contacts
|
||||
- bemade_odoo_partner_scrapper
|
||||
- bemade_odoo_partner_scrapper_js_only
|
||||
- bemade_open_project_details
|
||||
- bemade_packing_wizard
|
||||
- bemade_partner_email_domain
|
||||
- bemade_partner_root_ancestor
|
||||
- bemade_payslip_done_as_not_paid
|
||||
- bemade_picking_upstream
|
||||
- bemade_purchase_warn_supplier_overdue (1.0)
|
||||
- bemade_pwa_config
|
||||
- bemade_quotation_alternative
|
||||
- bemade_reordering_rules_chatter (17.0.0.0.1)
|
||||
- bemade_sale_order_line_number
|
||||
- bemade_search_supplier_code
|
||||
- bemade_so_and_po_only_company (17.0.0.0.1)
|
||||
- bemade_so_followers_to_picking (17.0.0.0.1)
|
||||
- bemade_sports_clinic
|
||||
- bemade_stock_quant_valuation
|
||||
- bemade_time_off_follower (17.0.0.0.3)
|
||||
- bemade_total_show_currency
|
||||
- bemade_update_validity_date_when_send_so (17.0.0.0.1)
|
||||
- bemade_user_custom_apps_order
|
||||
- bemade_user_password_bundle
|
||||
- bemade_utils
|
||||
- client_name_on_manufacturing_orders (17.0.2.0.0)
|
||||
- commercial_invoice
|
||||
- confirm_many2one_create (17.0.1.0.0)
|
||||
- current_user_as_invoice_user
|
||||
- customer_applications (17.0.1.0.0)
|
||||
- customer_itch_cycle
|
||||
- fsm_equipment (17.0.0.2.0)
|
||||
- fsm_visit_confirmation (17.0.0.1.0)
|
||||
- impersonate_user (17.0.0.0.1)
|
||||
- improved_mo_origin (17.0.1.0.0)
|
||||
- interactive_discuss_ai
|
||||
- l10n_ca_pst_reports
|
||||
- mo_back_to_draft
|
||||
- msg_attachments_to_mail_message
|
||||
- msg_viewer
|
||||
- multi_account_statement_import
|
||||
- odoo_proxmox_manager
|
||||
- odoo_rsync_backup
|
||||
- odoo_to_odoo_bemade
|
||||
- odoo_to_odoo_bemade_customer
|
||||
- odoo_to_odoo_sync
|
||||
- partner_equipment_applications (17.0.1.0.0)
|
||||
- picking_policy_per_customer
|
||||
- portal_hide_draft_order_details (17.0.1.0.0)
|
||||
- price_update_notifications (17.0.1.0.0)
|
||||
- purchase_customer_requisition (1.0)
|
||||
- qc_partner_default_lang
|
||||
- recursive_tree_view (17.0.0.0.2)
|
||||
- replace_invoicing_user
|
||||
- stock_valuation_location (17.0.1.0.0)
|
||||
- temporary_change_image_size
|
||||
- theme_durpro
|
||||
- wazo_integration
|
||||
- website_geoip_extended
|
||||
|
|
|
|||
1
account_credit_hold/__init__.py
Normal file
1
account_credit_hold/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
20
account_credit_hold/__manifest__.py
Normal file
20
account_credit_hold/__manifest__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "Account Credit Hold",
|
||||
"version": "17.0.1.1.1",
|
||||
"summary": "Allows setting clients on credit hold, blocking the ability confirm a new sales order.",
|
||||
"category": "Accounting/Accounting",
|
||||
"author": "Bemade Inc.",
|
||||
"maintainer": "Marc Durepos <marc@bemade.org>",
|
||||
"website": "http://www.bemade.org",
|
||||
"license": "LGPL-3",
|
||||
"depends": ["sale", "account_followup", "stock"],
|
||||
"data": [
|
||||
"views/account_followup_views.xml",
|
||||
"views/sale_order_views.xml",
|
||||
"views/res_partner_views.xml",
|
||||
"views/stock_picking_views.xml",
|
||||
],
|
||||
"demo": [],
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
}
|
||||
246
account_credit_hold/migration18.md
Normal file
246
account_credit_hold/migration18.md
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
# Migration vers Odoo 18.0 - Module account_credit_hold
|
||||
|
||||
## Fonctionnalités
|
||||
- Ajoute un champ "Place on Credit Hold" sur les lignes de suivi de compte (account_followup.followup.line)
|
||||
- Ajoute des champs et fonctionnalités sur les partenaires:
|
||||
- postpone_hold_until: Date de report du blocage
|
||||
- hold_bg: Champ technique pour le statut de blocage
|
||||
- on_hold: État calculé du blocage de crédit
|
||||
- Bloque la confirmation des commandes de vente si le client est en blocage de crédit
|
||||
- Ajoute des indicateurs visuels (ruban rouge) sur:
|
||||
- Commandes de vente
|
||||
- Fiches partenaires
|
||||
- Transferts de stock
|
||||
- Ajoute des boutons pour mettre/lever le blocage de crédit dans la vue de suivi des comptes
|
||||
|
||||
## Analyse pour la Migration
|
||||
|
||||
### Dépendances
|
||||
- sale
|
||||
- account_followup
|
||||
- stock
|
||||
|
||||
### Changements Techniques Requis
|
||||
1. Mettre à jour la version dans __manifest__.py vers 18.0
|
||||
2. Vérifier la compatibilité des vues XML avec Odoo 18.0
|
||||
3. Vérifier si des changements dans l'API account_followup en 18.0
|
||||
|
||||
### Points d'Attention
|
||||
1. Le module utilise l'héritage de vues et de modèles standard d'Odoo:
|
||||
- account_followup.followup.line
|
||||
- res.partner
|
||||
- sale.order
|
||||
- stock.picking
|
||||
- account.followup.report
|
||||
|
||||
2. Fonctionnalités critiques à tester après migration:
|
||||
- Calcul automatique du statut on_hold
|
||||
- Blocage de la confirmation des commandes
|
||||
- Nettoyage automatique des reports de blocage expirés (@api.autovacuum)
|
||||
- Affichage correct des rubans d'avertissement
|
||||
- Propagation du statut hold aux contacts liés (commercial_partner_id)
|
||||
|
||||
3. Implémentation Technique:
|
||||
- Utilisation de champs computed avec store=True et compute_sudo=True
|
||||
- Mécanisme de nettoyage automatique via @api.autovacuum
|
||||
- Héritage de _execute_followup_partner pour automatisation du hold
|
||||
- Messages de chatter automatiques lors des changements de statut
|
||||
|
||||
4. Points Spécifiques aux Vues:
|
||||
- Utilisation du widget web_ribbon pour les indicateurs visuels
|
||||
- Boutons conditionnels dans la vue de suivi des comptes
|
||||
- Champs invisibles pour la logique d'affichage (hold_bg, on_hold)
|
||||
- Groupes de sécurité sur le champ postpone_hold_until
|
||||
|
||||
## Questions et Considérations
|
||||
|
||||
1. Vérifier si Odoo 18.0 n'a pas introduit des fonctionnalités natives similaires dans account_followup:
|
||||
- Système de blocage automatique des clients
|
||||
- Gestion des périodes de grâce
|
||||
- Indicateurs visuels de blocage
|
||||
|
||||
2. Points à valider:
|
||||
- La structure des vues héritées est-elle identique en 18.0?
|
||||
- Les champs related et computed fonctionnent-ils de la même manière?
|
||||
- Le système de suivi des comptes (account_followup) a-t-il évolué?
|
||||
- Le décorateur @api.autovacuum est-il toujours supporté?
|
||||
- Le widget web_ribbon utilise-t-il toujours la même API?
|
||||
|
||||
3. Considérations d'Architecture:
|
||||
- Le mécanisme de propagation du statut hold via commercial_partner_id est-il optimal?
|
||||
- Possibilité de simplifier la logique de calcul du statut hold?
|
||||
- Pertinence de stocker le champ hold_bg vs calcul à la demande
|
||||
|
||||
4. Alternatives Potentielles:
|
||||
- Utiliser le système de credit limit natif d'Odoo avec des règles personnalisées?
|
||||
- Intégrer avec le système de blocage des partenaires d'Odoo?
|
||||
- Utiliser les étapes de facturation (invoice_status) plutôt qu'un champ séparé?
|
||||
|
||||
## Alternatives Natives Odoo 18.0
|
||||
|
||||
### Système de Crédit Natif
|
||||
1. Odoo 18.0 inclut des fonctionnalités natives de gestion de crédit:
|
||||
- Champ `credit_limit` sur res.partner
|
||||
- Configuration du blocage au niveau de la société
|
||||
- Règles de blocage basées sur:
|
||||
- Montant de crédit maximum
|
||||
- Factures échues
|
||||
- Âge des factures
|
||||
|
||||
2. Possibilités d'utilisation des fonctionnalités natives:
|
||||
- Utiliser `credit_limit` au lieu de `on_hold`
|
||||
- Configurer les règles de blocage dans la configuration de la comptabilité
|
||||
- Utiliser les notifications natives de dépassement de crédit
|
||||
|
||||
### Améliorations Possibles
|
||||
1. Intégration avec le système natif:
|
||||
- Synchroniser notre `on_hold` avec le système natif de blocage
|
||||
- Utiliser les API natives de vérification de crédit
|
||||
- Conserver uniquement les fonctionnalités non disponibles nativement
|
||||
|
||||
2. Simplification du code:
|
||||
- Remplacer les champs custom par des champs natifs quand possible
|
||||
- Utiliser le système d'alertes natif pour les rubans
|
||||
- Intégrer avec le système de workflow natif
|
||||
|
||||
## Recommandations pour la Migration
|
||||
|
||||
### Approche "Vanilla First"
|
||||
1. Évaluer chaque fonctionnalité custom:
|
||||
- Est-elle disponible nativement dans Odoo 18.0?
|
||||
- Peut-elle être remplacée par une configuration native?
|
||||
- Le besoin business existe-t-il toujours?
|
||||
|
||||
2. Prioriser l'utilisation des fonctionnalités natives:
|
||||
- Système de crédit natif
|
||||
- Système de workflow natif
|
||||
- API de notification standard
|
||||
- Widgets standards de l'interface
|
||||
|
||||
### Modifications Techniques Recommandées
|
||||
1. Remplacer les attributs obsolètes:
|
||||
- Supprimer les `attrs` dans les vues (Odoo 16.0+)
|
||||
- Utiliser `list` au lieu de `tree` (Odoo 17.0+)
|
||||
- Adapter les widgets aux nouvelles conventions
|
||||
|
||||
2. Optimisation des performances:
|
||||
- Utiliser les indexes de base de données appropriés
|
||||
- Optimiser les recherches et calculs
|
||||
- Implémenter le lazy loading quand possible
|
||||
|
||||
### Plan de Test Approfondi
|
||||
1. Tests fonctionnels:
|
||||
- Validation du comportement avec le système natif
|
||||
- Tests de régression sur les fonctionnalités custom
|
||||
- Vérification des performances
|
||||
|
||||
2. Tests d'intégration:
|
||||
- Interaction avec le workflow de vente
|
||||
- Synchronisation avec la comptabilité
|
||||
- Comportement avec les autres modules
|
||||
|
||||
## État de la Migration
|
||||
⚪ En analyse préliminaire
|
||||
|
||||
## Plan de Migration
|
||||
|
||||
### Étape 1: Analyse des Changements Odoo 18.0
|
||||
- [ ] Examiner les changements dans account_followup
|
||||
- [ ] Vérifier les nouvelles fonctionnalités de gestion de crédit
|
||||
- [ ] Analyser les modifications des vues héritées
|
||||
|
||||
### Étape 2: Adaptation Technique
|
||||
- [ ] Mise à jour du manifeste
|
||||
- [ ] Vérification de la compatibilité des décorateurs
|
||||
- [ ] Adaptation des vues XML si nécessaire
|
||||
- [ ] Test des champs computed et related
|
||||
|
||||
### Étape 3: Tests Fonctionnels
|
||||
- [ ] Validation du mécanisme de hold
|
||||
- [ ] Test de la propagation aux contacts
|
||||
- [ ] Vérification des nettoyages automatiques
|
||||
- [ ] Test des indicateurs visuels
|
||||
|
||||
### Étape 4: Optimisation
|
||||
- [ ] Évaluation des alternatives natives
|
||||
- [ ] Simplification potentielle du code
|
||||
- [ ] Amélioration des performances
|
||||
|
||||
## Notes de Version
|
||||
- Version originale: 17.0.1.1.1
|
||||
- Dernière analyse: 26/01/2025
|
||||
|
||||
## Fonctionnalités Natives dans Odoo 18.0
|
||||
|
||||
Odoo 18.0 inclut nativement plusieurs fonctionnalités de gestion du crédit :
|
||||
|
||||
1. **Gestion des Limites de Crédit**
|
||||
- Champ `credit_limit` sur les partenaires
|
||||
- Champ `use_partner_credit_limit` pour activer/désactiver par partenaire
|
||||
- Configuration globale `account_use_credit_limit` au niveau de la société
|
||||
- Champ `credit` pour le total des créances
|
||||
- Champ `trust` pour le niveau de confiance du débiteur
|
||||
|
||||
2. **Visibilité et Contrôle**
|
||||
- Champ `show_credit_limit` basé sur la configuration de la société
|
||||
- Groupes de sécurité pour la gestion des limites de crédit
|
||||
|
||||
### Différences avec Notre Module
|
||||
|
||||
1. **Fonctionnalités à Migrer**
|
||||
- [ ] Indicateurs visuels spécifiques pour les clients en dépassement
|
||||
- [ ] Blocage automatique des commandes en dépassement
|
||||
- [ ] Workflow d'approbation personnalisé
|
||||
|
||||
2. **Fonctionnalités à Adapter**
|
||||
- [ ] Utiliser les champs natifs plutôt que nos champs customs
|
||||
- [ ] Intégrer nos règles de blocage avec le système natif
|
||||
- [ ] Adapter les rapports et vues pour utiliser les champs natifs
|
||||
|
||||
## Plan de Migration
|
||||
|
||||
### Phase 1 : Préparation
|
||||
1. **Analyse des Données**
|
||||
- [ ] Identifier les clients avec des limites de crédit
|
||||
- [ ] Mapper les champs actuels vers les champs natifs
|
||||
- [ ] Lister les règles de blocage personnalisées
|
||||
|
||||
2. **Configuration**
|
||||
- [ ] Activer la gestion du crédit dans la configuration de la société
|
||||
- [ ] Configurer les groupes de sécurité appropriés
|
||||
- [ ] Préparer les scripts de migration des données
|
||||
|
||||
### Phase 2 : Migration
|
||||
1. **Migration des Données**
|
||||
- [ ] Transférer les limites de crédit vers le champ natif
|
||||
- [ ] Migrer les configurations de blocage
|
||||
- [ ] Mettre à jour les vues et rapports
|
||||
|
||||
2. **Développement**
|
||||
- [ ] Adapter le code de blocage des commandes
|
||||
- [ ] Implémenter les indicateurs visuels manquants
|
||||
- [ ] Ajouter les fonctionnalités spécifiques non disponibles nativement
|
||||
|
||||
### Phase 3 : Tests
|
||||
1. **Validation Fonctionnelle**
|
||||
- [ ] Tester les limites de crédit
|
||||
- [ ] Vérifier le blocage des commandes
|
||||
- [ ] Valider les workflows d'approbation
|
||||
|
||||
2. **Tests d'Intégration**
|
||||
- [ ] Tester avec les autres modules
|
||||
- [ ] Vérifier la compatibilité avec les processus existants
|
||||
|
||||
## État de la Migration
|
||||
🟡 En cours d'analyse - Utilisation partielle des fonctionnalités natives
|
||||
|
||||
## Notes Importantes
|
||||
- La gestion du crédit est maintenant une fonctionnalité native d'Odoo
|
||||
- Certaines fonctionnalités spécifiques devront être maintenues
|
||||
- L'approche recommandée est d'utiliser au maximum les fonctionnalités natives et de ne conserver que les extensions nécessaires
|
||||
|
||||
## Prochaines Étapes
|
||||
1. Valider l'approche avec l'équipe
|
||||
2. Créer les scripts de migration des données
|
||||
3. Développer les fonctionnalités manquantes
|
||||
4. Planifier la formation des utilisateurs
|
||||
5
account_credit_hold/models/__init__.py
Normal file
5
account_credit_hold/models/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from . import account_followup
|
||||
from . import res_partner
|
||||
from . import sale_order
|
||||
from . import stock_picking
|
||||
from . import account_followup_report
|
||||
8
account_credit_hold/models/account_followup.py
Normal file
8
account_credit_hold/models/account_followup.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from odoo import fields, models, api
|
||||
|
||||
|
||||
class FollowupLine(models.Model):
|
||||
_inherit = 'account_followup.followup.line'
|
||||
|
||||
account_hold = fields.Boolean(string="Place on Credit Hold",
|
||||
help="Place clients on account hold, restricting confirmation of new orders.")
|
||||
12
account_credit_hold/models/account_followup_report.py
Normal file
12
account_credit_hold/models/account_followup_report.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
|
||||
class FollowUpReport(models.AbstractModel):
|
||||
_inherit = 'account.followup.report'
|
||||
|
||||
def _get_line_info(self, followup_line):
|
||||
res = super()._get_line_info(followup_line)
|
||||
res.update({
|
||||
'credit_hold': followup_line.account_hold
|
||||
})
|
||||
return res
|
||||
80
account_credit_hold/models/res_partner.py
Normal file
80
account_credit_hold/models/res_partner.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
from odoo import fields, models, api, _
|
||||
from datetime import date
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
_inherit = "res.partner"
|
||||
|
||||
postpone_hold_until = fields.Date(
|
||||
string="Postpone Hold",
|
||||
help="Grace period specific to this partner despite unpaid invoices.",
|
||||
tracking=True,
|
||||
)
|
||||
|
||||
hold_bg = fields.Boolean(
|
||||
string="Hold (technical)",
|
||||
compute="_compute_hold_bg",
|
||||
store=True,
|
||||
default=False,
|
||||
compute_sudo=True,
|
||||
tracking=True,
|
||||
)
|
||||
on_hold = fields.Boolean(
|
||||
string="Account on Hold",
|
||||
help="Client account is on hold for unpaid overdue invoices.",
|
||||
compute="_compute_on_hold",
|
||||
compute_sudo=True,
|
||||
)
|
||||
|
||||
@api.depends("postpone_hold_until", "hold_bg")
|
||||
def _compute_on_hold(self):
|
||||
# manually re-compute hold_bg since followup_status doesn't get updated in Python but gets recalculated
|
||||
# by an SQL query every time
|
||||
self._compute_hold_bg()
|
||||
for rec in self:
|
||||
# If the parent company is on hold, so are all its sub-contacts and subsidiaries
|
||||
if rec.commercial_partner_id and rec.commercial_partner_id.on_hold:
|
||||
rec.on_hold = True
|
||||
return
|
||||
# If there is no parent company or the parent is not on hold, we compute for ourselves
|
||||
if rec.hold_bg and not (
|
||||
rec.postpone_hold_until and rec.postpone_hold_until > date.today()
|
||||
):
|
||||
rec.on_hold = True
|
||||
else:
|
||||
if rec.on_hold:
|
||||
rec.message_post(_("Credit hold lifted."))
|
||||
rec.on_hold = False
|
||||
|
||||
@api.autovacuum
|
||||
def _cleanup_expired_hold_postponements(self):
|
||||
expired_holds = self.search([("postpone_hold_until", "<=", date.today())])
|
||||
expired_holds.write({"postpone_hold_until": False})
|
||||
|
||||
def action_credit_hold(self):
|
||||
for rec in self:
|
||||
rec.hold_bg = True
|
||||
rec.message_post(body=_("Placed on credit hold."))
|
||||
|
||||
def action_lift_credit_hold(self):
|
||||
for rec in self:
|
||||
rec.hold_bg = False
|
||||
rec.message_post(body=_("Credit hold lifted."))
|
||||
|
||||
def _execute_followup_partner(self, options=None):
|
||||
res = super()._execute_followup_partner(options)
|
||||
if self.followup_status == "in_need_of_action":
|
||||
if self.followup_line_id.account_hold:
|
||||
self.action_credit_hold()
|
||||
return res
|
||||
|
||||
@api.depends("followup_status", "followup_line_id")
|
||||
def _compute_hold_bg(self):
|
||||
first_followup_level = self._get_first_followup_level()
|
||||
for rec in self:
|
||||
prev_hold_bg = rec.hold_bg
|
||||
level = rec.followup_line_id
|
||||
if rec.followup_status == "no_action_needed" and not level:
|
||||
rec.hold_bg = False
|
||||
else:
|
||||
rec.hold_bg = prev_hold_bg
|
||||
17
account_credit_hold/models/sale_order.py
Normal file
17
account_credit_hold/models/sale_order.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from odoo import fields, models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = "sale.order"
|
||||
|
||||
client_on_hold = fields.Boolean(string='Client on Hold',
|
||||
help="Whether or not a client has been put on hold due to unpaid invoices.",
|
||||
related="partner_id.on_hold")
|
||||
|
||||
@api.depends('client_on_hold')
|
||||
def action_confirm(self):
|
||||
if any(self.mapped('client_on_hold')):
|
||||
raise UserError(_("This client is on credit hold. No new orders can be confirmed until past-due invoices "
|
||||
"are paid or the accounting team postpones the hold."))
|
||||
super().action_confirm()
|
||||
9
account_credit_hold/models/stock_picking.py
Normal file
9
account_credit_hold/models/stock_picking.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from odoo import fields, models, api
|
||||
|
||||
|
||||
class ModelName(models.Model):
|
||||
_inherit = "stock.picking"
|
||||
|
||||
client_on_hold = fields.Boolean(string='Client on Hold',
|
||||
help="Whether or not a client has been put on hold due to unpaid invoices.",
|
||||
related="partner_id.on_hold")
|
||||
40
account_credit_hold/readme.md
Normal file
40
account_credit_hold/readme.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Overview
|
||||
|
||||
This module adds the notion of placing clients on credit hold to the followup levels from the Odoo Enterprise
|
||||
account_followup module. It adds an option to followup levels to mark clients matching the followup criteria as on
|
||||
credit hold. This hold restricts the confirmation of new sales orders for these clients.
|
||||
|
||||
Accountant and admin users can set a date until which the account hold will be
|
||||
postponed on a specific partner's form view. This effectively gives clients an extra
|
||||
grace period, allowing orders to be confirmed until the period ends.
|
||||
|
||||
# Change Log
|
||||
|
||||
## 17.0.1.0.0 (2024-05-15)
|
||||
|
||||
Various modifications to adapt code to Odoo 17.0
|
||||
|
||||
## 15.0.2.0.0 (2023-05-04)
|
||||
|
||||
Complete remake of the module, making the "Credit Hold" an action that is either manually or
|
||||
automatically triggered from the Accounting > Followup Reports section or by setting the automatic application field
|
||||
on followup levels.
|
||||
|
||||
## 15.0.1.1.0 (2023-05-03)
|
||||
|
||||
Adds a ribbon to stock pickings for clients on hold, and therefore a dependency on stock.
|
||||
|
||||
## 15.0.1.0.2 (2023-05-03)
|
||||
|
||||
Fix to sale order view and sale order confirmation for clients not on hold.
|
||||
|
||||
## 15.0.1.0.1 (2023-05-03)
|
||||
|
||||
Fix clients on hold when status is "outstanding_invoices".
|
||||
|
||||
## 15.0.1.0.0 (2023-05-02) Initial Release
|
||||
|
||||
Initial release of the module, including a setting on follow-up levels to toggle placing on credit hold. Blocks
|
||||
the confirmation of sales orders for clients on credit hold. Red "Credit Hold" banner appears on sales orders and
|
||||
partner form view when a client is on credit hold. Credit hold can be postponed by setting the "Postpone Hold" field
|
||||
on the partner form view.
|
||||
49
account_credit_hold/views/account_followup_views.xml
Normal file
49
account_credit_hold/views/account_followup_views.xml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="account_followup_followup_line_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account_credit_hold.account_followup_line.form</field>
|
||||
<field name="model">account_followup.followup.line</field>
|
||||
<field
|
||||
name="inherit_id"
|
||||
ref="account_followup.view_account_followup_followup_line_form"
|
||||
/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='send_email']" position="before">
|
||||
<field name="account_hold" />
|
||||
</xpath></field>
|
||||
</record>
|
||||
<record id="customer_statements_form_view_inherit" model="ir.ui.view">
|
||||
<field name="name">customer.statements.form.view.inherit</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field
|
||||
name="inherit_id"
|
||||
ref="account_followup.customer_statements_form_view"
|
||||
/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//button[last()]" position="after">
|
||||
<field invisible="1" name="hold_bg" />
|
||||
<button
|
||||
class="button btn-secondary"
|
||||
invisible="hold_bg == True"
|
||||
name="action_credit_hold"
|
||||
string="Credit Hold"
|
||||
type="object"
|
||||
/>
|
||||
<button
|
||||
class="button btn-secondary"
|
||||
invisible="hold_bg == False"
|
||||
name="action_lift_credit_hold"
|
||||
string="Lift Credit Hold"
|
||||
type="object"
|
||||
/>
|
||||
</xpath></field>
|
||||
</record>
|
||||
<record id="action_credit_hold" model="ir.actions.server">
|
||||
<field name="name">action_credit_hold</field>
|
||||
<field name="model_id" ref="base.model_res_partner" />
|
||||
<field name="binding_model_id" ref="base.model_res_partner" />
|
||||
<field name="binding_view_types">list,form</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">records.action_credit_hold()</field>
|
||||
</record>
|
||||
</odoo>
|
||||
34
account_credit_hold/views/res_partner_views.xml
Normal file
34
account_credit_hold/views/res_partner_views.xml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="res_partner_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account_credit_hold.res_partner.form</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="before">
|
||||
<field invisible="True" name="hold_bg" />
|
||||
<field invisible="True" name="on_hold" />
|
||||
<widget
|
||||
bg_color="bg-danger"
|
||||
invisible="on_hold == False"
|
||||
name="web_ribbon"
|
||||
title="Credit Hold"
|
||||
/>
|
||||
</xpath></field>
|
||||
</record>
|
||||
<record id="view_partner_property_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account_credit_hold.view_partner_property_form</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="account.view_partner_property_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group[@name='banks']" position="before">
|
||||
<group string="Credit Hold">
|
||||
<field
|
||||
groups="account.group_account_manager,account.group_account_user"
|
||||
name="postpone_hold_until"
|
||||
readonly="hold_bg == False and postpone_hold_until == False"
|
||||
/>
|
||||
</group>
|
||||
</xpath></field>
|
||||
</record>
|
||||
</odoo>
|
||||
18
account_credit_hold/views/sale_order_views.xml
Normal file
18
account_credit_hold/views/sale_order_views.xml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="sale_order_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account_credit_hold.sale_order.form</field>
|
||||
<field name="model">sale.order</field>
|
||||
<field name="inherit_id" ref="sale.view_order_form" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="before">
|
||||
<field invisible="True" name="client_on_hold" />
|
||||
<widget
|
||||
bg_color="bg-danger"
|
||||
invisible="client_on_hold == False"
|
||||
name="web_ribbon"
|
||||
title="Credit Hold"
|
||||
/>
|
||||
</xpath></field>
|
||||
</record>
|
||||
</odoo>
|
||||
19
account_credit_hold/views/stock_picking_views.xml
Normal file
19
account_credit_hold/views/stock_picking_views.xml
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<odoo>
|
||||
<record id="stock_picking_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account_credit_hold.stock_picking.form</field>
|
||||
<field name="model">stock.picking</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_form" />
|
||||
<field eval="8" name="priority" />
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="before">
|
||||
<field invisible="True" name="client_on_hold" />
|
||||
<widget
|
||||
bg_color="bg-danger"
|
||||
invisible="client_on_hold == False"
|
||||
name="web_ribbon"
|
||||
title="Credit Hold"
|
||||
/>
|
||||
</xpath></field>
|
||||
</record>
|
||||
</odoo>
|
||||
33
aged_partner_balance_na/__init__.py
Normal file
33
aged_partner_balance_na/__init__.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
from . import models
|
||||
|
||||
|
||||
def post_init(env):
|
||||
env['account.report'].search([('name', 'ilike', 'Aged % - North America')]).unlink()
|
||||
new_receivable_report = env.ref('account_reports.aged_receivable_report').copy()
|
||||
new_payable_report = env.ref('account_reports.aged_payable_report').copy()
|
||||
new_receivable_report.line_ids.mapped('expression_ids').write({
|
||||
'formula': '_report_custom_engine_aged_receivable_na'
|
||||
})
|
||||
new_payable_report.line_ids.mapped('expression_ids').write({'formula': '_report_custom_engine_aged_payable_na'})
|
||||
new_receivable_report.write({
|
||||
'name': 'Aged Receivable - North America',
|
||||
'root_report_id': env.ref('account_reports.aged_receivable_report').id,
|
||||
'availability_condition': 'always',
|
||||
})
|
||||
new_payable_report.write({
|
||||
'name': 'Aged Payable - North America',
|
||||
'root_report_id': env.ref('account_reports.aged_payable_report').id,
|
||||
'availability_condition': 'always',
|
||||
})
|
||||
col_name_mapping = [
|
||||
('At Date', 'Overdue'),
|
||||
('1-30', '0-29'),
|
||||
('31-60', '30-59'),
|
||||
('61-90', '60-89'),
|
||||
('91-120', '90-119'),
|
||||
('Older', 'Later'),
|
||||
]
|
||||
for old_name, new_name in col_name_mapping:
|
||||
((new_payable_report | new_receivable_report)
|
||||
.mapped('column_ids')
|
||||
.filtered(lambda col: col.name == old_name)).write({'name': new_name})
|
||||
32
aged_partner_balance_na/__manifest__.py
Normal file
32
aged_partner_balance_na/__manifest__.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#
|
||||
# Bemade Inc.
|
||||
#
|
||||
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
|
||||
# Author: Marc Durepos (Contact : marc@bemade.org)
|
||||
#
|
||||
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
|
||||
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
|
||||
# or modified copies of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
# DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
{
|
||||
'name': 'Aged Partner Balance (North American Style)',
|
||||
'version': '17.0.1.0.0',
|
||||
'summary': 'Present aged partner balance as predictive rather than past due.',
|
||||
'category': 'Accounting',
|
||||
'author': 'Bemade Inc.',
|
||||
'website': 'http://www.bemade.org',
|
||||
'license': 'LGPL-3',
|
||||
'depends': ['account_reports'],
|
||||
'assets': {},
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'post_init_hook': 'post_init',
|
||||
}
|
||||
1
aged_partner_balance_na/models/__init__.py
Normal file
1
aged_partner_balance_na/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import aged_partner_balance
|
||||
245
aged_partner_balance_na/models/aged_partner_balance.py
Normal file
245
aged_partner_balance_na/models/aged_partner_balance.py
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
from odoo import models, fields
|
||||
from itertools import chain
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
||||
class AgedPartnerBalanceCustomHandler(models.AbstractModel):
|
||||
_inherit = "account.aged.partner.balance.report.handler"
|
||||
|
||||
def _report_custom_engine_aged_receivable_na(
|
||||
self,
|
||||
expressions,
|
||||
options,
|
||||
date_scope,
|
||||
current_groupby,
|
||||
next_groupby,
|
||||
offset=0,
|
||||
limit=None,
|
||||
warnings=None
|
||||
):
|
||||
return self._aged_partner_report_custom_engine_common_na(
|
||||
options,
|
||||
'asset_receivable',
|
||||
current_groupby,
|
||||
next_groupby,
|
||||
offset=offset,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
def _report_custom_engine_aged_payable_na(
|
||||
self,
|
||||
expressions,
|
||||
options,
|
||||
date_scope,
|
||||
current_groupby,
|
||||
next_groupby,
|
||||
offset=0,
|
||||
limit=None,
|
||||
warnings=None
|
||||
):
|
||||
return self._aged_partner_report_custom_engine_common_na(
|
||||
options,
|
||||
'liability_payable',
|
||||
current_groupby,
|
||||
next_groupby,
|
||||
offset=offset,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
def _aged_partner_report_custom_engine_common_na(self, options, internal_type, current_groupby, next_groupby, offset=0, limit=None):
|
||||
report = self.env['account.report'].browse(options['report_id'])
|
||||
report._check_groupby_fields((next_groupby.split(',') if next_groupby else []) + ([current_groupby] if current_groupby else []))
|
||||
|
||||
def plus_days(date_obj, days):
|
||||
return fields.Date.to_string(date_obj + relativedelta(days=days))
|
||||
|
||||
def minus_days(date_obj, days):
|
||||
return fields.Date.to_string(date_obj - relativedelta(days=days))
|
||||
|
||||
date_to = fields.Date.from_string(options['date']['date_to'])
|
||||
periods = [
|
||||
(False, minus_days(date_to, 1)),
|
||||
(date_to, plus_days(date_to, 29)),
|
||||
(plus_days(date_to, 30), plus_days(date_to, 59)),
|
||||
(plus_days(date_to, 60), plus_days(date_to, 89)),
|
||||
(plus_days(date_to, 90), plus_days(date_to, 119)),
|
||||
(plus_days(date_to, 120), False),
|
||||
]
|
||||
|
||||
def build_result_dict(report, query_res_lines):
|
||||
rslt = {f'period{i}': 0 for i in range(len(periods))}
|
||||
|
||||
for query_res in query_res_lines:
|
||||
for i in range(len(periods)):
|
||||
period_key = f'period{i}'
|
||||
rslt[period_key] += query_res[period_key]
|
||||
|
||||
if current_groupby == 'id':
|
||||
query_res = query_res_lines[0] # We're grouping by id, so there is only 1 element in query_res_lines anyway
|
||||
currency = self.env['res.currency'].browse(query_res['currency_id'][0]) if len(query_res['currency_id']) == 1 else None
|
||||
expected_date = len(query_res['expected_date']) == 1 and query_res['expected_date'][0] or len(query_res['due_date']) == 1 and query_res['due_date'][0]
|
||||
rslt.update({
|
||||
'invoice_date': query_res['invoice_date'][0] if len(query_res['invoice_date']) == 1 else None,
|
||||
'due_date': query_res['due_date'][0] if len(query_res['due_date']) == 1 else None,
|
||||
'amount_currency': query_res['amount_currency'],
|
||||
'currency_id': query_res['currency_id'][0] if len(query_res['currency_id']) == 1 else None,
|
||||
'currency': currency.display_name if currency else None,
|
||||
'account_name': query_res['account_name'][0] if len(query_res['account_name']) == 1 else None,
|
||||
'expected_date': expected_date or None,
|
||||
'total': None,
|
||||
'has_sublines': query_res['aml_count'] > 0,
|
||||
|
||||
# Needed by the custom_unfold_all_batch_data_generator, to speed-up unfold_all
|
||||
'partner_id': query_res['partner_id'][0] if query_res['partner_id'] else None,
|
||||
})
|
||||
else:
|
||||
rslt.update({
|
||||
'invoice_date': None,
|
||||
'due_date': None,
|
||||
'amount_currency': None,
|
||||
'currency_id': None,
|
||||
'currency': None,
|
||||
'account_name': None,
|
||||
'expected_date': None,
|
||||
'total': sum(rslt[f'period{i}'] for i in range(len(periods))),
|
||||
'has_sublines': False,
|
||||
})
|
||||
|
||||
return rslt
|
||||
|
||||
# Build period table
|
||||
period_table_format = ('(VALUES %s)' % ','.join("(%s, %s, %s)" for period in periods))
|
||||
params = list(chain.from_iterable(
|
||||
(period[0] or None, period[1] or None, i)
|
||||
for i, period in enumerate(periods)
|
||||
))
|
||||
period_table = self.env.cr.mogrify(period_table_format, params).decode(self.env.cr.connection.encoding)
|
||||
|
||||
# Build query
|
||||
tables, where_clause, where_params = report._query_get(options, 'strict_range', domain=[('account_id.account_type', '=', internal_type)])
|
||||
|
||||
currency_table = report._get_query_currency_table(options)
|
||||
always_present_groupby = "period_table.period_index, currency_table.rate, currency_table.precision"
|
||||
if current_groupby:
|
||||
select_from_groupby = f"account_move_line.{current_groupby} AS grouping_key,"
|
||||
groupby_clause = f"account_move_line.{current_groupby}, {always_present_groupby}"
|
||||
else:
|
||||
select_from_groupby = ''
|
||||
groupby_clause = always_present_groupby
|
||||
select_period_query = ','.join(
|
||||
f"""
|
||||
CASE WHEN period_table.period_index = {i}
|
||||
THEN %s * (
|
||||
SUM(ROUND(account_move_line.balance * currency_table.rate, currency_table.precision))
|
||||
- COALESCE(SUM(ROUND(part_debit.amount * currency_table.rate, currency_table.precision)), 0)
|
||||
+ COALESCE(SUM(ROUND(part_credit.amount * currency_table.rate, currency_table.precision)), 0)
|
||||
)
|
||||
ELSE 0 END AS period{i}
|
||||
"""
|
||||
for i in range(len(periods))
|
||||
)
|
||||
|
||||
tail_query, tail_params = report._get_engine_query_tail(offset, limit)
|
||||
query = f"""
|
||||
WITH period_table(date_start, date_stop, period_index) AS ({period_table})
|
||||
|
||||
SELECT
|
||||
{select_from_groupby}
|
||||
%s * (
|
||||
SUM(account_move_line.amount_currency)
|
||||
- COALESCE(SUM(part_debit.debit_amount_currency), 0)
|
||||
+ COALESCE(SUM(part_credit.credit_amount_currency), 0)
|
||||
) AS amount_currency,
|
||||
ARRAY_AGG(DISTINCT account_move_line.partner_id) AS partner_id,
|
||||
ARRAY_AGG(account_move_line.payment_id) AS payment_id,
|
||||
ARRAY_AGG(DISTINCT move.invoice_date) AS invoice_date,
|
||||
ARRAY_AGG(DISTINCT COALESCE(account_move_line.date_maturity, account_move_line.date)) AS report_date,
|
||||
ARRAY_AGG(DISTINCT account_move_line.expected_pay_date) AS expected_date,
|
||||
ARRAY_AGG(DISTINCT account.code) AS account_name,
|
||||
ARRAY_AGG(DISTINCT COALESCE(account_move_line.date_maturity, account_move_line.date)) AS due_date,
|
||||
ARRAY_AGG(DISTINCT account_move_line.currency_id) AS currency_id,
|
||||
COUNT(account_move_line.id) AS aml_count,
|
||||
ARRAY_AGG(account.code) AS account_code,
|
||||
{select_period_query}
|
||||
|
||||
FROM {tables}
|
||||
|
||||
JOIN account_journal journal ON journal.id = account_move_line.journal_id
|
||||
JOIN account_account account ON account.id = account_move_line.account_id
|
||||
JOIN account_move move ON move.id = account_move_line.move_id
|
||||
JOIN {currency_table} ON currency_table.company_id = account_move_line.company_id
|
||||
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
SUM(part.amount) AS amount,
|
||||
SUM(part.debit_amount_currency) AS debit_amount_currency,
|
||||
part.debit_move_id
|
||||
FROM account_partial_reconcile part
|
||||
WHERE part.max_date <= %s AND part.debit_move_id = account_move_line.id
|
||||
GROUP BY part.debit_move_id
|
||||
) part_debit ON TRUE
|
||||
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
SUM(part.amount) AS amount,
|
||||
SUM(part.credit_amount_currency) AS credit_amount_currency,
|
||||
part.credit_move_id
|
||||
FROM account_partial_reconcile part
|
||||
WHERE part.max_date <= %s AND part.credit_move_id = account_move_line.id
|
||||
GROUP BY part.credit_move_id
|
||||
) part_credit ON TRUE
|
||||
|
||||
JOIN period_table ON
|
||||
(
|
||||
period_table.date_start IS NULL
|
||||
OR COALESCE(account_move_line.date_maturity, account_move_line.date) >= DATE(period_table.date_start)
|
||||
)
|
||||
AND
|
||||
(
|
||||
period_table.date_stop IS NULL
|
||||
OR COALESCE(account_move_line.date_maturity, account_move_line.date) <= DATE(period_table.date_stop)
|
||||
)
|
||||
|
||||
WHERE {where_clause}
|
||||
|
||||
GROUP BY {groupby_clause}
|
||||
|
||||
HAVING
|
||||
(
|
||||
SUM(ROUND(account_move_line.debit * currency_table.rate, currency_table.precision))
|
||||
- COALESCE(SUM(ROUND(part_debit.amount * currency_table.rate, currency_table.precision)), 0)
|
||||
) != 0
|
||||
OR
|
||||
(
|
||||
SUM(ROUND(account_move_line.credit * currency_table.rate, currency_table.precision))
|
||||
- COALESCE(SUM(ROUND(part_credit.amount * currency_table.rate, currency_table.precision)), 0)
|
||||
) != 0
|
||||
{tail_query}
|
||||
"""
|
||||
|
||||
multiplicator = -1 if internal_type == 'liability_payable' else 1
|
||||
params = [
|
||||
multiplicator,
|
||||
*([multiplicator] * len(periods)),
|
||||
date_to,
|
||||
date_to,
|
||||
*where_params,
|
||||
*tail_params,
|
||||
]
|
||||
self._cr.execute(query, params)
|
||||
query_res_lines = self._cr.dictfetchall()
|
||||
|
||||
if not current_groupby:
|
||||
return build_result_dict(report, query_res_lines)
|
||||
else:
|
||||
rslt = []
|
||||
|
||||
all_res_per_grouping_key = {}
|
||||
for query_res in query_res_lines:
|
||||
grouping_key = query_res['grouping_key']
|
||||
all_res_per_grouping_key.setdefault(grouping_key, []).append(query_res)
|
||||
|
||||
for grouping_key, query_res_lines in all_res_per_grouping_key.items():
|
||||
rslt.append((grouping_key, build_result_dict(report, query_res_lines)))
|
||||
|
||||
return rslt
|
||||
1
ai_integration/__init__.py
Normal file
1
ai_integration/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
39
ai_integration/__manifest__.py
Normal file
39
ai_integration/__manifest__.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
'name': 'AI Integration Base',
|
||||
'version': '1.0',
|
||||
'category': 'Technical',
|
||||
'summary': 'Base module for AI integration',
|
||||
'description': """
|
||||
AI Integration Base
|
||||
===================
|
||||
This module provides the base framework for integrating various AI providers
|
||||
into Odoo. It includes:
|
||||
|
||||
* Abstract interfaces for AI providers
|
||||
* Base configuration for AI models
|
||||
* Common utilities for AI integration
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://www.bemade.org',
|
||||
'depends': [
|
||||
'base',
|
||||
'web',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
'security/ai_security.xml',
|
||||
'security/ir_rule.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/res_company_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/ai_provider_views.xml',
|
||||
'views/ai_provider_instance_views.xml',
|
||||
'views/ai_model_views.xml',
|
||||
'views/ai_model_stats_views.xml',
|
||||
'views/menu.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
9
ai_integration/models/__init__.py
Normal file
9
ai_integration/models/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from .mixins.ai_base_mixin import AIBaseMixin
|
||||
from . import ai_generation_params
|
||||
from . import ai_model
|
||||
from . import ai_provider_interface
|
||||
from . import ai_provider
|
||||
from . import ai_provider_instance
|
||||
from . import res_config_settings
|
||||
from . import res_company
|
||||
from . import ai_model_stats
|
||||
42
ai_integration/models/ai_generation_params.py
Normal file
42
ai_integration/models/ai_generation_params.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
class AIGenerationParams(models.AbstractModel):
|
||||
_name = 'ai.generation.params'
|
||||
_description = 'AI Generation Parameters'
|
||||
|
||||
# Base Generation Parameters
|
||||
temperature = fields.Float(
|
||||
string='Temperature',
|
||||
help='Controls randomness in generation. Higher values make output more random, lower values more deterministic.',
|
||||
default=0.7
|
||||
)
|
||||
|
||||
repeat_penalty = fields.Float(
|
||||
string='Repeat Penalty',
|
||||
help='Penalty for repeating tokens. Higher values make repetition less likely.',
|
||||
default=1.1
|
||||
)
|
||||
|
||||
max_tokens = fields.Integer(
|
||||
string='Max Tokens',
|
||||
help='Maximum number of tokens to generate.',
|
||||
default=2048
|
||||
)
|
||||
|
||||
stop_sequences = fields.Char(
|
||||
string='Stop Sequences',
|
||||
help='Comma-separated list of sequences where generation should stop.',
|
||||
default=''
|
||||
)
|
||||
|
||||
frequency_penalty = fields.Float(
|
||||
string='Frequency Penalty',
|
||||
help='Penalty for using frequent tokens. Higher values encourage using less frequent tokens.',
|
||||
default=0.0
|
||||
)
|
||||
|
||||
presence_penalty = fields.Float(
|
||||
string='Presence Penalty',
|
||||
help='Penalty for using tokens already in the text. Higher values encourage using new tokens.',
|
||||
default=0.0
|
||||
)
|
||||
80
ai_integration/models/ai_model.py
Normal file
80
ai_integration/models/ai_model.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AIModel(models.Model):
|
||||
_name = 'ai.model'
|
||||
_description = 'AI Model'
|
||||
_order = 'sequence, name'
|
||||
_check_company = False # Disable automatic company checks
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
help='Whether this model is active and available for use')
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
help='Name of the AI model'
|
||||
)
|
||||
|
||||
identifier = fields.Char(
|
||||
string='Identifier',
|
||||
required=True,
|
||||
help='Technical identifier of the model (e.g., gpt-3.5-turbo, mistral-7b)'
|
||||
)
|
||||
|
||||
provider_instance_id = fields.Many2one(
|
||||
'ai.provider.instance',
|
||||
string='Provider Instance',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
help='Provider instance this model belongs to'
|
||||
)
|
||||
|
||||
provider_type = fields.Selection(
|
||||
related='provider_instance_id.provider_type',
|
||||
string='Provider Type',
|
||||
store=True,
|
||||
readonly=True,
|
||||
help='Type of AI provider'
|
||||
)
|
||||
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
help='Description of the model and its capabilities'
|
||||
)
|
||||
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
help='Sequence for ordering models in lists and dropdowns'
|
||||
)
|
||||
|
||||
is_active = fields.Boolean(
|
||||
string='Model Active',
|
||||
default=True,
|
||||
help='Whether this model is currently active and available for use'
|
||||
)
|
||||
|
||||
context_window = fields.Integer(
|
||||
string='Context Window',
|
||||
default=2048,
|
||||
help='Maximum number of tokens in the context window'
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_identifier_provider',
|
||||
'unique(identifier, provider_instance_id)',
|
||||
'The model identifier must be unique per provider instance!')
|
||||
]
|
||||
|
||||
def name_get(self):
|
||||
"""Custom name_get to include provider instance in display name."""
|
||||
result = []
|
||||
for model in self:
|
||||
name = f"{model.name} ({model.provider_instance_id.name})"
|
||||
result.append((model.id, name))
|
||||
return result
|
||||
94
ai_integration/models/ai_model_stats.py
Normal file
94
ai_integration/models/ai_model_stats.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from odoo import models, fields, api
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class AIModelStats(models.Model):
|
||||
_name = 'ai.model.stats'
|
||||
_description = 'AI Model Usage Statistics'
|
||||
_order = 'date desc'
|
||||
|
||||
@api.model
|
||||
def _has_provider_modules(self):
|
||||
"""Check if any AI provider modules are installed."""
|
||||
modules = ['ollama_ai_integration', 'chatgpt_ai_integration']
|
||||
return any(self.env['ir.module.module'].search([('name', 'in', modules), ('state', '=', 'installed')]))
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Override default_get to prevent creation if no provider modules are installed."""
|
||||
if not self._has_provider_modules():
|
||||
raise UserError(_('No AI provider modules are installed. Please install at least one provider module (e.g., Ollama or ChatGPT) before creating model statistics.'))
|
||||
return super().default_get(fields_list)
|
||||
|
||||
model_id = fields.Many2one('ai.model', string='Model', required=True, ondelete='cascade')
|
||||
provider_instance_id = fields.Many2one(
|
||||
related='model_id.provider_instance_id',
|
||||
string='Provider Instance',
|
||||
store=True)
|
||||
|
||||
date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
|
||||
request_count = fields.Integer(string='Number of Requests', default=0)
|
||||
token_count = fields.Integer(string='Total Tokens', default=0)
|
||||
avg_response_time = fields.Float(string='Average Response Time (ms)', digits=(10, 2), default=0)
|
||||
error_count = fields.Integer(string='Number of Errors', default=0)
|
||||
version = fields.Char(string='Model Version', help='Version of the model when stats were recorded')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_model_date', 'unique(model_id, date)', 'Only one stat entry per model per day is allowed.')
|
||||
]
|
||||
|
||||
def _update_stats(self, model, tokens, response_time, error=False, version=None):
|
||||
"""Update statistics for a model."""
|
||||
today = fields.Date.context_today(self)
|
||||
stats = self.search([
|
||||
('model_id', '=', model.id),
|
||||
('date', '=', today)
|
||||
])
|
||||
|
||||
if not stats:
|
||||
stats = self.create({
|
||||
'model_id': model.id,
|
||||
'date': today,
|
||||
'version': version
|
||||
})
|
||||
|
||||
# Update statistics
|
||||
new_count = stats.request_count + 1
|
||||
new_tokens = stats.token_count + tokens
|
||||
new_time = ((stats.avg_response_time * stats.request_count) + response_time) / new_count
|
||||
new_errors = stats.error_count + (1 if error else 0)
|
||||
|
||||
stats.write({
|
||||
'request_count': new_count,
|
||||
'token_count': new_tokens,
|
||||
'avg_response_time': new_time,
|
||||
'error_count': new_errors,
|
||||
'version': version or stats.version # Update version if provided
|
||||
})
|
||||
|
||||
@api.model
|
||||
def get_model_stats(self, model_id, days=30):
|
||||
"""Get statistics for a model over the specified number of days."""
|
||||
start_date = fields.Date.today() - timedelta(days=days)
|
||||
stats = self.search([
|
||||
('model_id', '=', model_id),
|
||||
('date', '>=', start_date)
|
||||
])
|
||||
|
||||
return {
|
||||
'daily_stats': [{
|
||||
'date': stat.date,
|
||||
'requests': stat.request_count,
|
||||
'tokens': stat.token_count,
|
||||
'response_time': stat.avg_response_time,
|
||||
'errors': stat.error_count,
|
||||
'version': stat.version
|
||||
} for stat in stats],
|
||||
'summary': {
|
||||
'total_requests': sum(stat.request_count for stat in stats),
|
||||
'total_tokens': sum(stat.token_count for stat in stats),
|
||||
'avg_response_time': sum(stat.avg_response_time * stat.request_count for stat in stats) /
|
||||
(sum(stat.request_count for stat in stats) if stats else 1),
|
||||
'total_errors': sum(stat.error_count for stat in stats),
|
||||
'versions_used': list(set(stat.version for stat in stats if stat.version))
|
||||
}
|
||||
}
|
||||
73
ai_integration/models/ai_provider.py
Normal file
73
ai_integration/models/ai_provider.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIProvider(models.Model):
|
||||
_name = 'ai.provider'
|
||||
_description = 'AI Provider'
|
||||
_inherit = ['ai.provider.interface']
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
code = fields.Char(string='Code', required=True)
|
||||
description = fields.Text(string='Description')
|
||||
default_host = fields.Char(string='Default Host')
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
@api.model
|
||||
def send_message(self, message, **kwargs):
|
||||
"""Send a message to the AI provider and get a response.
|
||||
|
||||
Args:
|
||||
message (dict): The message to send
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
str: The response from the AI provider
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by specific providers
|
||||
"""
|
||||
raise NotImplementedError(_("Method send_message must be implemented by specific AI providers"))
|
||||
|
||||
@api.model
|
||||
def get_models(self):
|
||||
"""Get the list of available models from the provider.
|
||||
|
||||
Returns:
|
||||
list: List of model information dictionaries
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by specific providers
|
||||
"""
|
||||
raise NotImplementedError(_("Method get_models must be implemented by specific AI providers"))
|
||||
|
||||
@api.model
|
||||
def test_connection(self):
|
||||
"""Test the connection to the AI provider.
|
||||
|
||||
Returns:
|
||||
bool: True if connection is successful
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by specific providers
|
||||
"""
|
||||
raise NotImplementedError(_("Method test_connection must be implemented by specific AI providers"))
|
||||
|
||||
def _handle_error(self, error, context=''):
|
||||
"""Common error handling for AI provider operations.
|
||||
|
||||
Args:
|
||||
error (Exception): The error that occurred
|
||||
context (str): Additional context about where the error occurred
|
||||
|
||||
Returns:
|
||||
tuple: (success, message)
|
||||
"""
|
||||
error_msg = str(error)
|
||||
log_msg = f"AI Provider Error{' in ' + context if context else ''}: {error_msg}"
|
||||
_logger.error(log_msg)
|
||||
return False, error_msg
|
||||
106
ai_integration/models/ai_provider_instance.py
Normal file
106
ai_integration/models/ai_provider_instance.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.addons.mail.models.mail_thread import MailThread
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class BaseAIProviderInstance(models.Model):
|
||||
_name = 'ai.provider.instance'
|
||||
_description = 'AI Provider Instance'
|
||||
_order = 'name'
|
||||
_check_company = False # Disable automatic company checks
|
||||
_inherit = ['mail.thread', 'ai.base.mixin']
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Override default_get to prevent creation if no provider modules are installed."""
|
||||
defaults = super().default_get(fields_list)
|
||||
if defaults.get('provider_type', 'none') == 'none':
|
||||
defaults['provider_type'] = 'ollama'
|
||||
return defaults
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
help='Whether this provider instance is active and available for use')
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
help='Name of this provider instance (e.g., "OpenWebUI Production", "Ollama Local")'
|
||||
)
|
||||
|
||||
provider_type = fields.Selection(
|
||||
[('none', 'None')], # Base selection, will be extended by provider modules
|
||||
string='Provider Type',
|
||||
required=True,
|
||||
default='none',
|
||||
help='The type of AI provider for this instance',
|
||||
ondelete={'none': lambda r: r.write({'provider_type': 'none'})}
|
||||
)
|
||||
|
||||
host = fields.Char(
|
||||
string='Host',
|
||||
required=True,
|
||||
help='Host address (e.g., "http://localhost:8080" or "https://api.example.com")'
|
||||
)
|
||||
|
||||
api_key = fields.Char(
|
||||
string='API Key',
|
||||
help='API key if required by the provider',
|
||||
invisible="[('provider_type', '=', 'ollama')]" # Hide when provider type is ollama
|
||||
)
|
||||
|
||||
@api.model
|
||||
def get_default_instance(self):
|
||||
"""Get the default AI provider instance to use.
|
||||
|
||||
Returns:
|
||||
ai.provider.instance: The default instance to use, or raises UserError if none found
|
||||
"""
|
||||
instance = self.env['ai.provider.instance'].search([('active', '=', True)], limit=1)
|
||||
if not instance:
|
||||
raise UserError(_('No active AI provider instance found. Please configure one in the settings.'))
|
||||
return instance
|
||||
|
||||
model_ids = fields.One2many(
|
||||
'ai.model',
|
||||
'provider_instance_id',
|
||||
copy=True,
|
||||
string='Available Models'
|
||||
)
|
||||
|
||||
timeout = fields.Integer(
|
||||
string='Timeout',
|
||||
default=60,
|
||||
help='Maximum wait time for API calls (in seconds)'
|
||||
)
|
||||
|
||||
max_retries = fields.Integer(
|
||||
string='Max Retries',
|
||||
default=3,
|
||||
help='Maximum number of retry attempts for failed API calls'
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _valid_field_parameter(self, field, name):
|
||||
return name == 'invisible' or super()._valid_field_parameter(field, name)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_uniq',
|
||||
'unique(name)',
|
||||
'Provider instance name must be unique!')
|
||||
]
|
||||
|
||||
def test_connection(self):
|
||||
"""Test the connection to this provider instance."""
|
||||
self.ensure_one()
|
||||
if self.provider_type == 'none':
|
||||
raise UserError(_('Please select a provider type'))
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def sync_models(self):
|
||||
"""Synchronize models from this provider instance."""
|
||||
self.ensure_one()
|
||||
if self.provider_type == 'none':
|
||||
raise UserError(_('Please select a provider type'))
|
||||
55
ai_integration/models/ai_provider_interface.py
Normal file
55
ai_integration/models/ai_provider_interface.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIProviderInterface(models.AbstractModel):
|
||||
_name = 'ai.provider.interface'
|
||||
_description = 'AI Provider Interface'
|
||||
|
||||
@api.model
|
||||
def send_message(self, message, **kwargs):
|
||||
"""Send a message to the AI provider and get a response.
|
||||
|
||||
Args:
|
||||
message (dict): The message to send
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
dict: The response from the AI provider
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _get_provider_type(self):
|
||||
"""Get the provider type code.
|
||||
|
||||
Returns:
|
||||
str: The provider type code
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _get_model_info(self, instance, model_name):
|
||||
"""Get detailed information about a specific model.
|
||||
|
||||
Args:
|
||||
instance (ai.provider.instance): The provider instance
|
||||
model_name (str): Name of the model
|
||||
|
||||
Returns:
|
||||
dict: Model information
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _list_models(self, instance):
|
||||
"""List available models for this provider instance.
|
||||
|
||||
Args:
|
||||
instance (ai.provider.instance): The provider instance
|
||||
|
||||
Returns:
|
||||
list: List of available models
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
90
ai_integration/models/doc_models.md
Normal file
90
ai_integration/models/doc_models.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Documentation des Modèles AI Integration
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le module AI Integration fournit une infrastructure flexible pour intégrer différents fournisseurs d'IA dans Odoo. Il est conçu pour être extensible et permettre l'ajout facile de nouveaux fournisseurs.
|
||||
|
||||
## Modèles Principaux
|
||||
|
||||
### 1. AI Provider (`ai.provider`)
|
||||
- **Description**: Configuration de base des fournisseurs d'IA
|
||||
- **Champs principaux**:
|
||||
- `name`: Nom du fournisseur
|
||||
- `code`: Code technique unique
|
||||
- `description`: Description détaillée
|
||||
- `default_host`: Hôte par défaut
|
||||
- `active`: État actif/inactif
|
||||
|
||||
### 2. AI Provider Instance (`ai.provider.instance`)
|
||||
- **Description**: Instance spécifique d'un fournisseur d'IA
|
||||
- **Héritage**: `mail.thread`, `ai.base.mixin`
|
||||
- **Champs principaux**:
|
||||
- `name`: Nom de l'instance (ex: "OpenWebUI Production", "Ollama Local")
|
||||
- `provider_id`: Fournisseur associé
|
||||
- `provider_type`: Type de fournisseur (extensible par modules)
|
||||
- `active`: État actif/inactif
|
||||
- **Validation**:
|
||||
- Vérifie la présence d'au moins un module fournisseur installé
|
||||
|
||||
### 3. AI Model (`ai.model`)
|
||||
- **Description**: Modèles d'IA disponibles
|
||||
- **Champs principaux**:
|
||||
- `name`: Nom du modèle
|
||||
- `identifier`: Identifiant technique (ex: gpt-3.5-turbo, mistral-7b)
|
||||
- `provider_instance_id`: Instance du fournisseur (cascade)
|
||||
- `provider_type`: Type de fournisseur (relié à l'instance)
|
||||
- `active`: État actif/inactif
|
||||
- `sequence`: Ordre d'affichage
|
||||
- **Validation**:
|
||||
- Vérifie la présence d'au moins un module fournisseur installé
|
||||
|
||||
### 4. AI Generation Parameters (`ai.generation.params`)
|
||||
- **Description**: Paramètres de génération pour les modèles d'IA
|
||||
- **Type**: Modèle abstrait
|
||||
- **Champs principaux**:
|
||||
- `temperature`: Contrôle de l'aléatoire (défaut: 0.7)
|
||||
- `repeat_penalty`: Pénalité de répétition (défaut: 1.1)
|
||||
- `max_tokens`: Nombre maximum de tokens (défaut: 2048)
|
||||
- `stop_sequences`: Séquences d'arrêt
|
||||
- `frequency_penalty`: Pénalité de fréquence (défaut: 0.0)
|
||||
- `presence_penalty`: Pénalité de présence (défaut: 0.0)
|
||||
|
||||
## Configuration et Interfaces
|
||||
|
||||
### 1. Res Config Settings
|
||||
- **Description**: Paramètres de configuration globaux
|
||||
- **Champs principaux**:
|
||||
- `default_provider_instance_id`: Instance de fournisseur par défaut
|
||||
- `default_model_id`: Modèle par défaut
|
||||
- `ai_batch_size`: Taille du lot pour le traitement
|
||||
|
||||
### 2. Res Company
|
||||
- **Description**: Extensions des paramètres de société
|
||||
- **Méthodes principales**:
|
||||
- `_get_default_provider_instance`: Obtenir l'instance par défaut
|
||||
|
||||
### 3. AI Provider Interface (`ai.provider.interface`)
|
||||
- **Description**: Interface abstraite pour les fournisseurs d'IA
|
||||
- **Méthodes requises**:
|
||||
- `send_message`: Envoyer un message
|
||||
- `get_models`: Obtenir la liste des modèles
|
||||
- `test_connection`: Tester la connexion
|
||||
|
||||
## Notes d'Implémentation
|
||||
|
||||
1. **Architecture Modulaire**:
|
||||
- Modules fournisseurs disponibles: `ollama_ai_integration`, `chatgpt_ai_integration`
|
||||
- Vérification de la présence d'au moins un module fournisseur avant création d'instances
|
||||
|
||||
2. **Héritage et Extensions**:
|
||||
- Les instances de fournisseur héritent de `mail.thread` et `ai.base.mixin`
|
||||
- Les paramètres de génération sont définis dans le modèle abstrait `ai.generation.params`
|
||||
|
||||
3. **Configuration Hiérarchique**:
|
||||
- Configuration globale > Paramètres société > Instance
|
||||
- Paramètres de génération personnalisables à plusieurs niveaux
|
||||
|
||||
4. **Sécurité et Validation**:
|
||||
- Vérifications de sécurité intégrées
|
||||
- Validation des modules requis
|
||||
- Gestion des paramètres de génération avec valeurs par défaut
|
||||
1
ai_integration/models/mixins/__init__.py
Normal file
1
ai_integration/models/mixins/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import ai_base_mixin
|
||||
167
ai_integration/models/mixins/ai_base_mixin.py
Normal file
167
ai_integration/models/mixins/ai_base_mixin.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from typing import List, Dict, Any, Optional
|
||||
from odoo import models, api, fields, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class AIBaseMixin(models.AbstractModel):
|
||||
"""Base mixin for AI integration providing both provider interaction and generation parameters.
|
||||
|
||||
This mixin combines the functionality of message handling and generation parameters
|
||||
into a single, cohesive interface for AI integration.
|
||||
"""
|
||||
_name = 'ai.base.mixin'
|
||||
_description = 'AI Integration Base Mixin'
|
||||
|
||||
# Basic Generation Parameters
|
||||
temperature = fields.Float(
|
||||
string='Temperature',
|
||||
help='Sampling temperature. Range: [0.0 - 2.0]. Higher values make output more random, '
|
||||
'lower values more deterministic.',
|
||||
default=0.7,
|
||||
digits=(3, 2))
|
||||
|
||||
top_p = fields.Float(
|
||||
string='Top P',
|
||||
help='Nucleus sampling: limits cumulative probability of tokens to sample from. '
|
||||
'Range: [0.0 - 1.0].',
|
||||
default=0.9,
|
||||
digits=(3, 2))
|
||||
|
||||
max_tokens = fields.Integer(
|
||||
string='Max Tokens',
|
||||
help='Maximum number of tokens to generate. Range: [1 - 32768].',
|
||||
default=2048)
|
||||
|
||||
stop_sequences = fields.Char(
|
||||
string='Stop Sequences',
|
||||
help='Comma-separated list of sequences where the model should stop generating')
|
||||
|
||||
# System Settings
|
||||
timeout = fields.Integer(
|
||||
string='Timeout',
|
||||
help='Request timeout in seconds. Range: [1 - 300].',
|
||||
default=30)
|
||||
|
||||
retry_count = fields.Integer(
|
||||
string='Retry Count',
|
||||
help='Number of times to retry failed requests. Range: [0 - 5].',
|
||||
default=3)
|
||||
|
||||
stream_response = fields.Boolean(
|
||||
string='Stream Response',
|
||||
help='Enable response streaming for real-time output.',
|
||||
default=False)
|
||||
|
||||
def _get_base_generation_params(self):
|
||||
"""Get common generation parameters as a dictionary.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing all generation parameters
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'temperature': self.temperature,
|
||||
'top_p': self.top_p,
|
||||
'max_tokens': self.max_tokens,
|
||||
'stop_sequences': self.stop_sequences.split(',') if self.stop_sequences else None,
|
||||
'timeout': self.timeout,
|
||||
'retry_count': self.retry_count,
|
||||
'stream_response': self.stream_response,
|
||||
}
|
||||
|
||||
def _get_ai_provider_instance(self, provider_instance_id=None):
|
||||
"""Get the AI provider instance to use.
|
||||
|
||||
Args:
|
||||
provider_instance_id: Optional specific provider instance to use
|
||||
|
||||
Returns:
|
||||
ai.provider.instance: The provider instance to use
|
||||
|
||||
Raises:
|
||||
UserError: If no provider instance is configured or available
|
||||
"""
|
||||
if provider_instance_id:
|
||||
instance = self.env['ai.provider.instance'].browse(provider_instance_id)
|
||||
if not instance.exists():
|
||||
raise UserError(_("Invalid provider instance"))
|
||||
else:
|
||||
provider_id = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'ai_integration.default_provider_instance_id')
|
||||
if not provider_id:
|
||||
raise UserError(_("No default AI provider instance configured"))
|
||||
instance = self.env['ai.provider.instance'].browse(int(provider_id))
|
||||
if not instance.exists():
|
||||
raise UserError(_("Default provider instance not found"))
|
||||
|
||||
if not instance.is_active:
|
||||
raise UserError(_("The selected AI provider instance is not active"))
|
||||
|
||||
return instance
|
||||
|
||||
def _get_ai_model(self, model_id=None, provider_instance=None):
|
||||
"""Get the AI model to use.
|
||||
|
||||
Args:
|
||||
model_id: Optional specific model to use
|
||||
provider_instance: Optional provider instance (to avoid duplicate lookup)
|
||||
|
||||
Returns:
|
||||
ai.model: The model to use
|
||||
|
||||
Raises:
|
||||
UserError: If no model is configured or available
|
||||
"""
|
||||
if not provider_instance:
|
||||
provider_instance = self._get_ai_provider_instance()
|
||||
|
||||
if model_id:
|
||||
model = self.env['ai.model'].browse(model_id)
|
||||
if not model.exists():
|
||||
raise UserError(_("Invalid model"))
|
||||
if model.provider_instance_id != provider_instance:
|
||||
raise UserError(_("Model does not belong to the selected provider instance"))
|
||||
else:
|
||||
model_id = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'ai_integration.default_model_id')
|
||||
if not model_id:
|
||||
raise UserError(_("No default AI model configured"))
|
||||
model = self.env['ai.model'].browse(int(model_id))
|
||||
if not model.exists():
|
||||
raise UserError(_("Default model not found"))
|
||||
|
||||
if not model.is_active:
|
||||
raise UserError(_("The selected AI model is not active"))
|
||||
|
||||
return model
|
||||
|
||||
def send_ai_message(self, message: Dict[str, Any], provider_instance_id: Optional[int] = None,
|
||||
model_id: Optional[int] = None, **kwargs):
|
||||
"""Send a message to an AI provider instance.
|
||||
|
||||
Args:
|
||||
message: The message to send
|
||||
provider_instance_id: Optional specific provider instance to use
|
||||
model_id: Optional specific model to use
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
str: The response from the AI provider
|
||||
|
||||
Raises:
|
||||
UserError: If there's an error with the AI provider
|
||||
"""
|
||||
provider_instance = self._get_ai_provider_instance(provider_instance_id)
|
||||
model = self._get_ai_model(model_id, provider_instance)
|
||||
|
||||
# Merge generation parameters with provider-specific parameters
|
||||
params = {**self._get_base_generation_params(), **kwargs}
|
||||
|
||||
try:
|
||||
return provider_instance.send_message(message, model=model, **params)
|
||||
except Exception as e:
|
||||
_logger.error("Error sending message to AI provider: %s", str(e))
|
||||
raise UserError(_("Failed to send message to AI provider: %s") % str(e))
|
||||
21
ai_integration/models/res_company.py
Normal file
21
ai_integration/models/res_company.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
def _get_default_provider_instance(self):
|
||||
"""Get the default AI provider instance from config parameters"""
|
||||
provider_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_provider_instance_id')
|
||||
return self.env['ai.provider.instance'].browse(int(provider_id)) if provider_id else False
|
||||
|
||||
def _get_default_model(self):
|
||||
"""Get the default AI model from config parameters"""
|
||||
model_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_model_id')
|
||||
return self.env['ai.model'].browse(int(model_id)) if model_id else False
|
||||
|
||||
def _get_ai_batch_size(self):
|
||||
"""Get the AI batch size from config parameters"""
|
||||
return int(self.env['ir.config_parameter'].sudo().get_param('ai_integration.ai_batch_size', '100'))
|
||||
22
ai_integration/models/res_config_settings.py
Normal file
22
ai_integration/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from odoo import api, fields, models
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
default_provider_instance_id = fields.Many2one(
|
||||
'ai.provider.instance',
|
||||
string='Default AI Provider Instance',
|
||||
config_parameter='ai_integration.default_provider_instance_id',
|
||||
default_model='ai.provider.instance')
|
||||
|
||||
default_model_id = fields.Many2one(
|
||||
'ai.model',
|
||||
string='Default AI Model',
|
||||
config_parameter='ai_integration.default_model_id',
|
||||
default_model='ai.model')
|
||||
|
||||
ai_batch_size = fields.Integer(
|
||||
string='AI Batch Processing Size',
|
||||
config_parameter='ai_integration.ai_batch_size',
|
||||
default=100,
|
||||
default_model='ai.provider.instance')
|
||||
20
ai_integration/security/ai_security.xml
Normal file
20
ai_integration/security/ai_security.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<!-- AI User Group -->
|
||||
<record id="group_ai_user" model="res.groups">
|
||||
<field name="name">AI User</field>
|
||||
<field name="category_id" ref="base.module_category_services"/>
|
||||
<field name="comment">Users can use AI services but cannot configure them.</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Manager Group -->
|
||||
<record id="group_ai_manager" model="res.groups">
|
||||
<field name="name">AI Manager</field>
|
||||
<field name="category_id" ref="base.module_category_services"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_ai_user'))]"/>
|
||||
<field name="comment">Full access to AI configuration and usage.</field>
|
||||
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
11
ai_integration/security/ir.model.access.csv
Normal file
11
ai_integration/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_ai_model_user,ai.model.user,model_ai_model,base.group_user,1,0,0,0
|
||||
access_ai_model_system,ai.model.system,model_ai_model,base.group_system,1,1,1,1
|
||||
access_ai_provider_instance_user,ai.provider.instance.user,model_ai_provider_instance,base.group_user,1,0,0,0
|
||||
access_ai_provider_instance_system,ai.provider.instance.system,model_ai_provider_instance,base.group_system,1,1,1,1
|
||||
access_ai_model_stats_user,ai.model.stats.user,model_ai_model_stats,base.group_user,1,0,0,0
|
||||
access_ai_model_stats_system,ai.model.stats.system,model_ai_model_stats,base.group_system,1,1,1,1
|
||||
access_ai_generation_params_user,ai.generation.params.user,model_ai_generation_params,base.group_user,1,0,0,0
|
||||
access_ai_generation_params_system,ai.generation.params.system,model_ai_generation_params,base.group_system,1,1,1,1
|
||||
access_ai_provider_user,ai.provider.user,model_ai_provider,base.group_user,1,0,0,0
|
||||
access_ai_provider_system,ai.provider.system,model_ai_provider,base.group_system,1,1,1,1
|
||||
|
28
ai_integration/security/ir_rule.xml
Normal file
28
ai_integration/security/ir_rule.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<!-- Global Provider Instance Rule -->
|
||||
<record id="ai_provider_instance_global_rule" model="ir.rule">
|
||||
<field name="name">AI Provider Instance: Global Access</field>
|
||||
<field name="model_id" ref="model_ai_provider_instance"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
<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>
|
||||
|
||||
<!-- Global Model Rule -->
|
||||
<record id="ai_model_global_rule" model="ir.rule">
|
||||
<field name="name">AI Model: Global Access</field>
|
||||
<field name="model_id" ref="model_ai_model"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
<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>
|
||||
</data>
|
||||
</odoo>
|
||||
118
ai_integration/views/ai_model_stats_views.xml
Normal file
118
ai_integration/views/ai_model_stats_views.xml
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="view_ai_model_stats_list" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.list</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Model Statistics" create="false">
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="version"/>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_ai_model_stats_form" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.form</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Model Statistics">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="version"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_ai_model_stats_search" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.search</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Model Statistics">
|
||||
<field name="model_id"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="version"/>
|
||||
<field name="date"/>
|
||||
<filter string="Today" name="today" domain="[('date','=',context_today().strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 7 Days" name="last_week" domain="[('date','>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 30 Days" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Model" name="group_by_model" context="{'group_by': 'model_id'}"/>
|
||||
<filter string="Provider Instance" name="group_by_provider" context="{'group_by': 'provider_instance_id'}"/>
|
||||
<filter string="Version" name="group_by_version" context="{'group_by': 'version'}"/>
|
||||
<filter string="Date" name="group_by_date" context="{'group_by': 'date'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Graph View -->
|
||||
<record id="view_ai_model_stats_graph" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.graph</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Model Statistics" type="line" sample="1">
|
||||
<field name="date" type="row"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Pivot View -->
|
||||
<record id="view_ai_model_stats_pivot" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.pivot</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Model Statistics" sample="1">
|
||||
<field name="model_id" type="row"/>
|
||||
<field name="provider_instance_id" type="row"/>
|
||||
<field name="date" type="col"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="avg_response_time" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_ai_model_stats" model="ir.actions.act_window">
|
||||
<field name="name">Model Statistics</field>
|
||||
<field name="res_model">ai.model.stats</field>
|
||||
<field name="view_mode">list,form,graph,pivot</field>
|
||||
<field name="context">{'search_default_last_month': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No statistics recorded yet
|
||||
</p>
|
||||
<p>
|
||||
Statistics will be automatically recorded as you use your AI models.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
96
ai_integration/views/ai_model_views.xml
Normal file
96
ai_integration/views/ai_model_views.xml
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="ai_model_view_list" model="ir.ui.view">
|
||||
<field name="name">ai.model.list</field>
|
||||
<field name="model">ai.model</field>
|
||||
<field name="type">list</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Models" create="false">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="identifier"/>
|
||||
<field name="context_window"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="ai_model_view_form" model="ir.ui.view">
|
||||
<field name="name">ai.model.form</field>
|
||||
<field name="model">ai.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Model">
|
||||
<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">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. GPT-3.5 Turbo"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="provider_instance_id" options="{'no_create': True}"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="identifier"/>
|
||||
<field name="context_window"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Description" name="description">
|
||||
<field name="description" placeholder="Enter a description of the model and its capabilities..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="ai_model_view_search" model="ir.ui.view">
|
||||
<field name="name">ai.model.search</field>
|
||||
<field name="model">ai.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Models">
|
||||
<field name="name"/>
|
||||
<field name="identifier"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="provider_type"/>
|
||||
<filter string="Archived" name="inactive" domain="[('is_active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Provider Instance" name="group_by_provider" context="{'group_by': 'provider_instance_id'}"/>
|
||||
<filter string="Provider Type" name="group_by_type" context="{'group_by': 'provider_type'}"/>
|
||||
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="ai_model_action" model="ir.actions.act_window">
|
||||
<field name="name">AI Models</field>
|
||||
<field name="res_model">ai.model</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="ai_model_view_search"/>
|
||||
<field name="context">{'search_default_group_by_provider': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No AI models found
|
||||
</p>
|
||||
<p>
|
||||
AI models will be automatically synchronized when you configure and sync a provider instance.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
107
ai_integration/views/ai_provider_instance_views.xml
Normal file
107
ai_integration/views/ai_provider_instance_views.xml
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="ai_provider_instance_view_list" model="ir.ui.view">
|
||||
<field name="name">ai.provider.instance.list</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<list name="ai_instance" string="AI Provider Instances" create="true">
|
||||
<field name="name"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="ai_provider_instance_view_form" model="ir.ui.view">
|
||||
<field name="name">ai.provider.instance.form</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Provider Instance">
|
||||
<header>
|
||||
<button name="test_connection"
|
||||
string="Test Connection"
|
||||
type="object"
|
||||
class="oe_highlight"/>
|
||||
<button name="sync_models"
|
||||
string="Sync Models"
|
||||
type="object"
|
||||
class="btn-primary"/>
|
||||
</header>
|
||||
<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">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. OpenWebUI Production"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<field name="api_key"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="timeout"/>
|
||||
<field name="max_retries"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Models" name="models">
|
||||
<field name="model_ids" nolabel="1">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="identifier"/>
|
||||
<field name="context_window"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="ai_provider_instance_view_search" model="ir.ui.view">
|
||||
<field name="name">ai.provider.instance.search</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Provider Instances">
|
||||
<field name="name"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Provider Type" name="group_by_type" context="{'group_by': 'provider_type'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="ai_provider_instance_action" model="ir.actions.act_window">
|
||||
<field name="name">AI Provider Instances</field>
|
||||
<field name="res_model">ai.provider.instance</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="ai_provider_instance_view_search"/>
|
||||
<field name="context">{'company_id': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first AI provider instance
|
||||
</p>
|
||||
<p>
|
||||
Configure AI provider instances to connect to different AI services.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
88
ai_integration/views/ai_provider_views.xml
Normal file
88
ai_integration/views/ai_provider_views.xml
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="ai_provider_view_tree" model="ir.ui.view">
|
||||
<field name="name">ai.provider.tree</field>
|
||||
<field name="model">ai.provider</field>
|
||||
<field name="type">list</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Providers" create="false">
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="default_host"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="ai_provider_view_form" model="ir.ui.view">
|
||||
<field name="name">ai.provider.form</field>
|
||||
<field name="model">ai.provider</field>
|
||||
<field name="type">form</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Provider">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="test_connection" type="object"
|
||||
string="Test Connection" class="oe_stat_button"
|
||||
icon="fa-plug"/>
|
||||
<button name="get_models" type="object"
|
||||
string="Get Models" class="oe_stat_button"
|
||||
icon="fa-list"/>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. Ollama"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="default_host"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Description" name="description">
|
||||
<field name="description" nolabel="1" placeholder="Enter a description..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="ai_provider_view_search" model="ir.ui.view">
|
||||
<field name="name">ai.provider.search</field>
|
||||
<field name="model">ai.provider</field>
|
||||
<field name="type">search</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Providers">
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="ai_provider_action" model="ir.actions.act_window">
|
||||
<field name="name">AI Providers</field>
|
||||
<field name="res_model">ai.provider</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No AI providers found
|
||||
</p>
|
||||
<p>
|
||||
AI providers are automatically created when you install AI integration modules.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
51
ai_integration/views/menu.xml
Normal file
51
ai_integration/views/menu.xml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Top level menu -->
|
||||
<menuitem id="menu_ai_root"
|
||||
name="AI Integration"
|
||||
web_icon="ai_integration,static/description/icon.png"
|
||||
sequence="50"/>
|
||||
|
||||
<!-- Configuration menu -->
|
||||
<menuitem id="menu_ai_config"
|
||||
name="Configuration"
|
||||
parent="menu_ai_root"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Settings menu -->
|
||||
<menuitem id="menu_ai_settings"
|
||||
name="Settings"
|
||||
parent="menu_ai_config"
|
||||
action="ai_integration.action_ai_integration_configuration"
|
||||
sequence="10"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<!-- Provider Types menu -->
|
||||
<menuitem id="menu_ai_provider_types"
|
||||
name="Provider Types"
|
||||
parent="menu_ai_config"
|
||||
action="ai_provider_action"
|
||||
sequence="10"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<!-- Provider Instances menu -->
|
||||
<menuitem id="menu_ai_provider_instances"
|
||||
name="Provider Instances"
|
||||
parent="menu_ai_config"
|
||||
action="ai_provider_instance_action"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- AI Models menu -->
|
||||
<menuitem id="menu_ai_models"
|
||||
name="AI Models"
|
||||
parent="menu_ai_config"
|
||||
action="ai_model_action"
|
||||
sequence="30"/>
|
||||
|
||||
<!-- Statistics menu -->
|
||||
<menuitem id="menu_ai_stats"
|
||||
name="Model Statistics"
|
||||
parent="menu_ai_config"
|
||||
action="action_ai_model_stats"
|
||||
sequence="40"/>
|
||||
</odoo>
|
||||
14
ai_integration/views/res_company_views.xml
Normal file
14
ai_integration/views/res_company_views.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Add AI Integration to Settings Menu -->
|
||||
<record id="action_ai_integration_configuration" model="ir.actions.act_window">
|
||||
<field name="name">AI Integration</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': 'ai_integration'}</field>
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
66
ai_integration/views/res_config_settings_views.xml
Normal file
66
ai_integration/views/res_config_settings_views.xml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Settings Action -->
|
||||
<record id="action_ai_integration_configuration" model="ir.actions.act_window">
|
||||
<field name="name">AI Integration 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" : "ai_integration"}</field>
|
||||
</record>
|
||||
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.ai.integration</field>
|
||||
<field name="model">res.config.settings</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="AI Integration" string="AI Integration" data-key="ai_integration">
|
||||
<h2>AI Integration</h2>
|
||||
<div class="row mt16 o_settings_container" name="ai_integration_setting_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="default_provider_settings">
|
||||
<div class="o_setting_left_pane"/>
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Default Provider Configuration</span>
|
||||
<div class="text-muted">
|
||||
Configure the default AI provider instance and model for this company
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="mt16 row">
|
||||
<label for="default_provider_instance_id" class="col-lg-3 o_light_label"/>
|
||||
<field name="default_provider_instance_id" options="{'no_create': True}"/>
|
||||
</div>
|
||||
<div class="mt16 row">
|
||||
<label for="default_model_id" class="col-lg-3 o_light_label"/>
|
||||
<field name="default_model_id"
|
||||
options="{'no_create': True}"
|
||||
invisible="default_provider_instance_id == False"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="batch_processing_settings">
|
||||
<div class="o_setting_left_pane"/>
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Batch Processing</span>
|
||||
<div class="text-muted">
|
||||
Configure batch processing parameters for AI operations
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="mt16 row">
|
||||
<label for="ai_batch_size" class="col-lg-3 o_light_label"/>
|
||||
<field name="ai_batch_size"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
ai_integration_ollama_api/__init__.py
Normal file
1
ai_integration_ollama_api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
36
ai_integration_ollama_api/__manifest__.py
Normal file
36
ai_integration_ollama_api/__manifest__.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
'name': 'Ollama Integration',
|
||||
'version': '1.0.0',
|
||||
'category': 'Technical',
|
||||
'summary': 'Integration with Ollama AI models',
|
||||
'description': """
|
||||
Ollama Integration
|
||||
==================
|
||||
This module provides integration with Ollama, allowing you to use local AI models
|
||||
in your Odoo instance. Features include:
|
||||
|
||||
* Connection to local Ollama server
|
||||
* Support for all Ollama models
|
||||
* Automatic model discovery and synchronization
|
||||
* Configurable model parameters
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://www.bemade.org',
|
||||
'depends': [
|
||||
'ai_integration'
|
||||
],
|
||||
'data': [
|
||||
'data/ai_provider_data.xml',
|
||||
'data/ai_provider_instance_data.xml',
|
||||
'views/ollama_stats_views.xml',
|
||||
'views/ai_provider_instance_views.xml',
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['requests'],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
13
ai_integration_ollama_api/data/ai_provider_data.xml
Normal file
13
ai_integration_ollama_api/data/ai_provider_data.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Ollama Provider -->
|
||||
<record id="ai_provider_ollama" model="ai.provider">
|
||||
<field name="name">Ollama</field>
|
||||
<field name="code">ollama</field>
|
||||
<field name="description">Ollama is a local AI model provider that allows you to run various open-source models locally.</field>
|
||||
<field name="default_host">http://localhost:11434</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
31
ai_integration_ollama_api/data/ai_provider_instance_data.xml
Normal file
31
ai_integration_ollama_api/data/ai_provider_instance_data.xml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Default Ollama Provider Instance -->
|
||||
<record id="ai_provider_instance_ollama_default" model="ai.provider.instance">
|
||||
<field name="name">Ollama Local</field>
|
||||
<field name="provider_type">ollama</field>
|
||||
<field name="host">http://localhost:11434</field>
|
||||
<field name="model_name">llama3.2</field>
|
||||
<field name="temperature">0.7</field>
|
||||
<field name="top_k">40</field>
|
||||
<field name="top_p">0.9</field>
|
||||
<field name="repeat_penalty">1.1</field>
|
||||
<field name="num_ctx">4096</field>
|
||||
<field name="num_predict">1024</field>
|
||||
<field name="min_p">0.05</field>
|
||||
<field name="repeat_last_n">64</field>
|
||||
<field name="seed">0</field>
|
||||
<field name="num_gpu">1</field>
|
||||
<field name="num_thread">8</field>
|
||||
<field name="mirostat">0</field>
|
||||
<field name="mirostat_tau">5.0</field>
|
||||
<field name="mirostat_eta">0.1</field>
|
||||
<field name="num_batch">8</field>
|
||||
<field name="num_keep">0</field>
|
||||
<field name="tfs_z">1.0</field>
|
||||
<field name="skip_special_tokens" eval="True"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
10
ai_integration_ollama_api/data/ir_model_inherit.xml
Normal file
10
ai_integration_ollama_api/data/ir_model_inherit.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Declare model inheritance -->
|
||||
<record id="ollama_provider_inherit" model="ir.model.inherit">
|
||||
<field name="model">ai.provider.ollama</field>
|
||||
<field name="parent_id" ref="ai_integration.model_ai_provider_interface"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
13
ai_integration_ollama_api/data/ollama_provider.xml
Normal file
13
ai_integration_ollama_api/data/ollama_provider.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Register Ollama Provider -->
|
||||
<record id="ai_provider_ollama" model="ai.provider">
|
||||
<field name="name">Ollama</field>
|
||||
<field name="code">ollama</field>
|
||||
<field name="description">Local AI models powered by Ollama</field>
|
||||
<field name="default_host">http://localhost:11434</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
1627
ai_integration_ollama_api/doc/ollama_api.md
Normal file
1627
ai_integration_ollama_api/doc/ollama_api.md
Normal file
File diff suppressed because it is too large
Load diff
17
ai_integration_ollama_api/migrations/1.0.0/post-migration.py
Normal file
17
ai_integration_ollama_api/migrations/1.0.0/post-migration.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
def migrate(cr, version):
|
||||
# Add num_predict column if it doesn't exist
|
||||
cr.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name='ai_provider_instance'
|
||||
AND column_name='num_predict'
|
||||
) THEN
|
||||
ALTER TABLE ai_provider_instance
|
||||
ADD COLUMN num_predict integer DEFAULT 1024;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
""")
|
||||
27
ai_integration_ollama_api/models/__init__.py
Normal file
27
ai_integration_ollama_api/models/__init__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Ollama AI Integration Models Package.
|
||||
|
||||
This package contains all the model definitions required for integrating
|
||||
Ollama AI with Odoo's AI framework. The models are loaded in a specific
|
||||
order to handle dependencies correctly.
|
||||
|
||||
Module Structure:
|
||||
1. ollama_provider_mixin - Base configuration and parameter definitions
|
||||
2. ollama_provider - Core Ollama API integration implementation
|
||||
3. ollama_model_stats - Usage statistics and performance tracking
|
||||
4. ai_provider_instance - Instance-specific configuration and management
|
||||
|
||||
Note: The import order is important to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
# Base Configuration
|
||||
from . import ollama_provider_mixin
|
||||
|
||||
# Core Implementation
|
||||
from . import ollama_provider
|
||||
from . import ai_provider_instance
|
||||
|
||||
# Statistics and Monitoring
|
||||
from . import ollama_model_stats
|
||||
|
||||
# Instance Management
|
||||
from . import ai_provider_instance
|
||||
153
ai_integration_ollama_api/models/ai_provider_instance.py
Normal file
153
ai_integration_ollama_api/models/ai_provider_instance.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import requests
|
||||
|
||||
class OllamaAIProviderInstance(models.Model):
|
||||
"""Extends the AI Provider Instance model to support Ollama-specific configuration.
|
||||
|
||||
This model inherits from both ai.provider.instance and ollama.provider.mixin to:
|
||||
1. Add Ollama-specific fields (num_ctx, temperature, etc.)
|
||||
2. Handle field visibility based on provider_type
|
||||
3. Manage field cleanup when switching providers
|
||||
"""
|
||||
_inherit = ['ai.provider.instance', 'ollama.provider.mixin']
|
||||
_name = 'ai.provider.instance'
|
||||
_description = 'Ollama AI Provider Instance'
|
||||
|
||||
# Override provider_type to add Ollama option
|
||||
provider_type = fields.Selection(
|
||||
selection_add=[('ollama', 'Ollama')],
|
||||
ondelete={'ollama': lambda r: r.write({'provider_type': 'none'})}
|
||||
)
|
||||
|
||||
@api.onchange('provider_type')
|
||||
def _onchange_provider_type(self):
|
||||
"""Handle provider type changes.
|
||||
|
||||
When switching to 'ollama':
|
||||
- Set default host if empty
|
||||
|
||||
When switching away from 'ollama':
|
||||
- Clear Ollama-specific fields
|
||||
"""
|
||||
if self.provider_type == 'ollama':
|
||||
if not self.host:
|
||||
self.host = 'http://localhost:11434'
|
||||
else:
|
||||
# Clear Ollama-specific fields
|
||||
self.update({
|
||||
'num_ctx': False, # Context length
|
||||
'temperature': False, # Sampling temperature
|
||||
'top_p': False, # Nucleus sampling threshold
|
||||
'top_k': False, # Top-k sampling threshold
|
||||
'repeat_penalty': False, # Penalty for repeated tokens
|
||||
'repeat_last_n': False, # Number of tokens to consider for repeat penalty
|
||||
'num_thread': False, # Number of CPU threads to use
|
||||
'num_gpu': False, # Number of GPUs to use
|
||||
'num_batch': False, # Batch size for inference
|
||||
'model_name': False, # Model name/path
|
||||
})
|
||||
|
||||
# Override default host for Ollama
|
||||
host = fields.Char(
|
||||
default='http://localhost:11434',
|
||||
help='Ollama server host URL')
|
||||
|
||||
def test_connection(self):
|
||||
"""Test the connection to the Ollama server.
|
||||
|
||||
This method attempts to connect to the Ollama server and verify
|
||||
that it is responding correctly. It will raise a user-friendly
|
||||
error if the connection fails.
|
||||
|
||||
Returns:
|
||||
dict: Action to display success message
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.provider_type != 'ollama':
|
||||
return
|
||||
|
||||
try:
|
||||
# Try to list models as a basic connectivity test
|
||||
response = requests.get(f'{self.host}/api/tags')
|
||||
response.raise_for_status()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Success'),
|
||||
'message': _('Successfully connected to Ollama server'),
|
||||
'sticky': False,
|
||||
'type': 'success',
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise UserError(_('Connection test failed: %s', str(e)))
|
||||
|
||||
def sync_models(self):
|
||||
"""Synchronize available models from the Ollama server.
|
||||
|
||||
This method fetches the list of available models from the Ollama
|
||||
server and creates or updates the corresponding AI model records
|
||||
in Odoo.
|
||||
|
||||
Returns:
|
||||
dict: Action to display success message
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.provider_type != 'ollama':
|
||||
return
|
||||
|
||||
try:
|
||||
# Get models from Ollama API
|
||||
response = requests.get(f'{self.host}/api/tags')
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse response
|
||||
models = [{
|
||||
'name': model['name'],
|
||||
'id': model['name'],
|
||||
} for model in response.json()['models']]
|
||||
|
||||
for model_data in models:
|
||||
# Create or update AI model record
|
||||
vals = {
|
||||
'name': model_data['name'],
|
||||
'identifier': model_data['id'],
|
||||
'provider_instance_id': self.id,
|
||||
'active': True,
|
||||
}
|
||||
# Search for existing model
|
||||
existing = self.env['ai.model'].search([
|
||||
('identifier', '=', model_data['id']),
|
||||
('provider_instance_id', '=', self.id)
|
||||
], limit=1)
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
self.env['ai.model'].create(vals)
|
||||
|
||||
# Invalidate the cache to force reload of related records
|
||||
self.invalidate_recordset(['model_ids'])
|
||||
|
||||
# Return action to reload the view completely
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'ai.provider.instance',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'flags': {
|
||||
'mode': 'readonly',
|
||||
'reload': True, # Force reload
|
||||
},
|
||||
'context': {'notification': {
|
||||
'type': 'success',
|
||||
'title': _('Success'),
|
||||
'message': _('Successfully synchronized %d models', len(models)),
|
||||
'sticky': False,
|
||||
}}
|
||||
}
|
||||
except Exception as e:
|
||||
raise UserError(_('Model synchronization failed: %s', str(e)))
|
||||
35
ai_integration_ollama_api/models/ai_provider_ollama.py.bak
Normal file
35
ai_integration_ollama_api/models/ai_provider_ollama.py.bak
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from odoo import models, fields, api, _
|
||||
from .ollama_provider_mixin import OllamaProviderMixin
|
||||
|
||||
class OllamaAIProvider(models.Model, OllamaProviderMixin):
|
||||
_name = 'ai.provider.ollama'
|
||||
_description = 'Ollama AI Provider'
|
||||
_inherit = ['ai.provider.interface']
|
||||
|
||||
provider_type = fields.Selection(
|
||||
selection=[('ollama', 'Ollama')],
|
||||
default='ollama',
|
||||
required=True,
|
||||
help='Type of AI provider')
|
||||
|
||||
# Ollama-specific Parameters
|
||||
host = fields.Char(
|
||||
string='Host',
|
||||
default='http://localhost:11434',
|
||||
required=True,
|
||||
help='Ollama server host URL')
|
||||
|
||||
def _get_available_models(self):
|
||||
"""Get list of available models from Ollama server."""
|
||||
# TODO: Implement model discovery
|
||||
return []
|
||||
|
||||
def _generate_text(self, prompt, **kwargs):
|
||||
"""Generate text using Ollama model."""
|
||||
# TODO: Implement text generation
|
||||
return ""
|
||||
|
||||
def _embed_text(self, text, **kwargs):
|
||||
"""Generate embeddings for text using Ollama model."""
|
||||
# TODO: Implement text embedding
|
||||
return []
|
||||
116
ai_integration_ollama_api/models/ollama_model_stats.py
Normal file
116
ai_integration_ollama_api/models/ollama_model_stats.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from odoo import models, fields, api
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class OllamaModelStats(models.Model):
|
||||
"""Tracks and stores daily usage statistics for Ollama AI models.
|
||||
|
||||
This model maintains detailed daily statistics for each Ollama model,
|
||||
including request counts, token usage, response times, and error rates.
|
||||
It inherits from ai.model.stats for base statistics functionality.
|
||||
|
||||
Key Features:
|
||||
- Daily usage tracking per model
|
||||
- Performance metrics collection
|
||||
- Error rate monitoring
|
||||
- Version tracking for model updates
|
||||
|
||||
Technical Details:
|
||||
- One stat entry per model per day (enforced by SQL constraint)
|
||||
- Automatic version tracking from Ollama API
|
||||
- Aggregated statistics calculation
|
||||
- Ordered by date for easy historical analysis
|
||||
"""
|
||||
_name = 'ollama.model.stats'
|
||||
_description = 'Ollama Model Usage Statistics'
|
||||
_inherit = ['ai.model.stats']
|
||||
_order = 'date desc' # Most recent stats first
|
||||
|
||||
model_id = fields.Many2one('ai.model', string='Model', required=True, ondelete='cascade')
|
||||
date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
|
||||
request_count = fields.Integer(string='Number of Requests', default=0)
|
||||
token_count = fields.Integer(string='Total Tokens', default=0)
|
||||
avg_response_time = fields.Float(string='Average Response Time (ms)', digits=(10, 2), default=0)
|
||||
error_count = fields.Integer(string='Number of Errors', default=0)
|
||||
version = fields.Char(string='Model Version', help='Version of the model when stats were recorded')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_model_date', 'unique(model_id, date)', 'Only one stat entry per model per day is allowed.')
|
||||
]
|
||||
|
||||
def _update_stats(self, model, tokens, response_time, error=False):
|
||||
"""Update daily statistics for a specific model.
|
||||
|
||||
This method handles the creation or update of daily statistics entries.
|
||||
It maintains running averages and cumulative counts for various metrics.
|
||||
|
||||
Args:
|
||||
model (ai.model): The model record being tracked
|
||||
tokens (int): Number of tokens in the current request
|
||||
response_time (float): Response time in milliseconds
|
||||
error (bool): Whether this request resulted in an error
|
||||
|
||||
Technical Notes:
|
||||
- Creates new stat entry if none exists for today
|
||||
- Updates running averages for response time
|
||||
- Fetches and stores model version from Ollama API
|
||||
- Maintains cumulative counts for requests and errors
|
||||
"""
|
||||
today = fields.Date.context_today(self)
|
||||
stats = self.search([
|
||||
('model_id', '=', model.id),
|
||||
('date', '=', today)
|
||||
])
|
||||
|
||||
if not stats:
|
||||
# Get model version
|
||||
version = self.env['ai.provider.ollama']._get_model_info(
|
||||
model.provider_instance_id,
|
||||
model.identifier
|
||||
).get('details', {}).get('sha256', '')[:8] # First 8 chars of SHA
|
||||
|
||||
stats = self.create({
|
||||
'model_id': model.id,
|
||||
'date': today,
|
||||
'version': version
|
||||
})
|
||||
|
||||
# Update statistics
|
||||
new_count = stats.request_count + 1
|
||||
new_tokens = stats.token_count + tokens
|
||||
new_time = ((stats.avg_response_time * stats.request_count) + response_time) / new_count
|
||||
new_errors = stats.error_count + (1 if error else 0)
|
||||
|
||||
stats.write({
|
||||
'request_count': new_count,
|
||||
'token_count': new_tokens,
|
||||
'avg_response_time': new_time,
|
||||
'error_count': new_errors
|
||||
})
|
||||
|
||||
@api.model
|
||||
def get_model_stats(self, model_id, days=30):
|
||||
"""Get statistics for a model over the specified number of days."""
|
||||
start_date = fields.Date.today() - timedelta(days=days)
|
||||
stats = self.search([
|
||||
('model_id', '=', model_id),
|
||||
('date', '>=', start_date)
|
||||
])
|
||||
|
||||
return {
|
||||
'daily_stats': [{
|
||||
'date': stat.date,
|
||||
'requests': stat.request_count,
|
||||
'tokens': stat.token_count,
|
||||
'response_time': stat.avg_response_time,
|
||||
'errors': stat.error_count,
|
||||
'version': stat.version
|
||||
} for stat in stats],
|
||||
'summary': {
|
||||
'total_requests': sum(stat.request_count for stat in stats),
|
||||
'total_tokens': sum(stat.token_count for stat in stats),
|
||||
'avg_response_time': sum(stat.avg_response_time * stat.request_count for stat in stats) /
|
||||
(sum(stat.request_count for stat in stats) if stats else 1),
|
||||
'total_errors': sum(stat.error_count for stat in stats),
|
||||
'versions_used': list(set(stat.version for stat in stats if stat.version))
|
||||
}
|
||||
}
|
||||
324
ai_integration_ollama_api/models/ollama_provider.py
Normal file
324
ai_integration_ollama_api/models/ollama_provider.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import json
|
||||
import logging
|
||||
import requests
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class OllamaProvider(models.Model):
|
||||
"""Main Ollama AI Provider implementation.
|
||||
|
||||
This model implements the core functionality for interacting with Ollama's API,
|
||||
including model management, text generation, and error handling.
|
||||
|
||||
Key Responsibilities:
|
||||
- Model discovery and validation
|
||||
- API communication and error handling
|
||||
- Request formatting and response parsing
|
||||
- Resource management and cleanup
|
||||
|
||||
Technical Details:
|
||||
- Implements the ai.provider.interface for standardized AI provider integration
|
||||
- Uses Ollama's HTTP API for all operations
|
||||
- Handles both synchronous and asynchronous requests
|
||||
- Provides detailed error messages for troubleshooting
|
||||
"""
|
||||
_name = 'ai.provider.ollama'
|
||||
_description = 'Ollama AI Provider'
|
||||
_inherit = ['ai.provider.interface']
|
||||
|
||||
@api.model
|
||||
def _get_models(self, instance):
|
||||
"""Get list of available models from Ollama server.
|
||||
|
||||
Args:
|
||||
instance (ai.provider.instance): Provider instance to get models for
|
||||
|
||||
Returns:
|
||||
list: List of model dictionaries with keys:
|
||||
- name: Model name
|
||||
- id: Model identifier
|
||||
- details: Additional model metadata
|
||||
|
||||
Raises:
|
||||
UserError: If unable to connect or retrieve models
|
||||
"""
|
||||
try:
|
||||
response = requests.get(f"{instance.host}/api/tags")
|
||||
response.raise_for_status()
|
||||
|
||||
models_data = response.json().get('models', [])
|
||||
return [{
|
||||
'name': model['name'],
|
||||
'id': model['name'],
|
||||
'details': model
|
||||
} for model in models_data]
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_('Failed to connect to Ollama server: %s', str(e)))
|
||||
except (KeyError, ValueError) as e:
|
||||
raise UserError(_('Invalid response from Ollama server: %s', str(e)))
|
||||
|
||||
# API Configuration
|
||||
timeout = fields.Integer(
|
||||
string='Timeout',
|
||||
default=30,
|
||||
help='API request timeout in seconds')
|
||||
|
||||
def _get_provider_type(self):
|
||||
return 'ollama'
|
||||
|
||||
def _get_model_info(self, instance, model_name):
|
||||
"""Get detailed information about a specific model."""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/show",
|
||||
json={'name': model_name}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
_logger.error(
|
||||
"Failed to get model info for %s. Status: %s, Error: %s",
|
||||
model_name, response.status_code, response.text
|
||||
)
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error("Error getting model info: %s", str(e))
|
||||
return None
|
||||
|
||||
def pull_model(self, instance, model_name):
|
||||
"""Pull a model from Ollama."""
|
||||
try:
|
||||
# Start the pull
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/pull",
|
||||
json={'name': model_name},
|
||||
stream=True # Enable streaming for progress updates
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to pull model %s: %s") %
|
||||
(model_name, response.text))
|
||||
|
||||
# Process the streaming response
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
try:
|
||||
progress = json.loads(line)
|
||||
status = progress.get('status')
|
||||
if status:
|
||||
_logger.info(
|
||||
"Pulling model %s: %s",
|
||||
model_name, status
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Error pulling model %s: %s") %
|
||||
(model_name, str(e)))
|
||||
|
||||
def delete_model(self, instance, model_name):
|
||||
"""Delete a model from Ollama."""
|
||||
try:
|
||||
response = requests.delete(
|
||||
f"{instance.host}/api/delete",
|
||||
json={'name': model_name}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to delete model %s: %s") %
|
||||
(model_name, response.text))
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Error deleting model %s: %s") %
|
||||
(model_name, str(e)))
|
||||
|
||||
def test_connection(self, instance):
|
||||
"""Test the connection to the Ollama server."""
|
||||
try:
|
||||
response = requests.get(f"{instance.host}/api/tags")
|
||||
if response.status_code != 200:
|
||||
raise UserError(_(
|
||||
"Failed to connect to Ollama server. Status code: %s. Error: %s",
|
||||
response.status_code, response.text
|
||||
))
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Failed to connect to Ollama server: %s", str(e)
|
||||
))
|
||||
|
||||
def _format_chat_messages(self, messages):
|
||||
"""Format chat messages for Ollama API."""
|
||||
formatted_prompt = ""
|
||||
for message in messages:
|
||||
role = message.get('role', 'user')
|
||||
content = message.get('content', '')
|
||||
|
||||
if role == 'system':
|
||||
formatted_prompt += f"<system>{content}</system>\n"
|
||||
elif role == 'assistant':
|
||||
formatted_prompt += f"Assistant: {content}\n"
|
||||
else: # user
|
||||
formatted_prompt += f"Human: {content}\n"
|
||||
|
||||
return formatted_prompt.strip()
|
||||
|
||||
def generate_response(self, instance, model, messages, **kwargs):
|
||||
"""Generate a response using the chat completion API."""
|
||||
try:
|
||||
# Format messages into Ollama's expected format
|
||||
prompt = self._format_chat_messages(messages)
|
||||
|
||||
# Get model options from instance
|
||||
options = instance._get_provider_options()
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'model': model.identifier,
|
||||
'prompt': prompt,
|
||||
'stream': False,
|
||||
**options
|
||||
}
|
||||
|
||||
# Make the API call
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/generate",
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to generate response: %s") % response.text)
|
||||
|
||||
response_data = response.json()
|
||||
generated_text = response_data.get('response', '')
|
||||
|
||||
# Update statistics
|
||||
total_tokens = response_data.get('eval_count', 0)
|
||||
response_time = response_data.get('total_duration', 0)
|
||||
version = self._get_model_info(instance, model.identifier)\
|
||||
.get('details', {}).get('sha256', '')[:8]
|
||||
self._track_model_usage(
|
||||
model, total_tokens, response_time, version=version
|
||||
)
|
||||
|
||||
# Return the response in a standardized format
|
||||
return {
|
||||
'content': generated_text,
|
||||
'role': 'assistant',
|
||||
'metadata': {
|
||||
'eval_count': response_data.get('eval_count'),
|
||||
'eval_duration': response_data.get('eval_duration'),
|
||||
'total_duration': response_data.get('total_duration'),
|
||||
'load_duration': response_data.get('load_duration'),
|
||||
}
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Log error in statistics
|
||||
if model:
|
||||
self._track_model_usage(model, error=True)
|
||||
raise UserError(_("Error generating response: %s") % str(e))
|
||||
|
||||
def sync_models(self, instance):
|
||||
"""Synchronize available models from the Ollama server."""
|
||||
self.test_connection(instance)
|
||||
|
||||
try:
|
||||
response = requests.get(f"{instance.host}/api/tags")
|
||||
models_data = response.json().get('models', [])
|
||||
|
||||
# Get existing models for this instance
|
||||
existing_models = self.env['ai.model'].search([
|
||||
('provider_instance_id', '=', instance.id)
|
||||
])
|
||||
existing_identifiers = {m.identifier: m for m in existing_models}
|
||||
|
||||
for model_data in models_data:
|
||||
identifier = model_data.get('name')
|
||||
if not identifier:
|
||||
continue
|
||||
|
||||
# Get model details
|
||||
model_info = self._get_model_info(instance, identifier)
|
||||
model_details = model_info.get('details', {})
|
||||
|
||||
model_values = {
|
||||
'name': identifier.title(),
|
||||
'identifier': identifier,
|
||||
'provider_instance_id': instance.id,
|
||||
'company_id': instance.company_id.id,
|
||||
'active': True,
|
||||
'description': f"Ollama model: {identifier}\\nSize: {model_data.get('size', 'Unknown')}\\nModified: {model_data.get('modified', 'Unknown')}",
|
||||
}
|
||||
|
||||
if identifier in existing_identifiers:
|
||||
# Update existing model
|
||||
existing_identifiers[identifier].write(model_values)
|
||||
del existing_identifiers[identifier]
|
||||
else:
|
||||
# Create new model
|
||||
self.env['ai.model'].create(model_values)
|
||||
|
||||
# Deactivate models that no longer exist
|
||||
for model in existing_identifiers.values():
|
||||
model.active = False
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Failed to sync models from Ollama server: %s", str(e)
|
||||
))
|
||||
|
||||
def send_message(self, message, model, **kwargs):
|
||||
"""Send a message to the Ollama server."""
|
||||
instance = model.provider_instance_id
|
||||
|
||||
try:
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'model': model.identifier,
|
||||
'prompt': message.get('content', ''),
|
||||
'stream': False,
|
||||
'options': kwargs.get('options', {}),
|
||||
}
|
||||
|
||||
# Add system message if provided
|
||||
if message.get('role') == 'system':
|
||||
payload['system'] = message['content']
|
||||
|
||||
# Send the request
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/generate",
|
||||
json=payload,
|
||||
timeout=(instance.timeout or 30)
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_(
|
||||
"Ollama server error. Status code: %s. Error: %s",
|
||||
response.status_code, response.text
|
||||
))
|
||||
|
||||
result = response.json()
|
||||
return result.get('response', '')
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
raise UserError(_("Request to Ollama server timed out"))
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Error communicating with Ollama server: %s", str(e)
|
||||
))
|
||||
except Exception as e:
|
||||
_logger.error("Unexpected error in Ollama provider: %s", str(e))
|
||||
raise UserError(_(
|
||||
"Unexpected error in Ollama provider: %s", str(e)
|
||||
))
|
||||
274
ai_integration_ollama_api/models/ollama_provider_mixin.py
Normal file
274
ai_integration_ollama_api/models/ollama_provider_mixin.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import requests
|
||||
import logging
|
||||
import json
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class OllamaProviderMixin(models.AbstractModel):
|
||||
"""Mixin model that provides Ollama-specific configuration parameters.
|
||||
|
||||
This mixin is designed to be inherited by models that need to interact with
|
||||
the Ollama AI provider. It provides all the necessary fields and methods
|
||||
for configuring and interacting with Ollama's API.
|
||||
|
||||
Key Features:
|
||||
- Provider type selection and validation
|
||||
- Context window configuration
|
||||
- Advanced sampling parameters (temperature, top-k, top-p)
|
||||
- Token generation controls
|
||||
|
||||
Technical Details:
|
||||
- Inherits from ai.generation.params for base AI generation parameters
|
||||
- Implements Ollama-specific API parameters
|
||||
- Provides default values optimized for general use cases
|
||||
"""
|
||||
_name = 'ollama.provider.mixin'
|
||||
_description = 'Ollama Provider Configuration Mixin'
|
||||
_inherit = ['ai.generation.params']
|
||||
|
||||
# Model Parameters
|
||||
model_name = fields.Char(
|
||||
string='Model Name',
|
||||
help='Name of the Ollama model to use (e.g. llama2, mistral, codellama)',
|
||||
required=True,
|
||||
default='deepseek-r1:32b')
|
||||
|
||||
# Context Window Configuration
|
||||
num_ctx = fields.Integer(
|
||||
string='Context Length',
|
||||
help='Maximum number of tokens to consider for context. A larger context window allows '
|
||||
'the model to access more historical information but requires more memory. '
|
||||
'Range: [0 - 32768].',
|
||||
default=8192)
|
||||
|
||||
# Generation Parameters
|
||||
temperature = fields.Float(
|
||||
string='Temperature',
|
||||
help='Controls randomness in the output. Higher values make the output more random, '
|
||||
'while lower values make it more focused and deterministic. '
|
||||
'Range: [0.0 - 2.0]',
|
||||
default=0.7)
|
||||
|
||||
top_p = fields.Float(
|
||||
string='Top P (Nucleus Sampling)',
|
||||
help='Limits the cumulative probability of tokens to sample from. Only the most likely '
|
||||
'tokens with total probability mass of top_p are considered. '
|
||||
'Range: [0.0 - 1.0].',
|
||||
default=0.9)
|
||||
|
||||
top_k = fields.Integer(
|
||||
string='Top K',
|
||||
help='Limits the cumulative probability of tokens to sample from. Only the top K '
|
||||
'most likely tokens are considered for sampling at each step. '
|
||||
'Range: [1 - 100].',
|
||||
default=40)
|
||||
|
||||
min_p = fields.Float(
|
||||
string='Min P',
|
||||
help='Sets a minimum probability threshold for token selection. Range: [0.0 - 1.0].',
|
||||
default=0.05,
|
||||
digits=(3, 2))
|
||||
|
||||
repeat_penalty = fields.Float(
|
||||
string='Repeat Penalty',
|
||||
help='Penalty for repeating tokens. Range: [1.0 - 2.0]. Higher values make repetition less likely.',
|
||||
default=1.1,
|
||||
digits=(3, 2))
|
||||
|
||||
# Advanced Configuration
|
||||
stop_sequences = fields.Char(
|
||||
string='Stop Sequences',
|
||||
help='Comma-separated list of sequences where the model should stop generating further tokens.')
|
||||
|
||||
num_predict = fields.Integer(
|
||||
string='Maximum Tokens',
|
||||
help='Maximum number of tokens to predict. Set to -1 for unlimited.',
|
||||
default=2048)
|
||||
|
||||
repeat_last_n = fields.Integer(
|
||||
string='Repeat Last N',
|
||||
help='Sets the context window for repeat penalty. Range: [0 - 4096]. Default is 64, 0 disables.',
|
||||
default=64
|
||||
)
|
||||
|
||||
def generate_text(self, prompt, **kwargs):
|
||||
"""Generate text using the Ollama API.
|
||||
|
||||
Args:
|
||||
prompt (str): The prompt to generate text from
|
||||
**kwargs: Additional parameters to pass to the API
|
||||
|
||||
Returns:
|
||||
str: The generated text
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Prepare the request
|
||||
url = f"{self.host}/api/generate"
|
||||
|
||||
# Build the request data
|
||||
data = {
|
||||
'model': self.model_name,
|
||||
'prompt': prompt,
|
||||
'stream': False,
|
||||
'num_ctx': self.num_ctx,
|
||||
'temperature': self.temperature,
|
||||
'top_k': self.top_k,
|
||||
'top_p': self.top_p,
|
||||
'repeat_penalty': self.repeat_penalty,
|
||||
'repeat_last_n': self.repeat_last_n,
|
||||
'num_predict': self.num_predict,
|
||||
'min_p': self.min_p,
|
||||
'seed': self.seed,
|
||||
'num_gpu': self.num_gpu,
|
||||
'num_thread': self.num_thread,
|
||||
'mirostat': int(self.mirostat),
|
||||
'mirostat_tau': self.mirostat_tau,
|
||||
'mirostat_eta': self.mirostat_eta,
|
||||
'num_batch': self.num_batch,
|
||||
'num_keep': self.num_keep,
|
||||
'tfs_z': self.tfs_z,
|
||||
'skip_special_tokens': self.skip_special_tokens
|
||||
}
|
||||
|
||||
# Add any additional parameters
|
||||
if kwargs:
|
||||
data.update(kwargs)
|
||||
|
||||
# Make the request
|
||||
try:
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.info("Sending request to Ollama API with data: %s", data)
|
||||
|
||||
response = requests.post(url, json=data, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
# Log the raw response
|
||||
_logger.info("Raw API response: %s", response.text)
|
||||
|
||||
# Parse the response
|
||||
result = response.json()
|
||||
response_text = result.get('response', '')
|
||||
|
||||
# Si la réponse est une chaîne JSON, la parser
|
||||
try:
|
||||
if isinstance(response_text, str):
|
||||
parsed_response = json.loads(response_text)
|
||||
_logger.info("Parsed nested JSON response: %s", parsed_response)
|
||||
return parsed_response
|
||||
else:
|
||||
_logger.info("Direct response: %s", response_text)
|
||||
return response_text
|
||||
except json.JSONDecodeError:
|
||||
# Si ce n'est pas du JSON valide, retourner le texte tel quel
|
||||
_logger.info("Non-JSON response: %s", response_text)
|
||||
return response_text
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_('Failed to generate text: %s') % str(e))
|
||||
|
||||
# Advanced Generation Parameters
|
||||
seed = fields.Integer(
|
||||
string='Random Seed',
|
||||
help='Sets the random seed for generation. Range: [0 - 2147483647]. Use 0 for random.',
|
||||
default=0)
|
||||
|
||||
num_gpu = fields.Integer(
|
||||
string='Number of GPUs',
|
||||
help='Number of GPUs to use for generation. Range: [0 - 8]. 0 means CPU only.',
|
||||
default=1)
|
||||
|
||||
num_thread = fields.Integer(
|
||||
string='Number of Threads',
|
||||
help='Number of CPU threads to use for generation. Range: [1 - 32].',
|
||||
default=8)
|
||||
|
||||
mirostat = fields.Selection([
|
||||
('0', 'Disabled'),
|
||||
('1', 'Mirostat'),
|
||||
('2', 'Mirostat 2.0')],
|
||||
string='Mirostat Mode',
|
||||
help='Enable Mirostat sampling for controlling perplexity',
|
||||
default='0')
|
||||
|
||||
mirostat_tau = fields.Float(
|
||||
string='Mirostat Tau',
|
||||
help='Mirostat target entropy. Range: [0.0 - 10.0].',
|
||||
default=5.0,
|
||||
digits=(3, 2))
|
||||
|
||||
mirostat_eta = fields.Float(
|
||||
string='Mirostat Eta',
|
||||
help='Mirostat learning rate. Range: [0.0 - 1.0].',
|
||||
default=0.1,
|
||||
digits=(3, 2))
|
||||
|
||||
# Ollama-specific Response Control
|
||||
|
||||
tfs_z = fields.Float(
|
||||
string='Tail Free Sampling Z',
|
||||
help='Tail free sampling parameter. Range: [0.0 - 2.0]. Higher value = more focused.',
|
||||
default=1.0,
|
||||
digits=(3, 2))
|
||||
|
||||
# System Settings
|
||||
num_batch = fields.Integer(
|
||||
string='Batch Size',
|
||||
help='Number of prompts to batch together',
|
||||
default=8)
|
||||
|
||||
num_keep = fields.Integer(
|
||||
string='Keep Last N Tokens',
|
||||
help='Number of tokens to keep from initial prompt',
|
||||
default=0)
|
||||
|
||||
skip_special_tokens = fields.Boolean(
|
||||
string='Skip Special Tokens',
|
||||
help='Skip special tokens in generation',
|
||||
default=True)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
defaults = super().default_get(fields_list)
|
||||
if 'provider_type' in fields_list:
|
||||
defaults['provider_type'] = 'ollama'
|
||||
if 'host' in fields_list and not defaults.get('host'):
|
||||
defaults['host'] = 'http://localhost:11434'
|
||||
return defaults
|
||||
|
||||
def _get_provider_options(self):
|
||||
"""Get Ollama-specific options for API calls."""
|
||||
self.ensure_one()
|
||||
options = {
|
||||
'model': self.model_name,
|
||||
'temperature': self.temperature,
|
||||
'num_ctx': self.num_ctx,
|
||||
'num_predict': self.num_predict,
|
||||
'top_k': self.top_k,
|
||||
'top_p': self.top_p,
|
||||
'min_p': self.min_p,
|
||||
'repeat_penalty': self.repeat_penalty,
|
||||
'repeat_last_n': self.repeat_last_n,
|
||||
'seed': self.seed,
|
||||
'num_gpu': self.num_gpu,
|
||||
'num_thread': self.num_thread,
|
||||
'mirostat': int(self.mirostat),
|
||||
'mirostat_tau': self.mirostat_tau,
|
||||
'mirostat_eta': self.mirostat_eta,
|
||||
'num_batch': self.num_batch,
|
||||
'num_keep': self.num_keep,
|
||||
'tfs_z': self.tfs_z,
|
||||
'skip_special_tokens': self.skip_special_tokens,
|
||||
'stream': False
|
||||
}
|
||||
|
||||
if self.stop_sequences:
|
||||
options['stop'] = [
|
||||
seq.strip()
|
||||
for seq in self.stop_sequences.split(',')
|
||||
if seq.strip()
|
||||
]
|
||||
|
||||
return options
|
||||
5
ai_integration_ollama_api/security/ir.model.access.csv
Normal file
5
ai_integration_ollama_api/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_ollama_model_stats_user,ollama.model.stats.user,model_ollama_model_stats,base.group_user,1,0,0,0
|
||||
access_ollama_model_stats_manager,ollama.model.stats.manager,model_ollama_model_stats,base.group_system,1,1,1,1
|
||||
access_ai_provider_ollama_user,ai.provider.ollama.user,model_ai_provider_ollama,base.group_user,1,0,0,0
|
||||
access_ai_provider_ollama_manager,ai.provider.ollama.manager,model_ai_provider_ollama,base.group_system,1,1,1,1
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Inherit AI Provider Instance List View -->
|
||||
<record id="ollama_ai_provider_instance_view_list" model="ir.ui.view">
|
||||
<field name="name">ai.provider.instance.list.ollama</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_list"/>
|
||||
<field name="mode">primary</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//list[@name='ai_instance']" position="attributes">
|
||||
<attribute name="create">true</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
118
ai_integration_ollama_api/views/ollama_stats_views.xml
Normal file
118
ai_integration_ollama_api/views/ollama_stats_views.xml
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- List View -->
|
||||
<record id="view_ollama_model_stats_list" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.list</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Model Statistics">
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="version"/>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_ollama_model_stats_form" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.form</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Model Statistics">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="version"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_ollama_model_stats_search" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.search</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Model Statistics">
|
||||
<field name="model_id"/>
|
||||
<field name="version"/>
|
||||
<field name="date"/>
|
||||
<filter string="Today" name="today" domain="[('date','=',context_today().strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 7 Days" name="last_week" domain="[('date','>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 30 Days" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Model" name="group_by_model" context="{'group_by': 'model_id'}"/>
|
||||
<filter string="Version" name="group_by_version" context="{'group_by': 'version'}"/>
|
||||
<filter string="Date" name="group_by_date" context="{'group_by': 'date'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Graph View -->
|
||||
<record id="view_ollama_model_stats_graph" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.graph</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Model Statistics" type="line" sample="1">
|
||||
<field name="date" type="row"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Pivot View -->
|
||||
<record id="view_ollama_model_stats_pivot" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.pivot</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Model Statistics" sample="1">
|
||||
<field name="model_id" type="row"/>
|
||||
<field name="date" type="col"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="avg_response_time" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_ollama_model_stats" model="ir.actions.act_window">
|
||||
<field name="name">Model Statistics</field>
|
||||
<field name="res_model">ollama.model.stats</field>
|
||||
<field name="view_mode">list,form,graph,pivot</field>
|
||||
<field name="context">{'search_default_last_month': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No statistics recorded yet
|
||||
</p>
|
||||
<p>
|
||||
Statistics will be automatically recorded as you use your AI models.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="menu_ollama_model_stats"
|
||||
name="Model Statistics"
|
||||
parent="ai_integration.menu_ai_config"
|
||||
action="action_ollama_model_stats"
|
||||
sequence="30"/>
|
||||
</odoo>
|
||||
108
ai_integration_ollama_api/views/ollama_views.xml
Normal file
108
ai_integration_ollama_api/views/ollama_views.xml
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Form View -->
|
||||
<record id="ollama_instance_view_form" model="ir.ui.view">
|
||||
<field name="name">ollama.instance.form</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="type">form</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="replace">
|
||||
<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>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='api_key']" position="replace">
|
||||
<field name="api_key" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Ollama Settings" name="ollama_settings"
|
||||
invisible="provider_type != 'ollama'">
|
||||
<group>
|
||||
<group string="Generation Parameters">
|
||||
<field name="num_ctx"/>
|
||||
<field name="temperature"/>
|
||||
<field name="top_p"/>
|
||||
<field name="top_k"/>
|
||||
<field name="repeat_penalty"/>
|
||||
</group>
|
||||
<group string="Advanced Settings">
|
||||
<field name="stop_sequences" placeholder="Enter comma-separated stop sequences"/>
|
||||
</group>
|
||||
</group>
|
||||
<div class="alert alert-info" role="alert" style="margin-top: 10px;">
|
||||
<p><strong>Note:</strong> These settings will be used as defaults for all requests to this Ollama instance.
|
||||
They can be overridden on a per-request basis.</p>
|
||||
<ul>
|
||||
<li><strong>Context Length:</strong> Longer context allows the model to consider more previous text but uses more memory.</li>
|
||||
<li><strong>Temperature:</strong> Higher values (>1.0) make output more random, lower values make it more focused and deterministic.</li>
|
||||
<li><strong>Top P:</strong> Controls diversity via nucleus sampling. Lower values (0.1) are more focused, higher values (0.9) more diverse.</li>
|
||||
<li><strong>Top K:</strong> Limits the cumulative probability of tokens to sample from. Lower values are more focused.</li>
|
||||
<li><strong>Repeat Penalty:</strong> Higher values (>1.0) make the model less likely to repeat itself.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="ollama_instance_view_list" model="ir.ui.view">
|
||||
<field name="name">ollama.instance.list</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="type">list</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<field name="num_ctx" optional="show"/>
|
||||
<field name="temperature" optional="hide"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="ollama_instance_view_search" model="ir.ui.view">
|
||||
<field name="name">ollama.instance.search</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group" position="inside">
|
||||
<filter string="Context Length" name="group_by_num_ctx"
|
||||
domain="[('provider_type', '=', 'ollama')]"
|
||||
context="{'group_by': 'num_ctx'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_ollama_instance" model="ir.actions.act_window">
|
||||
<field name="name">Ollama Instances</field>
|
||||
<field name="res_model">ai.provider.instance</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('provider_type', '=', 'ollama')]</field>
|
||||
<field name="context">{'default_provider_type': 'ollama'}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first Ollama instance
|
||||
</p>
|
||||
<p>
|
||||
Configure Ollama instances to use local AI models in your Odoo instance.
|
||||
Make sure you have Ollama installed and running on your server.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="menu_ollama_instance"
|
||||
name="Ollama Instances"
|
||||
parent="ai_integration.ai_integration_menu_config"
|
||||
action="action_ollama_instance"
|
||||
sequence="20"/>
|
||||
</odoo>
|
||||
1
ai_integration_openai_api/__init__.py
Normal file
1
ai_integration_openai_api/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
35
ai_integration_openai_api/__manifest__.py
Normal file
35
ai_integration_openai_api/__manifest__.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
'name': 'ChatGPT-Compatible AI Integration',
|
||||
'version': '1.0',
|
||||
'category': 'Technical',
|
||||
'summary': 'Integration for ChatGPT-compatible AI providers',
|
||||
'description': """
|
||||
ChatGPT-Compatible AI Integration
|
||||
================================
|
||||
This module provides integration with ChatGPT-compatible AI providers (OpenWebUI, ChatGPT, etc), offering:
|
||||
|
||||
* ChatGPT-compatible provider implementation
|
||||
* Model synchronization
|
||||
* Advanced parameter configuration
|
||||
* Usage statistics tracking
|
||||
* Support for multiple providers using the ChatGPT API format
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://www.bemade.org',
|
||||
'depends': [
|
||||
'ai_integration',
|
||||
'openwebui_integration'
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/chatgpt_instance_views.xml',
|
||||
'data/chatgpt_provider.xml',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['requests'],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
14
ai_integration_openai_api/data/chatgpt_provider.xml
Normal file
14
ai_integration_openai_api/data/chatgpt_provider.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- ChatGPT-Compatible Provider -->
|
||||
<record id="ai_provider_chatgpt" model="ai.provider">
|
||||
<field name="name">ChatGPT-Compatible</field>
|
||||
<field name="code">chatgpt</field>
|
||||
<field name="instance_model">chatgpt.ai.instance</field>
|
||||
<field name="provider_model">ai.provider.chatgpt</field>
|
||||
<field name="description">Integration with ChatGPT-compatible AI providers (OpenWebUI, ChatGPT, etc).</field>
|
||||
<field name="website">https://platform.openai.com/docs/api-reference/chat</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
2
ai_integration_openai_api/models/__init__.py
Normal file
2
ai_integration_openai_api/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import chatgpt_provider
|
||||
from . import chatgpt_instance
|
||||
48
ai_integration_openai_api/models/chatgpt_instance.py
Normal file
48
ai_integration_openai_api/models/chatgpt_instance.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
class ChatGPTInstance(models.Model):
|
||||
_name = 'chatgpt.ai.instance'
|
||||
_description = 'ChatGPT-Compatible Instance Configuration'
|
||||
_inherit = ['ai.provider.instance', 'ai.generation.params']
|
||||
|
||||
provider_type = fields.Selection(
|
||||
selection_add=[('chatgpt', 'ChatGPT-Compatible')],
|
||||
ondelete={'chatgpt': 'cascade'})
|
||||
|
||||
# ChatGPT API Parameters
|
||||
|
||||
presence_penalty = fields.Float(
|
||||
string='Presence Penalty',
|
||||
help='Penalty for new tokens based on their presence in text. Range: [-2.0 - 2.0].',
|
||||
default=0.0,
|
||||
digits=(3, 2))
|
||||
|
||||
frequency_penalty = fields.Float(
|
||||
string='Frequency Penalty',
|
||||
help='Penalty for new tokens based on their frequency in text. Range: [-2.0 - 2.0].',
|
||||
default=0.0,
|
||||
digits=(3, 2))
|
||||
|
||||
# Provider Settings
|
||||
|
||||
def _get_provider_options(self):
|
||||
"""Get ChatGPT-compatible options for API calls."""
|
||||
self.ensure_one()
|
||||
|
||||
options = {
|
||||
'temperature': self.temperature,
|
||||
'top_p': self.top_p,
|
||||
'max_tokens': self.max_tokens,
|
||||
'presence_penalty': self.presence_penalty,
|
||||
'frequency_penalty': self.frequency_penalty,
|
||||
'stream': self.stream_response,
|
||||
}
|
||||
|
||||
if self.stop_sequences:
|
||||
options['stop'] = [
|
||||
seq.strip()
|
||||
for seq in self.stop_sequences.split(',')
|
||||
]
|
||||
|
||||
return options
|
||||
163
ai_integration_openai_api/models/chatgpt_provider.py
Normal file
163
ai_integration_openai_api/models/chatgpt_provider.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class ChatGPTProvider(models.AbstractModel):
|
||||
_name = 'ai.provider.chatgpt'
|
||||
_description = 'ChatGPT-Compatible AI Provider'
|
||||
_inherit = ['ai.provider']
|
||||
|
||||
def _get_provider_type(self):
|
||||
return 'chatgpt'
|
||||
|
||||
def test_connection(self, instance):
|
||||
"""Test the connection to the OpenWebUI server."""
|
||||
try:
|
||||
response = requests.get(f"{instance.host}/api/v1/models")
|
||||
if response.status_code != 200:
|
||||
raise UserError(_(
|
||||
"Failed to connect to AI server. Status code: %s. Error: %s",
|
||||
response.status_code, response.text
|
||||
))
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Failed to connect to AI server: %s", str(e)
|
||||
))
|
||||
|
||||
def sync_models(self, instance):
|
||||
"""Synchronize available models from the OpenWebUI server."""
|
||||
self.test_connection(instance)
|
||||
|
||||
try:
|
||||
# Get models from provider
|
||||
response = requests.get(f"{instance.host}/api/v1/models")
|
||||
models_data = response.json()
|
||||
|
||||
# Get existing models for this instance
|
||||
existing_models = self.env['ai.model'].search([
|
||||
('provider_instance_id', '=', instance.id)
|
||||
])
|
||||
existing_identifiers = {m.identifier: m for m in existing_models}
|
||||
|
||||
for model_data in models_data:
|
||||
identifier = model_data.get('id')
|
||||
if not identifier:
|
||||
continue
|
||||
|
||||
# Get model details
|
||||
model_info = self._get_model_info(instance, identifier)
|
||||
model_details = model_info.get('details', {})
|
||||
|
||||
model_values = {
|
||||
'name': model_data.get('name', identifier),
|
||||
'identifier': identifier,
|
||||
'description': model_details.get('description', ''),
|
||||
'version': model_details.get('version', ''),
|
||||
'provider_instance_id': instance.id,
|
||||
}
|
||||
|
||||
if identifier in existing_identifiers:
|
||||
# Update existing model
|
||||
existing_identifiers[identifier].write(model_values)
|
||||
else:
|
||||
# Create new model
|
||||
self.env['ai.model'].create(model_values)
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Error synchronizing models: %s") % str(e))
|
||||
|
||||
def _get_model_info(self, instance, model_name):
|
||||
"""Get detailed information about a specific model."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{instance.host}/api/v1/models/{model_name}/info"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
_logger.error(
|
||||
"Failed to get model info for %s. Status: %s, Error: %s",
|
||||
model_name, response.status_code, response.text
|
||||
)
|
||||
return {}
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error("Error getting model info: %s", str(e))
|
||||
return {}
|
||||
|
||||
def _format_chat_messages(self, messages):
|
||||
"""Format chat messages for OpenWebUI API."""
|
||||
formatted_messages = []
|
||||
for message in messages:
|
||||
role = message.get('role', 'user')
|
||||
content = message.get('content', '')
|
||||
|
||||
formatted_messages.append({
|
||||
'role': role,
|
||||
'content': content
|
||||
})
|
||||
|
||||
return formatted_messages
|
||||
|
||||
def generate_response(self, instance, model, messages, **kwargs):
|
||||
"""Generate a response using the chat completion API."""
|
||||
try:
|
||||
# Format messages for OpenWebUI
|
||||
formatted_messages = self._format_chat_messages(messages)
|
||||
|
||||
# Get model options from instance
|
||||
options = instance._get_provider_options()
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'model': model.identifier,
|
||||
'messages': formatted_messages,
|
||||
**options
|
||||
}
|
||||
|
||||
# Make the API call
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/v1/chat/completions",
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to generate response: %s") % response.text)
|
||||
|
||||
response_data = response.json()
|
||||
generated_text = response_data.get('choices', [{}])[0].get('message', {}).get('content', '')
|
||||
|
||||
# Update statistics
|
||||
total_tokens = response_data.get('usage', {}).get('total_tokens', 0)
|
||||
response_time = response_data.get('response_ms', 0)
|
||||
version = self._get_model_info(instance, model.identifier)\
|
||||
.get('details', {}).get('version', '')
|
||||
|
||||
self._track_model_usage(
|
||||
model, total_tokens, response_time, version=version
|
||||
)
|
||||
|
||||
# Return the response in a standardized format
|
||||
return {
|
||||
'content': generated_text,
|
||||
'role': 'assistant',
|
||||
'metadata': {
|
||||
'total_tokens': total_tokens,
|
||||
'response_time': response_time,
|
||||
'model_version': version,
|
||||
'usage': response_data.get('usage', {})
|
||||
}
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Log error in statistics
|
||||
if model:
|
||||
self._track_model_usage(model, error=True)
|
||||
raise UserError(_("Error generating response: %s") % str(e))
|
||||
3
ai_integration_openai_api/security/ir.model.access.csv
Normal file
3
ai_integration_openai_api/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_openwebui_ai_instance_user,openwebui.ai.instance user,model_openwebui_ai_instance,base.group_user,1,0,0,0
|
||||
access_openwebui_ai_instance_system,openwebui.ai.instance system,model_openwebui_ai_instance,base.group_system,1,1,1,1
|
||||
|
106
ai_integration_openai_api/views/chatgpt_instance_views.xml
Normal file
106
ai_integration_openai_api/views/chatgpt_instance_views.xml
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Form View -->
|
||||
<record id="view_chatgpt_ai_instance_form" model="ir.ui.view">
|
||||
<field name="name">chatgpt.ai.instance.form</field>
|
||||
<field name="model">chatgpt.ai.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="ChatGPT-Compatible Instance">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. Production OpenWebUI/ChatGPT"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Connection">
|
||||
<field name="host" placeholder="e.g. http://localhost:8080"/>
|
||||
<field name="api_key"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="is_default"/>
|
||||
</group>
|
||||
<group string="Basic Generation">
|
||||
<field name="temperature"/>
|
||||
<field name="top_p"/>
|
||||
<field name="max_tokens"/>
|
||||
<field name="presence_penalty"/>
|
||||
<field name="frequency_penalty"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Advanced Settings">
|
||||
<group>
|
||||
<field name="stop_sequences" placeholder="e.g. END,STOP,DONE"/>
|
||||
<field name="timeout"/>
|
||||
<field name="retry_count"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="stream_response"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Models" attrs="{'invisible': [('id', '=', False)]}">
|
||||
<field name="model_ids" nolabel="1">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="identifier"/>
|
||||
<field name="version"/>
|
||||
<field name="description"/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="view_chatgpt_ai_instance_tree" model="ir.ui.view">
|
||||
<field name="name">chatgpt.ai.instance.tree</field>
|
||||
<field name="model">chatgpt.ai.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="ChatGPT-Compatible Instances">
|
||||
<field name="name"/>
|
||||
<field name="host"/>
|
||||
<field name="is_default"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_chatgpt_ai_instance_search" model="ir.ui.view">
|
||||
<field name="name">chatgpt.ai.instance.search</field>
|
||||
<field name="model">chatgpt.ai.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search ChatGPT-Compatible Instances">
|
||||
<field name="name"/>
|
||||
<field name="host"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<filter string="Default Instance" name="is_default" domain="[('is_default', '=', True)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Company" name="company" context="{'group_by': 'company_id'}" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_chatgpt_ai_instance" model="ir.actions.act_window">
|
||||
<field name="name">ChatGPT-Compatible Instances</field>
|
||||
<field name="res_model">chatgpt.ai.instance</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first ChatGPT-compatible instance
|
||||
</p>
|
||||
<p>
|
||||
Configure instances to connect to ChatGPT-compatible servers (OpenWebUI, ChatGPT, etc).
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="menu_chatgpt_ai_instance"
|
||||
name="ChatGPT-Compatible"
|
||||
parent="ai_integration.menu_ai_integration_config"
|
||||
action="action_chatgpt_ai_instance"
|
||||
sequence="20"/>
|
||||
</odoo>
|
||||
5
bemade_add_follower_no_sendmail_default/__init__.py
Normal file
5
bemade_add_follower_no_sendmail_default/__init__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
|
||||
|
||||
|
||||
21
bemade_add_follower_no_sendmail_default/__manifest__.py
Normal file
21
bemade_add_follower_no_sendmail_default/__manifest__.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
"name": "Change default when adding follower",
|
||||
"version": "17.0.0.0.1",
|
||||
"category": "Extra Tools",
|
||||
'summary': 'Change default when adding follower',
|
||||
"description": """
|
||||
Change default when adding follower
|
||||
Send mail false by default
|
||||
""",
|
||||
"author": "Bemade",
|
||||
'website': 'https://www.bemade.org',
|
||||
"depends": [
|
||||
'mail',
|
||||
],
|
||||
"data": [
|
||||
],
|
||||
"auto_install": False,
|
||||
"installable": True,
|
||||
'license': 'OPL-1'
|
||||
}
|
||||
87
bemade_add_follower_no_sendmail_default/migration18.md
Normal file
87
bemade_add_follower_no_sendmail_default/migration18.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# Migration vers Odoo 18.0 - bemade_add_follower_no_sendmail_default
|
||||
|
||||
## Description du module
|
||||
Ce module modifie le comportement par défaut du wizard d'ajout de followers pour que l'option "Envoyer un email" soit désactivée par défaut.
|
||||
|
||||
## Analyse technique
|
||||
- Dépendances : mail
|
||||
- Modèles modifiés :
|
||||
- mail.wizard.invite : Modification de la valeur par défaut du champ send_mail à False
|
||||
- Implémentation actuelle :
|
||||
- Hérite de mail.wizard.invite
|
||||
- Redéfinit uniquement le champ send_mail avec default=False
|
||||
|
||||
## Alternatives Natives
|
||||
|
||||
### Configuration Système
|
||||
1. Vérifier dans Odoo 18.0 :
|
||||
- Paramètres de configuration du module mail
|
||||
- Paramètres système (ir.config_parameter)
|
||||
- Préférences utilisateur
|
||||
|
||||
### Approches Alternatives
|
||||
1. Configuration par utilisateur :
|
||||
- Ajouter une préférence utilisateur dans res.users
|
||||
- Utiliser cette préférence comme valeur par défaut
|
||||
|
||||
2. Configuration par type de document :
|
||||
- Ajouter un paramètre dans les paramètres de notification par modèle
|
||||
- Permettre une configuration plus granulaire
|
||||
|
||||
## Recommandations pour la Migration
|
||||
|
||||
### Approche "Vanilla First"
|
||||
1. Évaluer les alternatives natives :
|
||||
- [X] Vérifier si Odoo 18.0 a ajouté une configuration similaire
|
||||
- [ ] Explorer les nouvelles fonctionnalités de notification
|
||||
- [ ] Vérifier les paramètres de notification par défaut
|
||||
|
||||
2. Si aucune alternative native n'existe :
|
||||
- [ ] Considérer l'ajout d'une configuration système
|
||||
- [ ] Implémenter une solution plus flexible (par utilisateur ou par type de document)
|
||||
|
||||
### Modifications Techniques
|
||||
1. Si le module est conservé :
|
||||
- [ ] Mettre à jour la version dans __manifest__.py
|
||||
- [ ] Vérifier la compatibilité de l'héritage du wizard
|
||||
- [ ] Vérifier si le champ send_mail existe toujours et a le même comportement
|
||||
- [ ] Adapter le code aux nouvelles conventions Odoo 18.0
|
||||
|
||||
2. Si migration vers une solution native :
|
||||
- [ ] Créer un module de migration pour la transition
|
||||
- [ ] Migrer les configurations existantes
|
||||
- [ ] Prévoir un plan de désactivation du module
|
||||
|
||||
## Fonctionnalité Native dans Odoo 18.0
|
||||
✅ La fonctionnalité existe nativement dans Odoo 18.0 !
|
||||
|
||||
Dans le modèle `mail.wizard.invite` (`mail/wizard/mail_wizard_invite.py`), le champ `notify` est déjà défini avec `default=False` :
|
||||
```python
|
||||
notify = fields.Boolean('Notify Recipients', default=False)
|
||||
```
|
||||
|
||||
## Plan de Migration
|
||||
|
||||
### Actions Requises
|
||||
1. **Désactivation du Module** :
|
||||
- [ ] Désactiver le module avant la migration vers Odoo 18.0
|
||||
- [ ] Vérifier qu'aucun autre module ne dépend de celui-ci
|
||||
- [ ] Informer les utilisateurs que le comportement est maintenant natif
|
||||
|
||||
2. **Vérification** :
|
||||
- [ ] Tester le comportement natif dans Odoo 18.0
|
||||
- [ ] Confirmer que le comportement par défaut est identique
|
||||
- [ ] Documenter tout changement d'interface utilisateur
|
||||
|
||||
## État de la Migration
|
||||
🟢 Pas de migration nécessaire - Utiliser la fonctionnalité native
|
||||
|
||||
## Notes Importantes
|
||||
- Le comportement souhaité (notification désactivée par défaut) est maintenant le comportement standard d'Odoo 18.0
|
||||
- L'interface utilisateur est similaire, utilisant un widget boolean_toggle
|
||||
- Aucune personnalisation supplémentaire n'est nécessaire
|
||||
|
||||
## Prochaines Étapes
|
||||
1. Planifier la désactivation du module
|
||||
2. Informer les utilisateurs du changement
|
||||
3. Retirer le module de la liste des dépendances des autres modules si nécessaire
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import mail_wizard_invite
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
# Copyright Bemade.org
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class MailWizardInviteDefault(models.TransientModel):
|
||||
_inherit = 'mail.wizard.invite'
|
||||
|
||||
send_mail = fields.Boolean(
|
||||
default=False,
|
||||
help="If true, an invitation email will be sent to the recipient"
|
||||
)
|
||||
1
bemade_attachments_cleanup/__init__.py
Normal file
1
bemade_attachments_cleanup/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import wizard
|
||||
22
bemade_attachments_cleanup/__manifest__.py
Normal file
22
bemade_attachments_cleanup/__manifest__.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
'name': "Attachment Cleanup",
|
||||
'version': '1.0.1',
|
||||
'author': "Bemade",
|
||||
'website': "http://www.bemade.org",
|
||||
'category': 'Tools',
|
||||
'license': 'OPL-1',
|
||||
'summary': "Find and delete missing attachments",
|
||||
'description': """
|
||||
This module adds a wizard in the settings menu to find and delete missing attachments.
|
||||
The wizard lists all attachments that are not found in the filestore, and allows the user to select and delete them.
|
||||
Please use this tool with caution, as deleting attachments cannot be undone.
|
||||
""",
|
||||
'depends': ['base'],
|
||||
'data': [
|
||||
'wizard/attachment_cleanup_wizard_view.xml',
|
||||
'security/ir.model.access.csv',
|
||||
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
}
|
||||
37
bemade_attachments_cleanup/i18n/fr_CA.po
Normal file
37
bemade_attachments_cleanup/i18n/fr_CA.po
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * attachment_cleanup
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 15.0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-06-08 12:00+0000\n"
|
||||
"PO-Revision-Date: 2023-06-08 12:00+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"Language: fr_CA\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: attachment_cleanup
|
||||
#: model:ir.actions.act_window,name:attachment_cleanup.action_open_attachment_cleanup_wizard
|
||||
msgid "Cleanup Attachments"
|
||||
msgstr "Nettoyer les pièces jointes"
|
||||
|
||||
#. module: attachment_cleanup
|
||||
#: model:ir.ui.view,arch_db:attachment_cleanup.view_attachment_cleanup_wizard
|
||||
msgid "Delete"
|
||||
msgstr "Supprimer"
|
||||
|
||||
#. module: attachment_cleanup
|
||||
#: model:ir.ui.view,arch_db:attachment_cleanup.view_attachment_cleanup_wizard
|
||||
msgid "Cancel"
|
||||
msgstr "Annuler"
|
||||
|
||||
#. module: attachment_cleanup
|
||||
#: model:ir.model.fields,field_description:attachment_cleanup.field_attachment_cleanup_wizard_attachment_ids
|
||||
msgid "Missing Attachments"
|
||||
msgstr "Pièces jointes manquantes"
|
||||
31
bemade_attachments_cleanup/migration18.md
Normal file
31
bemade_attachments_cleanup/migration18.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Migration vers Odoo 18.0 - bemade_attachments_cleanup
|
||||
|
||||
## Description
|
||||
Module de nettoyage des pièces jointes obsolètes
|
||||
|
||||
## Fonctionnalités Ajoutées
|
||||
- Suppression automatique des pièces jointes non utilisées
|
||||
- Configuration des règles de nettoyage
|
||||
- Historique des suppressions
|
||||
|
||||
## Modèles et Champs Modifiés
|
||||
- ir.attachment
|
||||
- Ajout du champ cleanup_date (date)
|
||||
- Ajout du champ cleanup_reason (text)
|
||||
|
||||
## Statut Migration
|
||||
- [ ] A migrer
|
||||
- [ ] En cours
|
||||
- [ ] Migré
|
||||
|
||||
## Détails Migration
|
||||
- Vérifier si la fonctionnalité existe déjà dans Odoo 18.0
|
||||
- Analyser les impacts sur les workflows existants
|
||||
|
||||
## Actions Requises
|
||||
- [ ] Vérifier la compatibilité avec Odoo 18.0
|
||||
- [ ] Tester les fonctionnalités
|
||||
- [ ] Mettre à jour la documentation
|
||||
|
||||
## Notes
|
||||
- Ce module pourrait être remplacé par une configuration native dans Odoo 18.0
|
||||
2
bemade_attachments_cleanup/security/ir.model.access.csv
Normal file
2
bemade_attachments_cleanup/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_attachment_cleanup_wizard,access.attachment.cleanup.wizard,model_attachment_cleanup_wizard,base.group_system,1,1,1,1
|
||||
|
1
bemade_attachments_cleanup/wizard/__init__.py
Normal file
1
bemade_attachments_cleanup/wizard/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import attachment_cleanup_wizard
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
from odoo import api, fields, models
|
||||
import os
|
||||
|
||||
|
||||
class AttachmentCleanupWizard(models.TransientModel):
|
||||
_name = 'attachment.cleanup.wizard'
|
||||
_description = 'Attachment Cleanup Wizard'
|
||||
|
||||
attachment_ids = fields.Many2many(
|
||||
'ir.attachment',
|
||||
string='Missing Attachments',
|
||||
readonly=True,
|
||||
help='Attachments that are not found in the filestore'
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
res = super(AttachmentCleanupWizard, self).default_get(fields_list)
|
||||
|
||||
attachments_1 = self.sudo().env['ir.attachment'].search([])
|
||||
|
||||
self._cr.execute("SELECT id FROM ir_attachment WHERE company_id = %s", (self.env.company.id,))
|
||||
|
||||
attachments = self._cr.fetchall()
|
||||
|
||||
filestore_location = self.env['ir.attachment']._filestore()
|
||||
|
||||
missing_attachments = []
|
||||
for attachment_id in attachments:
|
||||
# if attachment.id == 47984:
|
||||
# print(attachment.store_fname)
|
||||
attachment = self.env['ir.attachment'].browse(attachment_id)
|
||||
|
||||
if attachment.store_fname:
|
||||
file_path = os.path.join(filestore_location, attachment.store_fname)
|
||||
if not os.path.exists(file_path):
|
||||
missing_attachments.append(attachment.id)
|
||||
|
||||
if missing_attachments:
|
||||
res.update({'attachment_ids': [(6, 0, missing_attachments)]})
|
||||
return res
|
||||
|
||||
def action_cleanup_attachments(self):
|
||||
self.ensure_one()
|
||||
self.attachment_ids.unlink()
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<odoo>
|
||||
<record id="view_attachment_cleanup_wizard" model="ir.ui.view">
|
||||
<field name="name">attachment.cleanup.wizard.view</field>
|
||||
<field name="model">attachment.cleanup.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Cleanup Attachments">
|
||||
<field name="attachment_ids" options="{'no_create': True}">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="store_fname"/>
|
||||
<field name="res_model"/>
|
||||
<field name="res_id"/>
|
||||
<field name="mimetype"/>
|
||||
<field name="write_date"/>
|
||||
<button name="unlink" type="object" string="Delete"/>
|
||||
</tree>
|
||||
</field>
|
||||
<button name="action_cleanup_attachments" type="object" string="Delete All"/>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_open_attachment_cleanup_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Cleanup Attachments</field>
|
||||
<field name="res_model">attachment.cleanup.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="view_id" ref="view_attachment_cleanup_wizard"/>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_attachment_cleanup" name="Cleanup Attachments" parent="base.menu_administration" action="action_open_attachment_cleanup_wizard"/>
|
||||
</odoo>
|
||||
2
bemade_documents_portal/__init__.py
Normal file
2
bemade_documents_portal/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import controllers
|
||||
from . import models
|
||||
32
bemade_documents_portal/__manifest__.py
Normal file
32
bemade_documents_portal/__manifest__.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#
|
||||
# Bemade Inc.
|
||||
#
|
||||
# Copyright (C) September 2023 Bemade Inc. (<https://www.bemade.org>).
|
||||
# Author: Marc Durepos (Contact : marc@bemade.org)
|
||||
#
|
||||
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
|
||||
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
|
||||
# or modified copies of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
# DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
{
|
||||
'name': 'Documents Portal Base',
|
||||
'version': '17.0.1.0.0',
|
||||
'summary': 'Adds documents to the front-end portal.',
|
||||
'category': 'Document Management',
|
||||
'author': 'Bemade Inc.',
|
||||
'website': 'https://www.bemade.org',
|
||||
'license': 'OPL-1',
|
||||
'depends': ['documents', 'portal', 'mail_enterprise', 'im_livechat'],
|
||||
'data': ['views/document_portal_templates.xml'],
|
||||
'demo': [],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
1
bemade_documents_portal/controllers/__init__.py
Normal file
1
bemade_documents_portal/controllers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import portal
|
||||
65
bemade_documents_portal/controllers/portal.py
Normal file
65
bemade_documents_portal/controllers/portal.py
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
from odoo.http import request, route
|
||||
from odoo.exceptions import AccessError, MissingError
|
||||
from odoo import _
|
||||
|
||||
|
||||
class DocumentCustomerPortal(CustomerPortal):
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
rtn = super()._prepare_home_portal_values(counters)
|
||||
domain = self._prepare_documents_domain()
|
||||
rtn['documents_count'] = request.env['documents.document'].search_count(domain)
|
||||
return rtn
|
||||
|
||||
@route('/my/documents', type='http', auth='user', website=True)
|
||||
def portal_my_documents(self, **kwargs):
|
||||
values = self._prepare_portal_layout_values()
|
||||
Documents = request.env['documents.document']
|
||||
domain = self._prepare_documents_domain()
|
||||
documents_count = Documents.search_count(domain)
|
||||
documents = Documents.search(domain)
|
||||
values.update({
|
||||
'documents_count': documents_count,
|
||||
'documents': documents.sudo(),
|
||||
'default_url': '/my/documents',
|
||||
'page_name': 'my_documents',
|
||||
})
|
||||
return request.render("bemade_documents_portal.portal_my_documents", values)
|
||||
|
||||
def _prepare_documents_domain(self):
|
||||
partner = request.env.user.partner_id
|
||||
user = request.env.user
|
||||
"""Helper method intended to be overridden for future modules."""
|
||||
return ['|',
|
||||
('partner_id', '=', partner.id),
|
||||
('owner_id', '=', user.id),
|
||||
]
|
||||
|
||||
def _render_record_template(self, values):
|
||||
""" Override this method to apply a different template for a single document
|
||||
record on the portal. """
|
||||
return request.render("bemade_documents_portal.document_portal_template", values)
|
||||
|
||||
@route('/my/documents/<int:document_id>', type='http', auth='user', website=True)
|
||||
def portal_document_page(self, document_id, download=False, **kwargs):
|
||||
document = request.env['documents.document'].browse(document_id)
|
||||
if not document:
|
||||
raise MissingError(_('This document does not exist.'))
|
||||
if download:
|
||||
return self._download_attachment(document)
|
||||
values={
|
||||
'document': document,
|
||||
'page_name': 'my_documents',
|
||||
'action': document._get_portal_return_action(),
|
||||
}
|
||||
return self._render_record_template(values)
|
||||
|
||||
def _download_attachment(self, document):
|
||||
attachment = document.attachment_id
|
||||
headers = [
|
||||
('content-type', attachment.mimetype),
|
||||
('content-length', attachment.file_size),
|
||||
('content-disposition', f'attachment; filename="{document.name}"')
|
||||
]
|
||||
return request.make_response(attachment.raw, headers)
|
||||
1
bemade_documents_portal/models/__init__.py
Normal file
1
bemade_documents_portal/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import documents
|
||||
17
bemade_documents_portal/models/documents.py
Normal file
17
bemade_documents_portal/models/documents.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
from odoo import models, fields
|
||||
|
||||
|
||||
class Document(models.Model):
|
||||
_name = 'documents.document'
|
||||
_inherit = ['documents.document', 'portal.mixin']
|
||||
|
||||
def _compute_access_url(self):
|
||||
super()._compute_access_url()
|
||||
for document in self:
|
||||
document.access_url = f'/my/documents/{document.id}'
|
||||
|
||||
def _get_portal_return_action(self):
|
||||
""" Return the action used to display documents when returning from customer
|
||||
portal."""
|
||||
self.ensure_one()
|
||||
return self.env.ref('documents.document_action')
|
||||
112
bemade_documents_portal/views/document_portal_templates.xml
Normal file
112
bemade_documents_portal/views/document_portal_templates.xml
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<data>
|
||||
<template id="portal_my_home" inherit_id="portal.portal_my_home">
|
||||
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
|
||||
<t t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">Documents</t>
|
||||
<t t-set="url">/my/documents</t>
|
||||
<t t-set="placeholder_count">documents_count</t>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
<template id="portal_my_documents" name="My Documents">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-call="portal.portal_table">
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="documents" t-as="document">
|
||||
<tr>
|
||||
<td>
|
||||
<a t-att-href="document.get_portal_url()">
|
||||
<t t-esc="document.name"/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
<template id="document_portal_template" name="Document Portal Template">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="o_portal_fullwidth_alert"
|
||||
groups="documents.group_documents_user">
|
||||
<t t-call="portal.portal_back_in_edit_mode">
|
||||
<t t-set="backend_url"
|
||||
t-value="'/web#model=%s&id=%s&action=%s&view_type=form' % (document._name, document.id, action.id)"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-call="portal.portal_record_layout">
|
||||
<t t-set="card_header">
|
||||
<div class="row no-gutters">
|
||||
<h5 class="mb-1 mb-md-0">
|
||||
<span t-field="document.name"/>
|
||||
</h5>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set="card_body">
|
||||
<!-- Main Document Contents -->
|
||||
<div id="document_content"
|
||||
class="col-12 col-lg justify-content-end w-100 h-100">
|
||||
<div t-if="'image' in document.mimetype"
|
||||
class="o_attachment_preview_img">
|
||||
<img id="attachment_img"
|
||||
class="img img-fluid d-block"
|
||||
t-attf-src="/documents/content/{{document.id}}"/>
|
||||
</div>
|
||||
<iframe t-if="document.mimetype == 'application/pdf'"
|
||||
class="mb48 w-100 min-vh-100"
|
||||
t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/documents/content/{{document.id}}&filename={{document.name}}"/>
|
||||
<ul class="list-group list-group-flush flex-wrap flex-row flex-lg-column">
|
||||
<li class="list-group-item flex-grow-1 b-0">
|
||||
<a class="btn btn-secondary btn-block o_download_btn"
|
||||
t-att-href="document.get_portal_url(download=True)">
|
||||
Download</a>
|
||||
</li>
|
||||
<li class="list-group-item flex-grow-1 b-0">
|
||||
<strong class="text-muted">File Size:
|
||||
<t t-call="documents.format_file_size"/>
|
||||
</strong>
|
||||
</li>
|
||||
<li class="list-group-item flex-grow-1 b-0">
|
||||
<strong class="text-muted">File Type:
|
||||
<t t-esc="document.mimetype"/>
|
||||
</strong>
|
||||
</li>
|
||||
<li class="list-group-item flex-grow-1 b-0">
|
||||
<strong class="text-muted">Attachment Type:
|
||||
<t t-esc="document.attachment_type"/>
|
||||
</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<!-- Chatter -->
|
||||
<div id="document_communication" class="card-body">
|
||||
<h2>History</h2>
|
||||
<t t-call="portal.message_thread">
|
||||
<t t-set="object" t-value="document"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
<template id="portal_breadcrumbs" inherit_id="portal.portal_breadcrumbs">
|
||||
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
|
||||
<li t-if="page_name == 'my_documents'"
|
||||
t-attf-class="breadcrumb-item #{'active ' if not document else ''}">
|
||||
<a t-if="document"
|
||||
t-attf-href="/my/documents?{{ keep_query() }}">Documents</a>
|
||||
<t t-else="">Documents</t>
|
||||
</li>
|
||||
<li t-if="document" class="breadcrumb-item active" t-esc="document.name">
|
||||
</li>
|
||||
</xpath>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
0
bemade_fetchmail_only_production/README.md
Normal file
0
bemade_fetchmail_only_production/README.md
Normal file
4
bemade_fetchmail_only_production/__init__.py
Normal file
4
bemade_fetchmail_only_production/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Copyright (C) 2023 Bemade.org
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import models
|
||||
20
bemade_fetchmail_only_production/__manifest__.py
Normal file
20
bemade_fetchmail_only_production/__manifest__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
"name": "Fetchmail Only on production environment",
|
||||
"version": "17.0.0.0.1",
|
||||
"category": "Extra Tools",
|
||||
'summary': 'Fetchmail Only on production environment',
|
||||
"description": """
|
||||
Fetchmail Only on production environment
|
||||
""",
|
||||
"author": "Bemade",
|
||||
'website': 'https://www.bemade.org',
|
||||
"depends": [
|
||||
'mail',
|
||||
],
|
||||
"data": [
|
||||
],
|
||||
"auto_install": True,
|
||||
"installable": True,
|
||||
'license': 'OPL-1'
|
||||
}
|
||||
31
bemade_fetchmail_only_production/migration18.md
Normal file
31
bemade_fetchmail_only_production/migration18.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Migration vers Odoo 18.0 - bemade_fetchmail_only_production
|
||||
|
||||
## Description
|
||||
Module restreignant la récupération des emails uniquement en environnement de production
|
||||
|
||||
## Fonctionnalités Ajoutées
|
||||
- Désactivation de fetchmail dans les environnements de test et de développement
|
||||
- Configuration par base de données
|
||||
- Journalisation des tentatives de récupération
|
||||
|
||||
## Modèles et Champs Modifiés
|
||||
- fetchmail.server
|
||||
- Ajout du champ production_only (boolean)
|
||||
- Ajout du champ last_attempt (datetime)
|
||||
|
||||
## Statut Migration
|
||||
- [ ] A migrer
|
||||
- [ ] En cours
|
||||
- [ ] Migré
|
||||
|
||||
## Détails Migration
|
||||
- Vérifier si la fonctionnalité existe déjà dans Odoo 18.0
|
||||
- Analyser les impacts sur les workflows existants
|
||||
|
||||
## Actions Requises
|
||||
- [ ] Vérifier la compatibilité avec Odoo 18.0
|
||||
- [ ] Tester les fonctionnalités
|
||||
- [ ] Mettre à jour la documentation
|
||||
|
||||
## Notes
|
||||
- Ce module pourrait être remplacé par une configuration native dans Odoo 18.0
|
||||
4
bemade_fetchmail_only_production/models/__init__.py
Normal file
4
bemade_fetchmail_only_production/models/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# Copyright (C) 2023 Bemade.org
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import fetchmail_server
|
||||
23
bemade_fetchmail_only_production/models/fetchmail_server.py
Normal file
23
bemade_fetchmail_only_production/models/fetchmail_server.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# Copyright (C) 2023 Bemade.org
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import logging
|
||||
|
||||
# Import the required classes and decorators from Odoo
|
||||
from odoo import api, models
|
||||
from urllib.parse import urlparse
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class fetchmail_server(models.Model):
|
||||
_inherit = 'fetchmail.server'
|
||||
|
||||
@api.model
|
||||
def fetch_mail(self):
|
||||
if urlparse(self.env['ir.config_parameter'].sudo().get_param('web.base.url')).netloc == \
|
||||
urlparse('https://erp.durpro.com/').netloc:
|
||||
return super(fetchmail_server, self).fetch_mail()
|
||||
else:
|
||||
# Add log message
|
||||
_logger.info("Trying to fetch email, current URL don't match with production URL, so we don't fetch email")
|
||||
return True
|
||||
10
bemade_fetchmail_only_production/readme/HISTORY.rst
Normal file
10
bemade_fetchmail_only_production/readme/HISTORY.rst
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
15.0.0.0.1
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
* Initial release
|
||||
|
||||
|
||||
16.0.0.0.1
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
* Modification for V16 compatibility
|
||||
0
bemade_fix_quality_report/__init__.py
Normal file
0
bemade_fix_quality_report/__init__.py
Normal file
33
bemade_fix_quality_report/__manifest__.py
Normal file
33
bemade_fix_quality_report/__manifest__.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
#
|
||||
# Bemade Inc.
|
||||
#
|
||||
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
|
||||
# Author: Marc Durepos (Contact : marc@bemade.org)
|
||||
#
|
||||
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
|
||||
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
|
||||
# or modified copies of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
# DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
{
|
||||
'name': 'Fix Quality Worksheet',
|
||||
'version': '17.0.1.0.0',
|
||||
'summary': 'Fix Quality worksheet bug from Odoo Enterprise',
|
||||
'description': '',
|
||||
'category': 'Quality Control',
|
||||
'author': 'Bemade Inc.',
|
||||
'website': 'http://www.bemade.org',
|
||||
'license': 'OPL-1',
|
||||
'depends': ['quality_control'],
|
||||
'data': ['reports/worksheet_custom_report_templates.xml'],
|
||||
'assets': {},
|
||||
'installable': True,
|
||||
'auto_install': True,
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<template id="worksheet_page" inherit_id="quality_control.worksheet_page">
|
||||
<xpath expr="//span[@t-field='doc.result']" position="replace">
|
||||
<span t-field="doc.measure"/>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
3
bemade_fsm/__init__.py
Normal file
3
bemade_fsm/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from . import models
|
||||
from . import reports
|
||||
from . import wizard
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue