Compare commits
255 commits
17.0-calda
...
18.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
278540fbaf | ||
|
|
a080586ef4 | ||
|
|
3f5c5cc038 | ||
|
|
00a940672a | ||
|
|
ece737826c | ||
|
|
800214b19c | ||
|
|
9e7662ec7e | ||
|
|
b779529d91 | ||
|
|
8f252372e4 | ||
|
|
cbd9f2faab | ||
|
|
dffb2755db | ||
|
|
3cc5889ea4 | ||
|
|
f6cdd94f5f | ||
|
|
21cc63df0b | ||
|
|
f830cb676f | ||
|
|
73c74412e0 | ||
|
|
59fa4703ab | ||
|
|
7b8255abe2 | ||
|
|
6accef74e2 | ||
|
|
2d748d7b4e | ||
|
|
e0f55f4458 | ||
|
|
8e69382d84 | ||
|
|
4142187feb | ||
|
|
d428d673bc | ||
|
|
1a69864176 | ||
|
|
d69c2425ee | ||
|
|
e1a64337ff | ||
|
|
289e8f3731 | ||
|
|
7f62a24819 | ||
|
|
2d0577d7e3 | ||
|
|
c78b926565 | ||
|
|
ba9f1e0c56 | ||
|
|
5ec51c3554 | ||
|
|
c19f2e29e6 | ||
|
|
8662ad3ffd | ||
|
|
975870333a | ||
|
|
ca493863cb | ||
|
|
5793351bbe | ||
|
|
c7d0a1be2b | ||
|
|
bb8c1f2f35 | ||
|
|
06584b381a | ||
|
|
6ea4b324b3 | ||
|
|
15f96ec795 | ||
|
|
917e04d018 | ||
|
|
1fe33c306a | ||
|
|
c43a615dd4 | ||
|
|
ce69990df6 | ||
|
|
8424815d53 | ||
|
|
fc8248ff2e | ||
|
|
06b9c6d201 | ||
|
|
a47e829935 | ||
|
|
6872e5fac2 | ||
|
|
65b301b2e8 | ||
|
|
583e25e092 | ||
|
|
e5e90d6c7e | ||
|
|
736f7011a7 | ||
|
|
6c096430d5 | ||
|
|
0c156f089b | ||
|
|
03ca1649c9 | ||
|
|
f5e1751da7 | ||
|
|
8ca4ef5af3 | ||
|
|
c03886e812 | ||
|
|
29a087b207 | ||
|
|
37ac446c1e | ||
|
|
a48450849c | ||
|
|
580e61d840 | ||
|
|
2d052d630b | ||
|
|
fd43de7443 | ||
|
|
aea07cb5a5 | ||
|
|
3ce8d051bc | ||
|
|
78589bbcf3 | ||
|
|
e779b4da94 | ||
|
|
805c694e21 | ||
|
|
d2286d136e | ||
|
|
0c0d047f2e | ||
|
|
b755b80150 | ||
|
|
721ab5776c | ||
|
|
fd1751b058 | ||
|
|
e0c4fcba6f | ||
|
|
7f3b330ca9 | ||
|
|
c135c3eef7 | ||
|
|
5abdebb3a1 | ||
|
|
cdef6664d2 | ||
|
|
3ce3b2a1de | ||
|
|
8e98592306 | ||
|
|
53a027c87c | ||
|
|
5137d23562 | ||
|
|
02eff1880f | ||
|
|
14fa714fe8 | ||
|
|
f5911f0767 | ||
|
|
fae0d98637 | ||
|
|
82266644ca | ||
|
|
a6742c16b0 | ||
|
|
8e53a0863f | ||
|
|
85a2a3fafe | ||
|
|
a8aa7e71c2 | ||
|
|
90dc45245e | ||
|
|
6cfb9551b0 | ||
|
|
eff17d674d | ||
|
|
c0834eb327 | ||
|
|
8c9d60c550 | ||
|
|
e81efd7fdd | ||
|
|
ca57ec8f16 | ||
|
|
07c712f0b8 | ||
|
|
8328d2a277 | ||
|
|
aa16543a86 | ||
|
|
2164e2be54 | ||
|
|
b437a82a70 | ||
|
|
1f91c27a44 | ||
|
|
c60ffd5bfa | ||
|
|
b4eea01a20 | ||
|
|
7ca1c1ee02 | ||
|
|
771077b2b0 | ||
|
|
df5fc408de | ||
|
|
4b2b53caa7 | ||
|
|
1ef0102dd2 | ||
|
|
b33f25c688 | ||
|
|
9de2654b0f | ||
|
|
2e15f18285 | ||
|
|
c3af7223de | ||
|
|
a76182dcd4 | ||
|
|
a0e5d8664d | ||
|
|
d42f885c27 | ||
|
|
dd4062a0d3 | ||
|
|
57ba6be5b9 | ||
|
|
0956746fc8 | ||
|
|
3d89095ad0 | ||
|
|
e97b7186b1 | ||
|
|
32ab90c173 | ||
|
|
3f2414a415 | ||
|
|
8162d367f0 | ||
|
|
72530d8c35 | ||
|
|
640326629a | ||
|
|
bfbc5d6491 | ||
|
|
85964620cf | ||
|
|
ccf211c554 | ||
|
|
ad2bc057a2 | ||
|
|
f3259fd6a1 | ||
|
|
cc9a5e172d | ||
|
|
dec2e44c09 | ||
|
|
5390b6308e | ||
|
|
d9cc020e7f | ||
|
|
a7d280411e | ||
|
|
99e3ed03a7 | ||
|
|
d16304e57b | ||
|
|
e0ba8b6008 | ||
|
|
b83fb948d4 | ||
|
|
b2183d8601 | ||
|
|
5bfcaa37c2 | ||
|
|
ec757e1883 | ||
|
|
348fb6cb05 | ||
|
|
7c5bc93454 | ||
|
|
32ee016a92 | ||
|
|
a364a8f913 | ||
|
|
6bb1d95fdb | ||
|
|
236952924d | ||
|
|
68d415fa17 | ||
|
|
c507e5d416 | ||
|
|
f615f8c960 | ||
|
|
bb5b211d9c | ||
|
|
2df5651f12 | ||
|
|
e48e6d9aef | ||
|
|
d295f38355 | ||
|
|
0676cf1e7f | ||
|
|
9a130bae6b | ||
|
|
e1e8c94c94 | ||
|
|
996fb3767e | ||
|
|
b95ca97022 | ||
|
|
a3b4d3ed39 | ||
|
|
b605f77e55 | ||
|
|
b43f0f5c85 | ||
|
|
cc66512df2 | ||
|
|
1b95a6c2ea | ||
|
|
020ed0e99c | ||
|
|
4d06664928 | ||
|
|
c30130a3d6 | ||
|
|
1fb6ad5a2c | ||
|
|
4c5b55a7dd | ||
|
|
dd76c00ec1 | ||
|
|
cf12a5990d | ||
|
|
f41b3d2be1 | ||
|
|
6ea64007e0 | ||
|
|
36e62be4c1 | ||
|
|
1367cbf7b2 | ||
|
|
853c239deb | ||
|
|
5aa0f5b519 | ||
|
|
29c6f07691 | ||
|
|
25882dafab | ||
|
|
549cbf2579 | ||
|
|
55a69d68b1 | ||
|
|
4911ef0b9a | ||
|
|
c7c66a5501 | ||
|
|
def8e07135 | ||
|
|
362fc9b774 | ||
|
|
f4550d7723 | ||
|
|
066efe3ab2 | ||
|
|
9d97e13216 | ||
|
|
de44113336 | ||
|
|
fabc7a6740 | ||
|
|
5d4707df02 | ||
|
|
45ab7611a3 | ||
|
|
aa4fbe72ec | ||
|
|
98b5ed31fd | ||
|
|
0644586a52 | ||
|
|
30afde78cc | ||
|
|
b46301ed13 | ||
|
|
bd6aec9bf4 | ||
|
|
558313211c | ||
|
|
38b32ff892 | ||
|
|
af90b2c9f6 | ||
|
|
12274002fd | ||
|
|
f0d05b4003 | ||
|
|
3b499eada0 | ||
|
|
77c5b8ee82 | ||
|
|
064983d18a | ||
|
|
056d438413 | ||
|
|
35e04f678c | ||
|
|
1122f71628 | ||
|
|
245610362d | ||
|
|
2738ade92d | ||
|
|
f54ff1496f | ||
|
|
e767591739 | ||
|
|
ed848863f9 | ||
|
|
99dca038d3 | ||
|
|
69f815c3f5 | ||
|
|
23113e9a40 | ||
|
|
776e5aa8f5 | ||
|
|
d01295a930 | ||
|
|
c2c4e632af | ||
|
|
2a1be1885e | ||
|
|
5e4a818fc1 | ||
|
|
27b0df3649 | ||
|
|
9ed6ddd0df | ||
|
|
c6df3e15a2 | ||
|
|
a4a360aef1 | ||
|
|
f34860bfac | ||
|
|
56b755039d | ||
|
|
e9a2dd389d | ||
|
|
6e56609846 | ||
|
|
a968b64e80 | ||
|
|
c8bbeac919 | ||
|
|
a365609f21 | ||
|
|
15aee61953 | ||
|
|
01c424c485 | ||
|
|
b6eca04802 | ||
|
|
fdcc81d29e | ||
|
|
c1a2129359 | ||
|
|
5b696e8eab | ||
|
|
9b882ec7f8 | ||
|
|
d99acaa3ec | ||
|
|
34ec1a8230 | ||
|
|
f542d8fbf9 | ||
|
|
4163232371 | ||
|
|
e7fd3c3910 | ||
|
|
7ae6454947 |
1184 changed files with 107767 additions and 6568 deletions
|
|
@ -37,12 +37,12 @@ repos:
|
|||
language: fail
|
||||
files: '[a-zA-Z0-9_]*/i18n/en\.po$'
|
||||
- repo: https://github.com/OCA/odoo-pre-commit-hooks
|
||||
rev: v0.0.25
|
||||
rev: v0.1.6
|
||||
hooks:
|
||||
- id: oca-checks-odoo-module
|
||||
- id: oca-checks-po
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v2.7.1
|
||||
rev: v4.0.0-alpha.8
|
||||
hooks:
|
||||
- id: prettier
|
||||
name: prettier (with plugin-xml)
|
||||
|
|
@ -53,7 +53,7 @@ repos:
|
|||
- --plugin=@prettier/plugin-xml
|
||||
files: \.(css|htm|html|js|json|jsx|less|md|scss|toml|ts|xml|yaml|yml)$
|
||||
- repo: https://github.com/pre-commit/mirrors-eslint
|
||||
rev: v8.24.0
|
||||
rev: v9.33.0
|
||||
hooks:
|
||||
- id: eslint
|
||||
verbose: true
|
||||
|
|
@ -61,7 +61,7 @@ repos:
|
|||
- --color
|
||||
- --fix
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
# exclude autogenerated files
|
||||
|
|
@ -83,7 +83,7 @@ repos:
|
|||
- id: mixed-line-ending
|
||||
args: ["--fix=lf"]
|
||||
- repo: https://github.com/OCA/pylint-odoo
|
||||
rev: v9.1.2
|
||||
rev: v9.3.14
|
||||
hooks:
|
||||
- id: pylint_odoo
|
||||
name: pylint with optional checks
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "Account Credit Hold",
|
||||
"version": "17.0.1.1.1",
|
||||
"version": "18.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.",
|
||||
|
|
|
|||
|
|
@ -4,9 +4,13 @@ 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)
|
||||
def _get_followup_report_options(self, partner, options=None):
|
||||
"""
|
||||
Override to include credit hold information in followup report options.
|
||||
"""
|
||||
res = super()._get_followup_report_options(partner, options)
|
||||
res.update({
|
||||
'credit_hold': followup_line.account_hold
|
||||
'credit_hold': partner.followup_line_id.account_hold if partner.followup_line_id else False,
|
||||
'partner_on_hold': partner.on_hold
|
||||
})
|
||||
return res
|
||||
|
|
|
|||
|
|
@ -26,24 +26,21 @@ class Partner(models.Model):
|
|||
compute_sudo=True,
|
||||
)
|
||||
|
||||
@api.depends("postpone_hold_until", "hold_bg")
|
||||
@api.depends("postpone_hold_until", "hold_bg", "commercial_partner_id.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 rec.commercial_partner_id != rec and rec.commercial_partner_id.hold_bg:
|
||||
if not (rec.commercial_partner_id.postpone_hold_until and rec.commercial_partner_id.postpone_hold_until > date.today()):
|
||||
rec.on_hold = True
|
||||
continue
|
||||
|
||||
# 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
|
||||
|
|
@ -61,12 +58,13 @@ class Partner(models.Model):
|
|||
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.model
|
||||
def _get_first_followup_level(self):
|
||||
return self.env["account_followup.followup.line"].search(
|
||||
[("company_id", "parent_of", self.env.company.id)],
|
||||
order="delay asc",
|
||||
limit=1,
|
||||
)
|
||||
|
||||
@api.depends("followup_status", "followup_line_id")
|
||||
def _compute_hold_bg(self):
|
||||
|
|
@ -78,3 +76,33 @@ class Partner(models.Model):
|
|||
rec.hold_bg = False
|
||||
else:
|
||||
rec.hold_bg = prev_hold_bg
|
||||
|
||||
def _get_followup_report(self, options):
|
||||
# Override to prevent hanging on PDF generation
|
||||
# Just set minimal required options without generating the report
|
||||
options.setdefault('attachment_ids', [])
|
||||
options['report_attachment_id'] = False
|
||||
|
||||
def _execute_followup_partner(self, options=None):
|
||||
# Check if we need to place on credit hold before expensive operations
|
||||
should_hold = (
|
||||
self.followup_status == "in_need_of_action" and
|
||||
self.followup_line_id and
|
||||
hasattr(self.followup_line_id, 'account_hold') and
|
||||
self.followup_line_id.account_hold
|
||||
)
|
||||
|
||||
# If this is just for credit hold and we don't need reports/emails, skip heavy operations
|
||||
if options and options.get('credit_hold_only'):
|
||||
if should_hold:
|
||||
self.action_credit_hold()
|
||||
return should_hold
|
||||
|
||||
# Otherwise run the full followup process
|
||||
res = super()._execute_followup_partner(options)
|
||||
|
||||
# Apply credit hold after successful followup execution
|
||||
if should_hold:
|
||||
self.action_credit_hold()
|
||||
|
||||
return res
|
||||
|
|
|
|||
|
|
@ -9,9 +9,8 @@ class SaleOrder(models.Model):
|
|||
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()
|
||||
return super().action_confirm()
|
||||
|
|
|
|||
1
account_credit_hold/tests/__init__.py
Normal file
1
account_credit_hold/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_account_credit_hold
|
||||
342
account_credit_hold/tests/test_account_credit_hold.py
Normal file
342
account_credit_hold/tests/test_account_credit_hold.py
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import date, timedelta
|
||||
from odoo.tests import common, tagged, Form
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestAccountCreditHold(common.TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Create test partner
|
||||
self.partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Customer",
|
||||
"is_company": True,
|
||||
"customer_rank": 1,
|
||||
"email": "test@example.com",
|
||||
}
|
||||
)
|
||||
|
||||
# Try to find existing followup lines or create new ones with unique delays
|
||||
self.followup_line = self.env["account_followup.followup.line"].search(
|
||||
[("delay", "=", 15), ("company_id", "=", self.env.company.id)], limit=1
|
||||
)
|
||||
|
||||
if not self.followup_line:
|
||||
# Find a unique delay value
|
||||
existing_delays = (
|
||||
self.env["account_followup.followup.line"]
|
||||
.search([("company_id", "=", self.env.company.id)])
|
||||
.mapped("delay")
|
||||
)
|
||||
|
||||
delay = 15
|
||||
while delay in existing_delays:
|
||||
delay += 1
|
||||
|
||||
self.followup_line = self.env["account_followup.followup.line"].create(
|
||||
{
|
||||
"name": "First Reminder",
|
||||
"delay": delay,
|
||||
"account_hold": True,
|
||||
"send_email": True,
|
||||
"company_id": self.env.company.id,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Update existing line to have account_hold
|
||||
self.followup_line.account_hold = True
|
||||
|
||||
# Create followup line without credit hold
|
||||
self.followup_line_no_hold = self.env["account_followup.followup.line"].search(
|
||||
[("delay", "=", 30), ("company_id", "=", self.env.company.id)], limit=1
|
||||
)
|
||||
|
||||
if not self.followup_line_no_hold:
|
||||
# Find a unique delay value
|
||||
existing_delays = (
|
||||
self.env["account_followup.followup.line"]
|
||||
.search([("company_id", "=", self.env.company.id)])
|
||||
.mapped("delay")
|
||||
)
|
||||
|
||||
delay = 30
|
||||
while delay in existing_delays:
|
||||
delay += 1
|
||||
|
||||
self.followup_line_no_hold = self.env[
|
||||
"account_followup.followup.line"
|
||||
].create(
|
||||
{
|
||||
"name": "Second Reminder",
|
||||
"delay": delay,
|
||||
"account_hold": False,
|
||||
"send_email": True,
|
||||
"company_id": self.env.company.id,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# Update existing line to not have account_hold
|
||||
self.followup_line_no_hold.account_hold = False
|
||||
|
||||
def test_credit_hold_basic_functionality(self):
|
||||
"""Test basic credit hold functionality"""
|
||||
# Initially partner should not be on hold
|
||||
self.assertFalse(self.partner.on_hold)
|
||||
self.assertFalse(self.partner.hold_bg)
|
||||
|
||||
# Place partner on credit hold
|
||||
with Form(self.partner) as form:
|
||||
form.record.action_credit_hold()
|
||||
self.assertTrue(self.partner.hold_bg)
|
||||
self.assertTrue(self.partner.on_hold)
|
||||
|
||||
# Lift credit hold
|
||||
with Form(self.partner) as form:
|
||||
form.record.action_lift_credit_hold()
|
||||
self.assertFalse(self.partner.hold_bg)
|
||||
self.assertFalse(self.partner.on_hold)
|
||||
|
||||
def test_postpone_hold_functionality(self):
|
||||
"""Test postpone hold until functionality"""
|
||||
# Place partner on hold
|
||||
with Form(self.partner) as form:
|
||||
form.record.action_credit_hold()
|
||||
self.assertTrue(self.partner.on_hold)
|
||||
|
||||
# Set postpone date to tomorrow
|
||||
tomorrow = date.today() + timedelta(days=1)
|
||||
self.partner.postpone_hold_until = tomorrow
|
||||
|
||||
# Partner should not be on hold due to postponement
|
||||
self.assertFalse(self.partner.on_hold)
|
||||
|
||||
# Set postpone date to yesterday
|
||||
yesterday = date.today() - timedelta(days=1)
|
||||
self.partner.postpone_hold_until = yesterday
|
||||
|
||||
# Partner should be on hold again
|
||||
self.assertTrue(self.partner.on_hold)
|
||||
|
||||
def test_commercial_partner_hold_inheritance(self):
|
||||
"""Test that child contacts inherit hold status from commercial partner"""
|
||||
# Create child contact
|
||||
child_partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Child Contact",
|
||||
"parent_id": self.partner.id,
|
||||
"type": "contact",
|
||||
}
|
||||
)
|
||||
|
||||
# Place parent on hold
|
||||
with Form(self.partner) as form:
|
||||
form.record.action_credit_hold()
|
||||
|
||||
# Child should also be on hold
|
||||
self.assertTrue(child_partner.on_hold)
|
||||
|
||||
# Lift hold from parent
|
||||
self.partner.action_lift_credit_hold()
|
||||
|
||||
# Child should no longer be on hold
|
||||
self.assertFalse(child_partner.on_hold)
|
||||
|
||||
def test_sale_order_blocking(self):
|
||||
"""Test that sale orders are blocked when customer is on credit hold"""
|
||||
# Get or create a product for testing
|
||||
product = self.env["product.product"].search([("type", "=", "consu")], limit=1)
|
||||
if not product:
|
||||
product = self.env["product.product"].create(
|
||||
{
|
||||
"name": "Test Product",
|
||||
"type": "consu",
|
||||
"list_price": 100.0,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a sale order
|
||||
sale_order = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.partner.id,
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": product.id,
|
||||
"product_uom_qty": 1,
|
||||
"price_unit": 100.0,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Should be able to confirm when not on hold
|
||||
sale_order.action_confirm()
|
||||
self.assertEqual(sale_order.state, "sale")
|
||||
|
||||
# Create another order and place customer on hold
|
||||
sale_order2 = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.partner.id,
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": product.id,
|
||||
"product_uom_qty": 1,
|
||||
"price_unit": 100.0,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
with Form(self.partner) as form:
|
||||
form.record.action_credit_hold()
|
||||
|
||||
# Should raise error when trying to confirm
|
||||
with self.assertRaises(UserError):
|
||||
sale_order2.action_confirm()
|
||||
|
||||
def test_followup_integration(self):
|
||||
"""Test integration with followup system"""
|
||||
# Set partner to in_need_of_action status and assign followup line
|
||||
self.partner.write(
|
||||
{
|
||||
"followup_status": "in_need_of_action",
|
||||
"followup_line_id": self.followup_line.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Execute followup - should place on hold
|
||||
self.partner._execute_followup_partner()
|
||||
self.assertTrue(self.partner.hold_bg)
|
||||
|
||||
# Test with followup line that doesn't have account_hold
|
||||
self.partner.write(
|
||||
{
|
||||
"followup_line_id": self.followup_line_no_hold.id,
|
||||
"hold_bg": False, # Reset hold status
|
||||
}
|
||||
)
|
||||
|
||||
# Execute followup - should not place on hold
|
||||
self.partner._execute_followup_partner()
|
||||
self.assertFalse(self.partner.hold_bg)
|
||||
|
||||
def test_followup_report_options(self):
|
||||
"""Test that followup report includes credit hold information"""
|
||||
# Set up partner with followup line
|
||||
self.partner.write(
|
||||
{
|
||||
"followup_line_id": self.followup_line.id,
|
||||
}
|
||||
)
|
||||
self.partner.action_credit_hold()
|
||||
|
||||
# Get followup report options
|
||||
report = self.env["account.followup.report"]
|
||||
options = report._get_followup_report_options(self.partner)
|
||||
|
||||
# Should include credit hold information
|
||||
self.assertTrue(options.get("credit_hold"))
|
||||
self.assertTrue(options.get("partner_on_hold"))
|
||||
|
||||
def test_cleanup_expired_hold_postponements(self):
|
||||
"""Test automatic cleanup of expired hold postponements"""
|
||||
# Set expired postponement date
|
||||
expired_date = date.today() - timedelta(days=5)
|
||||
self.partner.postpone_hold_until = expired_date
|
||||
|
||||
# Run cleanup
|
||||
self.env["res.partner"]._cleanup_expired_hold_postponements()
|
||||
|
||||
# Postponement should be cleared
|
||||
self.assertFalse(self.partner.postpone_hold_until)
|
||||
|
||||
def test_hold_bg_computation(self):
|
||||
"""Test hold_bg field computation based on followup status"""
|
||||
# Test with no_action_needed status
|
||||
self.partner.write(
|
||||
{
|
||||
"followup_status": "no_action_needed",
|
||||
"followup_line_id": False,
|
||||
}
|
||||
)
|
||||
self.partner._compute_hold_bg()
|
||||
self.assertFalse(self.partner.hold_bg)
|
||||
|
||||
# Test with followup status and line
|
||||
self.partner.write(
|
||||
{
|
||||
"followup_status": "in_need_of_action",
|
||||
"followup_line_id": self.followup_line.id,
|
||||
"hold_bg": True, # Set initial state
|
||||
}
|
||||
)
|
||||
self.partner._compute_hold_bg()
|
||||
# Should preserve existing hold_bg value when there's a followup line
|
||||
self.assertTrue(self.partner.hold_bg)
|
||||
|
||||
def test_stock_picking_credit_hold_display(self):
|
||||
"""Test that stock pickings show credit hold status"""
|
||||
# Get warehouse and its outgoing picking type
|
||||
warehouse = self.env["stock.warehouse"].search([], limit=1)
|
||||
picking_type = warehouse.out_type_id
|
||||
|
||||
# Create a stock picking
|
||||
picking = self.env["stock.picking"].create(
|
||||
{
|
||||
"partner_id": self.partner.id,
|
||||
"picking_type_id": picking_type.id,
|
||||
"location_id": picking_type.default_location_src_id.id,
|
||||
"location_dest_id": picking_type.default_location_dest_id.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Initially should not show as on hold
|
||||
self.assertFalse(picking.client_on_hold)
|
||||
|
||||
# Place partner on hold
|
||||
with Form(self.partner) as form:
|
||||
form.record.action_credit_hold()
|
||||
|
||||
# Picking should now show as on hold
|
||||
self.assertTrue(picking.client_on_hold)
|
||||
|
||||
def test_get_first_followup_level(self):
|
||||
"""Test _get_first_followup_level method"""
|
||||
first_level = self.partner._get_first_followup_level()
|
||||
self.assertEqual(first_level, self.followup_line)
|
||||
|
||||
# Create an earlier followup level with unique delay
|
||||
existing_delays = (
|
||||
self.env["account_followup.followup.line"]
|
||||
.search([("company_id", "=", self.env.company.id)])
|
||||
.mapped("delay")
|
||||
)
|
||||
|
||||
delay = 5
|
||||
while delay in existing_delays:
|
||||
delay += 1
|
||||
|
||||
earlier_line = self.env["account_followup.followup.line"].create(
|
||||
{
|
||||
"name": "Early Reminder",
|
||||
"delay": delay,
|
||||
"account_hold": False,
|
||||
"company_id": self.env.company.id,
|
||||
}
|
||||
)
|
||||
|
||||
first_level = self.partner._get_first_followup_level()
|
||||
self.assertEqual(first_level, earlier_line)
|
||||
|
|
@ -12,32 +12,22 @@
|
|||
<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>
|
||||
|
||||
<record id="manual_reminder_view_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account_credit_hold.manual_reminder.form.inherit</field>
|
||||
<field name="model">account_followup.manual_reminder</field>
|
||||
<field
|
||||
name="inherit_id"
|
||||
ref="account_followup.customer_statements_form_view"
|
||||
ref="account_followup.manual_reminder_view_form"
|
||||
/>
|
||||
<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 expr="//footer" position="before">
|
||||
<div class="alert alert-warning" role="alert" invisible="not partner_id.on_hold">
|
||||
<strong>Credit Hold:</strong> This customer is currently on credit hold.
|
||||
</div>
|
||||
</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" />
|
||||
|
|
|
|||
23
account_email_to_pdf/__manifest__.py
Normal file
23
account_email_to_pdf/__manifest__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"name": "Account Email to PDF",
|
||||
"version": "18.0.1.0.0",
|
||||
"category": "Accounting",
|
||||
"summary": "Convert email messages to PDF attachments for vendor bills",
|
||||
"description": """
|
||||
Account Email to PDF
|
||||
====================
|
||||
This module converts email messages without attachments into PDF attachments
|
||||
when processing incoming emails for vendor bills.
|
||||
|
||||
Instead of rejecting emails without attachments, the system will create a PDF
|
||||
from the email content and attach it to the message, allowing the vendor bill
|
||||
creation process to continue.
|
||||
""",
|
||||
"author": "Bemade",
|
||||
"website": "https://bemade.org",
|
||||
"depends": ["account", "mail"],
|
||||
"data": [],
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
"license": "LGPL-3",
|
||||
}
|
||||
1
account_email_to_pdf/models/__init__.py
Normal file
1
account_email_to_pdf/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import account_move
|
||||
261
account_email_to_pdf/models/account_move.py
Normal file
261
account_email_to_pdf/models/account_move.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from contextlib import closing
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
import re
|
||||
|
||||
from odoo import models, api
|
||||
from odoo.tools.misc import find_in_path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AccountMove(models.Model):
|
||||
_inherit = "account.move"
|
||||
|
||||
@api.model
|
||||
def _routing_check_route(self, message, message_dict, route, raise_exception=True):
|
||||
"""Override to create a PDF attachment from the email content before checking the route.
|
||||
The standard Odoo behavior bounces emails without attachments, but we want to
|
||||
process them by generating a PDF from the email content.
|
||||
"""
|
||||
if route[0] == "account.move" and (
|
||||
len(message_dict.get("attachments", [])) < 1
|
||||
or message_dict["attachments"][0][0].lower().endswith(".eml")
|
||||
):
|
||||
try:
|
||||
# Create a PDF from the email content
|
||||
pdf_attachment = self._create_pdf_from_email(message_dict)
|
||||
if pdf_attachment:
|
||||
# Add the PDF to the message's attachments
|
||||
if not message_dict.get("attachments"):
|
||||
message_dict["attachments"] = []
|
||||
|
||||
# Convert the attachment to the format expected by mail_thread
|
||||
# Format: (name, base64_data, info_dict)
|
||||
attachment_data = (
|
||||
pdf_attachment["name"],
|
||||
pdf_attachment["datas"],
|
||||
{"mimetype": "application/pdf"},
|
||||
)
|
||||
message_dict["attachments"].append(attachment_data)
|
||||
except Exception as e:
|
||||
_logger.exception(
|
||||
"Error creating PDF from email in _routing_check_route: %s", e
|
||||
)
|
||||
|
||||
return super()._routing_check_route(
|
||||
message, message_dict, route, raise_exception=raise_exception
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _html_to_pdf(cls, html_content):
|
||||
"""Convert HTML content to PDF using wkhtmltopdf.
|
||||
|
||||
Args:
|
||||
html_content (str): HTML content to convert to PDF
|
||||
|
||||
Returns:
|
||||
bytes: PDF content as bytes or False if conversion failed
|
||||
"""
|
||||
# Check if the content is plain text (not HTML)
|
||||
# More comprehensive check for HTML content
|
||||
is_html = bool(re.search(r"<html", html_content, re.IGNORECASE))
|
||||
|
||||
if not is_html:
|
||||
# Escape the content if it's not already HTML
|
||||
escaped_content = escape(html_content)
|
||||
|
||||
# Wrap the content in basic HTML structure with proper styling for plain text
|
||||
html_content = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 20px; }}
|
||||
pre {{ white-space: pre-wrap; font-family: monospace; background-color: #f9f9f9; padding: 10px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<pre>{escaped_content}</pre>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Check if wkhtmltopdf is installed
|
||||
wkhtmltopdf_bin = find_in_path("wkhtmltopdf")
|
||||
if not wkhtmltopdf_bin:
|
||||
_logger.error("Cannot find wkhtmltopdf executable in system path")
|
||||
return False
|
||||
|
||||
# Create temporary files for the HTML input and PDF output
|
||||
html_file_fd, html_file_path = tempfile.mkstemp(
|
||||
suffix=".html", prefix="email_to_pdf."
|
||||
)
|
||||
pdf_file_fd, pdf_file_path = tempfile.mkstemp(
|
||||
suffix=".pdf", prefix="email_to_pdf."
|
||||
)
|
||||
|
||||
try:
|
||||
# Write the HTML content to the temporary file
|
||||
with closing(os.fdopen(html_file_fd, "wb")) as html_file:
|
||||
encoded_content = html_content.encode("utf-8")
|
||||
html_file.write(encoded_content)
|
||||
|
||||
# Close the PDF file descriptor as wkhtmltopdf will write to it
|
||||
os.close(pdf_file_fd)
|
||||
|
||||
# Basic wkhtmltopdf command arguments
|
||||
command = [wkhtmltopdf_bin]
|
||||
command.extend(["--encoding", "utf-8"])
|
||||
command.extend(["--page-size", "A4"])
|
||||
command.extend(["--margin-top", "10mm"])
|
||||
command.extend(["--margin-bottom", "10mm"])
|
||||
command.extend(["--margin-left", "10mm"])
|
||||
command.extend(["--margin-right", "10mm"])
|
||||
command.append(html_file_path)
|
||||
command.append(pdf_file_path)
|
||||
|
||||
process = subprocess.Popen(
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
out, err = process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
_logger.error(
|
||||
"wkhtmltopdf failed with error code %s: %s", process.returncode, err
|
||||
)
|
||||
return False
|
||||
|
||||
# Read the generated PDF
|
||||
try:
|
||||
with open(pdf_file_path, "rb") as pdf_file:
|
||||
pdf_content = pdf_file.read()
|
||||
return pdf_content
|
||||
except Exception as e:
|
||||
_logger.exception("Error reading generated PDF file: %s", e)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception("Error during PDF generation: %s", e)
|
||||
return False
|
||||
finally:
|
||||
# Clean up temporary files
|
||||
try:
|
||||
os.unlink(html_file_path)
|
||||
os.unlink(pdf_file_path)
|
||||
except (OSError, IOError):
|
||||
_logger.error("Failed to remove temporary files")
|
||||
|
||||
def _create_pdf_from_email(self, message_dict):
|
||||
"""Create a PDF attachment from an email message.
|
||||
|
||||
Args:
|
||||
message_dict (dict): Email message dictionary
|
||||
|
||||
Returns:
|
||||
dict: Dictionary with keys `name` and `datas` containing the name and
|
||||
base64 encoded data of the attachment
|
||||
"""
|
||||
# Log message dict keys for debugging
|
||||
_logger.info(f"Message dict keys: {list(message_dict.keys())}")
|
||||
# Extract email details
|
||||
email_from = message_dict.get("email_from", "Unknown Sender")
|
||||
email_date = message_dict.get("date", datetime.now())
|
||||
subject = message_dict.get("subject", "No Subject")
|
||||
body = message_dict.get("body", "")
|
||||
|
||||
# Format the date if it's a datetime object
|
||||
if isinstance(email_date, datetime):
|
||||
email_date = email_date.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
# Check if body is empty or None
|
||||
if not body:
|
||||
body = "<p>This email did not contain any body content.</p>"
|
||||
|
||||
# Check if the body is plain text based on content-type
|
||||
# The content type can be found in the message headers or directly in the message_dict
|
||||
content_type = ""
|
||||
|
||||
# Try to get content type from various places in the message dict
|
||||
if "content-type" in message_dict:
|
||||
content_type = message_dict["content-type"].lower()
|
||||
|
||||
# Try to get from headers
|
||||
headers = message_dict.get("headers", {})
|
||||
|
||||
if not content_type and headers:
|
||||
if isinstance(headers, dict):
|
||||
content_type = headers.get("Content-Type", "").lower()
|
||||
elif isinstance(headers, list):
|
||||
for header in headers:
|
||||
if (
|
||||
isinstance(header, tuple)
|
||||
and len(header) >= 2
|
||||
and header[0].lower() == "content-type"
|
||||
):
|
||||
content_type = header[1].lower()
|
||||
break
|
||||
|
||||
# Try to determine from the body content if we still don't have a content type
|
||||
if not content_type:
|
||||
if re.search(r"<html", body, re.IGNORECASE):
|
||||
content_type = "text/html"
|
||||
else:
|
||||
content_type = "text/plain"
|
||||
|
||||
# Convert plain text to HTML if needed
|
||||
if "text/plain" in content_type:
|
||||
# Replace newlines with <br> tags and wrap in paragraph tags
|
||||
# Escape HTML special characters to prevent injection
|
||||
body = f"<div style='white-space: pre-wrap; font-family: monospace;'>{escape(body)}</div>"
|
||||
|
||||
# Create HTML content for the PDF
|
||||
html_content = f"""
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 20px; }}
|
||||
.email-header {{ border-bottom: 1px solid #ccc; padding-bottom: 10px; margin-bottom: 20px; }}
|
||||
.email-meta {{ color: #666; font-size: 0.9em; margin-bottom: 5px; }}
|
||||
.email-subject {{ font-size: 1.2em; font-weight: bold; margin-bottom: 15px; }}
|
||||
.email-body {{ line-height: 1.5; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-header">
|
||||
<div class="email-meta">From: {escape(email_from)}</div>
|
||||
<div class="email-meta">Date: {escape(email_date)}</div>
|
||||
<div class="email-subject">{escape(subject)}</div>
|
||||
</div>
|
||||
<div class="email-body">
|
||||
{body}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Convert HTML to PDF
|
||||
pdf_content = self._html_to_pdf(html_content)
|
||||
|
||||
if not pdf_content:
|
||||
_logger.error("PDF generation failed")
|
||||
return False
|
||||
|
||||
# Create a proper ir.attachment record
|
||||
filename = f"Email_{subject.replace(' ', '_')[:30]}.pdf"
|
||||
|
||||
# Note: No need to base64 encode the PDF content here
|
||||
# When this attachment is added to message_dict["attachments"], Odoo expects raw binary data
|
||||
# Odoo will handle the base64 encoding when creating the actual ir.attachment record
|
||||
attachment = {
|
||||
"name": filename,
|
||||
"datas": pdf_content,
|
||||
}
|
||||
|
||||
return attachment
|
||||
1
account_email_to_pdf/tests/__init__.py
Normal file
1
account_email_to_pdf/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_email_to_pdf
|
||||
319
account_email_to_pdf/tests/test_email_to_pdf.py
Normal file
319
account_email_to_pdf/tests/test_email_to_pdf.py
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tools.misc import find_in_path
|
||||
from datetime import datetime
|
||||
import base64
|
||||
|
||||
|
||||
class TestHtmlToPdf(TransactionCase):
|
||||
"""
|
||||
Test the functionality of converting an email to a PDF when it's received for a supplier invoice.
|
||||
|
||||
This test simulates the full flow of an email being received through
|
||||
the mail alias and verifies that an account move is created with a PDF
|
||||
attachment generated from the email content.
|
||||
|
||||
It's important to also run the tests in account, which can be done using the test tag:
|
||||
|
||||
`/account:TestAccountIncomingSupplierInvoice.test_extend_with_attachments_document_formats`
|
||||
|
||||
This specific test ensures we are not creating more attachments than necessary.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
# Set up sender and alias for testing
|
||||
cls.sender_email = "sender@example.com"
|
||||
cls.alias_email = "test-invoices@example.com"
|
||||
|
||||
# Check if wkhtmltopdf is available
|
||||
cls.wkhtmltopdf_available = bool(find_in_path("wkhtmltopdf"))
|
||||
|
||||
# Get the model class to access the classmethod
|
||||
cls.account_move = cls.env["account.move"]
|
||||
|
||||
# Simple test HTML content
|
||||
cls.test_html = """
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; margin: 20px; }
|
||||
h1 { color: #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test HTML Document</h1>
|
||||
<p>This is a test paragraph with <b>bold text</b> and <i>italic text</i>.</p>
|
||||
<ul>
|
||||
<li>List item 1</li>
|
||||
<li>List item 2</li>
|
||||
<li>List item 3</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# Set up a supplier for testing
|
||||
cls.supplier = cls.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Supplier",
|
||||
"email": cls.sender_email,
|
||||
}
|
||||
)
|
||||
|
||||
# Set up a journal for incoming invoices
|
||||
cls.journal = cls.env["account.journal"].search(
|
||||
[("type", "=", "purchase")], limit=1
|
||||
)
|
||||
|
||||
# Set up the mail alias domain
|
||||
|
||||
cls.alias_domain = cls.env["mail.alias.domain"].create({"name": "example.com"})
|
||||
# Set up a mail alias for the journal
|
||||
cls.alias = cls.env["mail.alias"].create(
|
||||
{
|
||||
"alias_name": cls.alias_email,
|
||||
"alias_model_id": cls.env["ir.model"]
|
||||
.search([("model", "=", "account.move")], limit=1)
|
||||
.id,
|
||||
"alias_domain_id": cls.alias_domain.id,
|
||||
"alias_defaults": f'{{"move_type": "in_invoice", "journal_id": {cls.journal.id}}}',
|
||||
}
|
||||
)
|
||||
cls.journal.alias_id = cls.alias
|
||||
|
||||
def test_html_to_pdf_conversion(self):
|
||||
"""Test the direct HTML to PDF conversion."""
|
||||
if not self.wkhtmltopdf_available:
|
||||
self.skipTest("wkhtmltopdf not available")
|
||||
|
||||
# Call the method to convert HTML to PDF
|
||||
pdf_content = self.account_move._html_to_pdf(self.test_html)
|
||||
|
||||
# Verify the PDF was created
|
||||
self.assertTrue(pdf_content, "PDF content should be generated")
|
||||
|
||||
# Verify it's a valid PDF
|
||||
self.assertTrue(
|
||||
pdf_content.startswith(b"%PDF-"), "Content should be a valid PDF"
|
||||
)
|
||||
self.assertTrue(len(pdf_content) > 100, "PDF should have reasonable size")
|
||||
|
||||
def test_plain_text_to_pdf_conversion(self):
|
||||
"""Test the conversion of plain text email to PDF."""
|
||||
if not self.wkhtmltopdf_available:
|
||||
self.skipTest("wkhtmltopdf not available")
|
||||
|
||||
# Simple plain text content
|
||||
plain_text = "This is a plain text email.\nIt has no HTML formatting.\nJust plain text content.\n\nRegards,\nTest Sender"
|
||||
|
||||
|
||||
|
||||
# Call the method to convert plain text to PDF
|
||||
pdf_content = self.account_move._html_to_pdf(plain_text)
|
||||
|
||||
|
||||
|
||||
# Verify the PDF was created
|
||||
self.assertTrue(
|
||||
pdf_content, "PDF content should be generated even for plain text"
|
||||
)
|
||||
|
||||
# Verify it's a valid PDF
|
||||
is_pdf = pdf_content and pdf_content.startswith(b"%PDF-")
|
||||
|
||||
self.assertTrue(is_pdf, "Content should be a valid PDF")
|
||||
self.assertTrue(len(pdf_content) > 100, "PDF should have reasonable size")
|
||||
|
||||
# Integration test
|
||||
def test_account_email_to_pdf_full_flow(self):
|
||||
"""Test that an email without attachments is correctly converted to PDF
|
||||
and an account move is created.
|
||||
|
||||
This test simulates the full flow of an email being received through
|
||||
the mail alias and verifies that an account move is created with a PDF
|
||||
attachment generated from the email content.
|
||||
"""
|
||||
|
||||
# Get the current timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
message_id = f"<test123_{timestamp}@example.com>"
|
||||
subject = f"Invoice from Test Supplier {timestamp}"
|
||||
date = datetime.now().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
|
||||
# HTML body of the email
|
||||
html_body = "<html><body><h1>Invoice Test</h1><p>This is a test invoice from Test Supplier.</p><p>Amount: $100.00</p><p>Date: 2025-03-27</p></body></html>"
|
||||
|
||||
# Construct the raw email with proper headers
|
||||
# Make sure the From header is correctly formatted for Odoo to parse
|
||||
raw_email = f"""Return-Path: <{self.sender_email}>
|
||||
X-Original-To: {self.alias_email}
|
||||
Delivered-To: {self.alias_email}
|
||||
Received: from mail.example.com (mail.example.com [192.168.1.1])
|
||||
From: {self.sender_email}
|
||||
To: {self.alias_email}
|
||||
Subject: {subject}
|
||||
Date: {date}
|
||||
Message-ID: {message_id}
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/html; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
{html_body}"""
|
||||
|
||||
# Process the email through the mail gateway
|
||||
# This simulates what happens when fetchmail receives an email
|
||||
invoice_count_before = self.env["account.move"].search_count(
|
||||
[("move_type", "=", "in_invoice")]
|
||||
)
|
||||
|
||||
# Process the email through the mail gateway
|
||||
# We need to specify the model explicitly since we're in a test environment
|
||||
# In production, the alias would determine this automatically
|
||||
self.env["mail.thread"].with_context(fetchmail_server_id=1).message_process(
|
||||
model=None,
|
||||
message=raw_email,
|
||||
save_original=True,
|
||||
strip_attachments=False,
|
||||
)
|
||||
|
||||
# Count account moves after the test
|
||||
# Note: move_type is the correct field name in Odoo, even if the linter doesn't recognize it
|
||||
move_count_after = self.env["account.move"].search_count(
|
||||
[("move_type", "=", "in_invoice")]
|
||||
)
|
||||
self.assertEqual(
|
||||
move_count_after,
|
||||
invoice_count_before + 1,
|
||||
"A new account move should be created",
|
||||
)
|
||||
|
||||
# Find the newly created invoice
|
||||
invoice = self.env["account.move"].search(
|
||||
[("move_type", "=", "in_invoice")], order="id desc", limit=1
|
||||
)
|
||||
self.assertTrue(invoice, "An account move should be created")
|
||||
|
||||
messages = invoice.message_ids
|
||||
self.assertEqual(len(messages), 1, "The account move should have one message")
|
||||
|
||||
attachment = messages.attachment_ids
|
||||
|
||||
self.assertTrue(attachment, "The message should have an attachment")
|
||||
|
||||
attachment = attachment.filtered(
|
||||
lambda a: a.mimetype == "application/pdf" or ".pdf" in a.name
|
||||
)
|
||||
self.assertTrue(attachment, "The message should have a PDF attachment")
|
||||
|
||||
# Verify the invoice is linked to the correct supplier
|
||||
self.assertEqual(
|
||||
invoice.partner_id,
|
||||
self.supplier,
|
||||
"The invoice should be linked to the correct supplier",
|
||||
)
|
||||
|
||||
# Verify the invoice type
|
||||
self.assertEqual(
|
||||
invoice.move_type, "in_invoice", "The invoice should be an incoming invoice"
|
||||
)
|
||||
|
||||
def test_plain_text_email_to_pdf_full_flow(self):
|
||||
"""Test that a plain text email without attachments is correctly converted to PDF
|
||||
and an account move is created.
|
||||
|
||||
This test simulates the full flow of a plain text email being received through
|
||||
the mail alias and verifies that an account move is created with a PDF
|
||||
attachment generated from the email content.
|
||||
"""
|
||||
|
||||
|
||||
# Get the current timestamp
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
|
||||
message_id = f"<test123_{timestamp}@example.com>"
|
||||
subject = f"Plain Text Invoice from Test Supplier {timestamp}"
|
||||
date = datetime.now().strftime("%a, %d %b %Y %H:%M:%S +0000")
|
||||
|
||||
# Plain text body of the email
|
||||
plain_text_body = """Invoice Test
|
||||
|
||||
This is a test invoice from Test Supplier.
|
||||
Amount: $100.00
|
||||
Date: 2025-03-27
|
||||
|
||||
Thank you for your business.
|
||||
"""
|
||||
|
||||
# Construct the raw email with proper headers
|
||||
# Make sure the From header is correctly formatted for Odoo to parse
|
||||
raw_email = f"""Return-Path: <{self.sender_email}>
|
||||
X-Original-To: {self.alias_email}
|
||||
Delivered-To: {self.alias_email}
|
||||
Received: from mail.example.com (mail.example.com [192.168.1.1])
|
||||
From: {self.sender_email}
|
||||
To: {self.alias_email}
|
||||
Subject: {subject}
|
||||
Date: {date}
|
||||
Message-ID: {message_id}
|
||||
MIME-Version: 1.0
|
||||
Content-Type: text/plain; charset=UTF-8
|
||||
Content-Transfer-Encoding: 7bit
|
||||
|
||||
{plain_text_body}"""
|
||||
|
||||
# Process the email through the mail gateway
|
||||
# This simulates what happens when fetchmail receives an email
|
||||
invoice_count_before = self.env["account.move"].search_count(
|
||||
[("move_type", "=", "in_invoice")]
|
||||
)
|
||||
|
||||
# Process the email through the mail gateway
|
||||
|
||||
self.env["mail.thread"].with_context(fetchmail_server_id=1).message_process(
|
||||
model=None,
|
||||
message=raw_email,
|
||||
save_original=True,
|
||||
strip_attachments=False,
|
||||
)
|
||||
|
||||
# Count account moves after the test
|
||||
move_count_after = self.env["account.move"].search_count(
|
||||
[("move_type", "=", "in_invoice")]
|
||||
)
|
||||
self.assertEqual(
|
||||
move_count_after,
|
||||
invoice_count_before + 1,
|
||||
"A new account move should be created from plain text email",
|
||||
)
|
||||
|
||||
# Find the newly created invoice
|
||||
invoice = self.env["account.move"].search(
|
||||
[("move_type", "=", "in_invoice")], order="id desc", limit=1
|
||||
)
|
||||
self.assertTrue(
|
||||
invoice, "An account move should be created from plain text email"
|
||||
)
|
||||
|
||||
messages = invoice.message_ids
|
||||
self.assertEqual(len(messages), 1, "The account move should have one message")
|
||||
|
||||
attachment = messages.attachment_ids
|
||||
self.assertTrue(attachment, "The message should have an attachment")
|
||||
|
||||
attachment = attachment.filtered(
|
||||
lambda a: a.mimetype == "application/pdf" or ".pdf" in a.name
|
||||
)
|
||||
self.assertTrue(attachment, "The message should have a PDF attachment")
|
||||
|
||||
# Verify PDF content if possible
|
||||
if attachment:
|
||||
pdf_data = base64.b64decode(attachment.datas) if attachment.datas else b""
|
||||
|
||||
self.assertTrue(
|
||||
pdf_data.startswith(b"%PDF-"),
|
||||
f"Attachment should be a valid PDF even when source is plain text, starts with {pdf_data[:20]}",
|
||||
)
|
||||
self.assertTrue(
|
||||
len(pdf_data) > 100, "PDF from plain text should have reasonable size"
|
||||
)
|
||||
|
||||
|
|
@ -17,16 +17,16 @@
|
|||
# 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',
|
||||
"name": "Aged Partner Balance (North American Style)",
|
||||
"version": "18.0.1.0.0",
|
||||
"summary": "Present aged partner balance as predictive rather than past due.",
|
||||
"category": "Accounting",
|
||||
"author": "Bemade Inc.",
|
||||
"website": "http://www.bemade.org",
|
||||
"license": "LGPL-3",
|
||||
"depends": ["account_reports"],
|
||||
"assets": {},
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
"post_init_hook": "post_init",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
from odoo import models, fields
|
||||
from odoo.tools import SQL
|
||||
from itertools import chain
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
|
|
@ -57,13 +58,14 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
|
|||
return fields.Date.to_string(date_obj - relativedelta(days=days))
|
||||
|
||||
date_to = fields.Date.from_string(options['date']['date_to'])
|
||||
# North American style: show future due dates instead of past due
|
||||
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),
|
||||
(False, minus_days(date_to, 1)), # Overdue
|
||||
(date_to, plus_days(date_to, 29)), # 0-29 days
|
||||
(plus_days(date_to, 30), plus_days(date_to, 59)), # 30-59 days
|
||||
(plus_days(date_to, 60), plus_days(date_to, 89)), # 60-89 days
|
||||
(plus_days(date_to, 90), plus_days(date_to, 119)), # 90-119 days
|
||||
(plus_days(date_to, 120), False), # 120+ days
|
||||
]
|
||||
|
||||
def build_result_dict(report, query_res_lines):
|
||||
|
|
@ -77,7 +79,6 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
|
|||
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,
|
||||
|
|
@ -85,7 +86,6 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
|
|||
'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,
|
||||
|
||||
|
|
@ -100,74 +100,78 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
|
|||
'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
|
||||
# Build period table using SQL class
|
||||
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)
|
||||
period_table = SQL(period_table_format, *params)
|
||||
|
||||
# Build query
|
||||
tables, where_clause, where_params = report._query_get(options, 'strict_range', domain=[('account_id.account_type', '=', internal_type)])
|
||||
# Build query using new Odoo 18.0 methods
|
||||
query = report._get_report_query(options, 'strict_range', domain=[('account_id.account_type', '=', internal_type)])
|
||||
account_alias = query.left_join(lhs_alias='account_move_line', lhs_column='account_id', rhs_table='account_account', rhs_column='id', link='account_id')
|
||||
account_code = self.env['account.account']._field_to_sql(account_alias, 'code', query)
|
||||
|
||||
currency_table = report._get_query_currency_table(options)
|
||||
always_present_groupby = "period_table.period_index, currency_table.rate, currency_table.precision"
|
||||
always_present_groupby = SQL("period_table.period_index")
|
||||
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}"
|
||||
groupby_field_sql = self.env['account.move.line']._field_to_sql("account_move_line", current_groupby, query)
|
||||
select_from_groupby = SQL("%s AS grouping_key,", groupby_field_sql)
|
||||
groupby_clause = SQL("%s, %s", groupby_field_sql, always_present_groupby)
|
||||
else:
|
||||
select_from_groupby = ''
|
||||
select_from_groupby = SQL()
|
||||
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}
|
||||
"""
|
||||
|
||||
multiplicator = -1 if internal_type == 'liability_payable' else 1
|
||||
select_period_query = SQL(',').join(
|
||||
SQL("""
|
||||
CASE WHEN period_table.period_index = %(period_index)s
|
||||
THEN %(multiplicator)s * SUM(%(balance_select)s)
|
||||
ELSE 0 END AS %(column_name)s
|
||||
""",
|
||||
period_index=i,
|
||||
multiplicator=multiplicator,
|
||||
column_name=SQL.identifier(f"period{i}"),
|
||||
balance_select=report._currency_table_apply_rate(SQL(
|
||||
"account_move_line.balance - COALESCE(part_debit.amount, 0) + COALESCE(part_credit.amount, 0)"
|
||||
)),
|
||||
)
|
||||
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})
|
||||
tail_query = report._get_engine_query_tail(offset, limit)
|
||||
query = SQL(
|
||||
"""
|
||||
WITH period_table(date_start, date_stop, period_index) AS (%(period_table)s)
|
||||
|
||||
SELECT
|
||||
{select_from_groupby}
|
||||
%s * (
|
||||
%(select_from_groupby)s
|
||||
%(multiplicator)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 account_move_line.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 %(account_code)s) 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}
|
||||
ARRAY_AGG(%(account_code)s) AS account_code,
|
||||
%(select_period_query)s
|
||||
|
||||
FROM {tables}
|
||||
FROM %(table_references)s
|
||||
|
||||
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
|
||||
%(currency_table_join)s
|
||||
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT
|
||||
|
|
@ -175,7 +179,7 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
|
|||
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
|
||||
WHERE part.max_date <= %(date_to)s AND part.debit_move_id = account_move_line.id
|
||||
GROUP BY part.debit_move_id
|
||||
) part_debit ON TRUE
|
||||
|
||||
|
|
@ -185,7 +189,7 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
|
|||
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
|
||||
WHERE part.max_date <= %(date_to)s AND part.credit_move_id = account_move_line.id
|
||||
GROUP BY part.credit_move_id
|
||||
) part_credit ON TRUE
|
||||
|
||||
|
|
@ -200,33 +204,35 @@ class AgedPartnerBalanceCustomHandler(models.AbstractModel):
|
|||
OR COALESCE(account_move_line.date_maturity, account_move_line.date) <= DATE(period_table.date_stop)
|
||||
)
|
||||
|
||||
WHERE {where_clause}
|
||||
WHERE %(search_condition)s
|
||||
|
||||
GROUP BY {groupby_clause}
|
||||
GROUP BY %(groupby_clause)s
|
||||
|
||||
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}
|
||||
"""
|
||||
ROUND(SUM(%(having_debit)s), %(currency_precision)s) != 0
|
||||
OR ROUND(SUM(%(having_credit)s), %(currency_precision)s) != 0
|
||||
|
||||
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)
|
||||
ORDER BY %(groupby_clause)s
|
||||
|
||||
%(tail_query)s
|
||||
""",
|
||||
account_code=account_code,
|
||||
period_table=period_table,
|
||||
select_from_groupby=select_from_groupby,
|
||||
select_period_query=select_period_query,
|
||||
multiplicator=multiplicator,
|
||||
table_references=query.from_clause,
|
||||
currency_table_join=report._currency_table_aml_join(options),
|
||||
date_to=date_to,
|
||||
search_condition=query.where_clause,
|
||||
groupby_clause=groupby_clause,
|
||||
having_debit=report._currency_table_apply_rate(SQL("CASE WHEN account_move_line.balance > 0 THEN account_move_line.balance else 0 END - COALESCE(part_debit.amount, 0)")),
|
||||
having_credit=report._currency_table_apply_rate(SQL("CASE WHEN account_move_line.balance < 0 THEN -account_move_line.balance else 0 END - COALESCE(part_credit.amount, 0)")),
|
||||
currency_precision=self.env.company.currency_id.decimal_places,
|
||||
tail_query=tail_query,
|
||||
)
|
||||
|
||||
self._cr.execute(query)
|
||||
query_res_lines = self._cr.dictfetchall()
|
||||
|
||||
if not current_groupby:
|
||||
|
|
|
|||
39
ai_integration/__manifest__.py
Normal file
39
ai_integration/__manifest__.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
'name': 'AI Integration Base',
|
||||
'version': '1.0',
|
||||
'category': 'Technical',
|
||||
'summary': 'Base module for AI integration',
|
||||
'description': """
|
||||
AI Integration Base
|
||||
===================
|
||||
This module provides the base framework for integrating various AI providers
|
||||
into Odoo. It includes:
|
||||
|
||||
* Abstract interfaces for AI providers
|
||||
* Base configuration for AI models
|
||||
* Common utilities for AI integration
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://www.bemade.org',
|
||||
'depends': [
|
||||
'base',
|
||||
'web',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
'security/ai_security.xml',
|
||||
'security/ir_rule.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/res_company_views.xml',
|
||||
'views/res_config_settings_views.xml',
|
||||
'views/ai_provider_views.xml',
|
||||
'views/ai_provider_instance_views.xml',
|
||||
'views/ai_model_views.xml',
|
||||
'views/ai_model_stats_views.xml',
|
||||
'views/menu.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
9
ai_integration/models/__init__.py
Normal file
9
ai_integration/models/__init__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from .mixins.ai_base_mixin import AIBaseMixin
|
||||
from . import ai_generation_params
|
||||
from . import ai_model
|
||||
from . import ai_provider_interface
|
||||
from . import ai_provider
|
||||
from . import ai_provider_instance
|
||||
from . import res_config_settings
|
||||
from . import res_company
|
||||
from . import ai_model_stats
|
||||
42
ai_integration/models/ai_generation_params.py
Normal file
42
ai_integration/models/ai_generation_params.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from odoo import models, fields, api, _
|
||||
|
||||
class AIGenerationParams(models.AbstractModel):
|
||||
_name = 'ai.generation.params'
|
||||
_description = 'AI Generation Parameters'
|
||||
|
||||
# Base Generation Parameters
|
||||
temperature = fields.Float(
|
||||
string='Temperature',
|
||||
help='Controls randomness in generation. Higher values make output more random, lower values more deterministic.',
|
||||
default=0.7
|
||||
)
|
||||
|
||||
repeat_penalty = fields.Float(
|
||||
string='Repeat Penalty',
|
||||
help='Penalty for repeating tokens. Higher values make repetition less likely.',
|
||||
default=1.1
|
||||
)
|
||||
|
||||
max_tokens = fields.Integer(
|
||||
string='Max Tokens',
|
||||
help='Maximum number of tokens to generate.',
|
||||
default=2048
|
||||
)
|
||||
|
||||
stop_sequences = fields.Char(
|
||||
string='Stop Sequences',
|
||||
help='Comma-separated list of sequences where generation should stop.',
|
||||
default=''
|
||||
)
|
||||
|
||||
frequency_penalty = fields.Float(
|
||||
string='Frequency Penalty',
|
||||
help='Penalty for using frequent tokens. Higher values encourage using less frequent tokens.',
|
||||
default=0.0
|
||||
)
|
||||
|
||||
presence_penalty = fields.Float(
|
||||
string='Presence Penalty',
|
||||
help='Penalty for using tokens already in the text. Higher values encourage using new tokens.',
|
||||
default=0.0
|
||||
)
|
||||
80
ai_integration/models/ai_model.py
Normal file
80
ai_integration/models/ai_model.py
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AIModel(models.Model):
|
||||
_name = 'ai.model'
|
||||
_description = 'AI Model'
|
||||
_order = 'sequence, name'
|
||||
_check_company = False # Disable automatic company checks
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
help='Whether this model is active and available for use')
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
help='Name of the AI model'
|
||||
)
|
||||
|
||||
identifier = fields.Char(
|
||||
string='Identifier',
|
||||
required=True,
|
||||
help='Technical identifier of the model (e.g., gpt-3.5-turbo, mistral-7b)'
|
||||
)
|
||||
|
||||
provider_instance_id = fields.Many2one(
|
||||
'ai.provider.instance',
|
||||
string='Provider Instance',
|
||||
required=True,
|
||||
ondelete='cascade',
|
||||
help='Provider instance this model belongs to'
|
||||
)
|
||||
|
||||
provider_type = fields.Selection(
|
||||
related='provider_instance_id.provider_type',
|
||||
string='Provider Type',
|
||||
store=True,
|
||||
readonly=True,
|
||||
help='Type of AI provider'
|
||||
)
|
||||
|
||||
description = fields.Text(
|
||||
string='Description',
|
||||
help='Description of the model and its capabilities'
|
||||
)
|
||||
|
||||
sequence = fields.Integer(
|
||||
string='Sequence',
|
||||
default=10,
|
||||
help='Sequence for ordering models in lists and dropdowns'
|
||||
)
|
||||
|
||||
is_active = fields.Boolean(
|
||||
string='Model Active',
|
||||
default=True,
|
||||
help='Whether this model is currently active and available for use'
|
||||
)
|
||||
|
||||
context_window = fields.Integer(
|
||||
string='Context Window',
|
||||
default=2048,
|
||||
help='Maximum number of tokens in the context window'
|
||||
)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_identifier_provider',
|
||||
'unique(identifier, provider_instance_id)',
|
||||
'The model identifier must be unique per provider instance!')
|
||||
]
|
||||
|
||||
def name_get(self):
|
||||
"""Custom name_get to include provider instance in display name."""
|
||||
result = []
|
||||
for model in self:
|
||||
name = f"{model.name} ({model.provider_instance_id.name})"
|
||||
result.append((model.id, name))
|
||||
return result
|
||||
94
ai_integration/models/ai_model_stats.py
Normal file
94
ai_integration/models/ai_model_stats.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from odoo import models, fields, api
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class AIModelStats(models.Model):
|
||||
_name = 'ai.model.stats'
|
||||
_description = 'AI Model Usage Statistics'
|
||||
_order = 'date desc'
|
||||
|
||||
@api.model
|
||||
def _has_provider_modules(self):
|
||||
"""Check if any AI provider modules are installed."""
|
||||
modules = ['ollama_ai_integration', 'chatgpt_ai_integration']
|
||||
return any(self.env['ir.module.module'].search([('name', 'in', modules), ('state', '=', 'installed')]))
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Override default_get to prevent creation if no provider modules are installed."""
|
||||
if not self._has_provider_modules():
|
||||
raise UserError(_('No AI provider modules are installed. Please install at least one provider module (e.g., Ollama or ChatGPT) before creating model statistics.'))
|
||||
return super().default_get(fields_list)
|
||||
|
||||
model_id = fields.Many2one('ai.model', string='Model', required=True, ondelete='cascade')
|
||||
provider_instance_id = fields.Many2one(
|
||||
related='model_id.provider_instance_id',
|
||||
string='Provider Instance',
|
||||
store=True)
|
||||
|
||||
date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
|
||||
request_count = fields.Integer(string='Number of Requests', default=0)
|
||||
token_count = fields.Integer(string='Total Tokens', default=0)
|
||||
avg_response_time = fields.Float(string='Average Response Time (ms)', digits=(10, 2), default=0)
|
||||
error_count = fields.Integer(string='Number of Errors', default=0)
|
||||
version = fields.Char(string='Model Version', help='Version of the model when stats were recorded')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_model_date', 'unique(model_id, date)', 'Only one stat entry per model per day is allowed.')
|
||||
]
|
||||
|
||||
def _update_stats(self, model, tokens, response_time, error=False, version=None):
|
||||
"""Update statistics for a model."""
|
||||
today = fields.Date.context_today(self)
|
||||
stats = self.search([
|
||||
('model_id', '=', model.id),
|
||||
('date', '=', today)
|
||||
])
|
||||
|
||||
if not stats:
|
||||
stats = self.create({
|
||||
'model_id': model.id,
|
||||
'date': today,
|
||||
'version': version
|
||||
})
|
||||
|
||||
# Update statistics
|
||||
new_count = stats.request_count + 1
|
||||
new_tokens = stats.token_count + tokens
|
||||
new_time = ((stats.avg_response_time * stats.request_count) + response_time) / new_count
|
||||
new_errors = stats.error_count + (1 if error else 0)
|
||||
|
||||
stats.write({
|
||||
'request_count': new_count,
|
||||
'token_count': new_tokens,
|
||||
'avg_response_time': new_time,
|
||||
'error_count': new_errors,
|
||||
'version': version or stats.version # Update version if provided
|
||||
})
|
||||
|
||||
@api.model
|
||||
def get_model_stats(self, model_id, days=30):
|
||||
"""Get statistics for a model over the specified number of days."""
|
||||
start_date = fields.Date.today() - timedelta(days=days)
|
||||
stats = self.search([
|
||||
('model_id', '=', model_id),
|
||||
('date', '>=', start_date)
|
||||
])
|
||||
|
||||
return {
|
||||
'daily_stats': [{
|
||||
'date': stat.date,
|
||||
'requests': stat.request_count,
|
||||
'tokens': stat.token_count,
|
||||
'response_time': stat.avg_response_time,
|
||||
'errors': stat.error_count,
|
||||
'version': stat.version
|
||||
} for stat in stats],
|
||||
'summary': {
|
||||
'total_requests': sum(stat.request_count for stat in stats),
|
||||
'total_tokens': sum(stat.token_count for stat in stats),
|
||||
'avg_response_time': sum(stat.avg_response_time * stat.request_count for stat in stats) /
|
||||
(sum(stat.request_count for stat in stats) if stats else 1),
|
||||
'total_errors': sum(stat.error_count for stat in stats),
|
||||
'versions_used': list(set(stat.version for stat in stats if stat.version))
|
||||
}
|
||||
}
|
||||
73
ai_integration/models/ai_provider.py
Normal file
73
ai_integration/models/ai_provider.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIProvider(models.Model):
|
||||
_name = 'ai.provider'
|
||||
_description = 'AI Provider'
|
||||
_inherit = ['ai.provider.interface']
|
||||
|
||||
name = fields.Char(string='Name', required=True)
|
||||
code = fields.Char(string='Code', required=True)
|
||||
description = fields.Text(string='Description')
|
||||
default_host = fields.Char(string='Default Host')
|
||||
active = fields.Boolean(string='Active', default=True)
|
||||
|
||||
@api.model
|
||||
def send_message(self, message, **kwargs):
|
||||
"""Send a message to the AI provider and get a response.
|
||||
|
||||
Args:
|
||||
message (dict): The message to send
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
str: The response from the AI provider
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by specific providers
|
||||
"""
|
||||
raise NotImplementedError(_("Method send_message must be implemented by specific AI providers"))
|
||||
|
||||
@api.model
|
||||
def get_models(self):
|
||||
"""Get the list of available models from the provider.
|
||||
|
||||
Returns:
|
||||
list: List of model information dictionaries
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by specific providers
|
||||
"""
|
||||
raise NotImplementedError(_("Method get_models must be implemented by specific AI providers"))
|
||||
|
||||
@api.model
|
||||
def test_connection(self):
|
||||
"""Test the connection to the AI provider.
|
||||
|
||||
Returns:
|
||||
bool: True if connection is successful
|
||||
|
||||
Raises:
|
||||
NotImplementedError: Must be implemented by specific providers
|
||||
"""
|
||||
raise NotImplementedError(_("Method test_connection must be implemented by specific AI providers"))
|
||||
|
||||
def _handle_error(self, error, context=''):
|
||||
"""Common error handling for AI provider operations.
|
||||
|
||||
Args:
|
||||
error (Exception): The error that occurred
|
||||
context (str): Additional context about where the error occurred
|
||||
|
||||
Returns:
|
||||
tuple: (success, message)
|
||||
"""
|
||||
error_msg = str(error)
|
||||
log_msg = f"AI Provider Error{' in ' + context if context else ''}: {error_msg}"
|
||||
_logger.error(log_msg)
|
||||
return False, error_msg
|
||||
106
ai_integration/models/ai_provider_instance.py
Normal file
106
ai_integration/models/ai_provider_instance.py
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.addons.mail.models.mail_thread import MailThread
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class BaseAIProviderInstance(models.Model):
|
||||
_name = 'ai.provider.instance'
|
||||
_description = 'AI Provider Instance'
|
||||
_order = 'name'
|
||||
_check_company = False # Disable automatic company checks
|
||||
_inherit = ['mail.thread', 'ai.base.mixin']
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
"""Override default_get to prevent creation if no provider modules are installed."""
|
||||
defaults = super().default_get(fields_list)
|
||||
if defaults.get('provider_type', 'none') == 'none':
|
||||
defaults['provider_type'] = 'ollama'
|
||||
return defaults
|
||||
|
||||
active = fields.Boolean(
|
||||
string='Active',
|
||||
default=True,
|
||||
help='Whether this provider instance is active and available for use')
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
required=True,
|
||||
help='Name of this provider instance (e.g., "OpenWebUI Production", "Ollama Local")'
|
||||
)
|
||||
|
||||
provider_type = fields.Selection(
|
||||
[('none', 'None')], # Base selection, will be extended by provider modules
|
||||
string='Provider Type',
|
||||
required=True,
|
||||
default='none',
|
||||
help='The type of AI provider for this instance',
|
||||
ondelete={'none': lambda r: r.write({'provider_type': 'none'})}
|
||||
)
|
||||
|
||||
host = fields.Char(
|
||||
string='Host',
|
||||
required=True,
|
||||
help='Host address (e.g., "http://localhost:8080" or "https://api.example.com")'
|
||||
)
|
||||
|
||||
api_key = fields.Char(
|
||||
string='API Key',
|
||||
help='API key if required by the provider',
|
||||
invisible="[('provider_type', '=', 'ollama')]" # Hide when provider type is ollama
|
||||
)
|
||||
|
||||
@api.model
|
||||
def get_default_instance(self):
|
||||
"""Get the default AI provider instance to use.
|
||||
|
||||
Returns:
|
||||
ai.provider.instance: The default instance to use, or raises UserError if none found
|
||||
"""
|
||||
instance = self.env['ai.provider.instance'].search([('active', '=', True)], limit=1)
|
||||
if not instance:
|
||||
raise UserError(_('No active AI provider instance found. Please configure one in the settings.'))
|
||||
return instance
|
||||
|
||||
model_ids = fields.One2many(
|
||||
'ai.model',
|
||||
'provider_instance_id',
|
||||
copy=True,
|
||||
string='Available Models'
|
||||
)
|
||||
|
||||
timeout = fields.Integer(
|
||||
string='Timeout',
|
||||
default=60,
|
||||
help='Maximum wait time for API calls (in seconds)'
|
||||
)
|
||||
|
||||
max_retries = fields.Integer(
|
||||
string='Max Retries',
|
||||
default=3,
|
||||
help='Maximum number of retry attempts for failed API calls'
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _valid_field_parameter(self, field, name):
|
||||
return name == 'invisible' or super()._valid_field_parameter(field, name)
|
||||
|
||||
_sql_constraints = [
|
||||
('name_uniq',
|
||||
'unique(name)',
|
||||
'Provider instance name must be unique!')
|
||||
]
|
||||
|
||||
def test_connection(self):
|
||||
"""Test the connection to this provider instance."""
|
||||
self.ensure_one()
|
||||
if self.provider_type == 'none':
|
||||
raise UserError(_('Please select a provider type'))
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
|
||||
def sync_models(self):
|
||||
"""Synchronize models from this provider instance."""
|
||||
self.ensure_one()
|
||||
if self.provider_type == 'none':
|
||||
raise UserError(_('Please select a provider type'))
|
||||
55
ai_integration/models/ai_provider_interface.py
Normal file
55
ai_integration/models/ai_provider_interface.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIProviderInterface(models.AbstractModel):
|
||||
_name = 'ai.provider.interface'
|
||||
_description = 'AI Provider Interface'
|
||||
|
||||
@api.model
|
||||
def send_message(self, message, **kwargs):
|
||||
"""Send a message to the AI provider and get a response.
|
||||
|
||||
Args:
|
||||
message (dict): The message to send
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
dict: The response from the AI provider
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _get_provider_type(self):
|
||||
"""Get the provider type code.
|
||||
|
||||
Returns:
|
||||
str: The provider type code
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _get_model_info(self, instance, model_name):
|
||||
"""Get detailed information about a specific model.
|
||||
|
||||
Args:
|
||||
instance (ai.provider.instance): The provider instance
|
||||
model_name (str): Name of the model
|
||||
|
||||
Returns:
|
||||
dict: Model information
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def _list_models(self, instance):
|
||||
"""List available models for this provider instance.
|
||||
|
||||
Args:
|
||||
instance (ai.provider.instance): The provider instance
|
||||
|
||||
Returns:
|
||||
list: List of available models
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
90
ai_integration/models/doc_models.md
Normal file
90
ai_integration/models/doc_models.md
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
# Documentation des Modèles AI Integration
|
||||
|
||||
## Vue d'ensemble
|
||||
|
||||
Le module AI Integration fournit une infrastructure flexible pour intégrer différents fournisseurs d'IA dans Odoo. Il est conçu pour être extensible et permettre l'ajout facile de nouveaux fournisseurs.
|
||||
|
||||
## Modèles Principaux
|
||||
|
||||
### 1. AI Provider (`ai.provider`)
|
||||
- **Description**: Configuration de base des fournisseurs d'IA
|
||||
- **Champs principaux**:
|
||||
- `name`: Nom du fournisseur
|
||||
- `code`: Code technique unique
|
||||
- `description`: Description détaillée
|
||||
- `default_host`: Hôte par défaut
|
||||
- `active`: État actif/inactif
|
||||
|
||||
### 2. AI Provider Instance (`ai.provider.instance`)
|
||||
- **Description**: Instance spécifique d'un fournisseur d'IA
|
||||
- **Héritage**: `mail.thread`, `ai.base.mixin`
|
||||
- **Champs principaux**:
|
||||
- `name`: Nom de l'instance (ex: "OpenWebUI Production", "Ollama Local")
|
||||
- `provider_id`: Fournisseur associé
|
||||
- `provider_type`: Type de fournisseur (extensible par modules)
|
||||
- `active`: État actif/inactif
|
||||
- **Validation**:
|
||||
- Vérifie la présence d'au moins un module fournisseur installé
|
||||
|
||||
### 3. AI Model (`ai.model`)
|
||||
- **Description**: Modèles d'IA disponibles
|
||||
- **Champs principaux**:
|
||||
- `name`: Nom du modèle
|
||||
- `identifier`: Identifiant technique (ex: gpt-3.5-turbo, mistral-7b)
|
||||
- `provider_instance_id`: Instance du fournisseur (cascade)
|
||||
- `provider_type`: Type de fournisseur (relié à l'instance)
|
||||
- `active`: État actif/inactif
|
||||
- `sequence`: Ordre d'affichage
|
||||
- **Validation**:
|
||||
- Vérifie la présence d'au moins un module fournisseur installé
|
||||
|
||||
### 4. AI Generation Parameters (`ai.generation.params`)
|
||||
- **Description**: Paramètres de génération pour les modèles d'IA
|
||||
- **Type**: Modèle abstrait
|
||||
- **Champs principaux**:
|
||||
- `temperature`: Contrôle de l'aléatoire (défaut: 0.7)
|
||||
- `repeat_penalty`: Pénalité de répétition (défaut: 1.1)
|
||||
- `max_tokens`: Nombre maximum de tokens (défaut: 2048)
|
||||
- `stop_sequences`: Séquences d'arrêt
|
||||
- `frequency_penalty`: Pénalité de fréquence (défaut: 0.0)
|
||||
- `presence_penalty`: Pénalité de présence (défaut: 0.0)
|
||||
|
||||
## Configuration et Interfaces
|
||||
|
||||
### 1. Res Config Settings
|
||||
- **Description**: Paramètres de configuration globaux
|
||||
- **Champs principaux**:
|
||||
- `default_provider_instance_id`: Instance de fournisseur par défaut
|
||||
- `default_model_id`: Modèle par défaut
|
||||
- `ai_batch_size`: Taille du lot pour le traitement
|
||||
|
||||
### 2. Res Company
|
||||
- **Description**: Extensions des paramètres de société
|
||||
- **Méthodes principales**:
|
||||
- `_get_default_provider_instance`: Obtenir l'instance par défaut
|
||||
|
||||
### 3. AI Provider Interface (`ai.provider.interface`)
|
||||
- **Description**: Interface abstraite pour les fournisseurs d'IA
|
||||
- **Méthodes requises**:
|
||||
- `send_message`: Envoyer un message
|
||||
- `get_models`: Obtenir la liste des modèles
|
||||
- `test_connection`: Tester la connexion
|
||||
|
||||
## Notes d'Implémentation
|
||||
|
||||
1. **Architecture Modulaire**:
|
||||
- Modules fournisseurs disponibles: `ollama_ai_integration`, `chatgpt_ai_integration`
|
||||
- Vérification de la présence d'au moins un module fournisseur avant création d'instances
|
||||
|
||||
2. **Héritage et Extensions**:
|
||||
- Les instances de fournisseur héritent de `mail.thread` et `ai.base.mixin`
|
||||
- Les paramètres de génération sont définis dans le modèle abstrait `ai.generation.params`
|
||||
|
||||
3. **Configuration Hiérarchique**:
|
||||
- Configuration globale > Paramètres société > Instance
|
||||
- Paramètres de génération personnalisables à plusieurs niveaux
|
||||
|
||||
4. **Sécurité et Validation**:
|
||||
- Vérifications de sécurité intégrées
|
||||
- Validation des modules requis
|
||||
- Gestion des paramètres de génération avec valeurs par défaut
|
||||
1
ai_integration/models/mixins/__init__.py
Normal file
1
ai_integration/models/mixins/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import ai_base_mixin
|
||||
167
ai_integration/models/mixins/ai_base_mixin.py
Normal file
167
ai_integration/models/mixins/ai_base_mixin.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from typing import List, Dict, Any, Optional
|
||||
from odoo import models, api, fields, _
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class AIBaseMixin(models.AbstractModel):
|
||||
"""Base mixin for AI integration providing both provider interaction and generation parameters.
|
||||
|
||||
This mixin combines the functionality of message handling and generation parameters
|
||||
into a single, cohesive interface for AI integration.
|
||||
"""
|
||||
_name = 'ai.base.mixin'
|
||||
_description = 'AI Integration Base Mixin'
|
||||
|
||||
# Basic Generation Parameters
|
||||
temperature = fields.Float(
|
||||
string='Temperature',
|
||||
help='Sampling temperature. Range: [0.0 - 2.0]. Higher values make output more random, '
|
||||
'lower values more deterministic.',
|
||||
default=0.7,
|
||||
digits=(3, 2))
|
||||
|
||||
top_p = fields.Float(
|
||||
string='Top P',
|
||||
help='Nucleus sampling: limits cumulative probability of tokens to sample from. '
|
||||
'Range: [0.0 - 1.0].',
|
||||
default=0.9,
|
||||
digits=(3, 2))
|
||||
|
||||
max_tokens = fields.Integer(
|
||||
string='Max Tokens',
|
||||
help='Maximum number of tokens to generate. Range: [1 - 32768].',
|
||||
default=2048)
|
||||
|
||||
stop_sequences = fields.Char(
|
||||
string='Stop Sequences',
|
||||
help='Comma-separated list of sequences where the model should stop generating')
|
||||
|
||||
# System Settings
|
||||
timeout = fields.Integer(
|
||||
string='Timeout',
|
||||
help='Request timeout in seconds. Range: [1 - 300].',
|
||||
default=30)
|
||||
|
||||
retry_count = fields.Integer(
|
||||
string='Retry Count',
|
||||
help='Number of times to retry failed requests. Range: [0 - 5].',
|
||||
default=3)
|
||||
|
||||
stream_response = fields.Boolean(
|
||||
string='Stream Response',
|
||||
help='Enable response streaming for real-time output.',
|
||||
default=False)
|
||||
|
||||
def _get_base_generation_params(self):
|
||||
"""Get common generation parameters as a dictionary.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing all generation parameters
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'temperature': self.temperature,
|
||||
'top_p': self.top_p,
|
||||
'max_tokens': self.max_tokens,
|
||||
'stop_sequences': self.stop_sequences.split(',') if self.stop_sequences else None,
|
||||
'timeout': self.timeout,
|
||||
'retry_count': self.retry_count,
|
||||
'stream_response': self.stream_response,
|
||||
}
|
||||
|
||||
def _get_ai_provider_instance(self, provider_instance_id=None):
|
||||
"""Get the AI provider instance to use.
|
||||
|
||||
Args:
|
||||
provider_instance_id: Optional specific provider instance to use
|
||||
|
||||
Returns:
|
||||
ai.provider.instance: The provider instance to use
|
||||
|
||||
Raises:
|
||||
UserError: If no provider instance is configured or available
|
||||
"""
|
||||
if provider_instance_id:
|
||||
instance = self.env['ai.provider.instance'].browse(provider_instance_id)
|
||||
if not instance.exists():
|
||||
raise UserError(_("Invalid provider instance"))
|
||||
else:
|
||||
provider_id = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'ai_integration.default_provider_instance_id')
|
||||
if not provider_id:
|
||||
raise UserError(_("No default AI provider instance configured"))
|
||||
instance = self.env['ai.provider.instance'].browse(int(provider_id))
|
||||
if not instance.exists():
|
||||
raise UserError(_("Default provider instance not found"))
|
||||
|
||||
if not instance.is_active:
|
||||
raise UserError(_("The selected AI provider instance is not active"))
|
||||
|
||||
return instance
|
||||
|
||||
def _get_ai_model(self, model_id=None, provider_instance=None):
|
||||
"""Get the AI model to use.
|
||||
|
||||
Args:
|
||||
model_id: Optional specific model to use
|
||||
provider_instance: Optional provider instance (to avoid duplicate lookup)
|
||||
|
||||
Returns:
|
||||
ai.model: The model to use
|
||||
|
||||
Raises:
|
||||
UserError: If no model is configured or available
|
||||
"""
|
||||
if not provider_instance:
|
||||
provider_instance = self._get_ai_provider_instance()
|
||||
|
||||
if model_id:
|
||||
model = self.env['ai.model'].browse(model_id)
|
||||
if not model.exists():
|
||||
raise UserError(_("Invalid model"))
|
||||
if model.provider_instance_id != provider_instance:
|
||||
raise UserError(_("Model does not belong to the selected provider instance"))
|
||||
else:
|
||||
model_id = self.env['ir.config_parameter'].sudo().get_param(
|
||||
'ai_integration.default_model_id')
|
||||
if not model_id:
|
||||
raise UserError(_("No default AI model configured"))
|
||||
model = self.env['ai.model'].browse(int(model_id))
|
||||
if not model.exists():
|
||||
raise UserError(_("Default model not found"))
|
||||
|
||||
if not model.is_active:
|
||||
raise UserError(_("The selected AI model is not active"))
|
||||
|
||||
return model
|
||||
|
||||
def send_ai_message(self, message: Dict[str, Any], provider_instance_id: Optional[int] = None,
|
||||
model_id: Optional[int] = None, **kwargs):
|
||||
"""Send a message to an AI provider instance.
|
||||
|
||||
Args:
|
||||
message: The message to send
|
||||
provider_instance_id: Optional specific provider instance to use
|
||||
model_id: Optional specific model to use
|
||||
**kwargs: Additional provider-specific parameters
|
||||
|
||||
Returns:
|
||||
str: The response from the AI provider
|
||||
|
||||
Raises:
|
||||
UserError: If there's an error with the AI provider
|
||||
"""
|
||||
provider_instance = self._get_ai_provider_instance(provider_instance_id)
|
||||
model = self._get_ai_model(model_id, provider_instance)
|
||||
|
||||
# Merge generation parameters with provider-specific parameters
|
||||
params = {**self._get_base_generation_params(), **kwargs}
|
||||
|
||||
try:
|
||||
return provider_instance.send_message(message, model=model, **params)
|
||||
except Exception as e:
|
||||
_logger.error("Error sending message to AI provider: %s", str(e))
|
||||
raise UserError(_("Failed to send message to AI provider: %s") % str(e))
|
||||
21
ai_integration/models/res_company.py
Normal file
21
ai_integration/models/res_company.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_inherit = 'res.company'
|
||||
|
||||
def _get_default_provider_instance(self):
|
||||
"""Get the default AI provider instance from config parameters"""
|
||||
provider_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_provider_instance_id')
|
||||
return self.env['ai.provider.instance'].browse(int(provider_id)) if provider_id else False
|
||||
|
||||
def _get_default_model(self):
|
||||
"""Get the default AI model from config parameters"""
|
||||
model_id = self.env['ir.config_parameter'].sudo().get_param('ai_integration.default_model_id')
|
||||
return self.env['ai.model'].browse(int(model_id)) if model_id else False
|
||||
|
||||
def _get_ai_batch_size(self):
|
||||
"""Get the AI batch size from config parameters"""
|
||||
return int(self.env['ir.config_parameter'].sudo().get_param('ai_integration.ai_batch_size', '100'))
|
||||
22
ai_integration/models/res_config_settings.py
Normal file
22
ai_integration/models/res_config_settings.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
from odoo import api, fields, models
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
default_provider_instance_id = fields.Many2one(
|
||||
'ai.provider.instance',
|
||||
string='Default AI Provider Instance',
|
||||
config_parameter='ai_integration.default_provider_instance_id',
|
||||
default_model='ai.provider.instance')
|
||||
|
||||
default_model_id = fields.Many2one(
|
||||
'ai.model',
|
||||
string='Default AI Model',
|
||||
config_parameter='ai_integration.default_model_id',
|
||||
default_model='ai.model')
|
||||
|
||||
ai_batch_size = fields.Integer(
|
||||
string='AI Batch Processing Size',
|
||||
config_parameter='ai_integration.ai_batch_size',
|
||||
default=100,
|
||||
default_model='ai.provider.instance')
|
||||
20
ai_integration/security/ai_security.xml
Normal file
20
ai_integration/security/ai_security.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<!-- AI User Group -->
|
||||
<record id="group_ai_user" model="res.groups">
|
||||
<field name="name">AI User</field>
|
||||
<field name="category_id" ref="base.module_category_services"/>
|
||||
<field name="comment">Users can use AI services but cannot configure them.</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Manager Group -->
|
||||
<record id="group_ai_manager" model="res.groups">
|
||||
<field name="name">AI Manager</field>
|
||||
<field name="category_id" ref="base.module_category_services"/>
|
||||
<field name="implied_ids" eval="[(4, ref('group_ai_user'))]"/>
|
||||
<field name="comment">Full access to AI configuration and usage.</field>
|
||||
<field name="users" eval="[(4, ref('base.user_admin'))]"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
11
ai_integration/security/ir.model.access.csv
Normal file
11
ai_integration/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_ai_model_user,ai.model.user,model_ai_model,base.group_user,1,0,0,0
|
||||
access_ai_model_system,ai.model.system,model_ai_model,base.group_system,1,1,1,1
|
||||
access_ai_provider_instance_user,ai.provider.instance.user,model_ai_provider_instance,base.group_user,1,0,0,0
|
||||
access_ai_provider_instance_system,ai.provider.instance.system,model_ai_provider_instance,base.group_system,1,1,1,1
|
||||
access_ai_model_stats_user,ai.model.stats.user,model_ai_model_stats,base.group_user,1,0,0,0
|
||||
access_ai_model_stats_system,ai.model.stats.system,model_ai_model_stats,base.group_system,1,1,1,1
|
||||
access_ai_generation_params_user,ai.generation.params.user,model_ai_generation_params,base.group_user,1,0,0,0
|
||||
access_ai_generation_params_system,ai.generation.params.system,model_ai_generation_params,base.group_system,1,1,1,1
|
||||
access_ai_provider_user,ai.provider.user,model_ai_provider,base.group_user,1,0,0,0
|
||||
access_ai_provider_system,ai.provider.system,model_ai_provider,base.group_system,1,1,1,1
|
||||
|
28
ai_integration/security/ir_rule.xml
Normal file
28
ai_integration/security/ir_rule.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<!-- Global Provider Instance Rule -->
|
||||
<record id="ai_provider_instance_global_rule" model="ir.rule">
|
||||
<field name="name">AI Provider Instance: Global Access</field>
|
||||
<field name="model_id" ref="model_ai_provider_instance"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Global Model Rule -->
|
||||
<record id="ai_model_global_rule" model="ir.rule">
|
||||
<field name="name">AI Model: Global Access</field>
|
||||
<field name="model_id" ref="model_ai_model"/>
|
||||
<field name="domain_force">[(1, '=', 1)]</field>
|
||||
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
|
||||
<field name="perm_read" eval="True"/>
|
||||
<field name="perm_write" eval="True"/>
|
||||
<field name="perm_create" eval="True"/>
|
||||
<field name="perm_unlink" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
118
ai_integration/views/ai_model_stats_views.xml
Normal file
118
ai_integration/views/ai_model_stats_views.xml
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="view_ai_model_stats_list" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.list</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Model Statistics" create="false">
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="version"/>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_ai_model_stats_form" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.form</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Model Statistics">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="version"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_ai_model_stats_search" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.search</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Model Statistics">
|
||||
<field name="model_id"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="version"/>
|
||||
<field name="date"/>
|
||||
<filter string="Today" name="today" domain="[('date','=',context_today().strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 7 Days" name="last_week" domain="[('date','>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 30 Days" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Model" name="group_by_model" context="{'group_by': 'model_id'}"/>
|
||||
<filter string="Provider Instance" name="group_by_provider" context="{'group_by': 'provider_instance_id'}"/>
|
||||
<filter string="Version" name="group_by_version" context="{'group_by': 'version'}"/>
|
||||
<filter string="Date" name="group_by_date" context="{'group_by': 'date'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Graph View -->
|
||||
<record id="view_ai_model_stats_graph" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.graph</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Model Statistics" type="line" sample="1">
|
||||
<field name="date" type="row"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Pivot View -->
|
||||
<record id="view_ai_model_stats_pivot" model="ir.ui.view">
|
||||
<field name="name">ai.model.stats.pivot</field>
|
||||
<field name="model">ai.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Model Statistics" sample="1">
|
||||
<field name="model_id" type="row"/>
|
||||
<field name="provider_instance_id" type="row"/>
|
||||
<field name="date" type="col"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="avg_response_time" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_ai_model_stats" model="ir.actions.act_window">
|
||||
<field name="name">Model Statistics</field>
|
||||
<field name="res_model">ai.model.stats</field>
|
||||
<field name="view_mode">list,form,graph,pivot</field>
|
||||
<field name="context">{'search_default_last_month': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No statistics recorded yet
|
||||
</p>
|
||||
<p>
|
||||
Statistics will be automatically recorded as you use your AI models.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
96
ai_integration/views/ai_model_views.xml
Normal file
96
ai_integration/views/ai_model_views.xml
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="ai_model_view_list" model="ir.ui.view">
|
||||
<field name="name">ai.model.list</field>
|
||||
<field name="model">ai.model</field>
|
||||
<field name="type">list</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Models" create="false">
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="identifier"/>
|
||||
<field name="context_window"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="ai_model_view_form" model="ir.ui.view">
|
||||
<field name="name">ai.model.form</field>
|
||||
<field name="model">ai.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Model">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. GPT-3.5 Turbo"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="provider_instance_id" options="{'no_create': True}"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="identifier"/>
|
||||
<field name="context_window"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sequence"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Description" name="description">
|
||||
<field name="description" placeholder="Enter a description of the model and its capabilities..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="ai_model_view_search" model="ir.ui.view">
|
||||
<field name="name">ai.model.search</field>
|
||||
<field name="model">ai.model</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Models">
|
||||
<field name="name"/>
|
||||
<field name="identifier"/>
|
||||
<field name="provider_instance_id"/>
|
||||
<field name="provider_type"/>
|
||||
<filter string="Archived" name="inactive" domain="[('is_active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Provider Instance" name="group_by_provider" context="{'group_by': 'provider_instance_id'}"/>
|
||||
<filter string="Provider Type" name="group_by_type" context="{'group_by': 'provider_type'}"/>
|
||||
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="ai_model_action" model="ir.actions.act_window">
|
||||
<field name="name">AI Models</field>
|
||||
<field name="res_model">ai.model</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="ai_model_view_search"/>
|
||||
<field name="context">{'search_default_group_by_provider': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No AI models found
|
||||
</p>
|
||||
<p>
|
||||
AI models will be automatically synchronized when you configure and sync a provider instance.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
107
ai_integration/views/ai_provider_instance_views.xml
Normal file
107
ai_integration/views/ai_provider_instance_views.xml
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="ai_provider_instance_view_list" model="ir.ui.view">
|
||||
<field name="name">ai.provider.instance.list</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<list name="ai_instance" string="AI Provider Instances" create="true">
|
||||
<field name="name"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="ai_provider_instance_view_form" model="ir.ui.view">
|
||||
<field name="name">ai.provider.instance.form</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Provider Instance">
|
||||
<header>
|
||||
<button name="test_connection"
|
||||
string="Test Connection"
|
||||
type="object"
|
||||
class="oe_highlight"/>
|
||||
<button name="sync_models"
|
||||
string="Sync Models"
|
||||
type="object"
|
||||
class="btn-primary"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. OpenWebUI Production"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<field name="api_key"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="timeout"/>
|
||||
<field name="max_retries"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Models" name="models">
|
||||
<field name="model_ids" nolabel="1">
|
||||
<list>
|
||||
<field name="sequence" widget="handle"/>
|
||||
<field name="name"/>
|
||||
<field name="identifier"/>
|
||||
<field name="context_window"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="ai_provider_instance_view_search" model="ir.ui.view">
|
||||
<field name="name">ai.provider.instance.search</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Provider Instances">
|
||||
<field name="name"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Provider Type" name="group_by_type" context="{'group_by': 'provider_type'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="ai_provider_instance_action" model="ir.actions.act_window">
|
||||
<field name="name">AI Provider Instances</field>
|
||||
<field name="res_model">ai.provider.instance</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="search_view_id" ref="ai_provider_instance_view_search"/>
|
||||
<field name="context">{'company_id': False}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first AI provider instance
|
||||
</p>
|
||||
<p>
|
||||
Configure AI provider instances to connect to different AI services.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
88
ai_integration/views/ai_provider_views.xml
Normal file
88
ai_integration/views/ai_provider_views.xml
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Tree View -->
|
||||
<record id="ai_provider_view_tree" model="ir.ui.view">
|
||||
<field name="name">ai.provider.tree</field>
|
||||
<field name="model">ai.provider</field>
|
||||
<field name="type">list</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="AI Providers" create="false">
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<field name="default_host"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="ai_provider_view_form" model="ir.ui.view">
|
||||
<field name="name">ai.provider.form</field>
|
||||
<field name="model">ai.provider</field>
|
||||
<field name="type">form</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Provider">
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="test_connection" type="object"
|
||||
string="Test Connection" class="oe_stat_button"
|
||||
icon="fa-plug"/>
|
||||
<button name="get_models" type="object"
|
||||
string="Get Models" class="oe_stat_button"
|
||||
icon="fa-list"/>
|
||||
</div>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. Ollama"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="code"/>
|
||||
<field name="default_host"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Description" name="description">
|
||||
<field name="description" nolabel="1" placeholder="Enter a description..."/>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="ai_provider_view_search" model="ir.ui.view">
|
||||
<field name="name">ai.provider.search</field>
|
||||
<field name="model">ai.provider</field>
|
||||
<field name="type">search</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Providers">
|
||||
<field name="name"/>
|
||||
<field name="code"/>
|
||||
<filter string="Active" name="active" domain="[('active', '=', True)]"/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="ai_provider_action" model="ir.actions.act_window">
|
||||
<field name="name">AI Providers</field>
|
||||
<field name="res_model">ai.provider</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No AI providers found
|
||||
</p>
|
||||
<p>
|
||||
AI providers are automatically created when you install AI integration modules.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
51
ai_integration/views/menu.xml
Normal file
51
ai_integration/views/menu.xml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Top level menu -->
|
||||
<menuitem id="menu_ai_root"
|
||||
name="AI Integration"
|
||||
web_icon="ai_integration,static/description/icon.png"
|
||||
sequence="50"/>
|
||||
|
||||
<!-- Configuration menu -->
|
||||
<menuitem id="menu_ai_config"
|
||||
name="Configuration"
|
||||
parent="menu_ai_root"
|
||||
sequence="10"/>
|
||||
|
||||
<!-- Settings menu -->
|
||||
<menuitem id="menu_ai_settings"
|
||||
name="Settings"
|
||||
parent="menu_ai_config"
|
||||
action="ai_integration.action_ai_integration_configuration"
|
||||
sequence="10"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<!-- Provider Types menu -->
|
||||
<menuitem id="menu_ai_provider_types"
|
||||
name="Provider Types"
|
||||
parent="menu_ai_config"
|
||||
action="ai_provider_action"
|
||||
sequence="10"
|
||||
groups="base.group_system"/>
|
||||
|
||||
<!-- Provider Instances menu -->
|
||||
<menuitem id="menu_ai_provider_instances"
|
||||
name="Provider Instances"
|
||||
parent="menu_ai_config"
|
||||
action="ai_provider_instance_action"
|
||||
sequence="20"/>
|
||||
|
||||
<!-- AI Models menu -->
|
||||
<menuitem id="menu_ai_models"
|
||||
name="AI Models"
|
||||
parent="menu_ai_config"
|
||||
action="ai_model_action"
|
||||
sequence="30"/>
|
||||
|
||||
<!-- Statistics menu -->
|
||||
<menuitem id="menu_ai_stats"
|
||||
name="Model Statistics"
|
||||
parent="menu_ai_config"
|
||||
action="action_ai_model_stats"
|
||||
sequence="40"/>
|
||||
</odoo>
|
||||
14
ai_integration/views/res_company_views.xml
Normal file
14
ai_integration/views/res_company_views.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Add AI Integration to Settings Menu -->
|
||||
<record id="action_ai_integration_configuration" model="ir.actions.act_window">
|
||||
<field name="name">AI Integration</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">res.config.settings</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">inline</field>
|
||||
<field name="context">{'module': 'ai_integration'}</field>
|
||||
</record>
|
||||
|
||||
|
||||
</odoo>
|
||||
66
ai_integration/views/res_config_settings_views.xml
Normal file
66
ai_integration/views/res_config_settings_views.xml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Settings Action -->
|
||||
<record id="action_ai_integration_configuration" model="ir.actions.act_window">
|
||||
<field name="name">AI Integration Settings</field>
|
||||
<field name="type">ir.actions.act_window</field>
|
||||
<field name="res_model">res.config.settings</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">inline</field>
|
||||
<field name="context">{"module" : "ai_integration"}</field>
|
||||
</record>
|
||||
|
||||
<record id="res_config_settings_view_form" model="ir.ui.view">
|
||||
<field name="name">res.config.settings.view.form.inherit.ai.integration</field>
|
||||
<field name="model">res.config.settings</field>
|
||||
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form" position="inside">
|
||||
<div class="app_settings_block" data-string="AI Integration" string="AI Integration" data-key="ai_integration">
|
||||
<h2>AI Integration</h2>
|
||||
<div class="row mt16 o_settings_container" name="ai_integration_setting_container">
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="default_provider_settings">
|
||||
<div class="o_setting_left_pane"/>
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Default Provider Configuration</span>
|
||||
<div class="text-muted">
|
||||
Configure the default AI provider instance and model for this company
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="mt16 row">
|
||||
<label for="default_provider_instance_id" class="col-lg-3 o_light_label"/>
|
||||
<field name="default_provider_instance_id" options="{'no_create': True}"/>
|
||||
</div>
|
||||
<div class="mt16 row">
|
||||
<label for="default_model_id" class="col-lg-3 o_light_label"/>
|
||||
<field name="default_model_id"
|
||||
options="{'no_create': True}"
|
||||
invisible="default_provider_instance_id == False"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6 o_setting_box" id="batch_processing_settings">
|
||||
<div class="o_setting_left_pane"/>
|
||||
<div class="o_setting_right_pane">
|
||||
<span class="o_form_label">Batch Processing</span>
|
||||
<div class="text-muted">
|
||||
Configure batch processing parameters for AI operations
|
||||
</div>
|
||||
<div class="content-group">
|
||||
<div class="mt16 row">
|
||||
<label for="ai_batch_size" class="col-lg-3 o_light_label"/>
|
||||
<field name="ai_batch_size"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
36
ai_integration_ollama_api/__manifest__.py
Normal file
36
ai_integration_ollama_api/__manifest__.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
'name': 'Ollama Integration',
|
||||
'version': '1.0.0',
|
||||
'category': 'Technical',
|
||||
'summary': 'Integration with Ollama AI models',
|
||||
'description': """
|
||||
Ollama Integration
|
||||
==================
|
||||
This module provides integration with Ollama, allowing you to use local AI models
|
||||
in your Odoo instance. Features include:
|
||||
|
||||
* Connection to local Ollama server
|
||||
* Support for all Ollama models
|
||||
* Automatic model discovery and synchronization
|
||||
* Configurable model parameters
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://www.bemade.org',
|
||||
'depends': [
|
||||
'ai_integration'
|
||||
],
|
||||
'data': [
|
||||
'data/ai_provider_data.xml',
|
||||
'data/ai_provider_instance_data.xml',
|
||||
'views/ollama_stats_views.xml',
|
||||
'views/ai_provider_instance_views.xml',
|
||||
'security/ir.model.access.csv',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['requests'],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
13
ai_integration_ollama_api/data/ai_provider_data.xml
Normal file
13
ai_integration_ollama_api/data/ai_provider_data.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Ollama Provider -->
|
||||
<record id="ai_provider_ollama" model="ai.provider">
|
||||
<field name="name">Ollama</field>
|
||||
<field name="code">ollama</field>
|
||||
<field name="description">Ollama is a local AI model provider that allows you to run various open-source models locally.</field>
|
||||
<field name="default_host">http://localhost:11434</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
31
ai_integration_ollama_api/data/ai_provider_instance_data.xml
Normal file
31
ai_integration_ollama_api/data/ai_provider_instance_data.xml
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Default Ollama Provider Instance -->
|
||||
<record id="ai_provider_instance_ollama_default" model="ai.provider.instance">
|
||||
<field name="name">Ollama Local</field>
|
||||
<field name="provider_type">ollama</field>
|
||||
<field name="host">http://localhost:11434</field>
|
||||
<field name="model_name">llama3.2</field>
|
||||
<field name="temperature">0.7</field>
|
||||
<field name="top_k">40</field>
|
||||
<field name="top_p">0.9</field>
|
||||
<field name="repeat_penalty">1.1</field>
|
||||
<field name="num_ctx">4096</field>
|
||||
<field name="num_predict">1024</field>
|
||||
<field name="min_p">0.05</field>
|
||||
<field name="repeat_last_n">64</field>
|
||||
<field name="seed">0</field>
|
||||
<field name="num_gpu">1</field>
|
||||
<field name="num_thread">8</field>
|
||||
<field name="mirostat">0</field>
|
||||
<field name="mirostat_tau">5.0</field>
|
||||
<field name="mirostat_eta">0.1</field>
|
||||
<field name="num_batch">8</field>
|
||||
<field name="num_keep">0</field>
|
||||
<field name="tfs_z">1.0</field>
|
||||
<field name="skip_special_tokens" eval="True"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
10
ai_integration_ollama_api/data/ir_model_inherit.xml
Normal file
10
ai_integration_ollama_api/data/ir_model_inherit.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Declare model inheritance -->
|
||||
<record id="ollama_provider_inherit" model="ir.model.inherit">
|
||||
<field name="model">ai.provider.ollama</field>
|
||||
<field name="parent_id" ref="ai_integration.model_ai_provider_interface"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
13
ai_integration_ollama_api/data/ollama_provider.xml
Normal file
13
ai_integration_ollama_api/data/ollama_provider.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Register Ollama Provider -->
|
||||
<record id="ai_provider_ollama" model="ai.provider">
|
||||
<field name="name">Ollama</field>
|
||||
<field name="code">ollama</field>
|
||||
<field name="description">Local AI models powered by Ollama</field>
|
||||
<field name="default_host">http://localhost:11434</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
1627
ai_integration_ollama_api/doc/ollama_api.md
Normal file
1627
ai_integration_ollama_api/doc/ollama_api.md
Normal file
File diff suppressed because it is too large
Load diff
17
ai_integration_ollama_api/migrations/1.0.0/post-migration.py
Normal file
17
ai_integration_ollama_api/migrations/1.0.0/post-migration.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
def migrate(cr, version):
|
||||
# Add num_predict column if it doesn't exist
|
||||
cr.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name='ai_provider_instance'
|
||||
AND column_name='num_predict'
|
||||
) THEN
|
||||
ALTER TABLE ai_provider_instance
|
||||
ADD COLUMN num_predict integer DEFAULT 1024;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
""")
|
||||
27
ai_integration_ollama_api/models/__init__.py
Normal file
27
ai_integration_ollama_api/models/__init__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
"""Ollama AI Integration Models Package.
|
||||
|
||||
This package contains all the model definitions required for integrating
|
||||
Ollama AI with Odoo's AI framework. The models are loaded in a specific
|
||||
order to handle dependencies correctly.
|
||||
|
||||
Module Structure:
|
||||
1. ollama_provider_mixin - Base configuration and parameter definitions
|
||||
2. ollama_provider - Core Ollama API integration implementation
|
||||
3. ollama_model_stats - Usage statistics and performance tracking
|
||||
4. ai_provider_instance - Instance-specific configuration and management
|
||||
|
||||
Note: The import order is important to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
# Base Configuration
|
||||
from . import ollama_provider_mixin
|
||||
|
||||
# Core Implementation
|
||||
from . import ollama_provider
|
||||
from . import ai_provider_instance
|
||||
|
||||
# Statistics and Monitoring
|
||||
from . import ollama_model_stats
|
||||
|
||||
# Instance Management
|
||||
from . import ai_provider_instance
|
||||
153
ai_integration_ollama_api/models/ai_provider_instance.py
Normal file
153
ai_integration_ollama_api/models/ai_provider_instance.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import requests
|
||||
|
||||
class OllamaAIProviderInstance(models.Model):
|
||||
"""Extends the AI Provider Instance model to support Ollama-specific configuration.
|
||||
|
||||
This model inherits from both ai.provider.instance and ollama.provider.mixin to:
|
||||
1. Add Ollama-specific fields (num_ctx, temperature, etc.)
|
||||
2. Handle field visibility based on provider_type
|
||||
3. Manage field cleanup when switching providers
|
||||
"""
|
||||
_inherit = ['ai.provider.instance', 'ollama.provider.mixin']
|
||||
_name = 'ai.provider.instance'
|
||||
_description = 'Ollama AI Provider Instance'
|
||||
|
||||
# Override provider_type to add Ollama option
|
||||
provider_type = fields.Selection(
|
||||
selection_add=[('ollama', 'Ollama')],
|
||||
ondelete={'ollama': lambda r: r.write({'provider_type': 'none'})}
|
||||
)
|
||||
|
||||
@api.onchange('provider_type')
|
||||
def _onchange_provider_type(self):
|
||||
"""Handle provider type changes.
|
||||
|
||||
When switching to 'ollama':
|
||||
- Set default host if empty
|
||||
|
||||
When switching away from 'ollama':
|
||||
- Clear Ollama-specific fields
|
||||
"""
|
||||
if self.provider_type == 'ollama':
|
||||
if not self.host:
|
||||
self.host = 'http://localhost:11434'
|
||||
else:
|
||||
# Clear Ollama-specific fields
|
||||
self.update({
|
||||
'num_ctx': False, # Context length
|
||||
'temperature': False, # Sampling temperature
|
||||
'top_p': False, # Nucleus sampling threshold
|
||||
'top_k': False, # Top-k sampling threshold
|
||||
'repeat_penalty': False, # Penalty for repeated tokens
|
||||
'repeat_last_n': False, # Number of tokens to consider for repeat penalty
|
||||
'num_thread': False, # Number of CPU threads to use
|
||||
'num_gpu': False, # Number of GPUs to use
|
||||
'num_batch': False, # Batch size for inference
|
||||
'model_name': False, # Model name/path
|
||||
})
|
||||
|
||||
# Override default host for Ollama
|
||||
host = fields.Char(
|
||||
default='http://localhost:11434',
|
||||
help='Ollama server host URL')
|
||||
|
||||
def test_connection(self):
|
||||
"""Test the connection to the Ollama server.
|
||||
|
||||
This method attempts to connect to the Ollama server and verify
|
||||
that it is responding correctly. It will raise a user-friendly
|
||||
error if the connection fails.
|
||||
|
||||
Returns:
|
||||
dict: Action to display success message
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.provider_type != 'ollama':
|
||||
return
|
||||
|
||||
try:
|
||||
# Try to list models as a basic connectivity test
|
||||
response = requests.get(f'{self.host}/api/tags')
|
||||
response.raise_for_status()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Success'),
|
||||
'message': _('Successfully connected to Ollama server'),
|
||||
'sticky': False,
|
||||
'type': 'success',
|
||||
}
|
||||
}
|
||||
except Exception as e:
|
||||
raise UserError(_('Connection test failed: %s', str(e)))
|
||||
|
||||
def sync_models(self):
|
||||
"""Synchronize available models from the Ollama server.
|
||||
|
||||
This method fetches the list of available models from the Ollama
|
||||
server and creates or updates the corresponding AI model records
|
||||
in Odoo.
|
||||
|
||||
Returns:
|
||||
dict: Action to display success message
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.provider_type != 'ollama':
|
||||
return
|
||||
|
||||
try:
|
||||
# Get models from Ollama API
|
||||
response = requests.get(f'{self.host}/api/tags')
|
||||
response.raise_for_status()
|
||||
|
||||
# Parse response
|
||||
models = [{
|
||||
'name': model['name'],
|
||||
'id': model['name'],
|
||||
} for model in response.json()['models']]
|
||||
|
||||
for model_data in models:
|
||||
# Create or update AI model record
|
||||
vals = {
|
||||
'name': model_data['name'],
|
||||
'identifier': model_data['id'],
|
||||
'provider_instance_id': self.id,
|
||||
'active': True,
|
||||
}
|
||||
# Search for existing model
|
||||
existing = self.env['ai.model'].search([
|
||||
('identifier', '=', model_data['id']),
|
||||
('provider_instance_id', '=', self.id)
|
||||
], limit=1)
|
||||
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
self.env['ai.model'].create(vals)
|
||||
|
||||
# Invalidate the cache to force reload of related records
|
||||
self.invalidate_recordset(['model_ids'])
|
||||
|
||||
# Return action to reload the view completely
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'ai.provider.instance',
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
'flags': {
|
||||
'mode': 'readonly',
|
||||
'reload': True, # Force reload
|
||||
},
|
||||
'context': {'notification': {
|
||||
'type': 'success',
|
||||
'title': _('Success'),
|
||||
'message': _('Successfully synchronized %d models', len(models)),
|
||||
'sticky': False,
|
||||
}}
|
||||
}
|
||||
except Exception as e:
|
||||
raise UserError(_('Model synchronization failed: %s', str(e)))
|
||||
35
ai_integration_ollama_api/models/ai_provider_ollama.py.bak
Normal file
35
ai_integration_ollama_api/models/ai_provider_ollama.py.bak
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
from odoo import models, fields, api, _
|
||||
from .ollama_provider_mixin import OllamaProviderMixin
|
||||
|
||||
class OllamaAIProvider(models.Model, OllamaProviderMixin):
|
||||
_name = 'ai.provider.ollama'
|
||||
_description = 'Ollama AI Provider'
|
||||
_inherit = ['ai.provider.interface']
|
||||
|
||||
provider_type = fields.Selection(
|
||||
selection=[('ollama', 'Ollama')],
|
||||
default='ollama',
|
||||
required=True,
|
||||
help='Type of AI provider')
|
||||
|
||||
# Ollama-specific Parameters
|
||||
host = fields.Char(
|
||||
string='Host',
|
||||
default='http://localhost:11434',
|
||||
required=True,
|
||||
help='Ollama server host URL')
|
||||
|
||||
def _get_available_models(self):
|
||||
"""Get list of available models from Ollama server."""
|
||||
# TODO: Implement model discovery
|
||||
return []
|
||||
|
||||
def _generate_text(self, prompt, **kwargs):
|
||||
"""Generate text using Ollama model."""
|
||||
# TODO: Implement text generation
|
||||
return ""
|
||||
|
||||
def _embed_text(self, text, **kwargs):
|
||||
"""Generate embeddings for text using Ollama model."""
|
||||
# TODO: Implement text embedding
|
||||
return []
|
||||
116
ai_integration_ollama_api/models/ollama_model_stats.py
Normal file
116
ai_integration_ollama_api/models/ollama_model_stats.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from odoo import models, fields, api
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
class OllamaModelStats(models.Model):
|
||||
"""Tracks and stores daily usage statistics for Ollama AI models.
|
||||
|
||||
This model maintains detailed daily statistics for each Ollama model,
|
||||
including request counts, token usage, response times, and error rates.
|
||||
It inherits from ai.model.stats for base statistics functionality.
|
||||
|
||||
Key Features:
|
||||
- Daily usage tracking per model
|
||||
- Performance metrics collection
|
||||
- Error rate monitoring
|
||||
- Version tracking for model updates
|
||||
|
||||
Technical Details:
|
||||
- One stat entry per model per day (enforced by SQL constraint)
|
||||
- Automatic version tracking from Ollama API
|
||||
- Aggregated statistics calculation
|
||||
- Ordered by date for easy historical analysis
|
||||
"""
|
||||
_name = 'ollama.model.stats'
|
||||
_description = 'Ollama Model Usage Statistics'
|
||||
_inherit = ['ai.model.stats']
|
||||
_order = 'date desc' # Most recent stats first
|
||||
|
||||
model_id = fields.Many2one('ai.model', string='Model', required=True, ondelete='cascade')
|
||||
date = fields.Date(string='Date', required=True, default=fields.Date.context_today)
|
||||
request_count = fields.Integer(string='Number of Requests', default=0)
|
||||
token_count = fields.Integer(string='Total Tokens', default=0)
|
||||
avg_response_time = fields.Float(string='Average Response Time (ms)', digits=(10, 2), default=0)
|
||||
error_count = fields.Integer(string='Number of Errors', default=0)
|
||||
version = fields.Char(string='Model Version', help='Version of the model when stats were recorded')
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_model_date', 'unique(model_id, date)', 'Only one stat entry per model per day is allowed.')
|
||||
]
|
||||
|
||||
def _update_stats(self, model, tokens, response_time, error=False):
|
||||
"""Update daily statistics for a specific model.
|
||||
|
||||
This method handles the creation or update of daily statistics entries.
|
||||
It maintains running averages and cumulative counts for various metrics.
|
||||
|
||||
Args:
|
||||
model (ai.model): The model record being tracked
|
||||
tokens (int): Number of tokens in the current request
|
||||
response_time (float): Response time in milliseconds
|
||||
error (bool): Whether this request resulted in an error
|
||||
|
||||
Technical Notes:
|
||||
- Creates new stat entry if none exists for today
|
||||
- Updates running averages for response time
|
||||
- Fetches and stores model version from Ollama API
|
||||
- Maintains cumulative counts for requests and errors
|
||||
"""
|
||||
today = fields.Date.context_today(self)
|
||||
stats = self.search([
|
||||
('model_id', '=', model.id),
|
||||
('date', '=', today)
|
||||
])
|
||||
|
||||
if not stats:
|
||||
# Get model version
|
||||
version = self.env['ai.provider.ollama']._get_model_info(
|
||||
model.provider_instance_id,
|
||||
model.identifier
|
||||
).get('details', {}).get('sha256', '')[:8] # First 8 chars of SHA
|
||||
|
||||
stats = self.create({
|
||||
'model_id': model.id,
|
||||
'date': today,
|
||||
'version': version
|
||||
})
|
||||
|
||||
# Update statistics
|
||||
new_count = stats.request_count + 1
|
||||
new_tokens = stats.token_count + tokens
|
||||
new_time = ((stats.avg_response_time * stats.request_count) + response_time) / new_count
|
||||
new_errors = stats.error_count + (1 if error else 0)
|
||||
|
||||
stats.write({
|
||||
'request_count': new_count,
|
||||
'token_count': new_tokens,
|
||||
'avg_response_time': new_time,
|
||||
'error_count': new_errors
|
||||
})
|
||||
|
||||
@api.model
|
||||
def get_model_stats(self, model_id, days=30):
|
||||
"""Get statistics for a model over the specified number of days."""
|
||||
start_date = fields.Date.today() - timedelta(days=days)
|
||||
stats = self.search([
|
||||
('model_id', '=', model_id),
|
||||
('date', '>=', start_date)
|
||||
])
|
||||
|
||||
return {
|
||||
'daily_stats': [{
|
||||
'date': stat.date,
|
||||
'requests': stat.request_count,
|
||||
'tokens': stat.token_count,
|
||||
'response_time': stat.avg_response_time,
|
||||
'errors': stat.error_count,
|
||||
'version': stat.version
|
||||
} for stat in stats],
|
||||
'summary': {
|
||||
'total_requests': sum(stat.request_count for stat in stats),
|
||||
'total_tokens': sum(stat.token_count for stat in stats),
|
||||
'avg_response_time': sum(stat.avg_response_time * stat.request_count for stat in stats) /
|
||||
(sum(stat.request_count for stat in stats) if stats else 1),
|
||||
'total_errors': sum(stat.error_count for stat in stats),
|
||||
'versions_used': list(set(stat.version for stat in stats if stat.version))
|
||||
}
|
||||
}
|
||||
324
ai_integration_ollama_api/models/ollama_provider.py
Normal file
324
ai_integration_ollama_api/models/ollama_provider.py
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
import json
|
||||
import logging
|
||||
import requests
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class OllamaProvider(models.Model):
|
||||
"""Main Ollama AI Provider implementation.
|
||||
|
||||
This model implements the core functionality for interacting with Ollama's API,
|
||||
including model management, text generation, and error handling.
|
||||
|
||||
Key Responsibilities:
|
||||
- Model discovery and validation
|
||||
- API communication and error handling
|
||||
- Request formatting and response parsing
|
||||
- Resource management and cleanup
|
||||
|
||||
Technical Details:
|
||||
- Implements the ai.provider.interface for standardized AI provider integration
|
||||
- Uses Ollama's HTTP API for all operations
|
||||
- Handles both synchronous and asynchronous requests
|
||||
- Provides detailed error messages for troubleshooting
|
||||
"""
|
||||
_name = 'ai.provider.ollama'
|
||||
_description = 'Ollama AI Provider'
|
||||
_inherit = ['ai.provider.interface']
|
||||
|
||||
@api.model
|
||||
def _get_models(self, instance):
|
||||
"""Get list of available models from Ollama server.
|
||||
|
||||
Args:
|
||||
instance (ai.provider.instance): Provider instance to get models for
|
||||
|
||||
Returns:
|
||||
list: List of model dictionaries with keys:
|
||||
- name: Model name
|
||||
- id: Model identifier
|
||||
- details: Additional model metadata
|
||||
|
||||
Raises:
|
||||
UserError: If unable to connect or retrieve models
|
||||
"""
|
||||
try:
|
||||
response = requests.get(f"{instance.host}/api/tags")
|
||||
response.raise_for_status()
|
||||
|
||||
models_data = response.json().get('models', [])
|
||||
return [{
|
||||
'name': model['name'],
|
||||
'id': model['name'],
|
||||
'details': model
|
||||
} for model in models_data]
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_('Failed to connect to Ollama server: %s', str(e)))
|
||||
except (KeyError, ValueError) as e:
|
||||
raise UserError(_('Invalid response from Ollama server: %s', str(e)))
|
||||
|
||||
# API Configuration
|
||||
timeout = fields.Integer(
|
||||
string='Timeout',
|
||||
default=30,
|
||||
help='API request timeout in seconds')
|
||||
|
||||
def _get_provider_type(self):
|
||||
return 'ollama'
|
||||
|
||||
def _get_model_info(self, instance, model_name):
|
||||
"""Get detailed information about a specific model."""
|
||||
try:
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/show",
|
||||
json={'name': model_name}
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
_logger.error(
|
||||
"Failed to get model info for %s. Status: %s, Error: %s",
|
||||
model_name, response.status_code, response.text
|
||||
)
|
||||
return None
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error("Error getting model info: %s", str(e))
|
||||
return None
|
||||
|
||||
def pull_model(self, instance, model_name):
|
||||
"""Pull a model from Ollama."""
|
||||
try:
|
||||
# Start the pull
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/pull",
|
||||
json={'name': model_name},
|
||||
stream=True # Enable streaming for progress updates
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to pull model %s: %s") %
|
||||
(model_name, response.text))
|
||||
|
||||
# Process the streaming response
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
try:
|
||||
progress = json.loads(line)
|
||||
status = progress.get('status')
|
||||
if status:
|
||||
_logger.info(
|
||||
"Pulling model %s: %s",
|
||||
model_name, status
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Error pulling model %s: %s") %
|
||||
(model_name, str(e)))
|
||||
|
||||
def delete_model(self, instance, model_name):
|
||||
"""Delete a model from Ollama."""
|
||||
try:
|
||||
response = requests.delete(
|
||||
f"{instance.host}/api/delete",
|
||||
json={'name': model_name}
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to delete model %s: %s") %
|
||||
(model_name, response.text))
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Error deleting model %s: %s") %
|
||||
(model_name, str(e)))
|
||||
|
||||
def test_connection(self, instance):
|
||||
"""Test the connection to the Ollama server."""
|
||||
try:
|
||||
response = requests.get(f"{instance.host}/api/tags")
|
||||
if response.status_code != 200:
|
||||
raise UserError(_(
|
||||
"Failed to connect to Ollama server. Status code: %s. Error: %s",
|
||||
response.status_code, response.text
|
||||
))
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Failed to connect to Ollama server: %s", str(e)
|
||||
))
|
||||
|
||||
def _format_chat_messages(self, messages):
|
||||
"""Format chat messages for Ollama API."""
|
||||
formatted_prompt = ""
|
||||
for message in messages:
|
||||
role = message.get('role', 'user')
|
||||
content = message.get('content', '')
|
||||
|
||||
if role == 'system':
|
||||
formatted_prompt += f"<system>{content}</system>\n"
|
||||
elif role == 'assistant':
|
||||
formatted_prompt += f"Assistant: {content}\n"
|
||||
else: # user
|
||||
formatted_prompt += f"Human: {content}\n"
|
||||
|
||||
return formatted_prompt.strip()
|
||||
|
||||
def generate_response(self, instance, model, messages, **kwargs):
|
||||
"""Generate a response using the chat completion API."""
|
||||
try:
|
||||
# Format messages into Ollama's expected format
|
||||
prompt = self._format_chat_messages(messages)
|
||||
|
||||
# Get model options from instance
|
||||
options = instance._get_provider_options()
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'model': model.identifier,
|
||||
'prompt': prompt,
|
||||
'stream': False,
|
||||
**options
|
||||
}
|
||||
|
||||
# Make the API call
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/generate",
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to generate response: %s") % response.text)
|
||||
|
||||
response_data = response.json()
|
||||
generated_text = response_data.get('response', '')
|
||||
|
||||
# Update statistics
|
||||
total_tokens = response_data.get('eval_count', 0)
|
||||
response_time = response_data.get('total_duration', 0)
|
||||
version = self._get_model_info(instance, model.identifier)\
|
||||
.get('details', {}).get('sha256', '')[:8]
|
||||
self._track_model_usage(
|
||||
model, total_tokens, response_time, version=version
|
||||
)
|
||||
|
||||
# Return the response in a standardized format
|
||||
return {
|
||||
'content': generated_text,
|
||||
'role': 'assistant',
|
||||
'metadata': {
|
||||
'eval_count': response_data.get('eval_count'),
|
||||
'eval_duration': response_data.get('eval_duration'),
|
||||
'total_duration': response_data.get('total_duration'),
|
||||
'load_duration': response_data.get('load_duration'),
|
||||
}
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Log error in statistics
|
||||
if model:
|
||||
self._track_model_usage(model, error=True)
|
||||
raise UserError(_("Error generating response: %s") % str(e))
|
||||
|
||||
def sync_models(self, instance):
|
||||
"""Synchronize available models from the Ollama server."""
|
||||
self.test_connection(instance)
|
||||
|
||||
try:
|
||||
response = requests.get(f"{instance.host}/api/tags")
|
||||
models_data = response.json().get('models', [])
|
||||
|
||||
# Get existing models for this instance
|
||||
existing_models = self.env['ai.model'].search([
|
||||
('provider_instance_id', '=', instance.id)
|
||||
])
|
||||
existing_identifiers = {m.identifier: m for m in existing_models}
|
||||
|
||||
for model_data in models_data:
|
||||
identifier = model_data.get('name')
|
||||
if not identifier:
|
||||
continue
|
||||
|
||||
# Get model details
|
||||
model_info = self._get_model_info(instance, identifier)
|
||||
model_details = model_info.get('details', {})
|
||||
|
||||
model_values = {
|
||||
'name': identifier.title(),
|
||||
'identifier': identifier,
|
||||
'provider_instance_id': instance.id,
|
||||
'company_id': instance.company_id.id,
|
||||
'active': True,
|
||||
'description': f"Ollama model: {identifier}\\nSize: {model_data.get('size', 'Unknown')}\\nModified: {model_data.get('modified', 'Unknown')}",
|
||||
}
|
||||
|
||||
if identifier in existing_identifiers:
|
||||
# Update existing model
|
||||
existing_identifiers[identifier].write(model_values)
|
||||
del existing_identifiers[identifier]
|
||||
else:
|
||||
# Create new model
|
||||
self.env['ai.model'].create(model_values)
|
||||
|
||||
# Deactivate models that no longer exist
|
||||
for model in existing_identifiers.values():
|
||||
model.active = False
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Failed to sync models from Ollama server: %s", str(e)
|
||||
))
|
||||
|
||||
def send_message(self, message, model, **kwargs):
|
||||
"""Send a message to the Ollama server."""
|
||||
instance = model.provider_instance_id
|
||||
|
||||
try:
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'model': model.identifier,
|
||||
'prompt': message.get('content', ''),
|
||||
'stream': False,
|
||||
'options': kwargs.get('options', {}),
|
||||
}
|
||||
|
||||
# Add system message if provided
|
||||
if message.get('role') == 'system':
|
||||
payload['system'] = message['content']
|
||||
|
||||
# Send the request
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/generate",
|
||||
json=payload,
|
||||
timeout=(instance.timeout or 30)
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_(
|
||||
"Ollama server error. Status code: %s. Error: %s",
|
||||
response.status_code, response.text
|
||||
))
|
||||
|
||||
result = response.json()
|
||||
return result.get('response', '')
|
||||
|
||||
except requests.exceptions.Timeout:
|
||||
raise UserError(_("Request to Ollama server timed out"))
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Error communicating with Ollama server: %s", str(e)
|
||||
))
|
||||
except Exception as e:
|
||||
_logger.error("Unexpected error in Ollama provider: %s", str(e))
|
||||
raise UserError(_(
|
||||
"Unexpected error in Ollama provider: %s", str(e)
|
||||
))
|
||||
274
ai_integration_ollama_api/models/ollama_provider_mixin.py
Normal file
274
ai_integration_ollama_api/models/ollama_provider_mixin.py
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
import requests
|
||||
import logging
|
||||
import json
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class OllamaProviderMixin(models.AbstractModel):
|
||||
"""Mixin model that provides Ollama-specific configuration parameters.
|
||||
|
||||
This mixin is designed to be inherited by models that need to interact with
|
||||
the Ollama AI provider. It provides all the necessary fields and methods
|
||||
for configuring and interacting with Ollama's API.
|
||||
|
||||
Key Features:
|
||||
- Provider type selection and validation
|
||||
- Context window configuration
|
||||
- Advanced sampling parameters (temperature, top-k, top-p)
|
||||
- Token generation controls
|
||||
|
||||
Technical Details:
|
||||
- Inherits from ai.generation.params for base AI generation parameters
|
||||
- Implements Ollama-specific API parameters
|
||||
- Provides default values optimized for general use cases
|
||||
"""
|
||||
_name = 'ollama.provider.mixin'
|
||||
_description = 'Ollama Provider Configuration Mixin'
|
||||
_inherit = ['ai.generation.params']
|
||||
|
||||
# Model Parameters
|
||||
model_name = fields.Char(
|
||||
string='Model Name',
|
||||
help='Name of the Ollama model to use (e.g. llama2, mistral, codellama)',
|
||||
required=True,
|
||||
default='deepseek-r1:32b')
|
||||
|
||||
# Context Window Configuration
|
||||
num_ctx = fields.Integer(
|
||||
string='Context Length',
|
||||
help='Maximum number of tokens to consider for context. A larger context window allows '
|
||||
'the model to access more historical information but requires more memory. '
|
||||
'Range: [0 - 32768].',
|
||||
default=8192)
|
||||
|
||||
# Generation Parameters
|
||||
temperature = fields.Float(
|
||||
string='Temperature',
|
||||
help='Controls randomness in the output. Higher values make the output more random, '
|
||||
'while lower values make it more focused and deterministic. '
|
||||
'Range: [0.0 - 2.0]',
|
||||
default=0.7)
|
||||
|
||||
top_p = fields.Float(
|
||||
string='Top P (Nucleus Sampling)',
|
||||
help='Limits the cumulative probability of tokens to sample from. Only the most likely '
|
||||
'tokens with total probability mass of top_p are considered. '
|
||||
'Range: [0.0 - 1.0].',
|
||||
default=0.9)
|
||||
|
||||
top_k = fields.Integer(
|
||||
string='Top K',
|
||||
help='Limits the cumulative probability of tokens to sample from. Only the top K '
|
||||
'most likely tokens are considered for sampling at each step. '
|
||||
'Range: [1 - 100].',
|
||||
default=40)
|
||||
|
||||
min_p = fields.Float(
|
||||
string='Min P',
|
||||
help='Sets a minimum probability threshold for token selection. Range: [0.0 - 1.0].',
|
||||
default=0.05,
|
||||
digits=(3, 2))
|
||||
|
||||
repeat_penalty = fields.Float(
|
||||
string='Repeat Penalty',
|
||||
help='Penalty for repeating tokens. Range: [1.0 - 2.0]. Higher values make repetition less likely.',
|
||||
default=1.1,
|
||||
digits=(3, 2))
|
||||
|
||||
# Advanced Configuration
|
||||
stop_sequences = fields.Char(
|
||||
string='Stop Sequences',
|
||||
help='Comma-separated list of sequences where the model should stop generating further tokens.')
|
||||
|
||||
num_predict = fields.Integer(
|
||||
string='Maximum Tokens',
|
||||
help='Maximum number of tokens to predict. Set to -1 for unlimited.',
|
||||
default=2048)
|
||||
|
||||
repeat_last_n = fields.Integer(
|
||||
string='Repeat Last N',
|
||||
help='Sets the context window for repeat penalty. Range: [0 - 4096]. Default is 64, 0 disables.',
|
||||
default=64
|
||||
)
|
||||
|
||||
def generate_text(self, prompt, **kwargs):
|
||||
"""Generate text using the Ollama API.
|
||||
|
||||
Args:
|
||||
prompt (str): The prompt to generate text from
|
||||
**kwargs: Additional parameters to pass to the API
|
||||
|
||||
Returns:
|
||||
str: The generated text
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Prepare the request
|
||||
url = f"{self.host}/api/generate"
|
||||
|
||||
# Build the request data
|
||||
data = {
|
||||
'model': self.model_name,
|
||||
'prompt': prompt,
|
||||
'stream': False,
|
||||
'num_ctx': self.num_ctx,
|
||||
'temperature': self.temperature,
|
||||
'top_k': self.top_k,
|
||||
'top_p': self.top_p,
|
||||
'repeat_penalty': self.repeat_penalty,
|
||||
'repeat_last_n': self.repeat_last_n,
|
||||
'num_predict': self.num_predict,
|
||||
'min_p': self.min_p,
|
||||
'seed': self.seed,
|
||||
'num_gpu': self.num_gpu,
|
||||
'num_thread': self.num_thread,
|
||||
'mirostat': int(self.mirostat),
|
||||
'mirostat_tau': self.mirostat_tau,
|
||||
'mirostat_eta': self.mirostat_eta,
|
||||
'num_batch': self.num_batch,
|
||||
'num_keep': self.num_keep,
|
||||
'tfs_z': self.tfs_z,
|
||||
'skip_special_tokens': self.skip_special_tokens
|
||||
}
|
||||
|
||||
# Add any additional parameters
|
||||
if kwargs:
|
||||
data.update(kwargs)
|
||||
|
||||
# Make the request
|
||||
try:
|
||||
_logger = logging.getLogger(__name__)
|
||||
_logger.info("Sending request to Ollama API with data: %s", data)
|
||||
|
||||
response = requests.post(url, json=data, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
|
||||
# Log the raw response
|
||||
_logger.info("Raw API response: %s", response.text)
|
||||
|
||||
# Parse the response
|
||||
result = response.json()
|
||||
response_text = result.get('response', '')
|
||||
|
||||
# Si la réponse est une chaîne JSON, la parser
|
||||
try:
|
||||
if isinstance(response_text, str):
|
||||
parsed_response = json.loads(response_text)
|
||||
_logger.info("Parsed nested JSON response: %s", parsed_response)
|
||||
return parsed_response
|
||||
else:
|
||||
_logger.info("Direct response: %s", response_text)
|
||||
return response_text
|
||||
except json.JSONDecodeError:
|
||||
# Si ce n'est pas du JSON valide, retourner le texte tel quel
|
||||
_logger.info("Non-JSON response: %s", response_text)
|
||||
return response_text
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_('Failed to generate text: %s') % str(e))
|
||||
|
||||
# Advanced Generation Parameters
|
||||
seed = fields.Integer(
|
||||
string='Random Seed',
|
||||
help='Sets the random seed for generation. Range: [0 - 2147483647]. Use 0 for random.',
|
||||
default=0)
|
||||
|
||||
num_gpu = fields.Integer(
|
||||
string='Number of GPUs',
|
||||
help='Number of GPUs to use for generation. Range: [0 - 8]. 0 means CPU only.',
|
||||
default=1)
|
||||
|
||||
num_thread = fields.Integer(
|
||||
string='Number of Threads',
|
||||
help='Number of CPU threads to use for generation. Range: [1 - 32].',
|
||||
default=8)
|
||||
|
||||
mirostat = fields.Selection([
|
||||
('0', 'Disabled'),
|
||||
('1', 'Mirostat'),
|
||||
('2', 'Mirostat 2.0')],
|
||||
string='Mirostat Mode',
|
||||
help='Enable Mirostat sampling for controlling perplexity',
|
||||
default='0')
|
||||
|
||||
mirostat_tau = fields.Float(
|
||||
string='Mirostat Tau',
|
||||
help='Mirostat target entropy. Range: [0.0 - 10.0].',
|
||||
default=5.0,
|
||||
digits=(3, 2))
|
||||
|
||||
mirostat_eta = fields.Float(
|
||||
string='Mirostat Eta',
|
||||
help='Mirostat learning rate. Range: [0.0 - 1.0].',
|
||||
default=0.1,
|
||||
digits=(3, 2))
|
||||
|
||||
# Ollama-specific Response Control
|
||||
|
||||
tfs_z = fields.Float(
|
||||
string='Tail Free Sampling Z',
|
||||
help='Tail free sampling parameter. Range: [0.0 - 2.0]. Higher value = more focused.',
|
||||
default=1.0,
|
||||
digits=(3, 2))
|
||||
|
||||
# System Settings
|
||||
num_batch = fields.Integer(
|
||||
string='Batch Size',
|
||||
help='Number of prompts to batch together',
|
||||
default=8)
|
||||
|
||||
num_keep = fields.Integer(
|
||||
string='Keep Last N Tokens',
|
||||
help='Number of tokens to keep from initial prompt',
|
||||
default=0)
|
||||
|
||||
skip_special_tokens = fields.Boolean(
|
||||
string='Skip Special Tokens',
|
||||
help='Skip special tokens in generation',
|
||||
default=True)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields_list):
|
||||
defaults = super().default_get(fields_list)
|
||||
if 'provider_type' in fields_list:
|
||||
defaults['provider_type'] = 'ollama'
|
||||
if 'host' in fields_list and not defaults.get('host'):
|
||||
defaults['host'] = 'http://localhost:11434'
|
||||
return defaults
|
||||
|
||||
def _get_provider_options(self):
|
||||
"""Get Ollama-specific options for API calls."""
|
||||
self.ensure_one()
|
||||
options = {
|
||||
'model': self.model_name,
|
||||
'temperature': self.temperature,
|
||||
'num_ctx': self.num_ctx,
|
||||
'num_predict': self.num_predict,
|
||||
'top_k': self.top_k,
|
||||
'top_p': self.top_p,
|
||||
'min_p': self.min_p,
|
||||
'repeat_penalty': self.repeat_penalty,
|
||||
'repeat_last_n': self.repeat_last_n,
|
||||
'seed': self.seed,
|
||||
'num_gpu': self.num_gpu,
|
||||
'num_thread': self.num_thread,
|
||||
'mirostat': int(self.mirostat),
|
||||
'mirostat_tau': self.mirostat_tau,
|
||||
'mirostat_eta': self.mirostat_eta,
|
||||
'num_batch': self.num_batch,
|
||||
'num_keep': self.num_keep,
|
||||
'tfs_z': self.tfs_z,
|
||||
'skip_special_tokens': self.skip_special_tokens,
|
||||
'stream': False
|
||||
}
|
||||
|
||||
if self.stop_sequences:
|
||||
options['stop'] = [
|
||||
seq.strip()
|
||||
for seq in self.stop_sequences.split(',')
|
||||
if seq.strip()
|
||||
]
|
||||
|
||||
return options
|
||||
5
ai_integration_ollama_api/security/ir.model.access.csv
Normal file
5
ai_integration_ollama_api/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_ollama_model_stats_user,ollama.model.stats.user,model_ollama_model_stats,base.group_user,1,0,0,0
|
||||
access_ollama_model_stats_manager,ollama.model.stats.manager,model_ollama_model_stats,base.group_system,1,1,1,1
|
||||
access_ai_provider_ollama_user,ai.provider.ollama.user,model_ai_provider_ollama,base.group_user,1,0,0,0
|
||||
access_ai_provider_ollama_manager,ai.provider.ollama.manager,model_ai_provider_ollama,base.group_system,1,1,1,1
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Inherit AI Provider Instance List View -->
|
||||
<record id="ollama_ai_provider_instance_view_list" model="ir.ui.view">
|
||||
<field name="name">ai.provider.instance.list.ollama</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_list"/>
|
||||
<field name="mode">primary</field>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//list[@name='ai_instance']" position="attributes">
|
||||
<attribute name="create">true</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
118
ai_integration_ollama_api/views/ollama_stats_views.xml
Normal file
118
ai_integration_ollama_api/views/ollama_stats_views.xml
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- List View -->
|
||||
<record id="view_ollama_model_stats_list" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.list</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<list string="Model Statistics">
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="version"/>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Form View -->
|
||||
<record id="view_ollama_model_stats_form" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.form</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Model Statistics">
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="date"/>
|
||||
<field name="model_id"/>
|
||||
<field name="version"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="request_count"/>
|
||||
<field name="token_count"/>
|
||||
<field name="avg_response_time"/>
|
||||
<field name="error_count"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_ollama_model_stats_search" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.search</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search Model Statistics">
|
||||
<field name="model_id"/>
|
||||
<field name="version"/>
|
||||
<field name="date"/>
|
||||
<filter string="Today" name="today" domain="[('date','=',context_today().strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 7 Days" name="last_week" domain="[('date','>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Last 30 Days" name="last_month" domain="[('date','>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Model" name="group_by_model" context="{'group_by': 'model_id'}"/>
|
||||
<filter string="Version" name="group_by_version" context="{'group_by': 'version'}"/>
|
||||
<filter string="Date" name="group_by_date" context="{'group_by': 'date'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Graph View -->
|
||||
<record id="view_ollama_model_stats_graph" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.graph</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<graph string="Model Statistics" type="line" sample="1">
|
||||
<field name="date" type="row"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</graph>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Pivot View -->
|
||||
<record id="view_ollama_model_stats_pivot" model="ir.ui.view">
|
||||
<field name="name">ollama.model.stats.pivot</field>
|
||||
<field name="model">ollama.model.stats</field>
|
||||
<field name="arch" type="xml">
|
||||
<pivot string="Model Statistics" sample="1">
|
||||
<field name="model_id" type="row"/>
|
||||
<field name="date" type="col"/>
|
||||
<field name="request_count" type="measure"/>
|
||||
<field name="token_count" type="measure"/>
|
||||
<field name="avg_response_time" type="measure"/>
|
||||
<field name="error_count" type="measure"/>
|
||||
</pivot>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_ollama_model_stats" model="ir.actions.act_window">
|
||||
<field name="name">Model Statistics</field>
|
||||
<field name="res_model">ollama.model.stats</field>
|
||||
<field name="view_mode">list,form,graph,pivot</field>
|
||||
<field name="context">{'search_default_last_month': 1}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
No statistics recorded yet
|
||||
</p>
|
||||
<p>
|
||||
Statistics will be automatically recorded as you use your AI models.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="menu_ollama_model_stats"
|
||||
name="Model Statistics"
|
||||
parent="ai_integration.menu_ai_config"
|
||||
action="action_ollama_model_stats"
|
||||
sequence="30"/>
|
||||
</odoo>
|
||||
108
ai_integration_ollama_api/views/ollama_views.xml
Normal file
108
ai_integration_ollama_api/views/ollama_views.xml
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Form View -->
|
||||
<record id="ollama_instance_view_form" model="ir.ui.view">
|
||||
<field name="name">ollama.instance.form</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="type">form</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//div[@name='button_box']" position="replace">
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
|
||||
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
|
||||
</button>
|
||||
</div>
|
||||
</xpath>
|
||||
<xpath expr="//field[@name='api_key']" position="replace">
|
||||
<field name="api_key" invisible="1"/>
|
||||
</xpath>
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Ollama Settings" name="ollama_settings"
|
||||
invisible="provider_type != 'ollama'">
|
||||
<group>
|
||||
<group string="Generation Parameters">
|
||||
<field name="num_ctx"/>
|
||||
<field name="temperature"/>
|
||||
<field name="top_p"/>
|
||||
<field name="top_k"/>
|
||||
<field name="repeat_penalty"/>
|
||||
</group>
|
||||
<group string="Advanced Settings">
|
||||
<field name="stop_sequences" placeholder="Enter comma-separated stop sequences"/>
|
||||
</group>
|
||||
</group>
|
||||
<div class="alert alert-info" role="alert" style="margin-top: 10px;">
|
||||
<p><strong>Note:</strong> These settings will be used as defaults for all requests to this Ollama instance.
|
||||
They can be overridden on a per-request basis.</p>
|
||||
<ul>
|
||||
<li><strong>Context Length:</strong> Longer context allows the model to consider more previous text but uses more memory.</li>
|
||||
<li><strong>Temperature:</strong> Higher values (>1.0) make output more random, lower values make it more focused and deterministic.</li>
|
||||
<li><strong>Top P:</strong> Controls diversity via nucleus sampling. Lower values (0.1) are more focused, higher values (0.9) more diverse.</li>
|
||||
<li><strong>Top K:</strong> Limits the cumulative probability of tokens to sample from. Lower values are more focused.</li>
|
||||
<li><strong>Repeat Penalty:</strong> Higher values (>1.0) make the model less likely to repeat itself.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="ollama_instance_view_list" model="ir.ui.view">
|
||||
<field name="name">ollama.instance.list</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="type">list</field>
|
||||
<field name="arch" type="xml">
|
||||
<list>
|
||||
<field name="name"/>
|
||||
<field name="provider_type"/>
|
||||
<field name="host"/>
|
||||
<field name="num_ctx" optional="show"/>
|
||||
<field name="temperature" optional="hide"/>
|
||||
<field name="active"/>
|
||||
</list>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="ollama_instance_view_search" model="ir.ui.view">
|
||||
<field name="name">ollama.instance.search</field>
|
||||
<field name="model">ai.provider.instance</field>
|
||||
<field name="mode">primary</field>
|
||||
<field name="inherit_id" ref="ai_integration.ai_provider_instance_view_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//group" position="inside">
|
||||
<filter string="Context Length" name="group_by_num_ctx"
|
||||
domain="[('provider_type', '=', 'ollama')]"
|
||||
context="{'group_by': 'num_ctx'}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_ollama_instance" model="ir.actions.act_window">
|
||||
<field name="name">Ollama Instances</field>
|
||||
<field name="res_model">ai.provider.instance</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="domain">[('provider_type', '=', 'ollama')]</field>
|
||||
<field name="context">{'default_provider_type': 'ollama'}</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first Ollama instance
|
||||
</p>
|
||||
<p>
|
||||
Configure Ollama instances to use local AI models in your Odoo instance.
|
||||
Make sure you have Ollama installed and running on your server.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="menu_ollama_instance"
|
||||
name="Ollama Instances"
|
||||
parent="ai_integration.ai_integration_menu_config"
|
||||
action="action_ollama_instance"
|
||||
sequence="20"/>
|
||||
</odoo>
|
||||
35
ai_integration_openai_api/__manifest__.py
Normal file
35
ai_integration_openai_api/__manifest__.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
'name': 'ChatGPT-Compatible AI Integration',
|
||||
'version': '1.0',
|
||||
'category': 'Technical',
|
||||
'summary': 'Integration for ChatGPT-compatible AI providers',
|
||||
'description': """
|
||||
ChatGPT-Compatible AI Integration
|
||||
================================
|
||||
This module provides integration with ChatGPT-compatible AI providers (OpenWebUI, ChatGPT, etc), offering:
|
||||
|
||||
* ChatGPT-compatible provider implementation
|
||||
* Model synchronization
|
||||
* Advanced parameter configuration
|
||||
* Usage statistics tracking
|
||||
* Support for multiple providers using the ChatGPT API format
|
||||
""",
|
||||
'author': 'Bemade',
|
||||
'website': 'https://www.bemade.org',
|
||||
'depends': [
|
||||
'ai_integration',
|
||||
'openwebui_integration'
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/chatgpt_instance_views.xml',
|
||||
'data/chatgpt_provider.xml',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['requests'],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'auto_install': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
14
ai_integration_openai_api/data/chatgpt_provider.xml
Normal file
14
ai_integration_openai_api/data/chatgpt_provider.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- ChatGPT-Compatible Provider -->
|
||||
<record id="ai_provider_chatgpt" model="ai.provider">
|
||||
<field name="name">ChatGPT-Compatible</field>
|
||||
<field name="code">chatgpt</field>
|
||||
<field name="instance_model">chatgpt.ai.instance</field>
|
||||
<field name="provider_model">ai.provider.chatgpt</field>
|
||||
<field name="description">Integration with ChatGPT-compatible AI providers (OpenWebUI, ChatGPT, etc).</field>
|
||||
<field name="website">https://platform.openai.com/docs/api-reference/chat</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
2
ai_integration_openai_api/models/__init__.py
Normal file
2
ai_integration_openai_api/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import chatgpt_provider
|
||||
from . import chatgpt_instance
|
||||
48
ai_integration_openai_api/models/chatgpt_instance.py
Normal file
48
ai_integration_openai_api/models/chatgpt_instance.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
class ChatGPTInstance(models.Model):
|
||||
_name = 'chatgpt.ai.instance'
|
||||
_description = 'ChatGPT-Compatible Instance Configuration'
|
||||
_inherit = ['ai.provider.instance', 'ai.generation.params']
|
||||
|
||||
provider_type = fields.Selection(
|
||||
selection_add=[('chatgpt', 'ChatGPT-Compatible')],
|
||||
ondelete={'chatgpt': 'cascade'})
|
||||
|
||||
# ChatGPT API Parameters
|
||||
|
||||
presence_penalty = fields.Float(
|
||||
string='Presence Penalty',
|
||||
help='Penalty for new tokens based on their presence in text. Range: [-2.0 - 2.0].',
|
||||
default=0.0,
|
||||
digits=(3, 2))
|
||||
|
||||
frequency_penalty = fields.Float(
|
||||
string='Frequency Penalty',
|
||||
help='Penalty for new tokens based on their frequency in text. Range: [-2.0 - 2.0].',
|
||||
default=0.0,
|
||||
digits=(3, 2))
|
||||
|
||||
# Provider Settings
|
||||
|
||||
def _get_provider_options(self):
|
||||
"""Get ChatGPT-compatible options for API calls."""
|
||||
self.ensure_one()
|
||||
|
||||
options = {
|
||||
'temperature': self.temperature,
|
||||
'top_p': self.top_p,
|
||||
'max_tokens': self.max_tokens,
|
||||
'presence_penalty': self.presence_penalty,
|
||||
'frequency_penalty': self.frequency_penalty,
|
||||
'stream': self.stream_response,
|
||||
}
|
||||
|
||||
if self.stop_sequences:
|
||||
options['stop'] = [
|
||||
seq.strip()
|
||||
for seq in self.stop_sequences.split(',')
|
||||
]
|
||||
|
||||
return options
|
||||
163
ai_integration_openai_api/models/chatgpt_provider.py
Normal file
163
ai_integration_openai_api/models/chatgpt_provider.py
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class ChatGPTProvider(models.AbstractModel):
|
||||
_name = 'ai.provider.chatgpt'
|
||||
_description = 'ChatGPT-Compatible AI Provider'
|
||||
_inherit = ['ai.provider']
|
||||
|
||||
def _get_provider_type(self):
|
||||
return 'chatgpt'
|
||||
|
||||
def test_connection(self, instance):
|
||||
"""Test the connection to the OpenWebUI server."""
|
||||
try:
|
||||
response = requests.get(f"{instance.host}/api/v1/models")
|
||||
if response.status_code != 200:
|
||||
raise UserError(_(
|
||||
"Failed to connect to AI server. Status code: %s. Error: %s",
|
||||
response.status_code, response.text
|
||||
))
|
||||
return True
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_(
|
||||
"Failed to connect to AI server: %s", str(e)
|
||||
))
|
||||
|
||||
def sync_models(self, instance):
|
||||
"""Synchronize available models from the OpenWebUI server."""
|
||||
self.test_connection(instance)
|
||||
|
||||
try:
|
||||
# Get models from provider
|
||||
response = requests.get(f"{instance.host}/api/v1/models")
|
||||
models_data = response.json()
|
||||
|
||||
# Get existing models for this instance
|
||||
existing_models = self.env['ai.model'].search([
|
||||
('provider_instance_id', '=', instance.id)
|
||||
])
|
||||
existing_identifiers = {m.identifier: m for m in existing_models}
|
||||
|
||||
for model_data in models_data:
|
||||
identifier = model_data.get('id')
|
||||
if not identifier:
|
||||
continue
|
||||
|
||||
# Get model details
|
||||
model_info = self._get_model_info(instance, identifier)
|
||||
model_details = model_info.get('details', {})
|
||||
|
||||
model_values = {
|
||||
'name': model_data.get('name', identifier),
|
||||
'identifier': identifier,
|
||||
'description': model_details.get('description', ''),
|
||||
'version': model_details.get('version', ''),
|
||||
'provider_instance_id': instance.id,
|
||||
}
|
||||
|
||||
if identifier in existing_identifiers:
|
||||
# Update existing model
|
||||
existing_identifiers[identifier].write(model_values)
|
||||
else:
|
||||
# Create new model
|
||||
self.env['ai.model'].create(model_values)
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(_("Error synchronizing models: %s") % str(e))
|
||||
|
||||
def _get_model_info(self, instance, model_name):
|
||||
"""Get detailed information about a specific model."""
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{instance.host}/api/v1/models/{model_name}/info"
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
_logger.error(
|
||||
"Failed to get model info for %s. Status: %s, Error: %s",
|
||||
model_name, response.status_code, response.text
|
||||
)
|
||||
return {}
|
||||
except requests.exceptions.RequestException as e:
|
||||
_logger.error("Error getting model info: %s", str(e))
|
||||
return {}
|
||||
|
||||
def _format_chat_messages(self, messages):
|
||||
"""Format chat messages for OpenWebUI API."""
|
||||
formatted_messages = []
|
||||
for message in messages:
|
||||
role = message.get('role', 'user')
|
||||
content = message.get('content', '')
|
||||
|
||||
formatted_messages.append({
|
||||
'role': role,
|
||||
'content': content
|
||||
})
|
||||
|
||||
return formatted_messages
|
||||
|
||||
def generate_response(self, instance, model, messages, **kwargs):
|
||||
"""Generate a response using the chat completion API."""
|
||||
try:
|
||||
# Format messages for OpenWebUI
|
||||
formatted_messages = self._format_chat_messages(messages)
|
||||
|
||||
# Get model options from instance
|
||||
options = instance._get_provider_options()
|
||||
|
||||
# Prepare the request payload
|
||||
payload = {
|
||||
'model': model.identifier,
|
||||
'messages': formatted_messages,
|
||||
**options
|
||||
}
|
||||
|
||||
# Make the API call
|
||||
response = requests.post(
|
||||
f"{instance.host}/api/v1/chat/completions",
|
||||
json=payload
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise UserError(_("Failed to generate response: %s") % response.text)
|
||||
|
||||
response_data = response.json()
|
||||
generated_text = response_data.get('choices', [{}])[0].get('message', {}).get('content', '')
|
||||
|
||||
# Update statistics
|
||||
total_tokens = response_data.get('usage', {}).get('total_tokens', 0)
|
||||
response_time = response_data.get('response_ms', 0)
|
||||
version = self._get_model_info(instance, model.identifier)\
|
||||
.get('details', {}).get('version', '')
|
||||
|
||||
self._track_model_usage(
|
||||
model, total_tokens, response_time, version=version
|
||||
)
|
||||
|
||||
# Return the response in a standardized format
|
||||
return {
|
||||
'content': generated_text,
|
||||
'role': 'assistant',
|
||||
'metadata': {
|
||||
'total_tokens': total_tokens,
|
||||
'response_time': response_time,
|
||||
'model_version': version,
|
||||
'usage': response_data.get('usage', {})
|
||||
}
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
# Log error in statistics
|
||||
if model:
|
||||
self._track_model_usage(model, error=True)
|
||||
raise UserError(_("Error generating response: %s") % str(e))
|
||||
3
ai_integration_openai_api/security/ir.model.access.csv
Normal file
3
ai_integration_openai_api/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_openwebui_ai_instance_user,openwebui.ai.instance user,model_openwebui_ai_instance,base.group_user,1,0,0,0
|
||||
access_openwebui_ai_instance_system,openwebui.ai.instance system,model_openwebui_ai_instance,base.group_system,1,1,1,1
|
||||
|
106
ai_integration_openai_api/views/chatgpt_instance_views.xml
Normal file
106
ai_integration_openai_api/views/chatgpt_instance_views.xml
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Form View -->
|
||||
<record id="view_chatgpt_ai_instance_form" model="ir.ui.view">
|
||||
<field name="name">chatgpt.ai.instance.form</field>
|
||||
<field name="model">chatgpt.ai.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="ChatGPT-Compatible Instance">
|
||||
<sheet>
|
||||
<div class="oe_title">
|
||||
<label for="name" class="oe_edit_only"/>
|
||||
<h1><field name="name" placeholder="e.g. Production OpenWebUI/ChatGPT"/></h1>
|
||||
</div>
|
||||
<group>
|
||||
<group string="Connection">
|
||||
<field name="host" placeholder="e.g. http://localhost:8080"/>
|
||||
<field name="api_key"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="is_default"/>
|
||||
</group>
|
||||
<group string="Basic Generation">
|
||||
<field name="temperature"/>
|
||||
<field name="top_p"/>
|
||||
<field name="max_tokens"/>
|
||||
<field name="presence_penalty"/>
|
||||
<field name="frequency_penalty"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Advanced Settings">
|
||||
<group>
|
||||
<field name="stop_sequences" placeholder="e.g. END,STOP,DONE"/>
|
||||
<field name="timeout"/>
|
||||
<field name="retry_count"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="stream_response"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="Models" attrs="{'invisible': [('id', '=', False)]}">
|
||||
<field name="model_ids" nolabel="1">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="identifier"/>
|
||||
<field name="version"/>
|
||||
<field name="description"/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Tree View -->
|
||||
<record id="view_chatgpt_ai_instance_tree" model="ir.ui.view">
|
||||
<field name="name">chatgpt.ai.instance.tree</field>
|
||||
<field name="model">chatgpt.ai.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="ChatGPT-Compatible Instances">
|
||||
<field name="name"/>
|
||||
<field name="host"/>
|
||||
<field name="is_default"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Search View -->
|
||||
<record id="view_chatgpt_ai_instance_search" model="ir.ui.view">
|
||||
<field name="name">chatgpt.ai.instance.search</field>
|
||||
<field name="model">chatgpt.ai.instance</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search ChatGPT-Compatible Instances">
|
||||
<field name="name"/>
|
||||
<field name="host"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<filter string="Default Instance" name="is_default" domain="[('is_default', '=', True)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Company" name="company" context="{'group_by': 'company_id'}" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Action -->
|
||||
<record id="action_chatgpt_ai_instance" model="ir.actions.act_window">
|
||||
<field name="name">ChatGPT-Compatible Instances</field>
|
||||
<field name="res_model">chatgpt.ai.instance</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first ChatGPT-compatible instance
|
||||
</p>
|
||||
<p>
|
||||
Configure instances to connect to ChatGPT-compatible servers (OpenWebUI, ChatGPT, etc).
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Item -->
|
||||
<menuitem id="menu_chatgpt_ai_instance"
|
||||
name="ChatGPT-Compatible"
|
||||
parent="ai_integration.menu_ai_integration_config"
|
||||
action="action_chatgpt_ai_instance"
|
||||
sequence="20"/>
|
||||
</odoo>
|
||||
22
apply_inventory_prompt_for_reason/__manifest__.py
Normal file
22
apply_inventory_prompt_for_reason/__manifest__.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"name": "Stock Quant Apply Single Inventory with Reason",
|
||||
"author": "Bemade Inc.",
|
||||
"version": "18.0.1.0.0",
|
||||
"category": "Inventory/Inventory",
|
||||
"summary": "Add reason dialog when applying single inventory adjustment",
|
||||
"description": """
|
||||
This module modifies the behavior of the Apply button on stock quants to show
|
||||
the same reason dialog as when using Apply All, ensuring consistency in
|
||||
inventory adjustment tracking.
|
||||
""",
|
||||
"author": "Bemade",
|
||||
"website": "https://bemade.org",
|
||||
"depends": ["stock"],
|
||||
"data": [
|
||||
"views/stock_quant_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
"license": "LGPL-3",
|
||||
"application": False,
|
||||
}
|
||||
1
apply_inventory_prompt_for_reason/models/__init__.py
Normal file
1
apply_inventory_prompt_for_reason/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import stock_quant
|
||||
24
apply_inventory_prompt_for_reason/models/stock_quant.py
Normal file
24
apply_inventory_prompt_for_reason/models/stock_quant.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, _
|
||||
|
||||
|
||||
class StockQuant(models.Model):
|
||||
_inherit = 'stock.quant'
|
||||
|
||||
def action_apply_single_inventory(self):
|
||||
"""
|
||||
New method that opens the same reason dialog as action_apply_all
|
||||
for single inventory adjustments.
|
||||
"""
|
||||
ctx = dict(self.env.context or {}, default_quant_ids=self.ids)
|
||||
view = self.env.ref('stock.stock_inventory_adjustment_name_form_view', False)
|
||||
return {
|
||||
'name': _('Inventory Adjustment Reference / Reason'),
|
||||
'type': 'ir.actions.act_window',
|
||||
'views': [(view.id, 'form')],
|
||||
'res_model': 'stock.inventory.adjustment.name',
|
||||
'target': 'new',
|
||||
'context': ctx,
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_stock_quant_tree_inventory_editable_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.quant.tree.inventory.editable.inherit</field>
|
||||
<field name="model">stock.quant</field>
|
||||
<field name="inherit_id" ref="stock.view_stock_quant_tree_inventory_editable"/>
|
||||
<field name="arch" type="xml">
|
||||
<button name="action_apply_inventory" position="attributes">
|
||||
<attribute name="name">action_apply_single_inventory</attribute>
|
||||
</button>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
3
batch_picking_create_one_bill/__init__.py
Normal file
3
batch_picking_create_one_bill/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import models
|
||||
from . import wizard
|
||||
34
batch_picking_create_one_bill/__manifest__.py
Normal file
34
batch_picking_create_one_bill/__manifest__.py
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
{
|
||||
"name": "Batch Picking - Create One Bill",
|
||||
"version": "18.0.1.0.1",
|
||||
"category": "Inventory/Purchase",
|
||||
"summary": "Créer une seule facture fournisseur pour tous les bons de commande d'un batch picking",
|
||||
"description": """
|
||||
Ce module permet de générer une seule facture fournisseur pour tous les bons de commande
|
||||
associés à un batch picking.
|
||||
|
||||
Fonctionnalités:
|
||||
- Option pour initialiser les quantités à zéro lors de la création d'un batch
|
||||
- Bouton pour créer une facture groupée à partir d'un batch de transferts
|
||||
- Validation que tous les bons de commande proviennent du même fournisseur
|
||||
- Suivi des factures créées directement depuis le batch
|
||||
""",
|
||||
"author": "Pneumac",
|
||||
"website": "https://www.pneumac.ca",
|
||||
"depends": [
|
||||
"stock_picking_batch",
|
||||
"purchase",
|
||||
"purchase_stock",
|
||||
"account",
|
||||
"account_reports",
|
||||
],
|
||||
"data": [
|
||||
"wizard/stock_picking_to_batch_views.xml",
|
||||
"views/stock_picking_batch_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
"auto_install": False,
|
||||
"license": "LGPL-3",
|
||||
}
|
||||
2
batch_picking_create_one_bill/models/__init__.py
Normal file
2
batch_picking_create_one_bill/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import stock_picking_batch
|
||||
167
batch_picking_create_one_bill/models/stock_picking_batch.py
Normal file
167
batch_picking_create_one_bill/models/stock_picking_batch.py
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api, Command, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
class StockPickingBatch(models.Model):
|
||||
_inherit = "stock.picking.batch"
|
||||
|
||||
purchase_order_ids = fields.Many2many(
|
||||
"purchase.order",
|
||||
string="Bons de commande",
|
||||
compute="_compute_purchase_orders",
|
||||
compute_sudo=True,
|
||||
)
|
||||
|
||||
partner_ids = fields.Many2many(
|
||||
"res.partner",
|
||||
string="Fournisseur",
|
||||
compute="_compute_partner",
|
||||
compute_sudo=True,
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
# Handle zero_quantity_default from context if not explicitly set in vals
|
||||
for vals in vals_list:
|
||||
if (
|
||||
"zero_quantity_default" not in vals
|
||||
and self.env.context.get("default_zero_quantity_default") is not None
|
||||
):
|
||||
vals["zero_quantity_default"] = self.env.context.get(
|
||||
"default_zero_quantity_default"
|
||||
)
|
||||
return super().create(vals_list)
|
||||
|
||||
zero_quantity_default = fields.Boolean(
|
||||
string="Quantités à zéro par défaut",
|
||||
default=True,
|
||||
help="Initialiser les quantités à zéro lors de la création du batch",
|
||||
)
|
||||
invoice_ids = fields.Many2many(
|
||||
"account.move",
|
||||
string="Factures associées",
|
||||
compute="_compute_invoice_ids",
|
||||
compute_sudo=True,
|
||||
)
|
||||
invoice_count = fields.Integer(
|
||||
string="Nombre de factures",
|
||||
compute="_compute_invoice_count",
|
||||
compute_sudo=True,
|
||||
)
|
||||
|
||||
# Field computation methods
|
||||
|
||||
@api.depends("picking_ids.move_ids.purchase_line_id.order_id")
|
||||
def _compute_purchase_orders(self):
|
||||
for batch in self:
|
||||
batch.purchase_order_ids = (
|
||||
batch.picking_ids.move_ids.purchase_line_id.order_id
|
||||
)
|
||||
|
||||
@api.depends("purchase_order_ids")
|
||||
def _compute_partner(self):
|
||||
for wizard in self:
|
||||
wizard.partner_ids = wizard.purchase_order_ids.mapped("partner_id")
|
||||
|
||||
def _prepare_move_line_vals(self, **kwargs):
|
||||
vals = super()._prepare_move_line_vals(**kwargs)
|
||||
if self.zero_quantity_default:
|
||||
vals["quantity"] = 0.0
|
||||
return vals
|
||||
|
||||
@api.depends("move_line_ids.move_id.purchase_line_id.order_id.invoice_ids")
|
||||
def _compute_invoice_ids(self):
|
||||
for batch in self:
|
||||
batch.invoice_ids = (
|
||||
batch.move_line_ids.move_id.purchase_line_id.invoice_lines.move_id
|
||||
)
|
||||
|
||||
@api.depends("invoice_ids")
|
||||
def _compute_invoice_count(self):
|
||||
for batch in self:
|
||||
batch.invoice_count = len(batch.invoice_ids)
|
||||
|
||||
# Actions
|
||||
|
||||
def action_view_invoices(self):
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
"account.action_move_in_invoice_type"
|
||||
)
|
||||
if self.invoice_count == 1:
|
||||
action["views"] = [(False, "form")]
|
||||
action["res_id"] = self.invoice_ids.id
|
||||
else:
|
||||
action["domain"] = [("id", "in", self.invoice_ids.ids)]
|
||||
return action
|
||||
|
||||
def action_confirm(self):
|
||||
"""Override to set zero quantity on move lines at confirmation of the batch"""
|
||||
res = super().action_confirm()
|
||||
self.filtered("zero_quantity_default").move_line_ids.write({"quantity": 0})
|
||||
return res
|
||||
|
||||
def action_create_bill(self):
|
||||
self.ensure_one()
|
||||
if not self.purchase_order_ids:
|
||||
raise ValidationError(_("No purchase orders found in this batch."))
|
||||
|
||||
if len(self.partner_ids) > 1:
|
||||
raise ValidationError(_("The batch must have only one supplier."))
|
||||
|
||||
bill = self.env["account.move"].create(self._get_bill_values())
|
||||
return self._get_view_bill_action(bill)
|
||||
|
||||
# Helpers
|
||||
|
||||
def _get_view_bill_action(self, bill):
|
||||
action = self.env["ir.actions.act_window"]._for_xml_id(
|
||||
"account.action_move_in_invoice_type"
|
||||
)
|
||||
action["views"] = [(False, "form")]
|
||||
action["res_id"] = bill.id
|
||||
return action
|
||||
|
||||
def _get_currency_id(self):
|
||||
currency_id = self.purchase_order_ids.mapped("currency_id")
|
||||
if len(currency_id) > 1:
|
||||
raise UserError(_("The selected receipts do not have the same currency."))
|
||||
return currency_id.id
|
||||
|
||||
def _get_bill_values(self):
|
||||
"""Build a dictionary of the values for the vendor bill to create."""
|
||||
|
||||
company_id = self.company_id.id
|
||||
partner_id = self.partner_ids.id
|
||||
invoice_date = self.scheduled_date
|
||||
invoice_origin = ", ".join(self.purchase_order_ids.mapped("name"))
|
||||
move_line_ids = self._get_line_values()
|
||||
currency_id = self._get_currency_id()
|
||||
return {
|
||||
"company_id": company_id,
|
||||
"partner_id": partner_id,
|
||||
"move_type": "in_invoice",
|
||||
"invoice_date": invoice_date,
|
||||
"invoice_origin": invoice_origin,
|
||||
"currency_id": currency_id,
|
||||
"line_ids": move_line_ids,
|
||||
}
|
||||
|
||||
def _get_line_values(self):
|
||||
"""For each of the stock.move.line in the batch, build a dictionary of values for the invoice line to match."""
|
||||
line_vals = []
|
||||
for move_line in self.move_line_ids:
|
||||
purchase_line_id = move_line.move_id.purchase_line_id
|
||||
line_vals.append(
|
||||
Command.create(
|
||||
{
|
||||
"product_id": move_line.product_id.id,
|
||||
"quantity": move_line.quantity,
|
||||
"price_unit": purchase_line_id.price_unit,
|
||||
"discount": purchase_line_id.discount,
|
||||
"purchase_line_id": purchase_line_id.id,
|
||||
}
|
||||
)
|
||||
)
|
||||
return line_vals
|
||||
693
batch_picking_create_one_bill/specifications.md
Normal file
693
batch_picking_create_one_bill/specifications.md
Normal file
|
|
@ -0,0 +1,693 @@
|
|||
# Spécifications du module batch_picking_create_one_bill
|
||||
|
||||
## Objectif
|
||||
Créer un module Odoo 18.0 permettant de générer une seule facture fournisseur (vendor bill) consolidée pour tous les bons de commande (Purchase Orders) associés à un batch picking, en utilisant la méthode standard de création de factures d'Odoo basée sur les réceptions.
|
||||
|
||||
## Fonctionnalités principales
|
||||
|
||||
### 1. Modification du comportement des Batch Pickings
|
||||
|
||||
#### Héritage du modèle `stock.picking.batch`
|
||||
|
||||
##### Champs ajoutés
|
||||
```python
|
||||
# Dans stock_picking_batch.py
|
||||
class StockPickingBatch(models.Model):
|
||||
_inherit = 'stock.picking.batch'
|
||||
|
||||
zero_quantity_default = fields.Boolean(
|
||||
string='Quantités à zéro par défaut',
|
||||
default=True,
|
||||
help='Initialiser les quantités à zéro lors de la création du batch'
|
||||
)
|
||||
invoice_count = fields.Integer(
|
||||
compute='_compute_invoice_count',
|
||||
string='Nombre de factures'
|
||||
)
|
||||
invoice_ids = fields.Many2many(
|
||||
'account.move',
|
||||
string='Factures associées',
|
||||
compute='_compute_invoice_ids',
|
||||
store=True
|
||||
)
|
||||
```
|
||||
|
||||
##### Surcharge des méthodes
|
||||
|
||||
###### Initialisation des quantités
|
||||
```python
|
||||
def _prepare_move_line_vals(self, **kwargs):
|
||||
vals = super()._prepare_move_line_vals(**kwargs)
|
||||
if self.zero_quantity_default:
|
||||
vals['quantity'] = 0.0
|
||||
return vals
|
||||
```
|
||||
|
||||
###### Calcul des factures associées
|
||||
```python
|
||||
@api.depends('picking_ids', 'picking_ids.purchase_id.invoice_ids')
|
||||
def _compute_invoice_ids(self):
|
||||
for batch in self:
|
||||
invoices = batch.picking_ids.mapped('purchase_id.invoice_ids')
|
||||
batch.invoice_ids = invoices
|
||||
batch.invoice_count = len(invoices)
|
||||
```
|
||||
|
||||
##### Actions et boutons
|
||||
```python
|
||||
def action_view_invoices(self):
|
||||
self.ensure_one()
|
||||
action = self.env['ir.actions.act_window']._for_xml_id(
|
||||
'account.action_move_in_invoice_type'
|
||||
)
|
||||
if self.invoice_count == 1:
|
||||
action['views'] = [(False, 'form')]
|
||||
action['res_id'] = self.invoice_ids.id
|
||||
else:
|
||||
action['domain'] = [('id', 'in', self.invoice_ids.ids)]
|
||||
return action
|
||||
```
|
||||
|
||||
#### Modification des vues
|
||||
|
||||
##### Vue formulaire du batch picking
|
||||
```xml
|
||||
<record id="view_picking_batch_form_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.picking.batch.form.inherit</field>
|
||||
<field name="model">stock.picking.batch</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_batch_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="%(action_create_batch_bill_wizard)d"
|
||||
string="Créer Facture"
|
||||
type="action"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': [('state', '!=', 'done')]}"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button class="oe_stat_button"
|
||||
name="action_view_invoices"
|
||||
type="object"
|
||||
icon="fa-pencil-square-o">
|
||||
<field name="invoice_count" widget="statinfo" string="Factures"/>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//group[@name='group_misc']" position="inside">
|
||||
<field name="zero_quantity_default"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
#### Gestion des mouvements de stock
|
||||
|
||||
##### Modification du comportement des move lines
|
||||
```python
|
||||
def _update_move_lines_values(self, moves):
|
||||
res = super()._update_move_lines_values(moves)
|
||||
if self.zero_quantity_default:
|
||||
for move in moves:
|
||||
for line in move.move_line_ids:
|
||||
line.qty_done = 0.0
|
||||
return res
|
||||
```
|
||||
|
||||
##### Validation des quantités
|
||||
```python
|
||||
def _check_received_quantities(self):
|
||||
self.ensure_one()
|
||||
for move in self.move_lines:
|
||||
if move.quantity_done > move.product_uom_qty:
|
||||
raise ValidationError(
|
||||
_('La quantité reçue ne peut pas être supérieure '
|
||||
'à la quantité commandée pour le produit %s')
|
||||
% move.product_id.display_name
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Critères de regroupement
|
||||
- Regroupement uniquement des PO du même fournisseur
|
||||
- Facturation basée uniquement sur les quantités réellement reçues dans le batch picking
|
||||
- Les lignes de PO non reçues ne seront pas incluses dans la facture
|
||||
|
||||
### 2. Processus de création de facture
|
||||
- Un bouton dédié sera ajouté sur le batch picking après sa validation
|
||||
- Pour chaque PO du batch :
|
||||
1. Utilisation de la méthode standard d'Odoo `action_create_invoice` pour créer les factures basées sur les réceptions
|
||||
2. Les factures sont créées selon la politique 'Sur réception' (On received quantities)
|
||||
3. Création automatique de factures partielles pour les quantités reçues
|
||||
- Fusion automatique de toutes les factures créées en une seule facture
|
||||
1. Regroupement des lignes par produit
|
||||
2. Conservation des liens avec les PO d'origine
|
||||
3. Suppression des factures individuelles après fusion
|
||||
|
||||
### 3. Gestion des cas particuliers
|
||||
- Vérification des quantités reçues vs commandées
|
||||
- Validation que toutes les réceptions sont bien effectuées avant la création de la facture
|
||||
- Gestion des écarts entre les quantités commandées et reçues
|
||||
|
||||
### 4. Sécurité et droits d'accès
|
||||
- Restriction aux utilisateurs du groupe 'Invoicing user'
|
||||
- Vérification des droits d'accès sur les documents liés (PO, réceptions)
|
||||
|
||||
### 5. Interface utilisateur
|
||||
- Ajout d'un bouton sur la vue form du batch picking
|
||||
- Rapports spécifiques pour le suivi des factures groupées [À PRÉCISER]
|
||||
- Messages de confirmation/erreur clairs pour l'utilisateur
|
||||
|
||||
### 6. Aspects techniques
|
||||
- Pas de règles spécifiques pour la numérotation des factures (utilisation du système standard)
|
||||
- Intégration avec les modules stock_picking_batch et purchase
|
||||
- Respect des règles de facturation basées sur les réceptions
|
||||
|
||||
## Workflow
|
||||
1. Création et traitement normal du batch picking
|
||||
2. Validation du batch picking
|
||||
3. Utilisation du nouveau bouton pour créer la facture groupée
|
||||
4. Pour chaque PO impliqué dans le batch picking :
|
||||
- Utilisation de la méthode standard `action_create_invoice` d'Odoo
|
||||
- Création automatique des factures basées sur les quantités reçues
|
||||
- Les quantités non reçues restent en attente de facturation
|
||||
5. Fusion automatique de toutes les factures créées en une seule facture fournisseur
|
||||
- Regroupement des lignes par produit
|
||||
- Maintien des références aux PO d'origine
|
||||
- Conservation des taxes et comptes analytiques
|
||||
- Suppression des factures individuelles après fusion réussie
|
||||
6. Traitement normal de la facture fusionnée
|
||||
|
||||
## Impact sur les processus existants
|
||||
- Les utilisateurs devront entrer manuellement les quantités reçues au lieu de les ajuster
|
||||
- Réduction des erreurs de réception dues aux quantités pré-remplies
|
||||
- Meilleure traçabilité des quantités réellement reçues vs commandées
|
||||
|
||||
## Détails techniques d'implémentation
|
||||
|
||||
### 1. Modification du comportement des Batch Pickings
|
||||
- Héritage du modèle `stock.picking.batch`
|
||||
- Surcharge de la méthode de création pour initialiser les quantités à 0
|
||||
- Modification des champs de quantité dans `stock.move.line`
|
||||
|
||||
### 2. Gestion des factures
|
||||
|
||||
#### Wizard de création de facture (`create_merged_bill.py`)
|
||||
|
||||
##### 1. Modèles
|
||||
|
||||
###### Modèle principal : `batch.picking.create.bill.wizard`
|
||||
- Champs principaux :
|
||||
- `batch_id`: Many2one vers stock.picking.batch (readonly)
|
||||
- Batch picking source pour la création de la facture
|
||||
- `purchase_order_ids`: Many2many vers purchase.order (computed, readonly)
|
||||
- Liste des PO associés au batch
|
||||
- Calculé automatiquement depuis le batch
|
||||
- `partner_id`: Many2one vers res.partner (computed, readonly)
|
||||
- Fournisseur commun à tous les PO
|
||||
- Vérification d'unicité du fournisseur
|
||||
- `invoice_ids`: Many2many vers account.move (computed)
|
||||
- Factures créées par le processus standard d'Odoo
|
||||
- Utilisé temporairement avant la fusion
|
||||
- `merged_invoice_id`: Many2one vers account.move
|
||||
- Facture finale fusionnée
|
||||
- `preview_available`: Boolean (computed)
|
||||
- Indique si la prévisualisation est possible
|
||||
|
||||
###### Modèle temporaire : `batch.picking.create.bill.line.preview`
|
||||
- Champs :
|
||||
- `wizard_id`: Many2one vers le wizard principal
|
||||
- `product_id`: Many2one vers product.product
|
||||
- `description`: Char (nom du produit + description)
|
||||
- `quantity`: Float (quantité reçue)
|
||||
- `uom_id`: Many2one vers uom.uom
|
||||
- `price_unit`: Float
|
||||
- `price_subtotal`: Float
|
||||
- `purchase_line_ids`: Many2many vers purchase.order.line
|
||||
- `picking_ids`: Many2many vers stock.picking
|
||||
|
||||
##### 2. Fonctions principales
|
||||
|
||||
###### Calculs et vérifications
|
||||
- `_compute_purchase_orders()`:
|
||||
```python
|
||||
def _compute_purchase_orders(self):
|
||||
for wizard in self:
|
||||
pickings = wizard.batch_id.picking_ids
|
||||
purchase_orders = pickings.mapped('purchase_id')
|
||||
wizard.purchase_order_ids = purchase_orders
|
||||
```
|
||||
|
||||
- `_compute_partner()`:
|
||||
```python
|
||||
def _compute_partner(self):
|
||||
for wizard in self:
|
||||
partners = wizard.purchase_order_ids.mapped('partner_id')
|
||||
if len(partners) != 1:
|
||||
raise ValidationError(_('Tous les PO doivent avoir le même fournisseur'))
|
||||
wizard.partner_id = partners
|
||||
```
|
||||
|
||||
###### Préparation des données
|
||||
- `_prepare_invoice_lines()`:
|
||||
- Regroupe les lignes par produit
|
||||
- Calcule les quantités reçues
|
||||
- Vérifie la cohérence des prix
|
||||
- Génère les lignes de facture
|
||||
|
||||
- `_prepare_preview_lines()`:
|
||||
- Crée les lignes de prévisualisation
|
||||
- Affiche les détails des réceptions
|
||||
- Calcule les sous-totaux
|
||||
|
||||
###### Actions
|
||||
- `action_create_bill()`:
|
||||
1. Vérifications préalables
|
||||
2. Création des factures par PO
|
||||
3. Fusion des factures
|
||||
4. Liaison avec le batch picking
|
||||
5. Retour vers la facture créée
|
||||
|
||||
##### 3. Interface utilisateur
|
||||
|
||||
###### Vue formulaire principale
|
||||
```xml
|
||||
<form>
|
||||
<header>
|
||||
<button name="action_create_bill"
|
||||
string="Créer Facture"
|
||||
type="object"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': [('preview_available', '=', False)]}"/>
|
||||
<button special="cancel" string="Annuler" class="btn-secondary"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<group>
|
||||
<group>
|
||||
<field name="batch_id"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="currency_id"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="amount_total"/>
|
||||
<field name="preview_available" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Ordres d'achat">
|
||||
<field name="purchase_order_ids"/>
|
||||
</page>
|
||||
<page string="Prévisualisation des lignes">
|
||||
<field name="preview_line_ids">
|
||||
<tree>
|
||||
<field name="product_id"/>
|
||||
<field name="description"/>
|
||||
<field name="quantity"/>
|
||||
<field name="uom_id"/>
|
||||
<field name="price_unit"/>
|
||||
<field name="price_subtotal"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
</form>
|
||||
```
|
||||
|
||||
##### 4. Messages et Validations
|
||||
|
||||
###### Messages d'erreur
|
||||
- Fournisseur différent :
|
||||
```python
|
||||
_('Les bons de commande sélectionnés ont des fournisseurs différents : %s')
|
||||
```
|
||||
- Batch non validé :
|
||||
```python
|
||||
_('Le batch picking doit être validé avant de créer la facture')
|
||||
```
|
||||
- Quantités invalides :
|
||||
```python
|
||||
_('Certaines lignes ont des quantités reçues invalides')
|
||||
```
|
||||
|
||||
###### Validations automatiques
|
||||
- Vérification de l'état du batch
|
||||
- Contrôle des quantités reçues
|
||||
- Vérification des devises
|
||||
- Validation des droits d'accès
|
||||
|
||||
### 3. Modifications de l'interface
|
||||
|
||||
#### Modifications des vues principales
|
||||
|
||||
##### 1. Vue formulaire du batch picking (`stock_picking_batch_views.xml`)
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Héritage de la vue form du batch picking -->
|
||||
<record id="view_picking_batch_form_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.picking.batch.form.inherit</field>
|
||||
<field name="model">stock.picking.batch</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_batch_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Ajout du bouton de création de facture -->
|
||||
<xpath expr="//header" position="inside">
|
||||
<button name="%(action_create_batch_bill_wizard)d"
|
||||
string="Créer Facture"
|
||||
type="action"
|
||||
class="btn-primary"
|
||||
attrs="{'invisible': [('state', '!=', 'done')]}"/>
|
||||
</xpath>
|
||||
<!-- Ajout du smart button pour les factures -->
|
||||
<div name="button_box" position="inside">
|
||||
<button class="oe_stat_button"
|
||||
name="action_view_invoices"
|
||||
type="object"
|
||||
icon="fa-pencil-square-o">
|
||||
<field name="invoice_count" widget="statinfo" string="Factures"/>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Ajout de l'option quantités à zéro -->
|
||||
<group name="group_misc" position="inside">
|
||||
<field name="zero_quantity_default"/>
|
||||
</group>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue tree des batch pickings -->
|
||||
<record id="view_picking_batch_tree_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.picking.batch.tree.inherit</field>
|
||||
<field name="model">stock.picking.batch</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_batch_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="state" position="after">
|
||||
<field name="invoice_count"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Vue search des batch pickings -->
|
||||
<record id="view_picking_batch_search_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.picking.batch.search.inherit</field>
|
||||
<field name="model">stock.picking.batch</field>
|
||||
<field name="inherit_id" ref="stock.view_picking_batch_search"/>
|
||||
<field name="arch" type="xml">
|
||||
<filter name="to_process" position="after">
|
||||
<filter string="Non facturé"
|
||||
name="not_invoiced"
|
||||
domain="[('invoice_count', '=', 0)]"/>
|
||||
<filter string="Facturé"
|
||||
name="invoiced"
|
||||
domain="[('invoice_count', '>', 0)]"/>
|
||||
</filter>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
##### 2. Vue formulaire de la facture (`account_move_views.xml`)
|
||||
```xml
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_move_form_inherit" model="ir.ui.view">
|
||||
<field name="name">account.move.form.inherit</field>
|
||||
<field name="model">account.move</field>
|
||||
<field name="inherit_id" ref="account.view_move_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<!-- Ajout des références aux batch pickings -->
|
||||
<xpath expr="//field[@name='invoice_origin']" position="after">
|
||||
<field name="batch_picking_ids"
|
||||
widget="many2many_tags"
|
||||
readonly="1"
|
||||
attrs="{'invisible': [('move_type', '!=', 'in_invoice')]}"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
```
|
||||
|
||||
#### Actions et menus
|
||||
|
||||
##### Actions du wizard
|
||||
```xml
|
||||
<record id="action_create_batch_bill_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Créer Facture Fournisseur</field>
|
||||
<field name="res_model">batch.picking.create.bill.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="model_stock_picking_batch"/>
|
||||
<field name="binding_view_types">form</field>
|
||||
</record>
|
||||
```
|
||||
|
||||
#### Sécurité et droits d'accès
|
||||
|
||||
##### Groupes et droits
|
||||
```xml
|
||||
<record id="group_batch_invoice" model="res.groups">
|
||||
<field name="name">Création de factures depuis batch picking</field>
|
||||
<field name="category_id" ref="base.module_category_inventory"/>
|
||||
<field name="implied_ids" eval="[(4, ref('account.group_account_invoice'))]"/>
|
||||
</record>
|
||||
```
|
||||
|
||||
##### Règles d'accès
|
||||
```csv
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_batch_bill_wizard,access.batch.picking.create.bill.wizard,model_batch_picking_create_bill_wizard,group_batch_invoice,1,1,1,0
|
||||
access_batch_bill_line_preview,access.batch.picking.create.bill.line.preview,model_batch_picking_create_bill_line_preview,group_batch_invoice,1,1,1,1
|
||||
```
|
||||
|
||||
### 4. Points techniques clés
|
||||
|
||||
#### Manifest du module
|
||||
```python
|
||||
{
|
||||
'name': 'Batch Picking - Create One Bill',
|
||||
'version': '18.0.1.0.0',
|
||||
'category': 'Inventory/Purchase',
|
||||
'summary': 'Créer une seule facture pour tous les PO d’un batch picking',
|
||||
'author': 'Pneumac',
|
||||
'website': 'https://www.pneumac.com',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'stock_picking_batch',
|
||||
'purchase',
|
||||
'account',
|
||||
],
|
||||
'data': [
|
||||
'security/security_groups.xml',
|
||||
'security/ir.model.access.csv',
|
||||
'views/stock_picking_batch_views.xml',
|
||||
'views/account_move_views.xml',
|
||||
'wizard/create_merged_bill_views.xml',
|
||||
],
|
||||
'demo': [],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': False,
|
||||
}
|
||||
```
|
||||
|
||||
#### Structure complète des fichiers
|
||||
```
|
||||
batch_picking_create_one_bill/
|
||||
├── __init__.py # Import des sous-modules
|
||||
├── __manifest__.py # Configuration du module
|
||||
├── models/ # Définition des modèles
|
||||
│ ├── __init__.py
|
||||
│ ├── stock_picking_batch.py # Extension du modèle batch
|
||||
│ └── account_move.py # Extension du modèle facture
|
||||
├── wizard/ # Assistant de création de facture
|
||||
│ ├── __init__.py
|
||||
│ ├── create_merged_bill.py # Logique du wizard
|
||||
│ └── create_merged_bill_views.xml # Vues du wizard
|
||||
├── views/ # Vues des modèles
|
||||
│ ├── stock_picking_batch_views.xml
|
||||
│ └── account_move_views.xml
|
||||
├── security/ # Sécurité et droits d'accès
|
||||
│ ├── ir.model.access.csv # Règles d'accès aux modèles
|
||||
│ └── security_groups.xml # Définition des groupes
|
||||
├── static/ # Ressources statiques
|
||||
│ └── description/
|
||||
│ └── icon.png # Icône du module
|
||||
└── i18n/ # Traductions
|
||||
└── fr.po # Traduction française
|
||||
|
||||
```
|
||||
|
||||
#### Points techniques spécifiques
|
||||
|
||||
##### 1. Gestion des hooks
|
||||
```python
|
||||
# Dans stock_picking_batch.py
|
||||
from odoo import models, fields, api, _
|
||||
|
||||
class StockPickingBatch(models.Model):
|
||||
_inherit = 'stock.picking.batch'
|
||||
|
||||
@api.model
|
||||
def create(self, vals):
|
||||
# Hook de création pour initialiser les quantités à 0
|
||||
res = super().create(vals)
|
||||
if res.zero_quantity_default:
|
||||
res._reset_quantities()
|
||||
return res
|
||||
|
||||
def write(self, vals):
|
||||
# Hook d'écriture pour gérer les modifications
|
||||
res = super().write(vals)
|
||||
if 'zero_quantity_default' in vals:
|
||||
self._handle_quantity_change()
|
||||
return res
|
||||
```
|
||||
|
||||
##### 2. Gestion des contraintes
|
||||
```python
|
||||
@api.constrains('picking_ids', 'picking_ids.purchase_id')
|
||||
def _check_purchase_orders(self):
|
||||
for batch in self:
|
||||
purchases = batch.picking_ids.mapped('purchase_id')
|
||||
partners = purchases.mapped('partner_id')
|
||||
if len(partners) > 1:
|
||||
raise ValidationError(_(
|
||||
'Le batch picking ne peut contenir que des PO '
|
||||
'du même fournisseur.'
|
||||
))
|
||||
```
|
||||
|
||||
##### 3. Performance et optimisation
|
||||
```python
|
||||
# Dans create_merged_bill.py
|
||||
from odoo.tools.profiler import profile
|
||||
|
||||
@profile
|
||||
def _prepare_invoice_lines(self):
|
||||
# Optimisation avec prefetch
|
||||
products = self.env['product.product'].browse(
|
||||
self.purchase_order_ids.mapped('order_line.product_id.id')
|
||||
).exists()
|
||||
products.read(['name', 'type', 'invoice_policy'])
|
||||
|
||||
# Traitement par lots
|
||||
moves_by_product = {}
|
||||
for move in self.batch_id.move_lines:
|
||||
if move.product_id not in moves_by_product:
|
||||
moves_by_product[move.product_id] = self.env['stock.move']
|
||||
moves_by_product[move.product_id] |= move
|
||||
```
|
||||
|
||||
##### 4. Gestion des erreurs
|
||||
```python
|
||||
class BatchBillError(Exception):
|
||||
""" Erreur spécifique pour la création de facture depuis batch """
|
||||
pass
|
||||
|
||||
def _handle_bill_creation_error(self, error):
|
||||
if isinstance(error, BatchBillError):
|
||||
# Erreur métier spécifique
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': _('Erreur'),
|
||||
'message': str(error),
|
||||
'type': 'danger',
|
||||
}
|
||||
}
|
||||
# Autres erreurs
|
||||
raise error
|
||||
```
|
||||
|
||||
##### 5. Tests unitaires
|
||||
```python
|
||||
# Dans tests/test_batch_bill.py
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestBatchBill(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Préparation des données de test
|
||||
|
||||
def test_create_bill_from_batch(self):
|
||||
# Test de création de facture
|
||||
|
||||
def test_zero_quantity_default(self):
|
||||
# Test de l'initialisation des quantités
|
||||
|
||||
def test_merge_bills(self):
|
||||
# Test de la fusion des factures
|
||||
```
|
||||
- Gestion des états des documents (draft, done, etc.)
|
||||
- Traçabilité complète via les champs related et stored
|
||||
- Gestion des droits d'accès via les groupes de sécurité
|
||||
|
||||
### 5. Améliorations suggérées
|
||||
|
||||
#### 1. Fonctionnalités supplémentaires
|
||||
- **Annulation en masse** :
|
||||
- Possibilité d'annuler la facture créée et de revenir à l'état précédent
|
||||
- Traçabilité des annulations dans l'historique
|
||||
|
||||
- **Rapports et analyses** :
|
||||
- Rapport de réconciliation PO/Réceptions/Factures
|
||||
|
||||
#### 2. Améliorations techniques
|
||||
- **Cache et performance** :
|
||||
- Mise en cache des calculs fréquents
|
||||
- Optimisation des requêtes SQL
|
||||
- Indexation des champs clés
|
||||
|
||||
- **Gestion des erreurs avancée** :
|
||||
- Journal d'erreurs détaillé
|
||||
- Mécanisme de reprise sur erreur
|
||||
- Notifications utilisateur améliorées
|
||||
|
||||
- **Tests automatisés** :
|
||||
- Tests d'intégration avec scénarios complexes
|
||||
- Tests de performance
|
||||
- Tests de régression
|
||||
|
||||
#### 3. Améliorations UX
|
||||
- **Interface utilisateur** :
|
||||
- Vue kanban pour les batch pickings avec statut de facturation
|
||||
- Filtres et groupements avancés
|
||||
- Aperçu rapide des informations clés
|
||||
|
||||
- **Processus utilisateur** :
|
||||
- Assistant de validation en plusieurs étapes
|
||||
- Suggestions automatiques basées sur l'historique
|
||||
- Messages d'aide contextuels
|
||||
|
||||
#### 4. Personnalisation
|
||||
- **Configuration avancée** :
|
||||
- Paramètres par entreprise
|
||||
|
||||
### 5. Structure des fichiers
|
||||
```
|
||||
batch_picking_create_one_bill/
|
||||
├── __init__.py
|
||||
├── __manifest__.py
|
||||
├── models/
|
||||
│ ├── __init__.py
|
||||
│ ├── stock_picking_batch.py
|
||||
│ └── account_move.py
|
||||
├── wizard/
|
||||
│ ├── __init__.py
|
||||
│ └── create_merged_bill.py
|
||||
├── views/
|
||||
│ ├── stock_picking_batch_views.xml
|
||||
│ └── account_move_views.xml
|
||||
├── security/
|
||||
│ └── ir.model.access.csv
|
||||
└── data/
|
||||
└── security_groups.xml
|
||||
```
|
||||
|
||||
## Dépendances
|
||||
- stock_picking_batch
|
||||
- purchase
|
||||
- account
|
||||
|
||||
## Version
|
||||
- Odoo 18.0
|
||||
1
batch_picking_create_one_bill/tests/__init__.py
Normal file
1
batch_picking_create_one_bill/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_batch_picking_bill
|
||||
286
batch_picking_create_one_bill/tests/test_batch_picking_bill.py
Normal file
286
batch_picking_create_one_bill/tests/test_batch_picking_bill.py
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
from odoo import fields
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestBatchPickingBill(TransactionCase):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
# Create users with different access rights
|
||||
cls.warehouse_user = cls.env["res.users"].create(
|
||||
{
|
||||
"name": "Warehouse User",
|
||||
"login": "warehouse_user",
|
||||
"email": "warehouse@test.com",
|
||||
"groups_id": [
|
||||
(
|
||||
6,
|
||||
0,
|
||||
[
|
||||
cls.env.ref("stock.group_stock_user").id,
|
||||
cls.env.ref("purchase.group_purchase_user").id,
|
||||
cls.env.ref(
|
||||
"account.group_account_invoice"
|
||||
).id, # Basic invoice rights
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
cls.accountant = cls.env["res.users"].create(
|
||||
{
|
||||
"name": "Accountant",
|
||||
"login": "accountant",
|
||||
"email": "accountant@test.com",
|
||||
"groups_id": [
|
||||
(
|
||||
6,
|
||||
0,
|
||||
[
|
||||
cls.env.ref("account.group_account_invoice").id,
|
||||
cls.env.ref("account.group_account_manager").id,
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Create vendor
|
||||
cls.vendor = cls.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Vendor",
|
||||
"email": "vendor@test.com",
|
||||
"supplier_rank": 1,
|
||||
}
|
||||
)
|
||||
|
||||
# Create products
|
||||
cls.product_a = cls.env["product.product"].create(
|
||||
{
|
||||
"name": "Product A",
|
||||
"type": "consu",
|
||||
"tracking": "none",
|
||||
"purchase_ok": True,
|
||||
}
|
||||
)
|
||||
cls.product_b = cls.env["product.product"].create(
|
||||
{
|
||||
"name": "Product B",
|
||||
"type": "consu",
|
||||
"tracking": "none",
|
||||
"purchase_ok": True,
|
||||
}
|
||||
)
|
||||
|
||||
# Create purchase orders
|
||||
po_vals = {
|
||||
"partner_id": cls.vendor.id,
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": cls.product_a.id,
|
||||
"name": cls.product_a.name,
|
||||
"product_qty": 5.0,
|
||||
"product_uom": cls.product_a.uom_po_id.id,
|
||||
"price_unit": 100.0,
|
||||
},
|
||||
),
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": cls.product_b.id,
|
||||
"name": cls.product_b.name,
|
||||
"product_qty": 3.0,
|
||||
"product_uom": cls.product_b.uom_po_id.id,
|
||||
"price_unit": 200.0,
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
cls.po1 = cls.env["purchase.order"].create(po_vals)
|
||||
cls.po2 = cls.env["purchase.order"].create(po_vals)
|
||||
|
||||
# Confirm purchase orders and receive products
|
||||
cls.po1.button_confirm()
|
||||
cls.po2.button_confirm()
|
||||
|
||||
# Receive products in pickings
|
||||
for picking in (cls.po1 + cls.po2).picking_ids:
|
||||
for move in picking.move_ids:
|
||||
move.quantity = move.product_qty
|
||||
|
||||
# Create a batch picking with the pickings
|
||||
cls.batch = cls.env["stock.picking.batch"].create(
|
||||
{
|
||||
"name": "Test Batch",
|
||||
"company_id": cls.env.company.id,
|
||||
"scheduled_date": fields.Date.today(),
|
||||
"picking_ids": [(6, 0, (cls.po1 + cls.po2).picking_ids.ids)],
|
||||
"zero_quantity_default": False,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a batch with multiple vendors for testing validation
|
||||
cls.vendor2 = cls.env["res.partner"].create(
|
||||
{
|
||||
"name": "Second Vendor",
|
||||
"email": "vendor2@test.com",
|
||||
"supplier_rank": 1,
|
||||
}
|
||||
)
|
||||
|
||||
# Create a purchase order with a different vendor
|
||||
po_vals_vendor2 = {
|
||||
"partner_id": cls.vendor2.id,
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": cls.product_a.id,
|
||||
"name": cls.product_a.name,
|
||||
"product_qty": 2.0,
|
||||
"product_uom": cls.product_a.uom_po_id.id,
|
||||
"price_unit": 100.0,
|
||||
},
|
||||
),
|
||||
],
|
||||
}
|
||||
cls.po3 = cls.env["purchase.order"].create(po_vals_vendor2)
|
||||
cls.po3.button_confirm()
|
||||
|
||||
# Process the picking for the second vendor
|
||||
for picking in cls.po3.picking_ids:
|
||||
for move in picking.move_ids:
|
||||
move.quantity = move.product_qty
|
||||
|
||||
# Create a batch with multiple vendors
|
||||
cls.multi_vendor_batch = cls.env["stock.picking.batch"].create(
|
||||
{
|
||||
"name": "Multi Vendor Batch",
|
||||
"company_id": cls.env.company.id,
|
||||
"scheduled_date": fields.Date.today(),
|
||||
"picking_ids": [(6, 0, (cls.po1 + cls.po3).picking_ids.ids)],
|
||||
"zero_quantity_default": False,
|
||||
}
|
||||
)
|
||||
|
||||
def test_action_create_bill_success(self):
|
||||
"""Test that a bill is successfully created from a batch picking"""
|
||||
# Ensure the batch has no invoices initially
|
||||
self.assertEqual(len(self.batch.invoice_ids), 0)
|
||||
self.assertEqual(self.batch.invoice_count, 0)
|
||||
|
||||
# Execute the action to create a bill
|
||||
action = self.batch.action_create_bill()
|
||||
|
||||
# Verify that an invoice was created
|
||||
self.assertEqual(len(self.batch.invoice_ids), 1)
|
||||
self.assertEqual(self.batch.invoice_count, 1)
|
||||
|
||||
# Verify the action returns the correct view
|
||||
self.assertEqual(action.get("res_id"), self.batch.invoice_ids[-1].id)
|
||||
self.assertEqual(action.get("views")[0][1], "form")
|
||||
|
||||
# Verify the bill content
|
||||
bill = self.batch.invoice_ids[-1]
|
||||
self.assertEqual(bill.partner_id, self.vendor)
|
||||
self.assertEqual(bill.move_type, "in_invoice")
|
||||
self.assertEqual(bill.invoice_date, self.batch.scheduled_date.date())
|
||||
|
||||
# Verify that the bill contains the correct lines
|
||||
expected_products = self.batch.move_line_ids.mapped("product_id")
|
||||
bill_products = bill.invoice_line_ids.mapped("product_id")
|
||||
self.assertEqual(set(bill_products.ids), set(expected_products.ids))
|
||||
|
||||
# Verify the quantities match
|
||||
for line in bill.invoice_line_ids:
|
||||
# Find matching move lines
|
||||
move_lines = self.batch.move_line_ids.filtered(
|
||||
lambda ml: ml.product_id == line.product_id
|
||||
)
|
||||
self.assertEqual(line.quantity, sum(move_lines.mapped("quantity")))
|
||||
|
||||
# Verify price from purchase order line
|
||||
purchase_line = move_lines[0].move_id.purchase_line_id
|
||||
self.assertEqual(line.price_unit, purchase_line.price_unit)
|
||||
|
||||
def test_action_create_bill_no_purchase_orders(self):
|
||||
"""Test that an error is raised when there are no purchase orders"""
|
||||
# Create an empty batch
|
||||
empty_batch = self.env["stock.picking.batch"].create(
|
||||
{
|
||||
"name": "Empty Batch",
|
||||
"company_id": self.env.company.id,
|
||||
"scheduled_date": fields.Date.today(),
|
||||
}
|
||||
)
|
||||
|
||||
# Try to create a bill and expect a ValidationError
|
||||
with self.assertRaises(ValidationError):
|
||||
empty_batch.action_create_bill()
|
||||
|
||||
def test_action_create_bill_multiple_vendors(self):
|
||||
"""Test that an error is raised when there are multiple vendors"""
|
||||
# Try to create a bill from a batch with multiple vendors
|
||||
with self.assertRaises(ValidationError):
|
||||
self.multi_vendor_batch.action_create_bill()
|
||||
|
||||
def test_action_create_bill_access_rights(self):
|
||||
"""Test access rights for creating bills from batch pickings"""
|
||||
# Test with warehouse user (should have access)
|
||||
self.batch.with_user(self.warehouse_user).action_create_bill()
|
||||
|
||||
# Verify that a bill was created
|
||||
self.assertEqual(len(self.batch.invoice_ids), 1)
|
||||
|
||||
# Create a user without invoice creation rights
|
||||
no_invoice_user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "No Invoice User",
|
||||
"login": "no_invoice_user",
|
||||
"email": "no_invoice@test.com",
|
||||
"groups_id": [(6, 0, [self.env.ref("stock.group_stock_user").id])],
|
||||
}
|
||||
)
|
||||
|
||||
# Create a new batch for testing with the limited user
|
||||
new_batch = self.env["stock.picking.batch"].create(
|
||||
{
|
||||
"name": "New Test Batch",
|
||||
"company_id": self.env.company.id,
|
||||
"scheduled_date": fields.Date.today(),
|
||||
"picking_ids": [(6, 0, self.po2.picking_ids.ids)],
|
||||
}
|
||||
)
|
||||
|
||||
# Try to create a bill with a user that doesn't have invoice creation rights
|
||||
with self.assertRaises(AccessError):
|
||||
new_batch.with_user(no_invoice_user).action_create_bill()
|
||||
|
||||
def test_bill_values_calculation(self):
|
||||
"""Test the helper methods that calculate bill values"""
|
||||
# Test _get_bill_values method
|
||||
bill_values = self.batch._get_bill_values()
|
||||
|
||||
self.assertEqual(bill_values["company_id"], self.batch.company_id.id)
|
||||
self.assertEqual(bill_values["partner_id"], self.batch.partner_ids.id)
|
||||
self.assertEqual(bill_values["move_type"], "in_invoice")
|
||||
self.assertEqual(bill_values["invoice_date"], self.batch.scheduled_date)
|
||||
|
||||
# The invoice_origin should contain the purchase order names
|
||||
for po_name in self.batch.purchase_order_ids.mapped("name"):
|
||||
self.assertIn(po_name, bill_values["invoice_origin"])
|
||||
|
||||
# Test currency consistency
|
||||
currency_id = self.batch._get_currency_id()
|
||||
self.assertEqual(currency_id, self.batch.purchase_order_ids[0].currency_id.id)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_picking_batch_form_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.picking.batch.form.inherit</field>
|
||||
<field name="model">stock.picking.batch</field>
|
||||
<field name="inherit_id" ref="stock_picking_batch.stock_picking_batch_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//header" position="inside">
|
||||
<field name="id" invisible="1"/>
|
||||
<button name="action_create_bill" string="Create Bill" type="object" class="btn-primary" context="{'default_batch_id': id}" invisible="state != 'done'"/>
|
||||
</xpath>
|
||||
<xpath expr="//div[@name='button_box']" position="inside">
|
||||
<button class="oe_stat_button" name="action_view_invoices" type="object" icon="fa-pencil-square-o">
|
||||
<field name="invoice_count" widget="statinfo" string="Factures"/>
|
||||
</button>
|
||||
</xpath>
|
||||
<xpath expr="//group[@id='batch_delivery_data']" position="inside">
|
||||
<field name="zero_quantity_default"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Inherit the move line tree view to add the Picked field -->
|
||||
<record id="view_move_line_tree_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.move.line.tree.picked.inherit</field>
|
||||
<field name="model">stock.move.line</field>
|
||||
<field name="inherit_id" ref="stock_picking_batch.view_move_line_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='tracking']" position="before">
|
||||
<field name="picked" optional="show"/>
|
||||
</xpath>
|
||||
<xpath expr="//list" position="attributes">
|
||||
<attribute name="decoration-success">picked</attribute>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
2
batch_picking_create_one_bill/wizard/__init__.py
Normal file
2
batch_picking_create_one_bill/wizard/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import stock_picking_to_batch
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
from odoo import _, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class StockPickingToBatch(models.TransientModel):
|
||||
_inherit = "stock.picking.to.batch"
|
||||
|
||||
zero_quantity_default = fields.Boolean(
|
||||
string="Zero Quantity by Default",
|
||||
default=False,
|
||||
help="If checked, the default quantity for new move lines will be 0 instead of the computed quantity.",
|
||||
)
|
||||
|
||||
def attach_pickings(self):
|
||||
self.ensure_one()
|
||||
# Pass zero_quantity_default through context to be handled in batch create
|
||||
return super(
|
||||
StockPickingToBatch,
|
||||
self.with_context(default_zero_quantity_default=self.zero_quantity_default),
|
||||
).attach_pickings()
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="stock_picking_to_batch_form_inherit" model="ir.ui.view">
|
||||
<field name="name">stock.picking.to.batch.form.inherit</field>
|
||||
<field name="model">stock.picking.to.batch</field>
|
||||
<field name="inherit_id" ref="stock_picking_batch.stock_picking_to_batch_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="is_create_draft" position="after">
|
||||
<field name="zero_quantity_default" invisible="mode != 'new'"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
# -*- 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'
|
||||
}
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import mail_wizard_invite
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
# Copyright Bemade.org
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class MailWizardInviteDefault(models.TransientModel):
|
||||
_inherit = 'mail.wizard.invite'
|
||||
|
||||
send_mail = fields.Boolean(
|
||||
default=False,
|
||||
help="If true, an invitation email will be sent to the recipient"
|
||||
)
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
#
|
||||
# Bemade Inc.
|
||||
#
|
||||
# Copyright (C) September 2023 Bemade Inc. (<https://www.bemade.org>).
|
||||
# Author: Marc Durepos (Contact : marc@bemade.org)
|
||||
#
|
||||
# This program is under the terms of the Odoo Proprietary License v1.0 (OPL-1)
|
||||
# It is forbidden to publish, distribute, sublicense, or sell copies of the Software
|
||||
# or modified copies of the Software.
|
||||
#
|
||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
|
||||
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
# DEALINGS IN THE SOFTWARE.
|
||||
#
|
||||
{
|
||||
'name': 'Documents Portal Base',
|
||||
'version': '17.0.1.0.0',
|
||||
'summary': 'Adds documents to the front-end portal.',
|
||||
'category': 'Document Management',
|
||||
'author': 'Bemade Inc.',
|
||||
'website': 'https://www.bemade.org',
|
||||
'license': 'OPL-1',
|
||||
'depends': ['documents', 'portal', 'mail_enterprise', 'im_livechat'],
|
||||
'data': ['views/document_portal_templates.xml'],
|
||||
'demo': [],
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
}
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
from odoo.http import request, route
|
||||
from odoo.exceptions import AccessError, MissingError
|
||||
from odoo import _
|
||||
|
||||
|
||||
class DocumentCustomerPortal(CustomerPortal):
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
rtn = super()._prepare_home_portal_values(counters)
|
||||
domain = self._prepare_documents_domain()
|
||||
rtn['documents_count'] = request.env['documents.document'].search_count(domain)
|
||||
return rtn
|
||||
|
||||
@route('/my/documents', type='http', auth='user', website=True)
|
||||
def portal_my_documents(self, **kwargs):
|
||||
values = self._prepare_portal_layout_values()
|
||||
Documents = request.env['documents.document']
|
||||
domain = self._prepare_documents_domain()
|
||||
documents_count = Documents.search_count(domain)
|
||||
documents = Documents.search(domain)
|
||||
values.update({
|
||||
'documents_count': documents_count,
|
||||
'documents': documents.sudo(),
|
||||
'default_url': '/my/documents',
|
||||
'page_name': 'my_documents',
|
||||
})
|
||||
return request.render("bemade_documents_portal.portal_my_documents", values)
|
||||
|
||||
def _prepare_documents_domain(self):
|
||||
partner = request.env.user.partner_id
|
||||
user = request.env.user
|
||||
"""Helper method intended to be overridden for future modules."""
|
||||
return ['|',
|
||||
('partner_id', '=', partner.id),
|
||||
('owner_id', '=', user.id),
|
||||
]
|
||||
|
||||
def _render_record_template(self, values):
|
||||
""" Override this method to apply a different template for a single document
|
||||
record on the portal. """
|
||||
return request.render("bemade_documents_portal.document_portal_template", values)
|
||||
|
||||
@route('/my/documents/<int:document_id>', type='http', auth='user', website=True)
|
||||
def portal_document_page(self, document_id, download=False, **kwargs):
|
||||
document = request.env['documents.document'].browse(document_id)
|
||||
if not document:
|
||||
raise MissingError(_('This document does not exist.'))
|
||||
if download:
|
||||
return self._download_attachment(document)
|
||||
values={
|
||||
'document': document,
|
||||
'page_name': 'my_documents',
|
||||
'action': document._get_portal_return_action(),
|
||||
}
|
||||
return self._render_record_template(values)
|
||||
|
||||
def _download_attachment(self, document):
|
||||
attachment = document.attachment_id
|
||||
headers = [
|
||||
('content-type', attachment.mimetype),
|
||||
('content-length', attachment.file_size),
|
||||
('content-disposition', f'attachment; filename="{document.name}"')
|
||||
]
|
||||
return request.make_response(attachment.raw, headers)
|
||||
|
|
@ -1 +0,0 @@
|
|||
from . import documents
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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')
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<data>
|
||||
<template id="portal_my_home" inherit_id="portal.portal_my_home">
|
||||
<xpath expr="//div[hasclass('o_portal_docs')]" position="inside">
|
||||
<t t-call="portal.portal_docs_entry">
|
||||
<t t-set="title">Documents</t>
|
||||
<t t-set="url">/my/documents</t>
|
||||
<t t-set="placeholder_count">documents_count</t>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
<template id="portal_my_documents" name="My Documents">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-call="portal.portal_table">
|
||||
<thead>
|
||||
<tr class="active">
|
||||
<th>Name</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<t t-foreach="documents" t-as="document">
|
||||
<tr>
|
||||
<td>
|
||||
<a t-att-href="document.get_portal_url()">
|
||||
<t t-esc="document.name"/>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</t>
|
||||
</tbody>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
<template id="document_portal_template" name="Document Portal Template">
|
||||
<t t-call="portal.portal_layout">
|
||||
<t t-set="o_portal_fullwidth_alert"
|
||||
groups="documents.group_documents_user">
|
||||
<t t-call="portal.portal_back_in_edit_mode">
|
||||
<t t-set="backend_url"
|
||||
t-value="'/web#model=%s&id=%s&action=%s&view_type=form' % (document._name, document.id, action.id)"/>
|
||||
</t>
|
||||
</t>
|
||||
<t t-call="portal.portal_record_layout">
|
||||
<t t-set="card_header">
|
||||
<div class="row no-gutters">
|
||||
<h5 class="mb-1 mb-md-0">
|
||||
<span t-field="document.name"/>
|
||||
</h5>
|
||||
</div>
|
||||
</t>
|
||||
<t t-set="card_body">
|
||||
<!-- Main Document Contents -->
|
||||
<div id="document_content"
|
||||
class="col-12 col-lg justify-content-end w-100 h-100">
|
||||
<div t-if="'image' in document.mimetype"
|
||||
class="o_attachment_preview_img">
|
||||
<img id="attachment_img"
|
||||
class="img img-fluid d-block"
|
||||
t-attf-src="/documents/content/{{document.id}}"/>
|
||||
</div>
|
||||
<iframe t-if="document.mimetype == 'application/pdf'"
|
||||
class="mb48 w-100 min-vh-100"
|
||||
t-attf-src="/web/static/lib/pdfjs/web/viewer.html?file=/documents/content/{{document.id}}&filename={{document.name}}"/>
|
||||
<ul class="list-group list-group-flush flex-wrap flex-row flex-lg-column">
|
||||
<li class="list-group-item flex-grow-1 b-0">
|
||||
<a class="btn btn-secondary btn-block o_download_btn"
|
||||
t-att-href="document.get_portal_url(download=True)">
|
||||
Download</a>
|
||||
</li>
|
||||
<li class="list-group-item flex-grow-1 b-0">
|
||||
<strong class="text-muted">File Size:
|
||||
<t t-call="documents.format_file_size"/>
|
||||
</strong>
|
||||
</li>
|
||||
<li class="list-group-item flex-grow-1 b-0">
|
||||
<strong class="text-muted">File Type:
|
||||
<t t-esc="document.mimetype"/>
|
||||
</strong>
|
||||
</li>
|
||||
<li class="list-group-item flex-grow-1 b-0">
|
||||
<strong class="text-muted">Attachment Type:
|
||||
<t t-esc="document.attachment_type"/>
|
||||
</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<!-- Chatter -->
|
||||
<div id="document_communication" class="card-body">
|
||||
<h2>History</h2>
|
||||
<t t-call="portal.message_thread">
|
||||
<t t-set="object" t-value="document"/>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
</template>
|
||||
<template id="portal_breadcrumbs" inherit_id="portal.portal_breadcrumbs">
|
||||
<xpath expr="//ol[hasclass('o_portal_submenu')]" position="inside">
|
||||
<li t-if="page_name == 'my_documents'"
|
||||
t-attf-class="breadcrumb-item #{'active ' if not document else ''}">
|
||||
<a t-if="document"
|
||||
t-attf-href="/my/documents?{{ keep_query() }}">Documents</a>
|
||||
<t t-else="">Documents</t>
|
||||
</li>
|
||||
<li t-if="document" class="breadcrumb-item active" t-esc="document.name">
|
||||
</li>
|
||||
</xpath>
|
||||
</template>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# Copyright (C) 2023 Bemade.org
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import models
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# -*- 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'
|
||||
}
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
# Copyright (C) 2023 Bemade.org
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
from . import fetchmail_server
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
15.0.0.0.1
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
* Initial release
|
||||
|
||||
|
||||
16.0.0.0.1
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
* Modification for V16 compatibility
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
#
|
||||
# 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,
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
<?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>
|
||||
|
|
@ -20,7 +20,7 @@
|
|||
########################################################################################
|
||||
{
|
||||
"name": "Improved Field Service Management",
|
||||
"version": "17.0.0.4.2",
|
||||
"version": "18.0.0.4.3",
|
||||
"summary": (
|
||||
"Adds functionality necessary for managing field service operations at Durpro."
|
||||
),
|
||||
|
|
|
|||
|
|
@ -3,26 +3,19 @@
|
|||
<record id="planning_project_stage_waiting_parts" model="project.task.type">
|
||||
<field name="sequence">2</field>
|
||||
<field name="name">Waiting on Parts</field>
|
||||
<!-- BV: legend_blocked n'existe plus -->
|
||||
<!-- BV: <field name="legend_blocked">Blocked</field>-->
|
||||
<field name="fold" eval="False" />
|
||||
<!-- <field name="is_closed" eval="False"/>-->
|
||||
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]" />
|
||||
</record>
|
||||
<record id="planning_project_stage_work_completed" model="project.task.type">
|
||||
<field name="sequence">15</field>
|
||||
<field name="name">Work Executed</field>
|
||||
<!-- BV: <field name="legend_blocked">Blocked</field>-->
|
||||
<field name="fold" eval="False" />
|
||||
<!-- <field name="is_closed" eval="False"/>-->
|
||||
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]" />
|
||||
</record>
|
||||
<record id="planning_project_stage_exception" model="project.task.type">
|
||||
<field name="sequence">19</field>
|
||||
<field name="name">Exception</field>
|
||||
<!-- BV: <field name="legend_blocked">Blocked</field>-->
|
||||
<field name="fold" eval="False" />
|
||||
<!-- <field name="is_closed" eval="False"/>-->
|
||||
<field name="project_ids" eval="[(4,ref('industry_fsm.fsm_project'))]" />
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -1,81 +0,0 @@
|
|||
from odoo import api, SUPERUSER_ID
|
||||
import logging
|
||||
from openupgradelib.openupgrade import update_module_moved_models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def migrate(cr, version):
|
||||
"""In this version, we separate the bemade_fsm.equipment and its associated models
|
||||
out into a new module named fsm_equipment. We need to move those models and rename
|
||||
some fields."""
|
||||
env = api.Environment(cr, SUPERUSER_ID, {})
|
||||
_logger.info("Moving FSM equipment...")
|
||||
# Move the old equipment over to the new table
|
||||
cr.execute(
|
||||
"""
|
||||
INSERT INTO fsm_equipment
|
||||
(id, code, name, description, partner_id, location_notes, active)
|
||||
SELECT id, pid_tag code, name, description, partner_location_id, location_notes,
|
||||
active
|
||||
FROM bemade_fsm_equipment
|
||||
"""
|
||||
)
|
||||
|
||||
_logger.info("Moving FSM equipment tags...")
|
||||
# Move the tags
|
||||
cr.execute(
|
||||
"""
|
||||
INSERT INTO fsm_equipment_tag (id, name, color)
|
||||
SELECT id, name, color FROM bemade_fsm_equipment_tag
|
||||
"""
|
||||
)
|
||||
|
||||
_logger.info("Re-creating equipment to tag relations.")
|
||||
# Add the relations
|
||||
# Schema | Name | Type | Owner
|
||||
# --------+---------------------------------------------------+-------+-------
|
||||
# public | bemade_fsm_equipment_bemade_fsm_equipment_tag_rel | table | odoo
|
||||
# public | bemade_fsm_equipment_sale_order_line_rel | table | odoo
|
||||
# public | bemade_fsm_equipment_sale_order_rel | table | odoo
|
||||
# public | bemade_fsm_task_equipment_rel | table | odoo
|
||||
# public | bemade_fsm_task_template_equipment_rel | table | odoo
|
||||
# public | fsm_equipment_fsm_equipment_tag_rel | table | odoo
|
||||
# public | fsm_equipment_sale_order_rel | table | odoo
|
||||
# public | fsm_task_equipment_rel | table | odoo
|
||||
|
||||
cr.execute(
|
||||
"""
|
||||
INSERT INTO fsm_task_equipment_rel (equipment_id, task_id)
|
||||
SELECT equipment_id, task_id from bemade_fsm_task_equipment_rel
|
||||
"""
|
||||
)
|
||||
|
||||
cr.execute(
|
||||
"""
|
||||
INSERT INTO fsm_equipment_fsm_equipment_tag_rel (fsm_equipment_id, fsm_equipment_tag_id)
|
||||
SELECT bemade_fsm_equipment_id, bemade_fsm_equipment_tag_id
|
||||
FROM bemade_fsm_equipment_bemade_fsm_equipment_tag_rel
|
||||
"""
|
||||
)
|
||||
|
||||
# Clean up
|
||||
|
||||
_logger.info("Deleting menu items.")
|
||||
cr.execute(
|
||||
"""
|
||||
DELETE FROM ir_ui_menu WHERE id in (
|
||||
SELECT res_id from ir_model_data where model='ir.ui.menu'
|
||||
and module='bemade_fsm'
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
cr.execute(
|
||||
"DELETE FROM ir_model_data where model='ir.ui.menu' and module='bemade_fsm'"
|
||||
)
|
||||
|
||||
cr.execute("DELETE FROM ir_model_fields where model ilike 'bemade_fsm.equipment%'")
|
||||
cr.execute(
|
||||
"DELETE FROM ir_model WHERE name->>'en_US' ilike 'bemade_fsm.equipment%'"
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue