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:
Marc Durepos 2025-05-29 21:35:39 -04:00
parent 1ef0102dd2
commit 4b2b53caa7
977 changed files with 49159 additions and 106 deletions

106
README.md
View file

@ -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

View file

@ -0,0 +1 @@
from . import models

View 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,
}

View 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

View 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

View 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.")

View 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

View 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

View 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()

View 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")

View 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.

View 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>

View 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>

View 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>

View 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>

View 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})

View 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',
}

View file

@ -0,0 +1 @@
from . import aged_partner_balance

View 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

View file

@ -0,0 +1 @@
from . import models

View 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',
}

View 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

View 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
)

View 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

View 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))
}
}

View 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

View 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'))

View 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()

View 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

View file

@ -0,0 +1 @@
from . import ai_base_mixin

View 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))

View 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'))

View 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')

View 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>

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ai_model_user ai.model.user model_ai_model base.group_user 1 0 0 0
3 access_ai_model_system ai.model.system model_ai_model base.group_system 1 1 1 1
4 access_ai_provider_instance_user ai.provider.instance.user model_ai_provider_instance base.group_user 1 0 0 0
5 access_ai_provider_instance_system ai.provider.instance.system model_ai_provider_instance base.group_system 1 1 1 1
6 access_ai_model_stats_user ai.model.stats.user model_ai_model_stats base.group_user 1 0 0 0
7 access_ai_model_stats_system ai.model.stats.system model_ai_model_stats base.group_system 1 1 1 1
8 access_ai_generation_params_user ai.generation.params.user model_ai_generation_params base.group_user 1 0 0 0
9 access_ai_generation_params_system ai.generation.params.system model_ai_generation_params base.group_system 1 1 1 1
10 access_ai_provider_user ai.provider.user model_ai_provider base.group_user 1 0 0 0
11 access_ai_provider_system ai.provider.system model_ai_provider base.group_system 1 1 1 1

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View file

@ -0,0 +1 @@
from . import models

View 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',
}

View 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>

View 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>

View 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>

View 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>

File diff suppressed because it is too large Load diff

View 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
$$;
""")

View 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

View 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)))

View 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 []

View 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))
}
}

View 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)
))

View 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

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ollama_model_stats_user ollama.model.stats.user model_ollama_model_stats base.group_user 1 0 0 0
3 access_ollama_model_stats_manager ollama.model.stats.manager model_ollama_model_stats base.group_system 1 1 1 1
4 access_ai_provider_ollama_user ai.provider.ollama.user model_ai_provider_ollama base.group_user 1 0 0 0
5 access_ai_provider_ollama_manager ai.provider.ollama.manager model_ai_provider_ollama base.group_system 1 1 1 1

View file

@ -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>

View 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>

View 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>

View file

@ -0,0 +1 @@
from . import models

View 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',
}

View 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>

View file

@ -0,0 +1,2 @@
from . import chatgpt_provider
from . import chatgpt_instance

View 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

View 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))

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_openwebui_ai_instance_user openwebui.ai.instance user model_openwebui_ai_instance base.group_user 1 0 0 0
3 access_openwebui_ai_instance_system openwebui.ai.instance system model_openwebui_ai_instance base.group_system 1 1 1 1

View 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>

View file

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
from . import models

View 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'
}

View 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

View file

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import mail_wizard_invite

View file

@ -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"
)

View file

@ -0,0 +1 @@
from . import wizard

View 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,
}

View 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"

View 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

View 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 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_attachment_cleanup_wizard access.attachment.cleanup.wizard model_attachment_cleanup_wizard base.group_system 1 1 1 1

View file

@ -0,0 +1 @@
from . import attachment_cleanup_wizard

View file

@ -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()

View file

@ -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>

View file

@ -0,0 +1,2 @@
from . import controllers
from . import models

View 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,
}

View file

@ -0,0 +1 @@
from . import portal

View 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)

View file

@ -0,0 +1 @@
from . import documents

View 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')

View 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&amp;id=%s&amp;action=%s&amp;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}}&amp;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>

View 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

View 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'
}

View 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

View 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

View 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

View file

@ -0,0 +1,10 @@
15.0.0.0.1
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Initial release
16.0.0.0.1
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
* Modification for V16 compatibility

View file

View 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,
}

View file

@ -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
View 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