Compare commits
77 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7156cb440e | ||
|
|
18dc17a3bb | ||
|
|
8c8cb76c3f | ||
|
|
3a4fc5762c | ||
|
|
6af14a2f60 | ||
|
|
b5df3a9735 | ||
|
|
b5e4b2d257 | ||
|
|
4840d9ac9e | ||
|
|
8561aeca57 | ||
|
|
6fa8068be5 | ||
|
|
c08f4779d8 | ||
|
|
31892a892a | ||
|
|
1da36c1e1c | ||
|
|
b627b1f57c | ||
|
|
3c9835270e | ||
|
|
852c0723b9 | ||
|
|
4a778d7d81 | ||
|
|
4563b8b8b1 | ||
|
|
f45cd3a633 | ||
|
|
a7eb187202 | ||
|
|
f4c6d0c21e | ||
|
|
bcd92d5039 | ||
|
|
c8e492b481 | ||
|
|
6e61c065a5 | ||
|
|
a273cde7ba | ||
|
|
69faaec42f | ||
|
|
8093509995 | ||
|
|
1e57afd3ca | ||
|
|
7c38f76dea | ||
|
|
20e4f6c8b3 | ||
|
|
401818765a | ||
|
|
d1e97e3448 | ||
|
|
0b5a791e1f | ||
|
|
4b19f8262e | ||
|
|
3ebadcb969 | ||
|
|
db10db822c | ||
|
|
9fb835db34 | ||
|
|
512bf58da8 | ||
|
|
873a7ba71a | ||
|
|
b19da588cd | ||
|
|
ab60babd7d | ||
|
|
055ceede53 | ||
|
|
9859a3cf96 | ||
|
|
57b401ca1a | ||
|
|
2322dbaae1 | ||
|
|
81a4a904c7 | ||
|
|
95a95298bb | ||
|
|
f9f32aab34 | ||
|
|
65fbcb9b33 | ||
|
|
799e944112 | ||
|
|
7ab530bd29 | ||
|
|
b1a625a0e4 | ||
|
|
45c1bdf440 | ||
|
|
370ebae08b | ||
|
|
d334b63696 | ||
|
|
424e9e5159 | ||
|
|
1db303d0a5 | ||
|
|
5050839056 | ||
|
|
010752b01f | ||
|
|
d644841f99 | ||
|
|
3ba182fe0e | ||
|
|
b06e9c4569 | ||
|
|
f3a7072314 | ||
|
|
57c938bb8d | ||
|
|
fa4f1b2b19 | ||
|
|
536f10b61e | ||
|
|
f6ab94e0b3 | ||
|
|
9578594628 | ||
|
|
5a97745d42 | ||
|
|
a0ff56ade2 | ||
|
|
8ce49de715 | ||
|
|
3f6f452fe1 | ||
|
|
8c1758dacd | ||
|
|
58e49f0db5 | ||
|
|
5b18adb083 | ||
|
|
fb057ada9a | ||
|
|
7783b04e73 |
238 changed files with 17569 additions and 647 deletions
|
|
@ -20,7 +20,7 @@
|
|||
########################################################################################
|
||||
{
|
||||
"name": "Improved Field Service Management",
|
||||
"version": "17.0.0.4.2",
|
||||
"version": "17.0.0.4.3",
|
||||
"summary": (
|
||||
"Adds functionality necessary for managing field service operations at Durpro."
|
||||
),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ class SaleOrder(models.Model):
|
|||
_inherit = "sale.order"
|
||||
|
||||
valid_equipment_ids = fields.One2many(
|
||||
comodel_name="fsm.equipment", related="partner_id.owned_equipment_ids"
|
||||
comodel_name="fsm.equipment",
|
||||
related="partner_id.commercial_partner_id.owned_equipment_ids",
|
||||
)
|
||||
|
||||
default_equipment_ids = fields.Many2many(
|
||||
|
|
@ -50,7 +51,6 @@ class SaleOrder(models.Model):
|
|||
store=True,
|
||||
)
|
||||
|
||||
@api.depends("order_line.task_id")
|
||||
def get_relevant_order_lines(self, task_id):
|
||||
self.ensure_one()
|
||||
linked_lines = self.order_line.filtered(
|
||||
|
|
|
|||
|
|
@ -111,14 +111,16 @@ class SaleOrderLine(models.Model):
|
|||
for t in template.subtasks:
|
||||
subtask = _create_task_from_template(project, t, task)
|
||||
subtasks.append(subtask)
|
||||
# task.write({"child_ids": [Command.set([t.id for t in subtasks])]})
|
||||
|
||||
# We don't want to see the sub-tasks on the SO
|
||||
task.child_ids.write(
|
||||
{
|
||||
"sale_order_id": None,
|
||||
"sale_line_id": None,
|
||||
}
|
||||
)
|
||||
if task.child_ids:
|
||||
task.child_ids.write(
|
||||
{
|
||||
"sale_order_id": None,
|
||||
"sale_line_id": None,
|
||||
}
|
||||
)
|
||||
|
||||
return task
|
||||
|
||||
def _timesheet_create_task_prepare_values_from_template(
|
||||
|
|
@ -142,7 +144,11 @@ class SaleOrderLine(models.Model):
|
|||
vals["tag_ids"] = template.tags.ids
|
||||
vals["allocated_hours"] = template.planned_hours
|
||||
vals["sequence"] = template.sequence
|
||||
vals["partner_id"] = self.order_id.partner_id.id
|
||||
# Use shipping address for FSM tasks for consistency
|
||||
if project and project.is_fsm:
|
||||
vals["partner_id"] = self.order_id.partner_shipping_id.id
|
||||
else:
|
||||
vals["partner_id"] = self.order_id.partner_id.id
|
||||
if template.equipment_ids:
|
||||
vals["equipment_ids"] = template.equipment_ids.ids
|
||||
return vals
|
||||
|
|
@ -150,6 +156,9 @@ class SaleOrderLine(models.Model):
|
|||
tmpl = self.product_id.task_template_id
|
||||
if not tmpl:
|
||||
task = super()._timesheet_create_task(project)
|
||||
# For FSM tasks without a template, update partner_id to use shipping address
|
||||
if project.is_fsm and task:
|
||||
task.partner_id = self.order_id.partner_shipping_id.id
|
||||
else:
|
||||
task = _create_task_from_template(project, tmpl, None)
|
||||
self.write({"task_id": task.id})
|
||||
|
|
@ -163,6 +172,7 @@ class SaleOrderLine(models.Model):
|
|||
"product_name": self.product_id.name,
|
||||
}
|
||||
task.message_post(body=task_msg)
|
||||
|
||||
if not task.equipment_ids and self.equipment_ids:
|
||||
task.equipment_ids = self.equipment_ids.ids
|
||||
return task
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ from odoo.addons.project.models.project_task import CLOSED_STATES
|
|||
import re
|
||||
|
||||
|
||||
|
||||
|
||||
class Task(models.Model):
|
||||
_inherit = "project.task"
|
||||
|
||||
|
|
@ -65,6 +67,7 @@ class Task(models.Model):
|
|||
res = super().create(vals)
|
||||
for rec in res:
|
||||
if rec.parent_id and rec.is_fsm:
|
||||
# Always ensure FSM subtasks have a partner_id set from their parent
|
||||
rec.partner_id = rec.parent_id.partner_id
|
||||
if not rec.work_order_contacts and rec.parent_id:
|
||||
rec.work_order_contacts = rec.parent_id.work_order_contacts
|
||||
|
|
@ -123,7 +126,8 @@ class Task(models.Model):
|
|||
)
|
||||
if "partner_id" in vals:
|
||||
child_vals.update(partner_id=vals["partner_id"])
|
||||
rec.child_ids.write(child_vals)
|
||||
if child_vals:
|
||||
rec.child_ids.write(child_vals)
|
||||
return res
|
||||
|
||||
@api.depends("sale_order_id")
|
||||
|
|
@ -220,3 +224,42 @@ class Task(models.Model):
|
|||
def _compute_root_ancestor(self):
|
||||
for rec in self:
|
||||
rec.root_ancestor = rec.parent_id and rec.parent_id.root_ancestor or self
|
||||
|
||||
@api.depends(
|
||||
"partner_id",
|
||||
"sale_line_id.order_partner_id",
|
||||
"parent_id.sale_line_id",
|
||||
"project_id.sale_line_id",
|
||||
"milestone_id.sale_line_id",
|
||||
"allow_billable",
|
||||
)
|
||||
def _compute_sale_line(self):
|
||||
"""Override to prevent subtasks from inheriting parent's sale_line_id.
|
||||
|
||||
In the base implementation, if a task and its parent share the same commercial partner,
|
||||
the task will inherit the parent's sale_line_id. This causes issues with our FSM tasks
|
||||
where we explicitly want subtasks to NOT have a sale_line_id set.
|
||||
"""
|
||||
|
||||
# Only run on root tasks
|
||||
subtasks = self.filtered("parent_id")
|
||||
(subtasks - subtasks.filtered("sale_line_id")).sale_line_id = False
|
||||
super(Task, self - subtasks)._compute_sale_line()
|
||||
|
||||
@api.depends("parent_id.partner_id", "project_id")
|
||||
def _compute_partner_id(self):
|
||||
"""Override to prevent clearing partner_id for FSM tasks.
|
||||
|
||||
In the base implementation, if a task has a partner_id but no project_id or parent_id,
|
||||
the partner_id is cleared. This causes issues with our FSM tasks where we want to
|
||||
preserve the partner_id even if project_id or parent_id is temporarily not set.
|
||||
"""
|
||||
# Only run the standard logic on non-FSM tasks
|
||||
non_fsm_tasks = self.filtered(lambda t: not t.is_fsm)
|
||||
super(Task, non_fsm_tasks)._compute_partner_id()
|
||||
|
||||
# For FSM tasks, only set partner_id if it's not already set
|
||||
fsm_tasks = self - non_fsm_tasks
|
||||
for task in fsm_tasks:
|
||||
if not task.partner_id:
|
||||
task.partner_id = self._get_default_partner_id(task.project_id, task.parent_id)
|
||||
|
|
|
|||
|
|
@ -350,7 +350,8 @@
|
|||
t-esc="doc.partner_id"
|
||||
t-options='{
|
||||
"widget": "contact",
|
||||
"fields": ["name", "address",]
|
||||
"fields": ["name", "address",],
|
||||
"lang": "fr_FR"
|
||||
}'
|
||||
/>
|
||||
</t>
|
||||
|
|
@ -361,11 +362,11 @@
|
|||
>
|
||||
<div t-if="doc.planned_date_begin"><h6>Planned start: </h6></div>
|
||||
<div class="mb-3">
|
||||
<div t-out="doc.planned_date_begin.strftime('%Y-%m-%d %H:%M')" />
|
||||
<div t-esc="context_timestamp(doc.planned_date_begin).strftime('%Y-%m-%d %H:%M')" />
|
||||
</div>
|
||||
<div t-if="doc.date_deadline"><h6>Planned end: </h6></div>
|
||||
<div class="mb-3">
|
||||
<div t-out="doc.date_deadline.strftime('%Y-%m-%d %H:%M')" />
|
||||
<div t-out="context_timestamp(doc.date_deadline).strftime('%Y-%m-%d %H:%M')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -563,12 +564,10 @@
|
|||
<template id="work_order">
|
||||
<t t-call="web.html_container">
|
||||
<t t-foreach="docs" t-as="doc">
|
||||
<t t-set="doc" t-value="doc.root_ancestor" t-if="doc.parent_id" />
|
||||
<t t-set="doc" t-value="doc.root_ancestor.with_context(tz=doc.partner_id.tz)" t-if="doc.parent_id"/>
|
||||
<t t-set="lang" t-value="(doc.work_order_contacts and doc.work_order_contacts[0].lang) or (doc.partner_id and doc.partner_id.lang) or (doc.user_id and doc.user_id.lang) or user.lang"/>
|
||||
<t t-call="web.external_layout">
|
||||
<t
|
||||
t-call="bemade_fsm.work_order_page"
|
||||
t-lang="doc.partner_id.lang"
|
||||
/>
|
||||
<t t-call="bemade_fsm.work_order_page" t-lang="lang"/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
|
|
@ -578,11 +577,11 @@
|
|||
inherit_id="industry_fsm_report.worksheet_custom"
|
||||
priority="100"
|
||||
>
|
||||
<xpath
|
||||
expr="//t[@t-call='industry_fsm_report.worksheet_custom_page']"
|
||||
position="replace"
|
||||
>
|
||||
<div t-call="bemade_fsm.work_order_page" />
|
||||
<xpath expr="//t[@t-call='web.external_layout']" position="replace">
|
||||
<t t-set="lang" t-value="(doc.work_order_contacts and doc.work_order_contacts[0].lang) or (doc.partner_id and doc.partner_id.lang) or (doc.user_id and doc.user_id.lang) or user.lang"/>
|
||||
<t t-call="web.external_layout" t-lang="lang">
|
||||
<t t-call="bemade_fsm.work_order_page" t-lang="lang"/>
|
||||
</t>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,9 @@ class BemadeFSMBaseTest(TransactionCase):
|
|||
user_group_fsm_user = cls.env.ref("industry_fsm.group_fsm_user")
|
||||
user_group_sales_user = cls.env.ref("sales_team.group_sale_salesman")
|
||||
user_group_sales_manager = cls.env.ref("sales_team.group_sale_manager")
|
||||
user_group_delivery_address = cls.env.ref(
|
||||
"account.group_delivery_invoice_address"
|
||||
)
|
||||
user_product_customer = cls.env.ref(
|
||||
"customer_product_code.group_product_customer_code_user",
|
||||
raise_if_not_found=False,
|
||||
|
|
@ -53,6 +56,7 @@ class BemadeFSMBaseTest(TransactionCase):
|
|||
user_group_fsm_user.id,
|
||||
user_group_sales_manager.id,
|
||||
user_group_sales_user.id,
|
||||
user_group_delivery_address.id,
|
||||
]
|
||||
if user_product_customer:
|
||||
group_ids.append(user_product_customer.id)
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class TestEquipment(BemadeFSMBaseTest):
|
|||
partner = self._generate_partner()
|
||||
partner_2 = self._generate_partner()
|
||||
equipment_1 = self._generate_equipment(partner=partner)
|
||||
equipment_2 = self._generate_equipment(partner_2)
|
||||
equipment_2 = self._generate_equipment(partner=partner_2)
|
||||
sale_order = self._generate_sale_order(partner=partner)
|
||||
product = self._generate_product()
|
||||
self.assertEqual(sale_order.valid_equipment_ids, equipment_1)
|
||||
|
|
|
|||
|
|
@ -142,3 +142,55 @@ class FSMVisitTest(BemadeFSMBaseTest):
|
|||
|
||||
supposed_name = "SVR12345-1 - Test Company - Test Label"
|
||||
self.assertEqual(task.name, supposed_name)
|
||||
|
||||
def test_subtasks_inherit_partner_from_parent_task(self):
|
||||
"""Test that subtasks of tasks created from FSM sales orders have their partner_id set correctly."""
|
||||
# Create a sale order with a shipping address different from the billing address
|
||||
partner = self._generate_partner(name="Customer")
|
||||
shipping_partner = self.env['res.partner'].create({
|
||||
'name': 'Shipping Address',
|
||||
'parent_id': partner.id,
|
||||
'type': 'delivery',
|
||||
})
|
||||
|
||||
# Create a sale order with the customer and shipping address
|
||||
so = self._generate_sale_order(partner=partner)
|
||||
so.partner_shipping_id = shipping_partner
|
||||
|
||||
# Create a visit
|
||||
visit = self._generate_visit(sale_order=so)
|
||||
|
||||
# Create a product with a task template that has subtasks
|
||||
parent_template = self._generate_task_template(
|
||||
structure=[1],
|
||||
names=["Parent Task", "Child Task"],
|
||||
)
|
||||
product = self._generate_product(task_template=parent_template)
|
||||
|
||||
# Add the product to the sale order
|
||||
sol = self._generate_sale_order_line(sale_order=so, product=product)
|
||||
|
||||
# Set the sequence to ensure proper ordering
|
||||
visit.so_section_id.sequence = 1
|
||||
sol.sequence = 2
|
||||
|
||||
# Confirm the sale order to create tasks
|
||||
so.action_confirm()
|
||||
|
||||
# Get the created tasks
|
||||
parent_task = sol.task_id
|
||||
self.assertTrue(parent_task, "Parent task should be created")
|
||||
|
||||
# Check that the parent task has a partner_id set
|
||||
self.assertTrue(parent_task.partner_id, "Parent task should have a partner set")
|
||||
|
||||
# Check that the subtask exists
|
||||
self.assertTrue(parent_task.child_ids, "Parent task should have subtasks")
|
||||
child_task = parent_task.child_ids[0]
|
||||
|
||||
# The key test: Check that the subtask has a partner_id set
|
||||
self.assertTrue(child_task.partner_id, "Subtask should have a partner_id set")
|
||||
|
||||
# Check that the subtask has the same partner as the parent task
|
||||
self.assertEqual(child_task.partner_id, parent_task.partner_id,
|
||||
"Subtask should have the same partner as its parent task")
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ class TestSalesOrder(BemadeFSMBaseTest):
|
|||
parent = self._generate_partner()
|
||||
child = self._generate_partner(parent=parent)
|
||||
for i in range(3):
|
||||
self._generate_equipment(child)
|
||||
self._generate_equipment(partner=child)
|
||||
|
||||
sale_order = self._generate_sale_order(partner=parent)
|
||||
|
||||
|
|
@ -139,11 +139,11 @@ class TestSalesOrder(BemadeFSMBaseTest):
|
|||
parent = self._generate_partner()
|
||||
child = self._generate_partner(parent=parent)
|
||||
for i in range(4):
|
||||
self._generate_equipment(child)
|
||||
self._generate_equipment(partner=child)
|
||||
|
||||
sale_order = self._generate_sale_order(partner=parent)
|
||||
|
||||
self.assertEqual(sale_order.default_equipment_ids, parent.owned_equipment_ids)
|
||||
self.assertEqual(sale_order.default_equipment_ids, self.env["fsm.equipment"])
|
||||
|
||||
def test_sale_order_resets_default_equipment_on_partner_change(self):
|
||||
partner_1 = self._generate_partner()
|
||||
|
|
@ -360,4 +360,115 @@ class TestSalesOrder(BemadeFSMBaseTest):
|
|||
}
|
||||
)
|
||||
for task in parent_task._get_all_subtasks() | parent_task:
|
||||
self.assertEqual(so.partner_shipping_id, task.partner_id)
|
||||
self.assertEqual(
|
||||
so.partner_shipping_id,
|
||||
task.partner_id,
|
||||
f"{task.name} has a different partner than the SO",
|
||||
)
|
||||
|
||||
def test_task_hierarchy_maintained_after_cancel_reconfirm(self):
|
||||
"""Test that task hierarchy and project assignments are maintained when canceling
|
||||
and reconfirming a sale order with a templated FSM product."""
|
||||
self.env.user.groups_id |= self.env.ref(
|
||||
"account.group_delivery_invoice_address"
|
||||
)
|
||||
# Create a task template with subtasks
|
||||
parent_template = self._generate_task_template(
|
||||
structure=[2], # Two subtasks
|
||||
names=["Main Service", "Subtask"],
|
||||
planned_hours=8,
|
||||
)
|
||||
|
||||
# Create FSM product with template
|
||||
product = self._generate_product(task_template=parent_template)
|
||||
|
||||
# Create and confirm sale order
|
||||
partner = self._generate_partner()
|
||||
partner_2 = self._generate_partner(parent=partner, company_type="person")
|
||||
self.assertEqual(partner_2.commercial_partner_id, partner)
|
||||
so = self._generate_sale_order(partner=partner)
|
||||
sol = self._generate_sale_order_line(so, product=product)
|
||||
so.action_confirm()
|
||||
|
||||
# Get initial tasks and verify setup
|
||||
main_task = sol.task_id
|
||||
self.assertTrue(main_task, "Main task should be created")
|
||||
self.assertTrue(main_task.project_id, "Main task should have a project")
|
||||
|
||||
subtasks = main_task.child_ids
|
||||
self.assertEqual(len(subtasks), 2, "Should have created 2 subtasks")
|
||||
self.assertEqual(so.tasks_count, 1, "Should have only 1 task on confirmation")
|
||||
|
||||
# Verify initial task hierarchy
|
||||
initial_project = main_task.project_id
|
||||
for subtask in subtasks:
|
||||
self.assertEqual(
|
||||
subtask.project_id,
|
||||
initial_project,
|
||||
"Subtask should have same project as main task",
|
||||
)
|
||||
self.assertFalse(
|
||||
subtask.sale_order_id, "Subtask should not be linked to sale order"
|
||||
)
|
||||
self.assertFalse(
|
||||
subtask.sale_line_id, "Subtask should not be linked to sale order line"
|
||||
)
|
||||
|
||||
# Store initial names for comparison
|
||||
initial_subtask_names = subtasks.mapped("name")
|
||||
|
||||
original_task_names = (main_task | main_task._get_all_subtasks()).mapped("name")
|
||||
# Cancel and reconfirm the sale order
|
||||
so.with_context(disable_cancel_warning=True).action_cancel()
|
||||
|
||||
so.action_draft()
|
||||
so.write({"partner_shipping_id": partner_2.id})
|
||||
# Get new tasks
|
||||
new_main_task = sol.task_id
|
||||
self.assertEqual(
|
||||
new_main_task, main_task, "New main task should be same as old"
|
||||
)
|
||||
|
||||
new_subtasks = new_main_task.child_ids
|
||||
new_subtasks._compute_sale_line()
|
||||
self.assertEqual(
|
||||
len(new_subtasks), 2, "Should still have 2 subtasks after reconfirmation"
|
||||
)
|
||||
self.assertFalse(
|
||||
new_subtasks.sale_line_id,
|
||||
"Subtasks should not be linked to Sale Order Line",
|
||||
)
|
||||
new_task_names = (new_main_task | new_main_task._get_all_subtasks()).mapped(
|
||||
"name"
|
||||
)
|
||||
self.assertEqual(
|
||||
new_task_names, original_task_names, "New task names should be the same"
|
||||
)
|
||||
|
||||
# Verify task hierarchy is maintained
|
||||
self.assertEqual(
|
||||
new_main_task.project_id,
|
||||
initial_project,
|
||||
"New main task should have same project",
|
||||
)
|
||||
|
||||
for subtask in new_subtasks:
|
||||
self.assertEqual(
|
||||
subtask.project_id,
|
||||
initial_project,
|
||||
"New subtask should maintain same project as main task",
|
||||
)
|
||||
self.assertFalse(
|
||||
subtask.sale_order_id, "New subtask should not be linked to sale order"
|
||||
)
|
||||
self.assertFalse(
|
||||
subtask.sale_line_id,
|
||||
"New subtask should not be linked to sale order line",
|
||||
)
|
||||
|
||||
# Verify subtask names are maintained
|
||||
self.assertEqual(
|
||||
sorted(new_subtasks.mapped("name")),
|
||||
sorted(initial_subtask_names),
|
||||
"Subtask names should be maintained after reconfirmation",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -69,10 +69,10 @@ class SaleOrderLine(models.Model):
|
|||
line.purchase_price_actual = \
|
||||
(stock_missing * line.purchase_price_vendor
|
||||
+ qty_from_stock * line.purchase_price) \
|
||||
/ line.product_uom_qty
|
||||
/ line.product_uom_qty if line.product_uom_qty != 0 else 0
|
||||
line.gross_profit = line.price_subtotal - (
|
||||
line.purchase_price_actual * line.product_uom_qty)
|
||||
line.gross_profit_percent = line.price_subtotal and line.gross_profit / line.price_subtotal
|
||||
line.gross_profit_percent = line.price_subtotal and line.gross_profit / line.price_subtotal if line.price_subtotal != 0 else 0
|
||||
|
||||
def _determine_missing_stock(self) -> float:
|
||||
""" Compute how much stock is missing to meet an order line's demand. In the
|
||||
|
|
|
|||
|
|
@ -33,10 +33,7 @@ class Company(models.Model):
|
|||
("specific", "Specific Vendors"),
|
||||
],
|
||||
default="all",
|
||||
help=(
|
||||
"Choose whether to apply overdue warnings to all vendors or only to "
|
||||
"specific vendors."
|
||||
),
|
||||
help="Choose whether to apply overdue warnings to all vendors or only to specific vendors.",
|
||||
)
|
||||
|
||||
warn_supplier_specific_ids = fields.Many2many(
|
||||
|
|
|
|||
|
|
@ -54,8 +54,22 @@ Technical Details
|
|||
Change Log
|
||||
----------
|
||||
|
||||
17.0.0.6.0
|
||||
^^^^^^^^^^
|
||||
0.8.0
|
||||
^^^^^
|
||||
|
||||
* Disable sending of notification emails when events are created or updated
|
||||
in Odoo during a CalDAV server synchronization.
|
||||
* General code cleanup with improved type hints.
|
||||
|
||||
0.7.0
|
||||
^^^^^
|
||||
|
||||
* Stopped the import of past events when synchronizing from the CalDAV server.
|
||||
This should help with performance, timeouts and avoid importing events that
|
||||
are not relevant to the user.
|
||||
|
||||
0.6.0
|
||||
^^^^^
|
||||
|
||||
* Fixed an issue where synchronizing events created duplicate events on every sync.
|
||||
* Completely revamped and synchronization of recurring events in both directions.
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
{
|
||||
"name": "CalDAV Synchronization",
|
||||
"version": "17.0.0.6.0",
|
||||
"version": "17.0.0.8.0",
|
||||
"license": "LGPL-3",
|
||||
"category": "Productivity",
|
||||
"summary": "Synchronize Odoo Calendar Events with CalDAV Servers",
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@
|
|||
from . import calendar_event
|
||||
from . import res_users
|
||||
from . import calendar_recurrence
|
||||
from . import calendar_attendee
|
||||
|
|
|
|||
24
caldav_sync/models/calendar_attendee.py
Normal file
24
caldav_sync/models/calendar_attendee.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
from odoo import models, api
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalendarAttendee(models.Model):
|
||||
_inherit = "calendar.attendee"
|
||||
|
||||
def _send_mail_to_attendees(self, mail_template, force_send=False):
|
||||
"""Override to prevent sending emails when dont_notify context is set.
|
||||
|
||||
:param mail_template: a mail.template record
|
||||
:param force_send: if set to True, the mail(s) will be sent immediately (instead of the next queue processing)
|
||||
:return: Result of super or False if notification is skipped
|
||||
"""
|
||||
# Check for dont_notify in context
|
||||
if self.env.context.get("dont_notify"):
|
||||
_logger.info("Email notifications skipped due to dont_notify context")
|
||||
return False
|
||||
|
||||
return super(CalendarAttendee, self)._send_mail_to_attendees(
|
||||
mail_template, force_send
|
||||
)
|
||||
|
|
@ -2,20 +2,30 @@ import uuid
|
|||
|
||||
import icalendar.cal
|
||||
|
||||
from odoo import models, api, fields
|
||||
from odoo import models, api, fields, _
|
||||
from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
|
||||
import caldav
|
||||
from caldav.lib.error import NotFoundError
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event
|
||||
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event, vDate
|
||||
import re
|
||||
from pytz import timezone, utc
|
||||
from typing import List, Dict, TypeVar, Optional
|
||||
from typing import List, Dict, Optional, Any, TYPE_CHECKING
|
||||
from markdownify import markdownify as md
|
||||
import markdown2 as md2
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from odoo.addons.base.models.res_users import Users as User
|
||||
from odoo.addons.base.models.res_partner import Partner
|
||||
from odoo.addons.calendar.models.calendar_event import Meeting as OdooCalendarEvent
|
||||
else:
|
||||
User = models.Model
|
||||
Partner = models.Model
|
||||
OdooCalendarEvent = models.Model
|
||||
|
||||
WEEKDAY_MAP = {
|
||||
0: "MO",
|
||||
1: "TU",
|
||||
|
|
@ -26,35 +36,59 @@ WEEKDAY_MAP = {
|
|||
6: "SU",
|
||||
}
|
||||
|
||||
CalendarEvent = TypeVar("calendar.event", bound=models.Model)
|
||||
User = TypeVar("res.users", bound=models.Model)
|
||||
Partner = TypeVar("res.partner", bound=models.Model)
|
||||
|
||||
def _parse_rrule_string(rrule_str: str) -> Dict[str, Any]:
|
||||
"""Parse a string representing an RRULE into a dictionary of its parts.
|
||||
|
||||
def _parse_rrule_string(rrule_str):
|
||||
def try_to_int(part):
|
||||
try:
|
||||
return int(part)
|
||||
except Exception:
|
||||
return part
|
||||
Takes a string like "RRULE:FREQ=WEEKLY;UNTIL=20221231T000000Z;BYDAY=MO"
|
||||
and returns a dictionary with proper types for vRecur.
|
||||
"""
|
||||
from icalendar import vDDDTypes, vWeekday, vFrequency
|
||||
|
||||
regex_str = "RRULE:(.*)$"
|
||||
regex = re.compile(regex_str)
|
||||
params_match = regex.search(rrule_str)
|
||||
params_part = params_match.groups()[0]
|
||||
params = params_part.split(";")
|
||||
params_dict = {}
|
||||
for param in params:
|
||||
parts = param.split("=")
|
||||
if parts[0].upper() == "UNTIL":
|
||||
if "T" in parts[1]:
|
||||
parts[1] = datetime.strptime(parts[1], "%Y%m%dT%H%M%S")
|
||||
def parse_value(key: str, value: str) -> Any:
|
||||
if key == "UNTIL":
|
||||
# Convert to datetime and wrap in vDDDTypes
|
||||
if "T" in value:
|
||||
dt = datetime.strptime(value, "%Y%m%dT%H%M%S")
|
||||
else:
|
||||
parts[1] = datetime.strptime(parts[1], "%Y%m%d")
|
||||
if parts[0].upper() == "BYDAY":
|
||||
parts[1] = [part for part in parts[1].split(",")]
|
||||
params_dict.update({parts[0]: try_to_int(parts[1])})
|
||||
return params_dict
|
||||
dt = datetime.strptime(value, "%Y%m%d")
|
||||
return vDDDTypes(dt)
|
||||
elif key in ("WKST", "BYDAY", "BYWEEKDAY"):
|
||||
# Convert to vWeekday
|
||||
return [vWeekday(day) for day in value.split(",")]
|
||||
elif key == "FREQ":
|
||||
# vFrequency will handle the conversion
|
||||
return vFrequency(value)
|
||||
elif key in (
|
||||
"COUNT",
|
||||
"INTERVAL",
|
||||
"BYSECOND",
|
||||
"BYMINUTE",
|
||||
"BYHOUR",
|
||||
"BYWEEKNO",
|
||||
"BYMONTHDAY",
|
||||
"BYYEARDAY",
|
||||
"BYMONTH",
|
||||
"BYSETPOS",
|
||||
):
|
||||
# Convert to int or list of ints
|
||||
if "," in value:
|
||||
return [int(v) for v in value.split(",")]
|
||||
return int(value)
|
||||
return value
|
||||
|
||||
if not rrule_str.startswith("RRULE:"):
|
||||
return {}
|
||||
|
||||
params = rrule_str[6:] # Remove 'RRULE:'
|
||||
result = {}
|
||||
for param in params.split(";"):
|
||||
if "=" in param:
|
||||
key, value = param.split("=", 1)
|
||||
key = key.upper()
|
||||
result[key] = parse_value(key, value)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _extract_vcal_email(vcal_address):
|
||||
|
|
@ -133,19 +167,6 @@ class CalendarEvent(models.Model):
|
|||
or not event.follow_recurrence
|
||||
)
|
||||
|
||||
@api.depends("is_base_event")
|
||||
def _compute_update_all_recurrence(self):
|
||||
for rec in self:
|
||||
rec.update_all_recurrence = (
|
||||
rec.recurrency
|
||||
and rec.is_base_event
|
||||
and (
|
||||
rec.recurrence_update == "all_events"
|
||||
or not rec.recurrence_update
|
||||
or rec.recurrence_id.calendar_event_ids == rec
|
||||
)
|
||||
)
|
||||
|
||||
@api.depends("recurrency", "recurrence_id", "recurrence_id.base_event_id")
|
||||
def _compute_is_base_event(self):
|
||||
for rec in self:
|
||||
|
|
@ -195,7 +216,9 @@ class CalendarEvent(models.Model):
|
|||
try:
|
||||
caldav_events = event._create_in_icalendar(calendar)
|
||||
for caldav_event in caldav_events:
|
||||
caldav_uid = caldav_event.vobject_instance.vevent.uid.value
|
||||
caldav_uid = (
|
||||
caldav_event.vobject_instance.vevent.uid.value
|
||||
) # pyright: ignore[reportAttributeAccessIssue]
|
||||
event.with_context(caldav_no_sync=True).write(
|
||||
{"caldav_uid": caldav_uid}
|
||||
)
|
||||
|
|
@ -223,6 +246,11 @@ class CalendarEvent(models.Model):
|
|||
calendar = client.calendar(url=user.caldav_calendar_url)
|
||||
|
||||
base_event = self._get_caldav_base_event_by_uid(calendar, self.caldav_uid)
|
||||
if not base_event:
|
||||
_logger.warning(
|
||||
f"Failed to find base event for {self} on CalDAV server."
|
||||
)
|
||||
return
|
||||
if self.recurrence_id:
|
||||
tz = timezone(self.event_tz or self.env.user.tz)
|
||||
start = utc.localize(self.start).astimezone(tz)
|
||||
|
|
@ -259,12 +287,14 @@ class CalendarEvent(models.Model):
|
|||
|
||||
def _update_base_caldav_event(
|
||||
self,
|
||||
calendar: icalendar.cal.Calendar,
|
||||
calendar: caldav.Calendar,
|
||||
event: caldav.Event,
|
||||
ical_event_data: dict,
|
||||
):
|
||||
if event:
|
||||
self._update_ical_event_values(event.icalendar_component, ical_event_data)
|
||||
self._update_ical_event_values(
|
||||
event.icalendar_component, ical_event_data
|
||||
) # pyright: ignore[reportAttributeAccessIssue]
|
||||
event.save()
|
||||
else:
|
||||
calendar.add_event(**ical_event_data)
|
||||
|
|
@ -280,9 +310,11 @@ class CalendarEvent(models.Model):
|
|||
|
||||
def _get_caldav_base_event_by_uid(
|
||||
self, calendar: caldav.Calendar, uid: str
|
||||
) -> Optional[CalendarEvent]:
|
||||
) -> Optional[caldav.Event]:
|
||||
for event in calendar.events():
|
||||
component = event.icalendar_component
|
||||
component = (
|
||||
event.icalendar_component
|
||||
) # pyright: ignore[reportAttributeAccessIssue]
|
||||
event_uid = self._extract_component_text(component, "uid")
|
||||
if event_uid == uid and not component.get("recurrence-id"):
|
||||
return event
|
||||
|
|
@ -315,6 +347,7 @@ class CalendarEvent(models.Model):
|
|||
calendar = client.calendar(url=user.caldav_calendar_url)
|
||||
try:
|
||||
caldav_event = calendar.event_by_uid(self.caldav_uid)
|
||||
assert isinstance(caldav_event, caldav.Event)
|
||||
if not delete_all and self.recurrence_id and not self.is_base_event:
|
||||
index = self._get_subcomponent_index_for_recurrence(
|
||||
caldav_event
|
||||
|
|
@ -329,14 +362,16 @@ class CalendarEvent(models.Model):
|
|||
# of the event matches.
|
||||
if delete_all or self._matches_caldav_start(caldav_event):
|
||||
caldav_event.delete()
|
||||
except caldav.error.NotFoundError:
|
||||
except NotFoundError:
|
||||
# No worries - it just didn't exist so nothing to sync
|
||||
pass
|
||||
except Exception as e:
|
||||
_logger.error(f"Failed to remove event from CalDAV server: {e}")
|
||||
|
||||
def _matches_caldav_start(self, caldav_event: caldav.Event) -> bool:
|
||||
event_start = caldav_event.icalendar_component.get("dtstart").dt
|
||||
event_start = caldav_event.icalendar_component.get(
|
||||
"dtstart"
|
||||
).dt # pyright: ignore[reportAttributeAccessIssue]
|
||||
tz = event_start.tzinfo
|
||||
self_start = utc.localize(self.start).astimezone(tz)
|
||||
return self_start == event_start
|
||||
|
|
@ -453,7 +488,7 @@ class CalendarEvent(models.Model):
|
|||
|
||||
def _add_event_dates(self, event_data: Dict) -> None:
|
||||
"""Add pertinent dates to event data, based on self."""
|
||||
tz = self.event_tz or self.env.user.tz
|
||||
tz = self.event_tz or self.env.user.tz or self._context.get('tz')
|
||||
event_tz = timezone(tz)
|
||||
event_data["last-modified"] = vDatetime(
|
||||
utc.localize(self.write_date).astimezone(event_tz)
|
||||
|
|
@ -463,7 +498,6 @@ class CalendarEvent(models.Model):
|
|||
)
|
||||
event_data["dtstart"] = vDatetime(utc.localize(self.start).astimezone(event_tz))
|
||||
event_data["dtend"] = vDatetime(utc.localize(self.stop).astimezone(event_tz))
|
||||
return event_data
|
||||
|
||||
def _add_event_recurrence_id(self, event_data: Dict) -> None:
|
||||
"""Add the recurrence-id parameter to event data if self is linked
|
||||
|
|
@ -531,7 +565,8 @@ class CalendarEvent(models.Model):
|
|||
synchronize them with their Odoo calendar."""
|
||||
all_users = self.env["res.users"].search([("is_caldav_enabled", "=", True)])
|
||||
for user in all_users:
|
||||
self._poll_user_caldav_server(user)
|
||||
self.with_context(dont_notify=True)._poll_user_caldav_server(user)
|
||||
# self._poll_user_caldav_server(user)
|
||||
|
||||
@api.model
|
||||
def _poll_user_caldav_server(self, user) -> None:
|
||||
|
|
@ -568,7 +603,7 @@ class CalendarEvent(models.Model):
|
|||
# There are some events remaining in this recurrence series,
|
||||
# so we have synchronized them individually.
|
||||
pass
|
||||
except caldav.error.NotFoundError:
|
||||
except NotFoundError:
|
||||
# There are no more events with this UID, so we need to clear
|
||||
# out the whole recurrence chain from the Odoo side.
|
||||
ctx = {"caldav_no_sync": True}
|
||||
|
|
@ -582,9 +617,8 @@ class CalendarEvent(models.Model):
|
|||
).with_user(user).unlink()
|
||||
|
||||
@api.model
|
||||
def _sync_event_from_ical(
|
||||
self, ical_event: icalendar.cal.Event, user: User
|
||||
) -> CalendarEvent:
|
||||
@api.returns("calendar.event")
|
||||
def _sync_event_from_ical(self, ical_event: icalendar.cal.Event, user: User):
|
||||
"""Given an iCalendar event, compare the event with any existing
|
||||
Odoo event that it matches and synchronize the changes. If no event
|
||||
exists, create one.
|
||||
|
|
@ -610,9 +644,16 @@ class CalendarEvent(models.Model):
|
|||
owned = (
|
||||
existing_instance and existing_instance.partner_id == user.partner_id
|
||||
)
|
||||
values = self._get_values_from_ical_component(component, user)
|
||||
# Pass for_creation=True only when creating a new event
|
||||
values = self._get_values_from_ical_component(
|
||||
component, user, for_creation=not existing_instance
|
||||
)
|
||||
recurrency_vals = self._get_recurrency_values_from_ical_event(component)
|
||||
if not existing_instance:
|
||||
# If the event is in the past, we just ignore it.
|
||||
stop = values.get("stop")
|
||||
if stop and stop < datetime.now(tz=None):
|
||||
continue
|
||||
# If we're creating an instance and it doesn't follow the recurrence,
|
||||
# just scrap the recurrency vals, they're not useful
|
||||
if not recurrency_vals.get("follow_recurrence"):
|
||||
|
|
@ -646,7 +687,8 @@ class CalendarEvent(models.Model):
|
|||
return synced_events
|
||||
|
||||
@api.model
|
||||
def _get_existing_instance(self, uid, recurrence_id: datetime) -> CalendarEvent:
|
||||
@api.returns("calendar.event")
|
||||
def _get_existing_instance(self, uid, recurrence_id: Optional[datetime]):
|
||||
"""Find the Odoo calendar.event record matching uid and,
|
||||
if set, recurrence_id.
|
||||
"""
|
||||
|
|
@ -685,7 +727,7 @@ class CalendarEvent(models.Model):
|
|||
|
||||
return instance or self.env["calendar.event"].search(
|
||||
[
|
||||
("caldav_uid", "=", "uid"),
|
||||
("caldav_uid", "=", uid),
|
||||
("recurrence_id", "=", False),
|
||||
]
|
||||
)
|
||||
|
|
@ -718,14 +760,20 @@ class CalendarEvent(models.Model):
|
|||
"recurrence_update": "self_only",
|
||||
}
|
||||
|
||||
if not rrule:
|
||||
if not rrule or not isinstance(rrule, vRecur):
|
||||
return {}
|
||||
if rrule.get("until"):
|
||||
rrule["until"] = rrule.get("until")[0].astimezone(utc)
|
||||
until = rrule.get("until")
|
||||
if until and isinstance(until, list):
|
||||
until = until[0].astimezone(utc)
|
||||
rrule_str = rrule.to_ical() and rrule.to_ical().decode("utf-8")
|
||||
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
|
||||
"RRULE:" + rrule_str, component.get("dtstart").dt.astimezone(utc)
|
||||
)
|
||||
if rrule_str:
|
||||
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
|
||||
"RRULE:" + rrule_str, component.get("dtstart").dt.astimezone(utc)
|
||||
)
|
||||
else:
|
||||
_logger.warning(f"Could not convert RRULE to string: {rrule}")
|
||||
return {}
|
||||
|
||||
vals = {
|
||||
"recurrency": True,
|
||||
"follow_recurrence": True,
|
||||
|
|
@ -743,8 +791,9 @@ class CalendarEvent(models.Model):
|
|||
vals.update(end_type="count")
|
||||
if not vals.get("count"):
|
||||
vals.update(count=MAX_RECURRENT_EVENT)
|
||||
if vals.get("until"):
|
||||
until_day = vals.get("until").date()
|
||||
until = vals.get("until")
|
||||
if until and (isinstance(until, vDatetime) or isinstance(until, vDate)):
|
||||
until_day = until.dt if isinstance(until, vDatetime) else until.dt
|
||||
vals.update(until=until_day)
|
||||
vals.pop("count", None)
|
||||
return vals
|
||||
|
|
@ -813,8 +862,8 @@ class CalendarEvent(models.Model):
|
|||
def _get_outdated(
|
||||
self,
|
||||
component: icalendar.cal.Component,
|
||||
existing_instance: CalendarEvent,
|
||||
synced_events: CalendarEvent,
|
||||
existing_instance: OdooCalendarEvent,
|
||||
synced_events: OdooCalendarEvent,
|
||||
) -> bool:
|
||||
"""Check whether a component from the CalDAV server (typically an
|
||||
event) is outdated when compared to its existing Odoo calendar.event
|
||||
|
|
@ -840,14 +889,15 @@ class CalendarEvent(models.Model):
|
|||
|
||||
@api.model
|
||||
def _get_values_from_ical_component(
|
||||
self, component: icalendar.cal.Component, user: User
|
||||
self, component: icalendar.cal.Component, user: User, for_creation: bool = False
|
||||
) -> Dict:
|
||||
"""Get the dictionary representing calendar.event field values from
|
||||
an iCalendar event.
|
||||
|
||||
:param component: The iCalendar component from which to extract
|
||||
values.
|
||||
:param component: The iCalendar component from which to extract values.
|
||||
:param user: The res.users record the event will belong to.
|
||||
:param for_creation: Whether these values are for creating a new event (True)
|
||||
or updating an existing one (False).
|
||||
:return: The dictionary of values to construct a calendar.event."""
|
||||
start = component.get("dtstart") and component.decoded("dtstart")
|
||||
if isinstance(start, datetime):
|
||||
|
|
@ -855,8 +905,11 @@ class CalendarEvent(models.Model):
|
|||
end = component.get("dtend") and component.decoded("dtend")
|
||||
if isinstance(end, datetime):
|
||||
end = end.astimezone(utc).replace(tzinfo=None)
|
||||
organizer = self._get_organizer_partner(component)
|
||||
|
||||
# Get attendees regardless of creation/update
|
||||
attendee_ids = self._get_attendee_partners(component, user.partner_id.email)
|
||||
|
||||
# Basic values that apply to both creation and updates
|
||||
values = {
|
||||
"name": str(component.get("summary")),
|
||||
"start": start,
|
||||
|
|
@ -868,9 +921,33 @@ class CalendarEvent(models.Model):
|
|||
"videocall_location": self._extract_component_text(component, "conference"),
|
||||
"caldav_uid": str(component.get("uid")),
|
||||
"partner_ids": [(6, 0, attendee_ids.ids)],
|
||||
"partner_id": organizer.id if organizer else user.partner_id.id,
|
||||
"user_id": user.id,
|
||||
}
|
||||
|
||||
# Only set user_id and partner_id during creation
|
||||
if for_creation:
|
||||
organizer_partner = self._get_organizer_partner(component)
|
||||
if organizer_partner:
|
||||
# Get the Odoo user ID associated with the organizer partner
|
||||
organizer = (
|
||||
organizer_partner.user_ids[0].id
|
||||
if organizer_partner.user_ids
|
||||
else False
|
||||
)
|
||||
values.update(
|
||||
{
|
||||
"partner_id": organizer_partner.id,
|
||||
"user_id": organizer,
|
||||
}
|
||||
)
|
||||
else:
|
||||
# For new events without an organizer, use the current user
|
||||
values.update(
|
||||
{
|
||||
"partner_id": user.partner_id.id,
|
||||
"user_id": user.id,
|
||||
}
|
||||
)
|
||||
|
||||
return values
|
||||
|
||||
@api.model
|
||||
|
|
@ -886,6 +963,13 @@ class CalendarEvent(models.Model):
|
|||
matching Odoo event belongs to.
|
||||
:return: The res.partner records who are attendees for the event."""
|
||||
attendee_emails = self._get_ical_attendee_emails(component)
|
||||
# Add organizer to attendees if present
|
||||
organizer = component.get("organizer")
|
||||
if organizer:
|
||||
organizer_email = _extract_vcal_email(organizer)
|
||||
if organizer_email not in attendee_emails:
|
||||
attendee_emails.append(organizer_email)
|
||||
# Add current user if not already in attendees
|
||||
if current_user_email not in attendee_emails:
|
||||
attendee_emails.append(current_user_email)
|
||||
existing_partners = self.env["res.partner"].search(
|
||||
|
|
@ -896,14 +980,24 @@ class CalendarEvent(models.Model):
|
|||
for email in attendee_emails
|
||||
if email not in [partner.email for partner in existing_partners]
|
||||
]
|
||||
added_partners = self.env["res.partner"].create(
|
||||
[
|
||||
{
|
||||
"name": email,
|
||||
"email": email,
|
||||
}
|
||||
for email in missing_emails
|
||||
]
|
||||
# Create new partners without triggering notifications
|
||||
added_partners = (
|
||||
self.env["res.partner"]
|
||||
.with_context(
|
||||
mail_notify_author=False, # Don't notify the author
|
||||
mail_notify_force_send=False, # Don't force send notifications
|
||||
tracking_disable=True, # Disable tracking which can trigger notifications
|
||||
no_reset_password=True, # Don't trigger password reset emails
|
||||
)
|
||||
.create(
|
||||
[
|
||||
{
|
||||
"name": email,
|
||||
"email": email,
|
||||
}
|
||||
for email in missing_emails
|
||||
]
|
||||
)
|
||||
)
|
||||
final_attendees = {}
|
||||
all_partners = existing_partners | added_partners
|
||||
|
|
@ -933,11 +1027,29 @@ class CalendarEvent(models.Model):
|
|||
|
||||
organizer = component.get("organizer")
|
||||
if organizer:
|
||||
partner = self.env["res.partner"].search(
|
||||
[("email", "=", _extract_vcal_email(organizer))]
|
||||
)
|
||||
# TODO: prioritize partner with a user if there is one
|
||||
return partner[0] if partner else partner # partner[0] in case many matches
|
||||
email = _extract_vcal_email(organizer)
|
||||
_logger.info("Organizer email: %s", email)
|
||||
partner = self.env["res.partner"].search([("email", "=", email)], limit=1)
|
||||
_logger.info("Found partner: %s", partner)
|
||||
if not partner:
|
||||
# Create new partner without triggering notifications
|
||||
partner = (
|
||||
self.env["res.partner"]
|
||||
.with_context(
|
||||
mail_notify_author=False,
|
||||
mail_notify_force_send=False,
|
||||
tracking_disable=True,
|
||||
no_reset_password=True,
|
||||
)
|
||||
.create(
|
||||
{
|
||||
"name": email,
|
||||
"email": email,
|
||||
}
|
||||
)
|
||||
)
|
||||
_logger.info("Created partner: %s", partner)
|
||||
return partner
|
||||
else:
|
||||
return self.env["res.partner"]
|
||||
|
||||
|
|
|
|||
|
|
@ -15,16 +15,34 @@ class ResUsers(models.Model):
|
|||
caldav_password = fields.Char(string="CalDAV Password")
|
||||
is_caldav_enabled = fields.Boolean(compute="_compute_is_caldav_enabled", store=True)
|
||||
|
||||
@property
|
||||
def SELF_WRITEABLE_FIELDS(self):
|
||||
return super().SELF_READABLE_FIELDS + [
|
||||
"caldav_calendar_url",
|
||||
"caldav_username",
|
||||
"caldav_password",
|
||||
]
|
||||
|
||||
@api.depends("caldav_username", "caldav_password", "caldav_calendar_url")
|
||||
def _compute_is_caldav_enabled(self):
|
||||
"""This is a bit of an odd way of computing the field, but it works since any
|
||||
failed attempt to get the events from the server should mark the user as
|
||||
CalDAV being disabled. We just make sure to mute the logger in the case that
|
||||
an exception is raised because we are just computing the field, not actually
|
||||
attempting to synchronize anything."""
|
||||
"""Compute whether CalDAV is enabled for each user by validating their credentials.
|
||||
We only check if we can connect to the server and access the principal, without
|
||||
fetching any events to avoid timeouts with large calendars."""
|
||||
for rec in self:
|
||||
with mute_logger("odoo.addons.caldav_sync.models.res_users"):
|
||||
rec._get_caldav_events()
|
||||
# If any required field is empty, CalDAV is disabled
|
||||
if not (
|
||||
rec.caldav_username and rec.caldav_password and rec.caldav_calendar_url
|
||||
):
|
||||
rec.is_caldav_enabled = False
|
||||
continue
|
||||
try:
|
||||
client = rec._get_caldav_client()
|
||||
# Just try to access the principal, which is a lightweight operation
|
||||
client.principal()
|
||||
rec.is_caldav_enabled = True
|
||||
except Exception as e:
|
||||
rec.is_caldav_enabled = False
|
||||
_logger.error("Failed to validate CalDAV credentials: %s", e)
|
||||
|
||||
def _get_caldav_client(self):
|
||||
self.ensure_one()
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
from . import test_res_users
|
||||
from . import test_calendar
|
||||
from . import test_external_organizer
|
||||
|
|
|
|||
3312
caldav_sync/tests/data/Calendar.ics
Normal file
3312
caldav_sync/tests/data/Calendar.ics
Normal file
File diff suppressed because it is too large
Load diff
45
caldav_sync/tests/data/Mail Attachment.ics
Normal file
45
caldav_sync/tests/data/Mail Attachment.ics
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
BEGIN:VCALENDAR
|
||||
PRODID:-//Apple Inc.//macOS 15.3.2//EN
|
||||
VERSION:2.0
|
||||
METHOD:REQUEST
|
||||
CALSCALE:GREGORIAN
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/Toronto
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:20070311T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
||||
TZNAME:EDT
|
||||
TZOFFSETFROM:-0500
|
||||
TZOFFSETTO:-0400
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
DTSTART:20071104T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
||||
TZNAME:EST
|
||||
TZOFFSETFROM:-0400
|
||||
TZOFFSETTO:-0500
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
CREATED:20250410T122458Z
|
||||
DTEND;TZID=America/Toronto:20250414T140000
|
||||
DTSTAMP:20250410T122459Z
|
||||
DTSTART;TZID=America/Toronto:20250414T131500
|
||||
LAST-MODIFIED:20250410T122458Z
|
||||
SEQUENCE:0
|
||||
SUMMARY:Test
|
||||
TRANSP:OPAQUE
|
||||
UID:02EEF1D6-F3D2-4EB2-AD7A-1C381A629DC5
|
||||
X-APPLE-CREATOR-IDENTITY:com.apple.calendar
|
||||
X-APPLE-CREATOR-TEAM-IDENTITY:0000000000
|
||||
BEGIN:VALARM
|
||||
ACTION:NONE
|
||||
TRIGGER;VALUE=DATE-TIME:19760401T005545Z
|
||||
END:VALARM
|
||||
ATTENDEE;PARTSTAT=NEEDS-ACTION;EMAIL=mdurepos@durpro.com;CN=Marc Durepos;CU
|
||||
TYPE=INDIVIDUAL;RSVP=TRUE:mailto:mdurepos@durpro.com
|
||||
ORGANIZER;CN=Marc Durepos;EMAIL=deploy@bemade.org:mailto:marc@bemade.org
|
||||
CLASS:PUBLIC
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
|
||||
14
caldav_sync/tests/data/test_external_no_organizer.ics
Normal file
14
caldav_sync/tests/data/test_external_no_organizer.ics
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Odoo//CalDAV Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:external-organizer-test-123
|
||||
DTSTART:20250207T143000Z
|
||||
DTEND:20250207T153000Z
|
||||
DTSTAMP:20250207T143000Z
|
||||
ATTENDEE;PARTSTAT=ACCEPTED;CN=Test User:mailto:test@example.com
|
||||
ATTENDEE;PARTSTAT=ACCEPTED;CN=Mister External:mailto:mister.external@otherdomain.com
|
||||
SUMMARY:Meeting with External Organizer
|
||||
DESCRIPTION:This is a test event with an external organizer
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
14
caldav_sync/tests/data/test_external_organizer.ics
Normal file
14
caldav_sync/tests/data/test_external_organizer.ics
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Odoo//CalDAV Client//EN
|
||||
BEGIN:VEVENT
|
||||
UID:external-organizer-test-123
|
||||
DTSTART:20250207T143000Z
|
||||
DTEND:20250207T153000Z
|
||||
DTSTAMP:20250207T143000Z
|
||||
ORGANIZER;CN=External Person:mailto:external.person@otherdomain.com
|
||||
ATTENDEE;PARTSTAT=ACCEPTED;CN=Test User:mailto:test@example.com
|
||||
SUMMARY:Meeting with External Organizer
|
||||
DESCRIPTION:This is a test event with an external organizer
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
from collections.abc import Iterable
|
||||
from odoo.tests import TransactionCase
|
||||
from odoo.tests import TransactionCase, tagged
|
||||
from odoo import Command
|
||||
from unittest.mock import patch, MagicMock, DEFAULT
|
||||
import icalendar
|
||||
|
|
@ -25,24 +25,25 @@ def _get_ics_path(filename):
|
|||
|
||||
|
||||
@contextmanager
|
||||
def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
|
||||
with (
|
||||
patch("caldav.DAVClient") as MockDAVClient,
|
||||
patch("caldav.Calendar") as MockCalendar,
|
||||
):
|
||||
def _patch_caldav_with_events_from_ics(
|
||||
ics_paths, user, last_modified=None, futurize=True
|
||||
):
|
||||
with patch("caldav.DAVClient") as MockDAVClient:
|
||||
mock_client = MockDAVClient.return_value
|
||||
mock_calendar = MockCalendar.return_value
|
||||
mock_client.calendar = mock_calendar
|
||||
mock_calendars = {}
|
||||
|
||||
def calendar_side_effect(url):
|
||||
if url not in mock_calendars:
|
||||
mock_calendars[url] = MockCalendar()
|
||||
if url == user.caldav_calendar_url:
|
||||
return mock_calendars[url]
|
||||
raise Exception("Calendar does not exist.")
|
||||
mock_cal = MagicMock()
|
||||
mock_cal.events = MagicMock(return_value=[])
|
||||
mock_cal.event_by_uid = MagicMock()
|
||||
mock_calendars[url] = mock_cal
|
||||
return mock_calendars[url]
|
||||
|
||||
mock_calendar.side_effect = calendar_side_effect
|
||||
mock_client.calendar = calendar_side_effect
|
||||
|
||||
# Get or create the mock calendar for this user
|
||||
mock_calendar = calendar_side_effect(user.caldav_calendar_url)
|
||||
|
||||
def event_by_uid_side_effect(self, uid):
|
||||
for event in self.events():
|
||||
|
|
@ -64,6 +65,24 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
|
|||
if subcomponent.name == "VEVENT":
|
||||
subcomponent["last-modified"] = icalendar.vDate(last_modified)
|
||||
subcomponent["dtstamp"] = icalendar.vDate(last_modified)
|
||||
if futurize:
|
||||
for event in ical_events:
|
||||
for subcomponent in event.subcomponents:
|
||||
if subcomponent.name == "VEVENT":
|
||||
start = subcomponent.get("dtstart") and subcomponent.decoded(
|
||||
"dtstart"
|
||||
)
|
||||
end = subcomponent.get("dtend") and subcomponent.decoded(
|
||||
"dtend"
|
||||
)
|
||||
if isinstance(start, datetime) and isinstance(end, datetime):
|
||||
duration = end - start
|
||||
else:
|
||||
duration = timedelta(hours=1)
|
||||
subcomponent["dtstart"] = icalendar.vDDDTypes(datetime.now())
|
||||
subcomponent["dtend"] = icalendar.vDDDTypes(
|
||||
datetime.now() + duration
|
||||
)
|
||||
|
||||
base_events = [event for event in ical_events if not event.get("recurrence-id")]
|
||||
for base_event in base_events:
|
||||
|
|
@ -83,17 +102,33 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
|
|||
yield
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestCalendarEvent(TransactionCase, CaldavTestCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.env["res.users"].search([])._compute_is_caldav_enabled()
|
||||
cls.user_1_url = "https://mycaldav.test.com/test1calendar"
|
||||
cls.user_1 = cls._generate_user("test1", "test1", cls.user_1_url)
|
||||
cls.user_1 = cls._generate_user(
|
||||
"test1",
|
||||
caldav_username="user1",
|
||||
caldav_password="pass1",
|
||||
caldav_url=cls.user_1_url,
|
||||
)
|
||||
cls.user_2_url = "https://mycaldav.test.com/test2calendar"
|
||||
cls.user_2 = cls._generate_user("test2", "test2", cls.user_2_url)
|
||||
cls.user_2 = cls._generate_user(
|
||||
"test2",
|
||||
caldav_username="user2",
|
||||
caldav_password="pass2",
|
||||
caldav_url=cls.user_2_url,
|
||||
)
|
||||
cls.user_3_url = "https://mycaldav.test.com/test3calendar"
|
||||
cls.user_3 = cls._generate_user("test3", "test3", cls.user_3_url)
|
||||
cls.user_3 = cls._generate_user(
|
||||
"test3",
|
||||
caldav_username="user3",
|
||||
caldav_password="pass3",
|
||||
caldav_url=cls.user_3_url,
|
||||
)
|
||||
|
||||
def test_basic_event_from_server_create(self):
|
||||
user = self.user_1
|
||||
|
|
@ -110,17 +145,29 @@ class TestCalendarEvent(TransactionCase, CaldavTestCommon):
|
|||
ics_path = _get_ics_path("basic.ics")
|
||||
with _patch_caldav_with_events_from_ics(ics_path, user):
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
|
||||
# Verify the event was created correctly
|
||||
event = self.env["calendar.event"].search([("user_id", "=", user.id)])
|
||||
self.assertEqual(event.name, "Test")
|
||||
orig_start = event.start
|
||||
orig_stop = event.stop
|
||||
|
||||
# Now update the event with the updated ICS data
|
||||
ics_path = _get_ics_path("basic_updated.ics")
|
||||
with _patch_caldav_with_events_from_ics(
|
||||
ics_path,
|
||||
user,
|
||||
last_modified=(datetime.now(UTC)),
|
||||
):
|
||||
# Clear any caches to ensure fresh data
|
||||
self.env["calendar.event"].invalidate_model()
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
|
||||
# Refresh the event from the database to get updated values
|
||||
event.invalidate_recordset()
|
||||
event = self.env["calendar.event"].search([("user_id", "=", user.id)])
|
||||
|
||||
# Verify the event was updated correctly
|
||||
self.assertEqual(event.name, "Test Updated")
|
||||
# This next one is just lazy avoiding the HTML stripping
|
||||
self.assertIn("Some note ...", event.description)
|
||||
|
|
@ -224,21 +271,25 @@ class TestCalendarEvent(TransactionCase, CaldavTestCommon):
|
|||
self.env["calendar.event"].poll_caldav_server()
|
||||
with _patch_caldav_with_events_from_ics(ics_path, user3):
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
notification_method = "odoo.addons.calendar.models.calendar_attendee.Attendee._send_mail_to_attendees"
|
||||
# Now update it to remove one attendee
|
||||
# Shuffle the user polling order just to test more robustly
|
||||
ics_path = _get_ics_path("test_multi_user_update.ics")
|
||||
with _patch_caldav_with_events_from_ics(
|
||||
ics_path, user2, last_modified=datetime.now(UTC)
|
||||
):
|
||||
), patch(notification_method) as mock_notification_method:
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
mock_notification_method.assert_not_called()
|
||||
with _patch_caldav_with_events_from_ics(
|
||||
ics_path, user3, last_modified=datetime.now(UTC)
|
||||
):
|
||||
), patch(notification_method) as mock_notification_method:
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
mock_notification_method.assert_not_called()
|
||||
with _patch_caldav_with_events_from_ics(
|
||||
ics_path, user1, last_modified=datetime.now(UTC)
|
||||
):
|
||||
), patch(notification_method) as mock_notification_method:
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
mock_notification_method.assert_not_called()
|
||||
event = self.env["calendar.event"].search(
|
||||
[("caldav_uid", "=", "2495546B-5C9A-4632-AAD3-A179EF83CF20")]
|
||||
)
|
||||
|
|
|
|||
81
caldav_sync/tests/test_external_organizer.py
Normal file
81
caldav_sync/tests/test_external_organizer.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
from odoo.tests import TransactionCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
from .common import CaldavTestCommon
|
||||
from .test_calendar import _get_ics_path, _patch_caldav_with_events_from_ics
|
||||
|
||||
|
||||
class TestExternalOrganizer(TransactionCase, CaldavTestCommon):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls.user = cls._generate_user(
|
||||
"test",
|
||||
caldav_username="test",
|
||||
caldav_password="test",
|
||||
caldav_url="https://example.com/calendar",
|
||||
)
|
||||
|
||||
def test_external_organizer_event_sync(self):
|
||||
"""Test that events with external organizers are handled correctly."""
|
||||
# Setup mock for CalDAV client with our test ICS file
|
||||
with _patch_caldav_with_events_from_ics(
|
||||
[_get_ics_path("test_external_organizer.ics")], self.user
|
||||
):
|
||||
# Ensure caldav is enabled
|
||||
self.user._compute_is_caldav_enabled()
|
||||
# Sync events from the mock server
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
|
||||
# Find the synced event
|
||||
event = self.env["calendar.event"].search(
|
||||
[("caldav_uid", "=", "external-organizer-test-123")]
|
||||
)
|
||||
|
||||
# Verify event was created
|
||||
self.assertTrue(event, "Event should be created")
|
||||
|
||||
# Verify user_id is False for external organizer
|
||||
self.assertFalse(
|
||||
event.user_id,
|
||||
"Event with external organizer should have user_id set to False",
|
||||
)
|
||||
|
||||
# Verify the organizer's email is preserved in attendees
|
||||
external_attendee = event.attendee_ids.filtered(
|
||||
lambda a: a.email == "external.person@otherdomain.com"
|
||||
)
|
||||
self.assertTrue(
|
||||
external_attendee,
|
||||
"External organizer should be present in attendees",
|
||||
)
|
||||
# The external organizer should be in the attendees list
|
||||
self.assertTrue(
|
||||
external_attendee,
|
||||
"External organizer should be present in attendees list",
|
||||
)
|
||||
|
||||
def test_no_organizer_external_event_sync(self):
|
||||
"""Test that events without organizers are assigned to the calendar's user."""
|
||||
# Setup mock for CalDAV client with our test ICS file
|
||||
with _patch_caldav_with_events_from_ics(
|
||||
[_get_ics_path("test_external_no_organizer.ics")], self.user
|
||||
):
|
||||
self.user._compute_is_caldav_enabled()
|
||||
# Make sure that the current user is not the admin user
|
||||
self.assertNotEqual(self.user.id, self.env.ref("base.user_admin").id)
|
||||
self.env["calendar.event"].poll_caldav_server()
|
||||
|
||||
# Find the synced event
|
||||
event = self.env["calendar.event"].search(
|
||||
[("caldav_uid", "=", "external-organizer-test-123")]
|
||||
)
|
||||
|
||||
# Verify event was created
|
||||
self.assertTrue(event, "Event should be created")
|
||||
|
||||
# Verify user_id is set to the calendar's user
|
||||
self.assertEqual(
|
||||
event.user_id,
|
||||
self.user,
|
||||
"Event without organizer should have user_id set to calendar's user",
|
||||
)
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
from odoo.tests import TransactionCase
|
||||
from unittest.mock import patch, MagicMock
|
||||
from .common import CaldavTestCommon
|
||||
import caldav
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestUsers(TransactionCase, CaldavTestCommon):
|
||||
|
|
@ -9,24 +13,67 @@ class TestUsers(TransactionCase, CaldavTestCommon):
|
|||
super().setUpClass()
|
||||
|
||||
def test_caldav_enabled_false_without_url(self):
|
||||
user = self._generate_user("test", "test")
|
||||
# Create user with no CalDAV credentials
|
||||
user = self._generate_user("test")
|
||||
self.assertFalse(user.is_caldav_enabled)
|
||||
|
||||
def test_caldav_enabled_false_without_credentials(self):
|
||||
"""Test that is_caldav_enabled is False when any required field is missing."""
|
||||
# Test with missing URL - has username and password only
|
||||
user1 = self._generate_user(
|
||||
"test1", caldav_username="user1", caldav_password="pass1"
|
||||
)
|
||||
self.assertFalse(user1.is_caldav_enabled)
|
||||
|
||||
# Test with missing username - has password and URL only
|
||||
user2 = self._generate_user(
|
||||
"test2", caldav_password="pass2", caldav_url="https://example.com"
|
||||
)
|
||||
self.assertFalse(user2.is_caldav_enabled)
|
||||
|
||||
# Test with missing password - has username and URL only
|
||||
user3 = self._generate_user(
|
||||
"test3", caldav_username="user3", caldav_url="https://example.com"
|
||||
)
|
||||
self.assertFalse(user3.is_caldav_enabled)
|
||||
|
||||
@patch("caldav.DAVClient")
|
||||
def test_caldav_connection_succeeds_but_not_calendar(self, MockDAVClient):
|
||||
user = self._generate_user("test", "test", "https://example.com/abc123")
|
||||
# Create a mock client and mock calendar
|
||||
def test_caldav_enabled_success(self, MockDAVClient):
|
||||
"""Test that is_caldav_enabled is True when connection succeeds."""
|
||||
# Create user with name 'test' and set CalDAV credentials
|
||||
user = self._generate_user(
|
||||
"test",
|
||||
caldav_username="user",
|
||||
caldav_password="pass",
|
||||
caldav_url="https://example.com/abc123",
|
||||
)
|
||||
|
||||
# Mock successful connection
|
||||
mock_client = MockDAVClient.return_value
|
||||
mock_calendar = MagicMock()
|
||||
mock_client.calendar.return_value = mock_calendar
|
||||
mock_principal = MagicMock()
|
||||
mock_client.principal.return_value = mock_principal
|
||||
|
||||
# Mock the events method to raise an exception
|
||||
mock_calendar.events.side_effect = Exception("Failed to get events")
|
||||
# Compute should succeed and set is_caldav_enabled to True
|
||||
user._compute_is_caldav_enabled()
|
||||
self.assertTrue(user.is_caldav_enabled)
|
||||
|
||||
# An exception should not be raised because we need to continue to sync other
|
||||
# users' calendars. Instead the exception message is simply logged to error.
|
||||
@patch("caldav.DAVClient")
|
||||
def test_caldav_enabled_connection_fails(self, MockDAVClient):
|
||||
"""Test that is_caldav_enabled is False when connection fails."""
|
||||
user = self._generate_user(
|
||||
"test",
|
||||
caldav_username="user",
|
||||
caldav_password="pass",
|
||||
caldav_url="https://example.com/abc123",
|
||||
)
|
||||
|
||||
# Mock failed connection
|
||||
mock_client = MockDAVClient.return_value
|
||||
mock_client.principal.side_effect = caldav.error.AuthorizationError(
|
||||
"Invalid credentials"
|
||||
)
|
||||
|
||||
# Should handle the error gracefully and set is_caldav_enabled to False
|
||||
with self.assertLogs("odoo.addons.caldav_sync.models.res_users", "ERROR"):
|
||||
user._get_caldav_events()
|
||||
|
||||
# Ensure the is_caldav_enabled is set to False
|
||||
user._compute_is_caldav_enabled()
|
||||
self.assertFalse(user.is_caldav_enabled)
|
||||
|
|
|
|||
2
chatgpt_assistant/__init__.py
Normal file
2
chatgpt_assistant/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
from . import controllers
|
||||
20
chatgpt_assistant/__manifest__.py
Normal file
20
chatgpt_assistant/__manifest__.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
|
||||
{
|
||||
'name': 'ChatGPT Assistant for Discuss',
|
||||
'version': '1.0',
|
||||
'author': 'Bemade Inc.',
|
||||
'category': 'Tools',
|
||||
'summary': 'Integrate ChatGPT into Odoo Discuss',
|
||||
'depends': ['mail'],
|
||||
'data': [
|
||||
# 'views/assets.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
# '/chatgpt_assistant/static/src/js/discuss_extension.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
1
chatgpt_assistant/controllers/__init__.py
Normal file
1
chatgpt_assistant/controllers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import main
|
||||
23
chatgpt_assistant/controllers/main.py
Normal file
23
chatgpt_assistant/controllers/main.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
import openai
|
||||
import pandas as pd
|
||||
|
||||
class DiscussChatGPT(http.Controller):
|
||||
@http.route('/discuss/chatgpt', type='json', auth='user')
|
||||
def chatgpt_respond(self, message, attachment_id=None):
|
||||
openai.api_key = request.env['ir.config_parameter'].sudo().get_param('chatgpt.api_key')
|
||||
|
||||
if attachment_id:
|
||||
attachment = request.env['ir.attachment'].sudo().browse(attachment_id)
|
||||
if attachment.mimetype == 'text/csv':
|
||||
data = pd.read_csv(attachment._full_path(attachment.store_fname))
|
||||
processed_data = data.describe().to_string()
|
||||
return {'response': f"Analyse des données : \n{processed_data}"}
|
||||
|
||||
response = openai.ChatCompletion.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": message}]
|
||||
)
|
||||
return {'response': response['choices'][0]['message']['content']}
|
||||
28
chatgpt_assistant/static/src/js/discuss_extension.js
Normal file
28
chatgpt_assistant/static/src/js/discuss_extension.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
odoo.define('chatgpt_assistant.discuss_extension', [], function (Discuss, utils) {
|
||||
"use strict";
|
||||
|
||||
const { patch } = utils;
|
||||
|
||||
patch(Discuss.prototype, 'chatgpt_assistant.discuss_extension', {
|
||||
async _onChatGPTSendMessage(event) {
|
||||
const message = this.inputValue || '';
|
||||
if (!message.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.env.services.rpc({
|
||||
route: '/discuss/chatgpt',
|
||||
params: { message },
|
||||
});
|
||||
|
||||
if (result.response) {
|
||||
this._insertMessage(result.response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de l\'appel à ChatGPT:', error);
|
||||
this._insertMessage('Erreur : Impossible de contacter ChatGPT.');
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
8
chatgpt_assistant/views/assets.xml
Normal file
8
chatgpt_assistant/views/assets.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
|
||||
<odoo>
|
||||
<template id="assets_backend" name="chatgpt assistant assets" inherit_id="web.assets_backend">
|
||||
<xpath expr="." position="inside">
|
||||
<script type="text/javascript" src="/chatgpt_assistant/static/src/js/discuss_extension.js"></script>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
|
|
@ -1,12 +1,29 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import {Many2OneField} from "@web/views/fields/many2one/many2one_field";
|
||||
import {Many2OneField, m2oTupleFromData} from "@web/views/fields/many2one/many2one_field";
|
||||
import {patch} from "@web/core/utils/patch";
|
||||
|
||||
patch(Many2OneField.prototype, {
|
||||
get Many2XAutocompleteProps() {
|
||||
const props = super.Many2XAutocompleteProps;
|
||||
props.quickCreate = this.openConfirmationDialog.bind(this);
|
||||
return props;
|
||||
},
|
||||
/**
|
||||
* The original Many2OneField already has a quickCreate method and an openConfirmationDialog method.
|
||||
* The quickCreate method directly updates the record, while openConfirmationDialog shows a dialog
|
||||
* before calling quickCreate.
|
||||
*
|
||||
* We just need to modify the Many2XAutocompleteProps getter to use openConfirmationDialog
|
||||
* instead of quickCreate for the quickCreate property.
|
||||
*/
|
||||
get Many2XAutocompleteProps() {
|
||||
const props = super.Many2XAutocompleteProps;
|
||||
|
||||
// If quickCreate is defined, replace it with openConfirmationDialog
|
||||
if (props.quickCreate && this.openConfirmationDialog) {
|
||||
// Store the original quickCreate function
|
||||
const originalQuickCreate = props.quickCreate;
|
||||
|
||||
// Replace with openConfirmationDialog
|
||||
props.quickCreate = (name) => this.openConfirmationDialog(name);
|
||||
}
|
||||
|
||||
return props;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
|
||||
from . import models
|
||||
from . import wizards
|
||||
|
|
@ -1,15 +1,45 @@
|
|||
|
||||
{
|
||||
'name': 'Customer Itch Cycle Management',
|
||||
'version': '1.0',
|
||||
'depends': ['base', 'sale'],
|
||||
'author': 'Your Name',
|
||||
'category': 'Sales Management',
|
||||
'description': "Manage customer itch cycles by product for proactive sales engagement.",
|
||||
'name': 'Customer Itch Cycle',
|
||||
'version': '17.0.1.0.0',
|
||||
'category': 'Sales',
|
||||
'summary': 'Manage customer product cycles and predict future sales',
|
||||
'description': """
|
||||
This module helps track and manage customer purchasing cycles:
|
||||
* Track product purchase cycles per customer
|
||||
* Predict next purchase dates
|
||||
* Generate opportunities based on predictions
|
||||
* Monitor delayed purchases
|
||||
""",
|
||||
'author': 'DurPro',
|
||||
'website': 'https://www.durpro.com',
|
||||
'depends': [
|
||||
'base',
|
||||
'sale',
|
||||
'sale_management',
|
||||
'crm',
|
||||
'sale_crm',
|
||||
'base_automation',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/month_data.xml',
|
||||
'wizards/apply_to_child_categories.xml',
|
||||
'views/itch_cycle_product_partner_view.xml',
|
||||
'views/res_partner_view.xml',
|
||||
'views/itch_cycle_actions.xml',
|
||||
'views/itch_cycle_menu.xml',
|
||||
'views/crm_lead_view.xml',
|
||||
'views/product_category_view.xml',
|
||||
'data/ir_cron_data.xml',
|
||||
# 'data/crm_automation_data.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'customer_itch_cycle/static/src/js/cycle_product_partner_list_view.js',
|
||||
'customer_itch_cycle/static/src/xml/process_history_button.xml',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'application': True,
|
||||
'license': 'LGPL-3',
|
||||
}
|
||||
|
|
|
|||
13
customer_itch_cycle/data/ir_actions_server.xml
Normal file
13
customer_itch_cycle/data/ir_actions_server.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="action_reprocess_all_itch_cycles" model="ir.actions.server">
|
||||
<field name="name">Reprocess All Cycles</field>
|
||||
<field name="model_id" ref="model_itch_cycle_product_partner"/>
|
||||
<field name="binding_model_id" ref="model_itch_cycle_product_partner"/>
|
||||
<field name="binding_view_types">list,form</field>
|
||||
<field name="state">code</field>
|
||||
<field name="code">
|
||||
env['itch.cycle.product.partner'].populate_from_past_orders()
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
34
customer_itch_cycle/data/ir_cron_data.xml
Normal file
34
customer_itch_cycle/data/ir_cron_data.xml
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Automated Task for Creating Opportunities -->
|
||||
<record id="ir_cron_create_opportunities" model="ir.cron">
|
||||
<field name="name">Sales Cycles: Create Opportunities</field>
|
||||
<field name="model_id" ref="model_itch_cycle_product_partner"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_create_opportunities()</field>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="priority">5</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Automated Task for Processing History -->
|
||||
<record id="ir_cron_process_history" model="ir.cron">
|
||||
<field name="name">Sales Cycles: Process History</field>
|
||||
<field name="model_id" ref="model_itch_cycle_product_partner"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model.populate_from_past_orders()</field>
|
||||
<field name="user_id" ref="base.user_root"/>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="numbercall">-1</field>
|
||||
<field name="doall" eval="False"/>
|
||||
<field name="priority">10</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
66
customer_itch_cycle/data/month_data.xml
Normal file
66
customer_itch_cycle/data/month_data.xml
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<!-- Initialize months -->
|
||||
<record id="month_01" model="itch.month">
|
||||
<field name="name">January</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="number">1</field>
|
||||
</record>
|
||||
<record id="month_02" model="itch.month">
|
||||
<field name="name">February</field>
|
||||
<field name="sequence">2</field>
|
||||
<field name="number">2</field>
|
||||
</record>
|
||||
<record id="month_03" model="itch.month">
|
||||
<field name="name">March</field>
|
||||
<field name="sequence">3</field>
|
||||
<field name="number">3</field>
|
||||
</record>
|
||||
<record id="month_04" model="itch.month">
|
||||
<field name="name">April</field>
|
||||
<field name="sequence">4</field>
|
||||
<field name="number">4</field>
|
||||
</record>
|
||||
<record id="month_05" model="itch.month">
|
||||
<field name="name">May</field>
|
||||
<field name="sequence">5</field>
|
||||
<field name="number">5</field>
|
||||
</record>
|
||||
<record id="month_06" model="itch.month">
|
||||
<field name="name">June</field>
|
||||
<field name="sequence">6</field>
|
||||
<field name="number">6</field>
|
||||
</record>
|
||||
<record id="month_07" model="itch.month">
|
||||
<field name="name">July</field>
|
||||
<field name="sequence">7</field>
|
||||
<field name="number">7</field>
|
||||
</record>
|
||||
<record id="month_08" model="itch.month">
|
||||
<field name="name">August</field>
|
||||
<field name="sequence">8</field>
|
||||
<field name="number">8</field>
|
||||
</record>
|
||||
<record id="month_09" model="itch.month">
|
||||
<field name="name">September</field>
|
||||
<field name="sequence">9</field>
|
||||
<field name="number">9</field>
|
||||
</record>
|
||||
<record id="month_10" model="itch.month">
|
||||
<field name="name">October</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="number">10</field>
|
||||
</record>
|
||||
<record id="month_11" model="itch.month">
|
||||
<field name="name">November</field>
|
||||
<field name="sequence">11</field>
|
||||
<field name="number">11</field>
|
||||
</record>
|
||||
<record id="month_12" model="itch.month">
|
||||
<field name="name">December</field>
|
||||
<field name="sequence">12</field>
|
||||
<field name="number">12</field>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
|
||||
from . import itch_cycle_product_partner
|
||||
from . import sale_order
|
||||
from . import sale_order_line
|
||||
from . import product_category
|
||||
from . import res_partner
|
||||
from . import crm_lead
|
||||
|
|
|
|||
42
customer_itch_cycle/models/crm_lead.py
Normal file
42
customer_itch_cycle/models/crm_lead.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
from odoo import models, fields
|
||||
|
||||
|
||||
class CrmLead(models.Model):
|
||||
"""
|
||||
Extend CRM Lead model to add purchase cycle information.
|
||||
|
||||
Adds a link to the purchase cycle associated with the opportunity.
|
||||
This allows tracking customer purchasing patterns and forecasting
|
||||
future opportunities based on historical data.
|
||||
"""
|
||||
|
||||
_inherit = 'crm.lead'
|
||||
|
||||
itch_cycle_id = fields.Many2one(
|
||||
comodel_name='itch.cycle.product.partner',
|
||||
string='Purchase Cycle',
|
||||
ondelete='set null',
|
||||
help="""
|
||||
Linked purchase cycle for this opportunity.
|
||||
Used to track the customer's purchasing patterns.
|
||||
"""
|
||||
)
|
||||
|
||||
date_last_purchase = fields.Date(
|
||||
related='itch_cycle_id.date_last_purchase',
|
||||
string='Last Purchase Date',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
cycle_duration = fields.Integer(
|
||||
related='itch_cycle_id.cycle_duration',
|
||||
string='Cycle Duration (days)',
|
||||
readonly=True
|
||||
)
|
||||
|
||||
cycle_sales = fields.One2many(
|
||||
comodel_name='sale.order.line',
|
||||
related='itch_cycle_id.sale_order_line_ids',
|
||||
string='Cycle Sales',
|
||||
readonly=True
|
||||
)
|
||||
|
|
@ -1,33 +1,903 @@
|
|||
|
||||
from datetime import timedelta, date
|
||||
import logging
|
||||
import numpy as np
|
||||
from odoo import models, fields, api
|
||||
from datetime import timedelta, datetime
|
||||
from odoo.exceptions import ValidationError, UserError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
|
||||
class ItchCycleProductPartner(models.Model):
|
||||
_name = 'itch_cycle_product_partner'
|
||||
_description = 'Itch Cycle by Product and Partner'
|
||||
"""
|
||||
Model representing the purchase cycle between a product and a partner.
|
||||
|
||||
Tracks purchase patterns, predicts future orders, and manages related
|
||||
opportunities.
|
||||
|
||||
Attributes:
|
||||
_name (str): Model technical name
|
||||
_description (str): Model description
|
||||
_inherit (list): Inherited models
|
||||
"""
|
||||
_name = "itch.cycle.product.partner"
|
||||
_description = "Product/Partner Purchase Cycle"
|
||||
_inherit = ["mail.thread", "mail.activity.mixin"]
|
||||
|
||||
partner_id = fields.Many2one('res.partner', string="Client", required=True, ondelete='cascade')
|
||||
product_id = fields.Many2one('product.product', string="Produit", required=True)
|
||||
last_purchase_date = fields.Date(string="Dernière Date d'Achat")
|
||||
itch_cycle_duration = fields.Integer(string="Durée du Itch-Cycle (jours)", default=0)
|
||||
next_follow_up_date = fields.Date(string="Prochaine Date de Suivi", compute="_compute_next_follow_up_date", store=True)
|
||||
_sql_constraints = [
|
||||
(
|
||||
"positive_cycle_duration",
|
||||
"CHECK(cycle_duration_override >= 0)",
|
||||
"The forced cycle duration must be positive, zero or FALSE."
|
||||
)
|
||||
]
|
||||
|
||||
@api.depends('last_purchase_date', 'itch_cycle_duration')
|
||||
def _compute_next_follow_up_date(self):
|
||||
active = fields.Boolean(
|
||||
string="Active",
|
||||
default=True,
|
||||
help="If unchecked, this cycle will be considered archived",
|
||||
tracking=True
|
||||
)
|
||||
|
||||
partner_id = fields.Many2one(
|
||||
comodel_name="res.partner",
|
||||
string="Customer",
|
||||
help="Customer associated with this cycle",
|
||||
required=True,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
partner_email = fields.Char(
|
||||
related="partner_id.email",
|
||||
string="Partner Email"
|
||||
)
|
||||
|
||||
partner_phone = fields.Char(
|
||||
related="partner_id.phone",
|
||||
string="Partner Phone"
|
||||
)
|
||||
|
||||
partner_mobile = fields.Char(
|
||||
related="partner_id.mobile",
|
||||
string="Partner Mobile"
|
||||
)
|
||||
|
||||
partner_city = fields.Char(
|
||||
related="partner_id.city",
|
||||
string="Partner City"
|
||||
)
|
||||
|
||||
partner_country_id = fields.Many2one(
|
||||
related="partner_id.country_id",
|
||||
string="Partner Country"
|
||||
)
|
||||
|
||||
product_id = fields.Many2one(
|
||||
comodel_name="product.product",
|
||||
string="Product",
|
||||
help="Product associated with this cycle",
|
||||
required=True,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
product_categ_id = fields.Many2one(
|
||||
related="product_id.categ_id",
|
||||
string="Product Category"
|
||||
)
|
||||
|
||||
product_type = fields.Selection(
|
||||
related="product_id.type",
|
||||
string="Product Type"
|
||||
)
|
||||
|
||||
product_lst_price = fields.Float(
|
||||
related="product_id.lst_price",
|
||||
string="Product List Price"
|
||||
)
|
||||
|
||||
product_qty_available = fields.Float(
|
||||
related="product_id.qty_available",
|
||||
string="Product Quantity Available"
|
||||
)
|
||||
|
||||
sale_order_line_ids = fields.One2many(
|
||||
comodel_name="sale.order.line",
|
||||
inverse_name="itch_cycle_id",
|
||||
string="Sale Order Lines",
|
||||
help="Historical sales order lines for this product/partner combination"
|
||||
)
|
||||
|
||||
opportunity_ids = fields.Many2many(
|
||||
comodel_name="crm.lead",
|
||||
string="Related Opportunities",
|
||||
domain=[("type", "=", "opportunity")],
|
||||
help="Opportunities automatically generated from cycle predictions"
|
||||
)
|
||||
|
||||
opportunity_count = fields.Integer(
|
||||
string="Number of Opportunities",
|
||||
compute="_compute_opportunity_count",
|
||||
help="Count of related opportunities"
|
||||
)
|
||||
|
||||
quantity_total_ordered = fields.Float(
|
||||
string="Total Quantity Ordered",
|
||||
compute="_compute_sale_order_line_related_fields",
|
||||
help="Total quantity ordered by the customer for this product",
|
||||
store=True
|
||||
)
|
||||
|
||||
quantity_of_orders = fields.Integer(
|
||||
string="Number of Orders",
|
||||
compute="_compute_sale_order_line_related_fields",
|
||||
help="Number of orders placed by the customer for this product",
|
||||
store=True
|
||||
)
|
||||
|
||||
quantity_min_ordered = fields.Float(
|
||||
string="Minimum Quantity Ordered",
|
||||
compute="_compute_sale_order_line_related_fields",
|
||||
help="Minimum quantity ordered by the customer for this product",
|
||||
store=True
|
||||
)
|
||||
|
||||
quantity_max_ordered = fields.Float(
|
||||
string="Maximum Quantity Ordered",
|
||||
compute="_compute_sale_order_line_related_fields",
|
||||
help="Maximum quantity ordered by the customer for this product",
|
||||
store=True
|
||||
)
|
||||
|
||||
quantity_mean_ordered = fields.Float(
|
||||
string="Average Quantity Ordered",
|
||||
compute="_compute_sale_order_line_related_fields",
|
||||
help="Average quantity ordered by the customer for this product",
|
||||
store=True
|
||||
)
|
||||
|
||||
quantity_manual_override = fields.Float(
|
||||
string="Manual Quantity Override",
|
||||
help="Manually defined quantity",
|
||||
tracking=True
|
||||
)
|
||||
|
||||
quantity_planned = fields.Float(
|
||||
string="Planned Quantity",
|
||||
help="Planned quantity for the next order",
|
||||
compute="_compute_quantity_planned",
|
||||
store=True,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
cycle_duration_calculated = fields.Integer(
|
||||
string="Calculated Average Cycle (days)",
|
||||
compute="_compute_average_cycle",
|
||||
help="Calculated average cycle in days",
|
||||
store=True
|
||||
)
|
||||
|
||||
cycle_duration_override = fields.Integer(
|
||||
string="Cycle Duration (forced)",
|
||||
help="Cycle duration in days (forced value)",
|
||||
default=0,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
cycle_duration = fields.Integer(
|
||||
string="Average Cycle (days)",
|
||||
compute="_compute_itch_cycle_duration",
|
||||
help="Average cycle in days",
|
||||
store=True
|
||||
)
|
||||
|
||||
date_expected_evaluated = fields.Date(
|
||||
string="Next Expected Sale by Calculation",
|
||||
compute="_compute_date_expected_evaluated",
|
||||
store=True
|
||||
)
|
||||
|
||||
date_expected_override = fields.Date(
|
||||
string="Next Expected Sale Manual Override",
|
||||
help="Manually defined next expected sale date",
|
||||
tracking=True
|
||||
)
|
||||
|
||||
date_expected = fields.Date(
|
||||
string="Expected Date",
|
||||
help="Expected date for the next order",
|
||||
compute="_compute_date_expected",
|
||||
store=True,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
date_next_follow_up = fields.Date(
|
||||
string="Follow-up Date",
|
||||
help="Planned follow-up date",
|
||||
compute="_compute_next_follow_up_date",
|
||||
store=True,
|
||||
readonly=False,
|
||||
tracking=True
|
||||
)
|
||||
|
||||
date_last_purchase = fields.Date(
|
||||
string="Last Purchase Date",
|
||||
compute="_compute_last_purchase_date",
|
||||
help="Date of last purchase",
|
||||
store=True
|
||||
)
|
||||
|
||||
state = fields.Selection(
|
||||
selection=[
|
||||
("new", "New"),
|
||||
("pending", "Pending"),
|
||||
("on_time", "On Time"),
|
||||
("upcoming", "Upcoming"),
|
||||
("delayed", "Delayed"),
|
||||
("critical", "Critical"),
|
||||
("archived", "Archived"),
|
||||
],
|
||||
string="State",
|
||||
help="Current cycle state",
|
||||
compute="_compute_state",
|
||||
store=True
|
||||
)
|
||||
|
||||
notes = fields.Text(
|
||||
string="Notes",
|
||||
help="Additional notes about this cycle",
|
||||
tracking=True
|
||||
)
|
||||
|
||||
deviation_percent = fields.Float(
|
||||
string="Average Deviation (%)",
|
||||
compute="_compute_deviation",
|
||||
help="Percentage deviation from average orders",
|
||||
store=True
|
||||
)
|
||||
|
||||
name = fields.Char(
|
||||
string='Name',
|
||||
compute='_compute_name',
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends('partner_id.name', 'product_id.name')
|
||||
def _compute_name(self):
|
||||
for record in self:
|
||||
if record.last_purchase_date and record.itch_cycle_duration > 0:
|
||||
record.next_follow_up_date = record.last_purchase_date + timedelta(days=record.itch_cycle_duration)
|
||||
record.name = f"{record.partner_id.name}/{record.product_id.name}"
|
||||
|
||||
@api.depends("cycle_duration_override", "cycle_duration_calculated")
|
||||
def _compute_itch_cycle_duration(self):
|
||||
"""Compute the cycle duration.
|
||||
|
||||
If a cycle is manually defined, it is used instead.
|
||||
Otherwise, the calculated average cycle is used.
|
||||
"""
|
||||
for record in self:
|
||||
record.cycle_duration = (
|
||||
record.cycle_duration_override or
|
||||
record.cycle_duration_calculated)
|
||||
|
||||
@api.depends("quantity_manual_override", "quantity_mean_ordered")
|
||||
def _compute_quantity_planned(self):
|
||||
"""Compute the planned quantity for the next order.
|
||||
|
||||
If a quantity is manually defined, it is used.
|
||||
Otherwise, the average quantity is used.
|
||||
"""
|
||||
for record in self:
|
||||
record.quantity_planned = (
|
||||
record.quantity_manual_override or
|
||||
record.quantity_mean_ordered
|
||||
)
|
||||
|
||||
@api.depends("sale_order_line_ids")
|
||||
def _compute_last_purchase_date(self):
|
||||
"""Update the last purchase date."""
|
||||
for record in self:
|
||||
orders = record.sale_order_line_ids.mapped('order_id')
|
||||
last_order = orders.sorted(key=lambda o: o.date_order, reverse=True)
|
||||
record.date_last_purchase = (
|
||||
last_order[0].date_order if last_order else None
|
||||
)
|
||||
|
||||
@api.depends("date_expected")
|
||||
def _compute_next_follow_up_date(self):
|
||||
"""Compute the follow-up date.
|
||||
|
||||
Can be based on `next_expected_date`,
|
||||
but modifiable by the user.
|
||||
"""
|
||||
for record in self:
|
||||
record.date_next_follow_up = (
|
||||
record.date_expected - timedelta(days=7)
|
||||
if record.date_expected
|
||||
else None
|
||||
)
|
||||
|
||||
@api.depends("sale_order_line_ids", "product_id.categ_id")
|
||||
def _compute_average_cycle(self):
|
||||
"""Calculate the average cycle in days.
|
||||
|
||||
Takes into account seasonal factors
|
||||
if defined in product category.
|
||||
"""
|
||||
for record in self:
|
||||
product_category = record.product_id.categ_id
|
||||
if (product_category.seasonal_factor and
|
||||
product_category.season_months):
|
||||
active_months = list(map(
|
||||
int,
|
||||
product_category.season_months.split(',')
|
||||
))
|
||||
dates = sorted([
|
||||
line.order_id.date_order
|
||||
for line in record.sale_order_line_ids
|
||||
if line.order_id.date_order.month in active_months
|
||||
])
|
||||
else:
|
||||
record.next_follow_up_date = False
|
||||
dates = sorted(
|
||||
record.sale_order_line_ids.mapped('order_id.date_order')
|
||||
)
|
||||
|
||||
class ResPartner(models.Model):
|
||||
_inherit = 'res.partner'
|
||||
if len(dates) > 1:
|
||||
intervals = [
|
||||
(dates[i + 1] - dates[i]).days
|
||||
for i in range(len(dates) - 1)
|
||||
]
|
||||
record.cycle_duration_calculated = (
|
||||
int(np.mean(intervals)) if intervals else 0
|
||||
)
|
||||
else:
|
||||
record.cycle_duration_calculated = 0
|
||||
|
||||
itch_cycle_product_ids = fields.One2many('itch_cycle_product_partner', 'partner_id', string="Itch Cycles Produits")
|
||||
itch_next_delay = fields.Date(string="Prochaine Date de Suivi (Itch-Cycle Min)", compute="_compute_itch_next_delay", store=True)
|
||||
@api.constrains("cycle_duration_override", "date_expected_override")
|
||||
def _check_cycle_constraints(self):
|
||||
"""Validate cycle constraints.
|
||||
|
||||
Ensures forced cycle duration is positive
|
||||
and expected date is not in past.
|
||||
"""
|
||||
for record in self:
|
||||
if record.cycle_duration_override and record.cycle_duration_override < 0:
|
||||
raise ValidationError('The forced cycle duration must be positive.')
|
||||
if record.date_expected_override and record.date_expected_override < fields.Date.today():
|
||||
raise ValidationError('The forced expected date cannot be in the past.')
|
||||
|
||||
@api.depends('itch_cycle_product_ids.next_follow_up_date')
|
||||
def _compute_itch_next_delay(self):
|
||||
for partner in self:
|
||||
follow_up_dates = partner.itch_cycle_product_ids.mapped('next_follow_up_date')
|
||||
partner.itch_next_delay = min(follow_up_dates) if follow_up_dates else False
|
||||
@api.constrains('product_id')
|
||||
def _check_product_category_tracked(self):
|
||||
"""Ensure cycles can only be created for products in tracked categories.
|
||||
|
||||
Raises ValidationError if product category
|
||||
is not configured for cycle tracking.
|
||||
"""
|
||||
for record in self:
|
||||
if not record.product_id.categ_id.is_cycle_tracked:
|
||||
msg = (
|
||||
f"Cannot create cycle for product '{record.product_id.name}' "
|
||||
f"because its category '{record.product_id.categ_id.name}' "
|
||||
"is not configured for cycle tracking. Enable 'Track Sales Cycle' "
|
||||
"in the product category settings first."
|
||||
)
|
||||
raise ValidationError(msg)
|
||||
|
||||
@api.depends(
|
||||
'cycle_duration',
|
||||
'date_last_purchase'
|
||||
)
|
||||
def _compute_date_expected_evaluated(self):
|
||||
"""Calculate next sale date based on average cycle and last purchase date.
|
||||
|
||||
Handles edge cases and logs warnings for invalid data.
|
||||
"""
|
||||
for record in self:
|
||||
try:
|
||||
# Check if cycle_duration is valid
|
||||
if not isinstance(record.cycle_duration, int):
|
||||
record.date_expected_evaluated = None
|
||||
_logger.warning(
|
||||
"Invalid cycle duration type for record %s: "
|
||||
"Expected int, got %s",
|
||||
record.id, type(record.cycle_duration)
|
||||
)
|
||||
continue
|
||||
|
||||
if record.cycle_duration < 0:
|
||||
record.date_expected_evaluated = None
|
||||
_logger.warning(
|
||||
"Invalid cycle duration value for record %s: "
|
||||
"Must be positive, got %s",
|
||||
record.id, record.cycle_duration
|
||||
)
|
||||
continue
|
||||
|
||||
# Check if date_last_purchase is valid
|
||||
if not isinstance(record.date_last_purchase, date):
|
||||
record.date_expected_evaluated = None
|
||||
_logger.warning(
|
||||
"Invalid last purchase date type for record %s: "
|
||||
"Expected date, got %s",
|
||||
record.id, type(record.date_last_purchase)
|
||||
)
|
||||
continue
|
||||
|
||||
# Calculate expected date
|
||||
record.date_expected_evaluated = (
|
||||
record.date_last_purchase +
|
||||
timedelta(days=record.cycle_duration)
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"Successfully computed date_expected_evaluated for record %s: "
|
||||
"Last purchase: %s, Cycle duration: %s days, "
|
||||
"Expected date: %s",
|
||||
record.id, record.date_last_purchase,
|
||||
record.cycle_duration, record.date_expected_evaluated
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
record.date_expected_evaluated = None
|
||||
_logger.error(
|
||||
"Error computing date_expected_evaluated for record %s: %s",
|
||||
record.id, str(e)
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'date_expected_evaluated',
|
||||
'date_expected_override',
|
||||
'cycle_duration',
|
||||
'date_last_purchase'
|
||||
)
|
||||
def _compute_date_expected(self):
|
||||
"""Calculate expected date considering:
|
||||
|
||||
- The override date if defined
|
||||
- The evaluated date if future
|
||||
- The last order date + cycle if a cycle is defined
|
||||
- Otherwise None
|
||||
"""
|
||||
for record in self:
|
||||
if (record.date_expected_override and
|
||||
record.date_expected_override > fields.Date.today()):
|
||||
record.date_expected = record.date_expected_override
|
||||
elif record.date_expected_evaluated:
|
||||
record.date_expected = record.date_expected_evaluated
|
||||
else:
|
||||
record.date_expected = None
|
||||
|
||||
@api.depends('date_expected', 'active', 'quantity_of_orders')
|
||||
def _compute_state(self):
|
||||
"""Determine current state based on various conditions.
|
||||
|
||||
Possible states: new, pending, on_time,
|
||||
upcoming, delayed, critical, archived.
|
||||
"""
|
||||
today = fields.Date.today()
|
||||
for record in self:
|
||||
if not record.active:
|
||||
record.state = 'archived'
|
||||
continue
|
||||
|
||||
if record.quantity_of_orders < 2:
|
||||
record.state = 'new'
|
||||
elif not record.date_expected:
|
||||
record.state = 'pending'
|
||||
elif record.date_expected < today:
|
||||
days_late = (today - record.date_expected).days
|
||||
record.state = 'critical' if days_late > 30 else 'delayed'
|
||||
elif (record.date_expected - today).days <= 7:
|
||||
record.state = 'upcoming'
|
||||
else:
|
||||
record.state = 'on_time'
|
||||
|
||||
@api.model
|
||||
def populate_from_past_orders(self):
|
||||
"""Process historical sales data to create or update product cycles.
|
||||
|
||||
Analyzes past orders to calculate
|
||||
cycle durations and expected dates.
|
||||
"""
|
||||
_logger.info("Starting historical sales data processing...")
|
||||
|
||||
sale_lines = self.env['sale.order.line'].search([
|
||||
('state', 'in', ['sale', 'done']),
|
||||
('order_id.state', 'in', ['sale', 'done']),
|
||||
('order_id.date_order', '!=', False),
|
||||
('product_id', '!=', False),
|
||||
('order_id.partner_id', '!=', False),
|
||||
('qty_delivered', '!=', 0)
|
||||
])
|
||||
|
||||
partner_product_lines = {}
|
||||
total_lines = len(sale_lines)
|
||||
|
||||
for i, line in enumerate(sale_lines, 1):
|
||||
if i % (total_lines // 10 or 1) == 0:
|
||||
progress = (i / total_lines) * 100
|
||||
_logger.info(f"Progress: {progress:.0f}%")
|
||||
|
||||
if not (line.product_id and line.order_id.partner_id and line.order_id.date_order):
|
||||
continue
|
||||
|
||||
key = (line.order_id.partner_id.id, line.product_id.id)
|
||||
if key not in partner_product_lines:
|
||||
partner_product_lines[key] = []
|
||||
partner_product_lines[key].append(line)
|
||||
|
||||
notifications = []
|
||||
processed_count = 0
|
||||
error_count = 0
|
||||
total_combinations = len(partner_product_lines)
|
||||
|
||||
for (partner_id, product_id), lines in partner_product_lines.items():
|
||||
cr = self.env.cr
|
||||
try:
|
||||
with cr.savepoint():
|
||||
if not partner_id or not product_id:
|
||||
continue
|
||||
|
||||
sorted_lines = sorted(
|
||||
lines,
|
||||
key=lambda line: line.order_id.date_order or fields.Datetime.now()
|
||||
)
|
||||
|
||||
total_quantity = sum(line.product_uom_qty for line in sorted_lines)
|
||||
mean_quantity = total_quantity / len(sorted_lines)
|
||||
last_order_date = sorted_lines[-1].order_id.date_order if sorted_lines else False
|
||||
|
||||
if len(sorted_lines) > 1:
|
||||
time_diffs = [
|
||||
(sorted_lines[i].order_id.date_order -
|
||||
sorted_lines[i-1].order_id.date_order).days
|
||||
for i in range(1, len(sorted_lines))
|
||||
if sorted_lines[i].order_id.date_order and
|
||||
sorted_lines[i-1].order_id.date_order
|
||||
]
|
||||
|
||||
if time_diffs:
|
||||
cycle_duration = sum(time_diffs) / len(time_diffs)
|
||||
if cycle_duration < 1:
|
||||
cycle_duration = 1
|
||||
else:
|
||||
cycle_duration = 30
|
||||
else:
|
||||
cycle_duration = 30
|
||||
|
||||
date_expected = last_order_date + timedelta(days=cycle_duration) if last_order_date else False
|
||||
|
||||
cycle = self.with_context(active_test=False).search([
|
||||
('partner_id', '=', partner_id),
|
||||
('product_id', '=', product_id)
|
||||
])
|
||||
|
||||
values = {
|
||||
'quantity_mean_ordered': mean_quantity,
|
||||
'quantity_total_ordered': total_quantity,
|
||||
'cycle_duration': cycle_duration,
|
||||
'sale_order_line_ids': [(6, 0, [line.id for line in sorted_lines])],
|
||||
'date_expected': date_expected,
|
||||
'date_last_purchase': last_order_date,
|
||||
'active': True,
|
||||
}
|
||||
|
||||
if cycle:
|
||||
cycle.write(values)
|
||||
else:
|
||||
values.update({
|
||||
'partner_id': partner_id,
|
||||
'product_id': product_id,
|
||||
})
|
||||
self.create(values)
|
||||
|
||||
processed_count += 1
|
||||
if processed_count % (total_combinations // 10 or 1) == 0:
|
||||
progress = (processed_count / total_combinations) * 100
|
||||
_logger.info(f"Progress: {progress:.0f}%")
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
_logger.error(f"Error processing partner {partner_id} and product {product_id}: {str(e)}")
|
||||
notifications.append({
|
||||
'params': {
|
||||
'message': f"Error processing partner {partner_id} and product {product_id}: {str(e)}",
|
||||
'type': 'warning',
|
||||
'sticky': False
|
||||
}
|
||||
})
|
||||
continue
|
||||
|
||||
# Add final notification
|
||||
status = 'warning' if error_count > 0 else 'success'
|
||||
message = f"Processed {processed_count} combinations"
|
||||
if error_count > 0:
|
||||
message += f" with {error_count} errors"
|
||||
|
||||
notifications.append({
|
||||
'params': {
|
||||
'message': message,
|
||||
'type': status,
|
||||
'sticky': False
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
'tag': 'display_notifications',
|
||||
'params': {'notifications': notifications}
|
||||
}
|
||||
|
||||
@api.depends(
|
||||
'sale_order_line_ids'
|
||||
)
|
||||
def _compute_sale_order_line_related_fields(self):
|
||||
"""Compute statistics from related sale order lines.
|
||||
|
||||
Calculates:
|
||||
- Total quantity ordered
|
||||
- Number of orders
|
||||
- Minimum quantity ordered
|
||||
- Maximum quantity ordered
|
||||
- Average quantity ordered
|
||||
"""
|
||||
for record in self:
|
||||
quantities = record.sale_order_line_ids.mapped('product_uom_qty')
|
||||
record.quantity_of_orders = len(quantities)
|
||||
if quantities:
|
||||
record.quantity_total_ordered = sum(quantities)
|
||||
record.quantity_min_ordered = min(quantities)
|
||||
record.quantity_max_ordered = max(quantities)
|
||||
record.quantity_mean_ordered = sum(quantities) / len(quantities)
|
||||
else:
|
||||
record.quantity_total_ordered = 0
|
||||
record.quantity_min_ordered = 0
|
||||
record.quantity_max_ordered = 0
|
||||
record.quantity_mean_ordered = 0
|
||||
|
||||
@api.constrains(
|
||||
'quantity_manual_override'
|
||||
)
|
||||
def _check_quantity_manual_override(self):
|
||||
"""Validate manual quantity override.
|
||||
|
||||
Ensures manual quantity override is positive.
|
||||
Raises ValidationError if negative value
|
||||
is provided.
|
||||
"""
|
||||
for record in self:
|
||||
if record.quantity_manual_override < 0:
|
||||
raise ValidationError('The manual quantity override must be positive.')
|
||||
|
||||
@api.depends(
|
||||
'quantity_planned',
|
||||
'quantity_mean_ordered'
|
||||
)
|
||||
def _compute_deviation(self):
|
||||
"""Calculate deviation between planned and average quantity.
|
||||
|
||||
Returns percentage difference between
|
||||
planned quantity and historical average.
|
||||
"""
|
||||
for record in self:
|
||||
if record.quantity_mean_ordered and record.quantity_planned:
|
||||
record.deviation_percent = ((record.quantity_planned - record.quantity_mean_ordered) / record.quantity_mean_ordered) * 100
|
||||
else:
|
||||
record.deviation_percent = 0
|
||||
|
||||
@api.depends(
|
||||
'opportunity_ids'
|
||||
)
|
||||
def _compute_opportunity_count(self):
|
||||
"""Count related opportunities.
|
||||
|
||||
Returns number of opportunities
|
||||
linked to this cycle.
|
||||
"""
|
||||
for record in self:
|
||||
record.opportunity_count = len(record.opportunity_ids)
|
||||
|
||||
def action_view_opportunities(self):
|
||||
"""Open CRM opportunities view.
|
||||
|
||||
Displays all opportunities related
|
||||
to this cycle in pipeline view.
|
||||
"""
|
||||
self.ensure_one()
|
||||
action = self.env["ir.actions.actions"]._for_xml_id("crm.crm_lead_action_pipeline")
|
||||
action['domain'] = [('id', 'in', self.opportunity_ids.ids)]
|
||||
action['context'] = {
|
||||
'default_partner_id': self.partner_id.id,
|
||||
'default_expected_revenue': self.product_lst_price * self.quantity_mean_ordered,
|
||||
}
|
||||
return action
|
||||
|
||||
def _create_opportunity_data(self):
|
||||
"""Prepare data for opportunity creation.
|
||||
|
||||
Returns:
|
||||
dict: Dictionary containing opportunity data
|
||||
"""
|
||||
self.ensure_one()
|
||||
expected_date = self.date_expected or fields.Date.today()
|
||||
expected_revenue = self.product_lst_price * self.quantity_mean_ordered
|
||||
|
||||
return {
|
||||
'name': f"Predicted Sale: {self.product_id.name}",
|
||||
'partner_id': self.partner_id.id,
|
||||
'type': 'opportunity',
|
||||
'expected_revenue': expected_revenue,
|
||||
'date_deadline': expected_date,
|
||||
'itch_cycle_id': self.id,
|
||||
'description': f"""
|
||||
<h3>Automatically generated from sales cycle prediction</h3>
|
||||
<ul>
|
||||
<li><strong>Product:</strong> {self.product_id.name}</li>
|
||||
<li><strong>Expected Quantity:</strong> {self.quantity_mean_ordered}</li>
|
||||
<li><strong>Cycle Duration:</strong> {self.cycle_duration} days</li>
|
||||
<li><strong>Previous Order Date:</strong> {self.date_last_purchase}</li>
|
||||
</ul>
|
||||
""",
|
||||
}
|
||||
|
||||
def create_opportunity(self):
|
||||
"""Create new opportunity from cycle prediction.
|
||||
|
||||
Generates opportunity with expected revenue
|
||||
and deadline based on cycle data.
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
# Create opportunity using prepared data
|
||||
opportunity = self.env['crm.lead'].create(
|
||||
self._create_opportunity_data()
|
||||
)
|
||||
|
||||
# Link the opportunity to this cycle
|
||||
self.write({
|
||||
'opportunity_ids': [(4, opportunity.id)]
|
||||
})
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'crm.lead',
|
||||
'res_id': opportunity.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_create_opportunity(self):
|
||||
"""Create opportunity from button click.
|
||||
|
||||
Wrapper method for create_opportunity()
|
||||
to handle button actions.
|
||||
"""
|
||||
return self.create_opportunity()
|
||||
|
||||
def action_view_latest_opportunity(self):
|
||||
"""View most recent opportunity.
|
||||
|
||||
Opens form view of the latest created
|
||||
opportunity for this cycle.
|
||||
"""
|
||||
self.ensure_one()
|
||||
if not self.opportunity_ids:
|
||||
raise UserError("Aucune opportunité associée à ce cycle.")
|
||||
|
||||
latest_opportunity = self.opportunity_ids.sorted(key=lambda o: o.create_date, reverse=True)[0]
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'crm.lead',
|
||||
'res_id': latest_opportunity.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
|
||||
def action_create_batch_opportunities(self):
|
||||
"""
|
||||
Create opportunities in batch for selected cycles.
|
||||
|
||||
Returns:
|
||||
dict: Action result to display notification
|
||||
"""
|
||||
created_count = 0
|
||||
error_count = 0
|
||||
|
||||
for cycle in self:
|
||||
try:
|
||||
# Check if there's already an open opportunity for this cycle
|
||||
existing_opportunities = cycle.opportunity_ids.filtered(
|
||||
lambda o: not o.stage_id.is_won
|
||||
)
|
||||
|
||||
if existing_opportunities:
|
||||
continue
|
||||
|
||||
# Create new opportunity using prepared data
|
||||
opportunity = self.env['crm.lead'].create(
|
||||
cycle._create_opportunity_data()
|
||||
)
|
||||
cycle.write({
|
||||
'opportunity_ids': [(4, opportunity.id)]
|
||||
})
|
||||
created_count += 1
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
_logger.error(
|
||||
f"Error creating opportunity for cycle {cycle.id}: {str(e)}"
|
||||
)
|
||||
continue
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'title': 'Batch Opportunity Creation',
|
||||
'message': f"Created {created_count} opportunities, {error_count} errors",
|
||||
'sticky': False,
|
||||
'type': 'success' if error_count == 0 else 'warning',
|
||||
}
|
||||
}
|
||||
|
||||
def _cron_create_opportunities(self):
|
||||
"""Automatically create opportunities for upcoming predicted sales.
|
||||
|
||||
This cron job will:
|
||||
- Find all active cycles with expected dates in the next 30 days
|
||||
- Create new opportunities for cycles without existing open opportunities
|
||||
- Log the results of the operation
|
||||
"""
|
||||
_logger.info("Starting automatic opportunity creation...")
|
||||
|
||||
# Find upcoming cycles in the next 30 days
|
||||
today = fields.Date.today()
|
||||
upcoming_cycles = self.search([
|
||||
('date_expected', '!=', False),
|
||||
('date_expected', '>=', today),
|
||||
('date_expected', '<=', today + timedelta(days=30)),
|
||||
('active', '=', True),
|
||||
])
|
||||
|
||||
created_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
for cycle in upcoming_cycles:
|
||||
try:
|
||||
# Check if there's already an open opportunity for this cycle
|
||||
existing_opportunities = cycle.opportunity_ids.filtered(
|
||||
lambda o: not o.stage_id.is_won and
|
||||
o.date_deadline >= today
|
||||
)
|
||||
|
||||
if existing_opportunities:
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Create new opportunity using prepared data
|
||||
opportunity = self.env['crm.lead'].create(
|
||||
cycle._create_opportunity_data()
|
||||
)
|
||||
cycle.write({
|
||||
'opportunity_ids': [(4, opportunity.id)]
|
||||
})
|
||||
created_count += 1
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
_logger.error(
|
||||
f"Error creating opportunity for cycle {cycle.id}: {str(e)}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Log final results
|
||||
_logger.info(
|
||||
f"Opportunity creation completed: "
|
||||
f"{created_count} created, "
|
||||
f"{skipped_count} skipped, "
|
||||
f"{error_count} errors"
|
||||
)
|
||||
|
||||
return {
|
||||
'created_count': created_count,
|
||||
'skipped_count': skipped_count,
|
||||
'error_count': error_count,
|
||||
}
|
||||
|
|
|
|||
124
customer_itch_cycle/models/product_category.py
Normal file
124
customer_itch_cycle/models/product_category.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
from odoo import models, fields, api
|
||||
from odoo.exceptions import ValidationError
|
||||
import logging
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class Month(models.Model):
|
||||
"""
|
||||
Model to represent months for seasonal tracking.
|
||||
This allows for better organization and selection of active months
|
||||
for seasonal product categories.
|
||||
"""
|
||||
_name = 'itch.month'
|
||||
_description = 'Month for Seasonal Tracking'
|
||||
_order = 'sequence'
|
||||
|
||||
name = fields.Char(string='Name', required=True, translate=True)
|
||||
sequence = fields.Integer(string='Sequence', required=True)
|
||||
number = fields.Integer(string='Month Number', required=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_number', 'unique(number)', 'Month number must be unique!'),
|
||||
('number_range', 'CHECK(number >= 1 AND number <= 12)', 'Month number must be between 1 and 12!')
|
||||
]
|
||||
|
||||
|
||||
class ProductCategory(models.Model):
|
||||
"""
|
||||
Extends the product category model to add seasonal behavior functionality.
|
||||
|
||||
This is used in the sales cycle calculation to better predict next sales
|
||||
by taking into account seasonal patterns.
|
||||
"""
|
||||
_inherit = 'product.category'
|
||||
|
||||
is_cycle_tracked = fields.Boolean(
|
||||
string="Track Sales Cycle",
|
||||
help="If enabled, products in this category will be tracked by the sales cycle system",
|
||||
default=True,
|
||||
)
|
||||
|
||||
temp_is_cycle_tracked = fields.Boolean(
|
||||
string="Temporary Track Sales Cycle",
|
||||
help="Technical field to track changes in is_cycle_tracked",
|
||||
default=True,
|
||||
)
|
||||
|
||||
@api.onchange('is_cycle_tracked')
|
||||
def _onchange_is_cycle_tracked(self):
|
||||
"""Store the new value temporarily and restore the original value"""
|
||||
if self.id and self.is_cycle_tracked != self.temp_is_cycle_tracked:
|
||||
self.temp_is_cycle_tracked = self.is_cycle_tracked
|
||||
self.is_cycle_tracked = not self.is_cycle_tracked
|
||||
return {
|
||||
'warning': {
|
||||
'title': 'Confirmation Required',
|
||||
'message': 'Please use the "Apply Changes" button to change tracking status.'
|
||||
}
|
||||
}
|
||||
|
||||
def action_apply_cycle_tracked(self):
|
||||
"""Open wizard to apply cycle tracked changes"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': 'Apply to Child Categories',
|
||||
'res_model': 'itch.apply.to.child.categories',
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
'context': {
|
||||
'default_category_id': self.id,
|
||||
'default_new_value': self.temp_is_cycle_tracked,
|
||||
}
|
||||
}
|
||||
|
||||
def write(self, vals):
|
||||
"""
|
||||
Override write to handle changes in is_cycle_tracked field.
|
||||
|
||||
If a category is no longer tracked, we archive all related cycles.
|
||||
"""
|
||||
if 'is_cycle_tracked' in vals and not vals['is_cycle_tracked']:
|
||||
# Find all cycles for products in this category
|
||||
cycles = self.env['itch.cycle.product.partner'].search([
|
||||
('product_id.categ_id', 'in', self.ids)
|
||||
])
|
||||
if cycles:
|
||||
# Archive the cycles
|
||||
cycles.write({
|
||||
'active': False,
|
||||
'notes': f'{fields.Date.today()}: Archived automatically - Category no longer tracked'
|
||||
})
|
||||
# Log the action
|
||||
_logger.info(
|
||||
f"Archived {len(cycles)} cycles due to category {self.name} "
|
||||
f"being marked as not tracked"
|
||||
)
|
||||
|
||||
return super().write(vals)
|
||||
|
||||
seasonal_factor = fields.Boolean(
|
||||
string="Seasonal Category",
|
||||
help="Enable this if the products in this category have seasonal sales patterns",
|
||||
default=False,
|
||||
)
|
||||
|
||||
season_months = fields.Many2many(
|
||||
'itch.month',
|
||||
string="Active Months",
|
||||
help="Select the months when this category is typically active",
|
||||
)
|
||||
|
||||
@api.constrains('season_months')
|
||||
def _check_season_months(self):
|
||||
"""
|
||||
Validate the selection of season_months field.
|
||||
|
||||
Rules:
|
||||
- Cannot be empty if seasonal_factor is True
|
||||
"""
|
||||
for record in self:
|
||||
if record.seasonal_factor and not record.season_months:
|
||||
raise ValidationError("Active months must be specified for seasonal categories")
|
||||
53
customer_itch_cycle/models/res_partner.py
Normal file
53
customer_itch_cycle/models/res_partner.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResPartner(models.Model):
|
||||
"""
|
||||
Extends the partner model to add sales cycle tracking functionality.
|
||||
"""
|
||||
_inherit = 'res.partner'
|
||||
|
||||
itch_cycle_product_ids = fields.One2many(
|
||||
comodel_name='itch.cycle.product.partner',
|
||||
inverse_name='partner_id',
|
||||
string="Product Sales Cycles",
|
||||
help="List of all product sales cycles associated with this customer",
|
||||
)
|
||||
|
||||
reorder_cycle_product_ids = fields.One2many(
|
||||
comodel_name='itch.cycle.product.partner',
|
||||
inverse_name='partner_id',
|
||||
string="Active Sales Cycles",
|
||||
domain=[('quantity_of_orders', '>', 1)],
|
||||
help="List of product sales cycles with established patterns",
|
||||
)
|
||||
|
||||
itch_next_delay = fields.Date(
|
||||
string="Next Follow-up Date",
|
||||
compute="_compute_itch_next_delay",
|
||||
store=True,
|
||||
help="Earliest follow-up date",
|
||||
)
|
||||
|
||||
@api.depends(
|
||||
'itch_cycle_product_ids',
|
||||
'itch_cycle_product_ids.date_next_follow_up'
|
||||
)
|
||||
def _compute_itch_next_delay(self):
|
||||
"""
|
||||
Compute the earliest follow-up date.
|
||||
|
||||
The date is calculated from all associated product cycles.
|
||||
"""
|
||||
for partner in self:
|
||||
cycles = partner.reorder_cycle_product_ids
|
||||
dates = cycles.mapped('date_next_follow_up')
|
||||
partner.itch_next_delay = min(dates) if dates else False
|
||||
|
||||
def action_populate_itch_cycles(self):
|
||||
"""
|
||||
Initialize or update product cycles.
|
||||
|
||||
The cycles are created from historical order data.
|
||||
"""
|
||||
self.env['itch.cycle.product.partner'].populate_from_past_orders()
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
|
||||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
from datetime import datetime
|
||||
|
||||
class SaleOrder(models.Model):
|
||||
_inherit = 'sale.order'
|
||||
|
||||
def action_confirm(self):
|
||||
super(SaleOrder, self).action_confirm()
|
||||
for line in self.order_line:
|
||||
partner_id = self.partner_id
|
||||
product_id = line.product_id
|
||||
|
||||
itch_cycle_record = self.env['itch_cycle_product_partner'].search([
|
||||
('partner_id', '=', partner_id.id),
|
||||
('product_id', '=', product_id.id)
|
||||
], limit=1)
|
||||
|
||||
if itch_cycle_record:
|
||||
if itch_cycle_record.last_purchase_date:
|
||||
days_since_last_purchase = (datetime.now().date() - itch_cycle_record.last_purchase_date).days
|
||||
if itch_cycle_record.itch_cycle_duration == 0:
|
||||
raise UserError(_(
|
||||
f"Veuillez définir la durée du itch-cycle pour le produit '{product_id.name}' "
|
||||
f"pour le client '{partner_id.name}'.\n\n"
|
||||
f"Il y a eu {days_since_last_purchase} jours depuis le dernier achat."
|
||||
))
|
||||
else:
|
||||
itch_cycle_record.last_purchase_date = datetime.now().date()
|
||||
itch_cycle_record.itch_cycle_duration = days_since_last_purchase
|
||||
else:
|
||||
itch_cycle_record.last_purchase_date = datetime.now().date()
|
||||
else:
|
||||
new_record = self.env['itch_cycle_product_partner'].create({
|
||||
'partner_id': partner_id.id,
|
||||
'product_id': product_id.id,
|
||||
'last_purchase_date': datetime.now().date(),
|
||||
'itch_cycle_duration': 0
|
||||
})
|
||||
raise UserError(_(
|
||||
f"Il n'y a pas encore de itch-cycle défini pour le produit '{product_id.name}' "
|
||||
f"avec le client '{partner_id.name}'.\n\nVeuillez entrer une durée pour ce cycle."
|
||||
))
|
||||
51
customer_itch_cycle/models/sale_order_line.py
Normal file
51
customer_itch_cycle/models/sale_order_line.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class SaleOrderLine(models.Model):
|
||||
"""
|
||||
Extends sale order line to add itch cycle tracking.
|
||||
"""
|
||||
_inherit = 'sale.order.line'
|
||||
|
||||
itch_cycle_id = fields.Many2one(
|
||||
comodel_name='itch.cycle.product.partner',
|
||||
string="Cycle")
|
||||
|
||||
stock_move_ids = fields.One2many(
|
||||
comodel_name='stock.move',
|
||||
inverse_name='sale_line_id',
|
||||
string="Movements")
|
||||
|
||||
sale_date = fields.Datetime(
|
||||
string="Date",
|
||||
related='order_id.date_order',
|
||||
store=True)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
"""
|
||||
Create sale order lines and associate them with their itch cycle.
|
||||
|
||||
If the product is cycle tracked and no existing cycle exists for the
|
||||
product/partner combination, a new cycle is created.
|
||||
"""
|
||||
lines = super().create(vals_list)
|
||||
|
||||
for line in lines:
|
||||
if (line.product_id and line.order_id and
|
||||
line.product_id.categ_id.is_cycle_tracked):
|
||||
partner_id = line.order_id.partner_id.id
|
||||
itch_cycle = self.env['itch.cycle.product.partner'].search([
|
||||
('partner_id', '=', partner_id),
|
||||
('product_id', '=', line.product_id.id)
|
||||
], limit=1)
|
||||
if not itch_cycle:
|
||||
cycle_vals = {
|
||||
'partner_id': partner_id,
|
||||
'product_id': line.product_id.id,
|
||||
}
|
||||
itch_cycle = self.env['itch.cycle.product.partner'].create(
|
||||
cycle_vals)
|
||||
line.itch_cycle_id = itch_cycle.id
|
||||
|
||||
return lines
|
||||
5
customer_itch_cycle/security/ir.model.access.csv
Normal file
5
customer_itch_cycle/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_itch_cycle_product_partner,itch.cycle.product.partner access,model_itch_cycle_product_partner,base.group_user,1,1,1,1
|
||||
access_itch_month_user,itch.month access user,model_itch_month,base.group_user,1,0,0,0
|
||||
access_itch_month_manager,itch.month access manager,model_itch_month,sales_team.group_sale_manager,1,1,1,1
|
||||
access_itch_apply_to_child_categories,itch.apply.to.child.categories access,model_itch_apply_to_child_categories,base.group_user,1,1,1,1
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { listView } from "@web/views/list/list_view";
|
||||
import { ListController } from "@web/views/list/list_controller";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
/**
|
||||
* Custom List Controller for Product/Partner Cycle Management
|
||||
* Extends standard list view to add history processing functionality
|
||||
*/
|
||||
class CycleProductPartnerListController extends ListController {
|
||||
setup() {
|
||||
super.setup();
|
||||
this.orm = useService("orm");
|
||||
this.notification = useService("notification");
|
||||
}
|
||||
|
||||
/**
|
||||
* Process historical sales data to update cycles
|
||||
* Shows notifications for processing status
|
||||
*/
|
||||
async ProcessHistoryButton() {
|
||||
try {
|
||||
this.notification.add("Starting history processing...", {
|
||||
type: "info",
|
||||
sticky: false,
|
||||
});
|
||||
|
||||
const result = await this.orm.call("itch.cycle.product.partner", "populate_from_past_orders", []);
|
||||
|
||||
if (result && result.tag === 'display_notifications' && result.params.notifications) {
|
||||
for (const notif of result.params.notifications) {
|
||||
this.notification.add(notif.params.message, {
|
||||
type: notif.params.type,
|
||||
sticky: notif.params.sticky,
|
||||
});
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
}
|
||||
|
||||
await this.model.load();
|
||||
} catch (error) {
|
||||
this.notification.add(`An error occurred: ${error.message}`, {
|
||||
type: "danger",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register the custom list view for cycle product partner
|
||||
registry.category("views").add("cycle_product_partner_list", {
|
||||
...listView,
|
||||
Controller: CycleProductPartnerListController,
|
||||
buttonTemplate: "customer_itch_cycle.ListButtons",
|
||||
});
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="customer_itch_cycle.ListButtons" name="ProcessHistoryButton">
|
||||
<div>
|
||||
<button class="btn btn-primary" t-on-click="ProcessHistoryButton">
|
||||
Update from Sales History
|
||||
</button>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
51
customer_itch_cycle/views/crm_lead_view.xml
Normal file
51
customer_itch_cycle/views/crm_lead_view.xml
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<!-- Add itch cycle field to CRM lead form -->
|
||||
<record id="view_crm_lead_form_inherit_itch_cycle" model="ir.ui.view">
|
||||
<field name="name">crm.lead.form.inherit.itch.cycle</field>
|
||||
<field name="model">crm.lead</field>
|
||||
<field name="inherit_id" ref="crm.crm_lead_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='partner_id']" position="after">
|
||||
<field name="itch_cycle_id" widget="many2one"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Add itch cycle tab to CRM lead form -->
|
||||
<record id="view_crm_lead_form_inherit_itch_cycle_tab" model="ir.ui.view">
|
||||
<field name="name">crm.lead.form.inherit.itch.cycle.tab</field>
|
||||
<field name="model">crm.lead</field>
|
||||
<field name="inherit_id" ref="crm.crm_lead_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//notebook" position="inside">
|
||||
<page string="Purchase Cycle">
|
||||
<field name="itch_cycle_id" readonly="1"/>
|
||||
</page>
|
||||
<page string="Opportunities">
|
||||
<tree>
|
||||
<field name="expected_revenue"/>
|
||||
<field name="probability"/>
|
||||
<field name="date_deadline"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="tag_ids"/>
|
||||
</tree>
|
||||
<field name="description" widget="html"/>
|
||||
</page>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Add itch cycle filter to CRM lead search -->
|
||||
<record id="view_crm_lead_search_inherit_itch_cycle" model="ir.ui.view">
|
||||
<field name="name">crm.lead.search.inherit.itch.cycle</field>
|
||||
<field name="model">crm.lead</field>
|
||||
<field name="inherit_id" ref="crm.view_crm_case_opportunities_filter"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//filter[@name='assigned_to_me']" position="after">
|
||||
<filter string="Purchase Cycle" name="itch_cycle"
|
||||
domain="[('itch_cycle_id','!=',False)]"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
13
customer_itch_cycle/views/itch_cycle_actions.xml
Normal file
13
customer_itch_cycle/views/itch_cycle_actions.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<record id="action_cycle_product_partner" model="ir.actions.act_window">
|
||||
<field name="name">Product Cycle Management</field>
|
||||
<field name="res_model">itch.cycle.product.partner</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_id" ref="view_cycle_product_partner_tree"/>
|
||||
<field name="domain">[('state', '!=', 'new'), ('active', '=', True)]
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
10
customer_itch_cycle/views/itch_cycle_menu.xml
Normal file
10
customer_itch_cycle/views/itch_cycle_menu.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
|
||||
<menuitem id="menu_itch_cycle_product_root"
|
||||
name="Product Cycle"
|
||||
sequence="5"
|
||||
parent="sale.menu_sale_report"
|
||||
action="action_cycle_product_partner"/>
|
||||
|
||||
</odoo>
|
||||
|
|
@ -1,44 +1,191 @@
|
|||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<odoo>
|
||||
<record id="view_itch_cycle_product_partner_tree" model="ir.ui.view">
|
||||
<record id="action_itch_cycle_product_partner" model="ir.actions.act_window">
|
||||
<field name="name">Cycles Produit/Partenaire</field>
|
||||
<field name="res_model">itch.cycle.product.partner</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="display_name">name_get</field>
|
||||
</record>
|
||||
|
||||
<record id="action_create_batch_opportunities" model="ir.actions.server">
|
||||
<field name="name">Create Opportunities</field>
|
||||
<field name="model_id" ref="customer_itch_cycle.model_itch_cycle_product_partner"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">records.action_create_batch_opportunities()</field>
|
||||
<field name="binding_model_id" ref="customer_itch_cycle.model_itch_cycle_product_partner"/>
|
||||
<field name="binding_type">action</field>
|
||||
<field name="binding_view_types">tree,form</field>
|
||||
</record>
|
||||
|
||||
<record id="view_cycle_product_partner_tree" model="ir.ui.view">
|
||||
<field name="name">itch.cycle.product.partner.tree</field>
|
||||
<field name="model">itch_cycle_product_partner</field>
|
||||
<field name="model">itch.cycle.product.partner</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<tree js_class="cycle_product_partner_list" decoration-bf="state == 'delayed'" decoration-it="state == 'archived'">
|
||||
<field name="partner_id" widget="many2one_avatar"/>
|
||||
<field name="partner_city"/>
|
||||
<field name="partner_country_id" widget="country_flag" optional="hide"/>
|
||||
<field name="product_id" widget="many2one_avatar"/>
|
||||
<field name="product_categ_id" optional="hide"/>
|
||||
<field name="quantity_planned" sum="Total prévu"/>
|
||||
<field name="quantity_of_orders" sum="Total commandé"/>
|
||||
<field name="cycle_duration" widget="duration"/>
|
||||
<field name="date_last_purchase" optional="hide"/>
|
||||
<field name="date_expected" optional="show" string="Date attendue"/>
|
||||
<field name="date_next_follow_up" optional="show" string="Prochain suivi"/>
|
||||
<field name="state" widget="badge" options="{'classes': {'new': 'bg-info', 'on_time': 'bg-success', 'delayed': 'bg-danger', 'archived': 'bg-secondary'}}"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_itch_cycle_product_partner_search" model="ir.ui.view">
|
||||
<field name="name">itch.cycle.product.partner.search</field>
|
||||
<field name="model">itch.cycle.product.partner</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="last_purchase_date"/>
|
||||
<field name="itch_cycle_duration"/>
|
||||
<field name="next_follow_up_date"/>
|
||||
</tree>
|
||||
<field name="state"/>
|
||||
<separator/>
|
||||
<filter string="Archivé" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<separator/>
|
||||
<filter string="Suivi cette semaine" name="follow_up_this_week"
|
||||
domain="[('date_next_follow_up', '>=', context_today().strftime('%Y-%m-%d')),
|
||||
('date_next_follow_up', '<=', (context_today() + datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<filter string="Commande attendue cette semaine" name="expected_this_week"
|
||||
domain="[('date_expected', '>=', context_today().strftime('%Y-%m-%d')),
|
||||
('date_expected', '<=', (context_today() + datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
|
||||
<group expand="0" string="Grouper par">
|
||||
<filter string="État" name="group_by_state" context="{'group_by': 'state'}"/>
|
||||
<filter string="Partenaire" name="group_by_partner" context="{'group_by': 'partner_id'}"/>
|
||||
<filter string="Ville" name="group_by_city" context="{'group_by': 'partner_id:city'}"/>
|
||||
<filter string="Pays" name="group_by_country" context="{'group_by': 'partner_id:country_id'}"/>
|
||||
<filter string="Produit" name="group_by_product" context="{'group_by': 'product_id'}"/>
|
||||
<filter string="Catégorie de produit" name="group_by_product_categ" context="{'group_by': 'product_id:categ_id'}"/>
|
||||
<filter string="Date de suivi" name="group_by_follow_up" context="{'group_by': 'date_next_follow_up:week'}"/>
|
||||
<filter string="Date attendue" name="group_by_expected" context="{'group_by': 'date_expected:week'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_itch_cycle_product_partner_form" model="ir.ui.view">
|
||||
<field name="name">itch.cycle.product.partner.form</field>
|
||||
<field name="model">itch_cycle_product_partner</field>
|
||||
<field name="model">itch.cycle.product.partner</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<field name="state" widget="statusbar" statusbar_visible="new,on_time,delayed,archived"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<widget name="web_ribbon" title="Archivé" bg_color="bg-secondary" invisible="active"/>
|
||||
<button name="toggle_active"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-archive"
|
||||
help="Archiver/Désarchiver cet enregistrement">
|
||||
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
|
||||
</button>
|
||||
<button name="action_create_opportunity"
|
||||
type="object"
|
||||
string="Créer une opportunité"
|
||||
class="oe_stat_button"
|
||||
icon="fa-plus"
|
||||
help="Créer une nouvelle opportunité pour ce partenaire et ce produit"/>
|
||||
<button name="action_view_latest_opportunity"
|
||||
type="object"
|
||||
string="Dernière opportunité"
|
||||
class="oe_stat_button"
|
||||
icon="fa-eye"
|
||||
help="Afficher la dernière opportunité créée pour ce partenaire et ce produit"/>
|
||||
</div>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="last_purchase_date"/>
|
||||
<field name="itch_cycle_duration"/>
|
||||
<field name="next_follow_up_date"/>
|
||||
<group string="Partenaire" col="2">
|
||||
<field name="partner_id"
|
||||
context="{'form_view_ref': 'base.view_partner_form'}"
|
||||
options="{'always_reload': true}"
|
||||
help="Partenaire associé à ce cycle"/>
|
||||
<field name="partner_email" help="Adresse e-mail principale du partenaire"/>
|
||||
<field name="partner_phone" help="Téléphone principal du partenaire"/>
|
||||
<field name="partner_mobile" help="Téléphone mobile du partenaire"/>
|
||||
<field name="partner_city" help="Ville du partenaire"/>
|
||||
<field name="partner_country_id" help="Pays du partenaire"/>
|
||||
</group>
|
||||
<group string="Produit" col="2">
|
||||
<field name="product_id"
|
||||
context="{'form_view_ref': 'product.product_normal_form_view'}"
|
||||
options="{'always_reload': true}"
|
||||
help="Produit associé à ce cycle"/>
|
||||
<field name="product_categ_id" help="Catégorie de produit"/>
|
||||
<field name="product_type" help="Type de produit (stockable, consommable, service)"/>
|
||||
<field name="product_lst_price" widget="monetary" help="Prix de vente du produit"/>
|
||||
<field name="product_qty_available" help="Quantité disponible en stock"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Remplacement manuel" col="2">
|
||||
<field name="cycle_duration_override" help="Remplacement manuel de la durée du cycle"/>
|
||||
<field name="quantity_manual_override" help="Remplacement manuel de la quantité prévue"/>
|
||||
<field name="date_expected_override" help="Remplacement manuel de la date attendue"/>
|
||||
</group>
|
||||
<group string="Statut" col="2">
|
||||
<field name="date_expected" readonly="1" widget="date" help="Date attendue calculée"/>
|
||||
<field name="cycle_duration" readonly="1" widget="duration" help="Durée du cycle calculée"/>
|
||||
<field name="quantity_planned" readonly="1" widget="float_time" help="Quantité prévue calculée"/>
|
||||
<field name="state" readonly="1" widget="badge" help="Statut actuel du cycle"/>
|
||||
<field name="active" invisible="1"/>
|
||||
</group>
|
||||
</group>
|
||||
<group>
|
||||
<group string="Statistiques de commande" col="2">
|
||||
<field name="quantity_of_orders" readonly="1" help="Nombre total de commandes"/>
|
||||
<field name="quantity_total_ordered" readonly="1" help="Quantité totale commandée"/>
|
||||
<field name="quantity_min_ordered" readonly="1" help="Quantité minimale commandée"/>
|
||||
<field name="quantity_max_ordered" readonly="1" help="Quantité maximale commandée"/>
|
||||
<field name="quantity_mean_ordered" readonly="1" help="Quantité moyenne commandée"/>
|
||||
</group>
|
||||
<group string="Statistiques de cycle" col="2">
|
||||
<field name="date_last_purchase" readonly="1" widget="date" help="Date de la dernière commande"/>
|
||||
<field name="date_next_follow_up" widget="date" options="{'datepicker': {'minDate': context_today().strftime('%Y-%m-%d')}}" help="Date du prochain suivi"/>
|
||||
<field name="cycle_duration_calculated" readonly="1" widget="duration" help="Durée du cycle calculée"/>
|
||||
<field name="date_expected_evaluated" readonly="1" widget="date" help="Date attendue évaluée"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Lignes de commande associées">
|
||||
<field name="sale_order_line_ids" readonly="1">
|
||||
<tree editable="bottom">
|
||||
<field name="order_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="price_unit"/>
|
||||
<field name="product_uom_qty"/>
|
||||
<field name="sale_date"/>
|
||||
<field name="discount"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Opportunités associées">
|
||||
<field name="opportunity_ids" readonly="1">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="partner_id"/>
|
||||
<field name="expected_revenue"/>
|
||||
<field name="probability"/>
|
||||
<field name="stage_id"/>
|
||||
<field name="create_date"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_itch_cycle_product_partner" name="Itch Cycles"
|
||||
parent="sale.sale_order_menu"
|
||||
action="action_itch_cycle_product_partner"/>
|
||||
|
||||
<record id="action_itch_cycle_product_partner" model="ir.actions.act_window">
|
||||
<field name="name">Itch Cycles</field>
|
||||
<field name="res_model">itch_cycle_product_partner</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
|
|||
30
customer_itch_cycle/views/product_category_view.xml
Normal file
30
customer_itch_cycle/views/product_category_view.xml
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<odoo>
|
||||
<record id="view_product_category_form" model="ir.ui.view">
|
||||
<field name="name">product.category.form</field>
|
||||
<field name="model">product.category</field>
|
||||
<field name="inherit_id" ref="product.product_category_form_view"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//form[1]/sheet[1]" position="inside">
|
||||
<group string="Sales Cycle">
|
||||
<group>
|
||||
<field name="is_cycle_tracked"/>
|
||||
<field name="temp_is_cycle_tracked" invisible="1"/>
|
||||
<button name="action_apply_cycle_tracked"
|
||||
string="Apply Changes"
|
||||
type="object"
|
||||
class="btn btn-primary"
|
||||
invisible="is_cycle_tracked == temp_is_cycle_tracked"/>
|
||||
<field name="seasonal_factor"
|
||||
help="Factor used to adjust sales predictions based on seasonal patterns"/>
|
||||
<field name="season_months"
|
||||
widget="many2many_tags"
|
||||
options="{'no_create': True}"
|
||||
help="Months where this product category has peak sales"
|
||||
invisible="not seasonal_factor"
|
||||
required="seasonal_factor"/>
|
||||
</group>
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -1,23 +1,89 @@
|
|||
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_partner_form_inherit_itch_cycle" model="ir.ui.view">
|
||||
<field name="name">res.partner.form.itch.cycle</field>
|
||||
<field name="model">res.partner</field>
|
||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<sheet position="before">
|
||||
<group string="Itch Cycle Information">
|
||||
<field name="itch_next_delay" readonly="1"/>
|
||||
<field name="itch_cycle_product_ids" readonly="1">
|
||||
<notebook position="inside">
|
||||
<page string="Product Cycles" name="product_cycles">
|
||||
<field name="itch_cycle_product_ids">
|
||||
<tree>
|
||||
<field name="product_id"/>
|
||||
<field name="last_purchase_date"/>
|
||||
<field name="itch_cycle_duration"/>
|
||||
<field name="next_follow_up_date"/>
|
||||
<field name="quantity_mean_ordered"/>
|
||||
<field name="date_expected"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</field>
|
||||
</group>
|
||||
</sheet>
|
||||
</page>
|
||||
</notebook>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_cycle_product_partner_form" model="ir.ui.view">
|
||||
<field name="name">itch.cycle.product.partner.form</field>
|
||||
<field name="model">itch.cycle.product.partner</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<header>
|
||||
<button name="create_opportunity"
|
||||
string="Create Opportunity"
|
||||
type="object"
|
||||
class="oe_highlight"
|
||||
invisible="not active"/>
|
||||
</header>
|
||||
<sheet>
|
||||
<div class="oe_button_box" name="button_box">
|
||||
<button name="action_view_opportunities"
|
||||
type="object"
|
||||
class="oe_stat_button"
|
||||
icon="fa-star">
|
||||
<field name="opportunity_count" widget="statinfo" string="Opportunities"/>
|
||||
</button>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="partner_id"/>
|
||||
<field name="product_id"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="date_expected"/>
|
||||
<field name="cycle_duration"/>
|
||||
<field name="quantity_mean_ordered"/>
|
||||
<field name="quantity_total_ordered"/>
|
||||
</group>
|
||||
</group>
|
||||
<notebook>
|
||||
<page string="Opportunities" name="opportunities">
|
||||
<field name="opportunity_ids">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="date_deadline"/>
|
||||
<field name="expected_revenue"/>
|
||||
<field name="stage_id"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
<page string="Sales History" name="sales_history">
|
||||
<field name="sale_order_line_ids">
|
||||
<tree>
|
||||
<field name="order_id"/>
|
||||
<field name="product_uom_qty"/>
|
||||
<field name="price_unit"/>
|
||||
<field name="price_subtotal"/>
|
||||
<field name="state"/>
|
||||
</tree>
|
||||
</field>
|
||||
</page>
|
||||
</notebook>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids" groups="base.group_user"/>
|
||||
<field name="activity_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
|
|||
1
customer_itch_cycle/wizards/__init__.py
Normal file
1
customer_itch_cycle/wizards/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import apply_to_child_categories
|
||||
48
customer_itch_cycle/wizards/apply_to_child_categories.py
Normal file
48
customer_itch_cycle/wizards/apply_to_child_categories.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
from odoo import models, fields, api
|
||||
|
||||
class ApplyToChildCategories(models.TransientModel):
|
||||
"""
|
||||
Wizard to apply cycle tracking settings to child categories
|
||||
This wizard is shown when changing is_cycle_tracked on a product category
|
||||
"""
|
||||
_name = 'itch.apply.to.child.categories'
|
||||
_description = 'Apply Settings to Child Categories'
|
||||
|
||||
category_id = fields.Many2one('product.category', string='Category', required=True)
|
||||
new_value = fields.Boolean(string='New Tracking Value')
|
||||
apply_to_children = fields.Boolean(
|
||||
string='Apply to Child Categories',
|
||||
help='If checked, the cycle tracking settings will be applied to all child categories',
|
||||
default=True
|
||||
)
|
||||
child_count = fields.Integer(
|
||||
string='Number of Child Categories',
|
||||
compute='_compute_child_count'
|
||||
)
|
||||
|
||||
@api.depends('category_id')
|
||||
def _compute_child_count(self):
|
||||
"""Compute the number of child categories that would be affected"""
|
||||
for wizard in self:
|
||||
wizard.child_count = self.env['product.category'].search_count([
|
||||
('id', 'child_of', wizard.category_id.id),
|
||||
('id', '!=', wizard.category_id.id)
|
||||
])
|
||||
|
||||
def apply_settings(self):
|
||||
"""Apply the settings to the selected categories"""
|
||||
self.ensure_one()
|
||||
if self.apply_to_children:
|
||||
categories = self.env['product.category'].search([
|
||||
('id', 'child_of', self.category_id.id)
|
||||
])
|
||||
else:
|
||||
categories = self.category_id
|
||||
|
||||
# Apply the settings
|
||||
values = {
|
||||
'is_cycle_tracked': self.new_value,
|
||||
'temp_is_cycle_tracked': self.new_value,
|
||||
}
|
||||
categories.write(values)
|
||||
return {'type': 'ir.actions.act_window_close'}
|
||||
20
customer_itch_cycle/wizards/apply_to_child_categories.xml
Normal file
20
customer_itch_cycle/wizards/apply_to_child_categories.xml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_apply_to_child_categories_form" model="ir.ui.view">
|
||||
<field name="name">itch.apply.to.child.categories.form</field>
|
||||
<field name="model">itch.apply.to.child.categories</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Apply to Child Categories">
|
||||
<group>
|
||||
<field name="category_id" invisible="1"/>
|
||||
<field name="child_count"/>
|
||||
<field name="apply_to_children"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button string="Apply" name="apply_settings" type="object" class="btn-primary" data-hotkey="q"/>
|
||||
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
|
|
@ -19,7 +19,7 @@
|
|||
#
|
||||
{
|
||||
"name": "Carrier Accounts by Partner",
|
||||
"version": "17.0.0.1.1",
|
||||
"version": "17.0.0.1.4",
|
||||
"summary": "Add one or many carrier accounts per partner",
|
||||
"category": "Delivery",
|
||||
"author": "Bemade Inc.",
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<record id="action_open_delivery_carrier_accounts" model="ir.actions.act_window">
|
||||
<field name="name">Carrier Accounts</field>
|
||||
<field name="res_model">delivery.carrier.account</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="target">current</field>
|
||||
<field name="domain">[('delivery_carrier_id', '=', context.get("carrier_id"))]</field>
|
||||
<field name="context">{'default_delivery_carrier_id': "active_id"}</field>
|
||||
|
|
@ -11,7 +11,7 @@
|
|||
<record id="action_open_all_delivery_carrier_accounts" model="ir.actions.act_window">
|
||||
<field name="name">Carrier Accounts</field>
|
||||
<field name="res_model">delivery.carrier.account</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="view_mode">list,form</field>
|
||||
<field name="target">current</field>
|
||||
</record>
|
||||
<menuitem id="delivery_carrier_accounts"
|
||||
|
|
@ -21,4 +21,4 @@
|
|||
name="Carrier Accounts"
|
||||
/>
|
||||
/>
|
||||
</odoo>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
from odoo import models, fields, api, _
|
||||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CarrierAccountMixin(models.AbstractModel):
|
||||
|
|
@ -23,7 +26,17 @@ class CarrierAccountMixin(models.AbstractModel):
|
|||
|
||||
sender_id = fields.Many2one(comodel_name="res.partner", string="Sender")
|
||||
recipient_id = fields.Many2one(comodel_name="res.partner", string="Recipient")
|
||||
carrier_id = fields.Many2one(comodel_name="delivery.carrier", string="Carrier")
|
||||
carrier_id = fields.Many2one(
|
||||
comodel_name="delivery.carrier",
|
||||
string="Carrier",
|
||||
compute="_compute_carrier_id",
|
||||
store=True,
|
||||
inverse="_on_carrier_fields_changed",
|
||||
compute_sudo=True,
|
||||
)
|
||||
|
||||
_default_carrier_field = "property_delivery_carrier_id"
|
||||
_default_carrier_account_field = "default_carrier_account_id"
|
||||
|
||||
delivery_billing_mode = fields.Selection(
|
||||
[
|
||||
|
|
@ -33,7 +46,7 @@ class CarrierAccountMixin(models.AbstractModel):
|
|||
("collect", "Collect"),
|
||||
("third party", "Third Party"),
|
||||
],
|
||||
help=_(
|
||||
help=(
|
||||
"""
|
||||
Prepaid: The shipper will pay the carrier and the client pays the estimate.
|
||||
Prepaid & Charge: The shipper will pay the carrier and bill the client based on the actual price paid.
|
||||
|
|
@ -42,16 +55,20 @@ class CarrierAccountMixin(models.AbstractModel):
|
|||
"""
|
||||
),
|
||||
string="Delivery Billing Mode",
|
||||
compute="_compute_delivery_billing_mode",
|
||||
inverse="_on_carrier_fields_changed",
|
||||
store=True,
|
||||
compute_sudo=True,
|
||||
)
|
||||
|
||||
carrier_account_id = fields.Many2one(
|
||||
comodel_name="delivery.carrier.account",
|
||||
ondelete="restrict",
|
||||
string="Carrier Account",
|
||||
compute="_compute_carrier_account_id",
|
||||
inverse="_inverse_carrier_account_id",
|
||||
inverse="_on_carrier_fields_changed",
|
||||
store=True,
|
||||
compute_sudo=True,
|
||||
string="Carrier Account",
|
||||
)
|
||||
|
||||
carrier_account_owner_id = fields.Many2one(
|
||||
|
|
@ -67,111 +84,202 @@ class CarrierAccountMixin(models.AbstractModel):
|
|||
string="Valid Carrier Accounts",
|
||||
)
|
||||
|
||||
@api.depends("delivery_billing_mode", "carrier_id", "recipient_id", "sender_id")
|
||||
valid_carrier_ids = fields.One2many(
|
||||
comodel_name="delivery.carrier",
|
||||
compute="_compute_valid_carrier_ids",
|
||||
compute_sudo=True,
|
||||
)
|
||||
|
||||
def _on_carrier_fields_changed(self):
|
||||
"""Hook for subclasses to perform additional actions when carrier fields change."""
|
||||
pass
|
||||
|
||||
def _get_valid_carrier_partners(self):
|
||||
"""Get partners that can have valid carrier accounts for the current billing mode.
|
||||
|
||||
Returns:
|
||||
Tuple containing:
|
||||
- partners: recordset of partners that can have valid accounts
|
||||
"""
|
||||
self.ensure_one()
|
||||
invalid_partners = self.env["res.partner"]
|
||||
|
||||
match self.delivery_billing_mode:
|
||||
case "collect":
|
||||
return self.recipient_id | self.recipient_id.commercial_partner_id
|
||||
case "prepaid" | "ppc" | "no charge":
|
||||
return self.sender_id | self.sender_id.commercial_partner_id
|
||||
case "third party":
|
||||
invalid_partners = (
|
||||
self.recipient_id | self.recipient_id.commercial_partner_id
|
||||
) | (self.sender_id | self.sender_id.commercial_partner_id)
|
||||
return self.env["res.partner"].search(
|
||||
[("id", "not in", invalid_partners.ids)]
|
||||
)
|
||||
case _:
|
||||
return self.env["res.partner"]
|
||||
|
||||
@api.depends(
|
||||
"sender_id",
|
||||
"recipient_id",
|
||||
"delivery_billing_mode",
|
||||
)
|
||||
def _compute_carrier_id(self):
|
||||
for rec in self:
|
||||
if not rec.carrier_id:
|
||||
rec.carrier_id = rec._get_default_carrier()
|
||||
elif (
|
||||
rec.delivery_billing_mode
|
||||
and rec.carrier_id not in rec.valid_carrier_ids
|
||||
and not rec._has_valid_account_for_carrier()
|
||||
):
|
||||
rec.carrier_id = rec._get_default_carrier()
|
||||
|
||||
def _has_valid_account_for_carrier(self):
|
||||
"""Check if there's a valid account for the current carrier and billing mode."""
|
||||
self.ensure_one()
|
||||
if not self.carrier_id or not self.delivery_billing_mode:
|
||||
return False
|
||||
|
||||
partners = self._get_valid_carrier_partners()
|
||||
valid_accounts = partners.mapped("carrier_account_ids").filtered(
|
||||
lambda account: account.delivery_carrier_id == self.carrier_id
|
||||
)
|
||||
return bool(valid_accounts)
|
||||
|
||||
def _get_default_carrier(self):
|
||||
self.ensure_one()
|
||||
recipient = self.recipient_id
|
||||
sender = self.sender_id
|
||||
def_car = self._default_carrier_field
|
||||
match self.delivery_billing_mode:
|
||||
case "collect":
|
||||
return getattr(recipient, def_car) or getattr(
|
||||
recipient.commercial_partner_id, def_car
|
||||
)
|
||||
case "ppc" | "prepaid" | "no charge":
|
||||
return getattr(sender, def_car) or getattr(
|
||||
sender.commercial_partner_id, def_car
|
||||
)
|
||||
case _:
|
||||
return False
|
||||
|
||||
@api.depends(
|
||||
"sender_id",
|
||||
"recipient_id",
|
||||
"delivery_billing_mode",
|
||||
"carrier_id",
|
||||
"valid_carrier_account_ids",
|
||||
)
|
||||
def _compute_carrier_account_id(self):
|
||||
for rec in self.filtered(
|
||||
lambda rec: not rec.carrier_account_id
|
||||
or rec.carrier_account_id not in rec.valid_carrier_account_ids
|
||||
):
|
||||
rec.carrier_account_id = rec._get_default_carrier_account()
|
||||
|
||||
def _get_default_carrier_account(self):
|
||||
self.ensure_one()
|
||||
match self.delivery_billing_mode:
|
||||
case "collect":
|
||||
default_acct = getattr(
|
||||
self.recipient_id, self._default_carrier_account_field
|
||||
) or getattr(
|
||||
self.recipient_id.commercial_partner_id,
|
||||
self._default_carrier_account_field,
|
||||
)
|
||||
if default_acct and default_acct.delivery_carrier_id == self.carrier_id:
|
||||
return default_acct
|
||||
return self.recipient_id.get_carrier_account(self.carrier_id)
|
||||
case "ppc" | "prepaid" | "no charge":
|
||||
default_acct = getattr(
|
||||
self.sender_id, self._default_carrier_account_field
|
||||
) or getattr(
|
||||
self.sender_id.commercial_partner_id,
|
||||
self._default_carrier_account_field,
|
||||
)
|
||||
if default_acct and default_acct.delivery_carrier_id == self.carrier_id:
|
||||
return default_acct
|
||||
return self.sender_id.get_carrier_account(self.carrier_id)
|
||||
case _:
|
||||
return False
|
||||
|
||||
@api.depends("carrier_account_id")
|
||||
def _compute_delivery_billing_mode(self):
|
||||
for rec in self.filtered(lambda rec: not rec.delivery_billing_mode):
|
||||
if not rec.carrier_account_id:
|
||||
rec.delivery_billing_mode = False
|
||||
continue
|
||||
account_partner = rec.carrier_account_id.partner_id
|
||||
if account_partner in (
|
||||
rec.recipient_id | rec.recipient_id.commercial_partner_id
|
||||
):
|
||||
rec.delivery_billing_mode = "collect"
|
||||
elif account_partner in (
|
||||
rec.sender_id | rec.sender_id.commercial_partner_id
|
||||
):
|
||||
rec.delivery_billing_mode = "ppc"
|
||||
else:
|
||||
rec.delivery_billing_mode = "third party"
|
||||
|
||||
@api.depends(
|
||||
"delivery_billing_mode",
|
||||
"carrier_id",
|
||||
"recipient_id",
|
||||
"sender_id",
|
||||
"carrier_account_id",
|
||||
)
|
||||
def _compute_valid_carrier_account_ids(self):
|
||||
for rec in self:
|
||||
if rec.delivery_billing_mode == "collect":
|
||||
rec.valid_carrier_account_ids = (
|
||||
(rec.recipient_id | rec.recipient_id.commercial_partner_id)
|
||||
.mapped("carrier_account_ids")
|
||||
.filtered(
|
||||
lambda account: account.delivery_carrier_id == rec.carrier_id
|
||||
)
|
||||
partners = rec._get_valid_carrier_partners()
|
||||
if rec.carrier_id and partners:
|
||||
rec.valid_carrier_account_ids = partners.mapped(
|
||||
"carrier_account_ids"
|
||||
).filtered(
|
||||
lambda account: account.delivery_carrier_id == rec.carrier_id
|
||||
)
|
||||
if rec.delivery_billing_mode == "third party":
|
||||
rec.valid_carrier_account_ids = self.env[
|
||||
"delivery.carrier.account"
|
||||
].search(
|
||||
[
|
||||
("delivery_carrier_id", "=", rec.carrier_id.id),
|
||||
(
|
||||
"partner_id",
|
||||
"not in",
|
||||
[
|
||||
rec.sender_id.id,
|
||||
rec.recipient_id.id,
|
||||
rec.recipient_id.commercial_partner_id.id,
|
||||
],
|
||||
),
|
||||
]
|
||||
)
|
||||
if rec.delivery_billing_mode in ["prepaid", "ppc"]:
|
||||
rec.valid_carrier_account_ids = (
|
||||
rec.sender_id.carrier_account_ids.filtered(
|
||||
lambda account: account.delivery_carrier_id == rec.carrier_id
|
||||
)
|
||||
)
|
||||
if rec.delivery_billing_mode == "no charge":
|
||||
rec.valid_carrier_account_ids = self.env["delivery.carrier.account"]
|
||||
if not rec.delivery_billing_mode:
|
||||
rec.valid_carrier_account_ids = self.env["delivery.carrier.account"]
|
||||
else:
|
||||
rec.valid_carrier_account_ids = partners.mapped("carrier_account_ids")
|
||||
|
||||
@api.depends("delivery_billing_mode", "carrier_id", "valid_carrier_account_ids")
|
||||
def _compute_carrier_account_id(self):
|
||||
"""Compute the carrier account to use for this record if one is not set or if
|
||||
the current one doesn't match the carrier_id selected.
|
||||
|
||||
When delivery_billing_mode is collect, we need to choose a carrier account that
|
||||
matches both the carrier_id and the partner_id or its commercial partner.
|
||||
|
||||
When it is third party, any account matching the carrier_id is fine.
|
||||
|
||||
When it is prepaid or ppc, we select the company's account.
|
||||
"""
|
||||
@api.depends("delivery_billing_mode", "sender_id", "recipient_id")
|
||||
def _compute_valid_carrier_ids(self):
|
||||
"""Compute all valid carriers for the current billing mode and partners."""
|
||||
for rec in self:
|
||||
if rec.delivery_billing_mode == "collect":
|
||||
if rec.carrier_account_id not in rec.valid_carrier_account_ids:
|
||||
if (
|
||||
rec.recipient_id.default_carrier_account_id.delivery_carrier_id
|
||||
== rec.carrier_id
|
||||
):
|
||||
rec.carrier_account_id = (
|
||||
rec.recipient_id.default_carrier_account_id
|
||||
)
|
||||
elif rec.valid_carrier_account_ids:
|
||||
rec.carrier_account_id = rec.valid_carrier_account_ids[0]
|
||||
else:
|
||||
raise UserError(
|
||||
"The client does not have an account with the selected carrier."
|
||||
)
|
||||
if rec.delivery_billing_mode == "third party":
|
||||
if rec.carrier_account_id not in rec.valid_carrier_account_ids:
|
||||
rec.carrier_account_id = False
|
||||
if rec.delivery_billing_mode in ["prepaid", "ppc"]:
|
||||
rec.carrier_account_id = (
|
||||
self.env["delivery.carrier.account"]
|
||||
.search([("partner_id", "=", rec.sender_id.id)])
|
||||
.filtered(
|
||||
lambda account: account.delivery_carrier_id == rec.carrier_id
|
||||
)
|
||||
)
|
||||
if (
|
||||
rec.delivery_billing_mode == "no charge"
|
||||
or not rec.delivery_billing_mode
|
||||
):
|
||||
rec.carrier_account_id = False
|
||||
partners = rec._get_valid_carrier_partners()
|
||||
rec.valid_carrier_ids = partners.mapped(
|
||||
"carrier_account_ids.delivery_carrier_id"
|
||||
)
|
||||
|
||||
@api.constrains("carrier_account_id")
|
||||
def _check_account_id(self):
|
||||
@api.constrains("delivery_billing_mode", "carrier_id", "carrier_account_id")
|
||||
def _check_carrier_account(self):
|
||||
for rec in self:
|
||||
if (
|
||||
not rec.delivery_billing_mode
|
||||
or rec.delivery_billing_mode == "no charge"
|
||||
rec.carrier_account_id
|
||||
and rec.delivery_billing_mode
|
||||
and rec.valid_carrier_account_ids # Use the computed field directly
|
||||
and rec.carrier_account_id not in rec.valid_carrier_account_ids
|
||||
):
|
||||
if rec.carrier_account_id:
|
||||
_logger.warning(
|
||||
"Billing mode: %s, sender: %s (commercial: %s), recipient: %s (commercial: %s), account: %s, id: %s, valid: %s",
|
||||
rec.delivery_billing_mode,
|
||||
rec.sender_id.name,
|
||||
rec.sender_id.commercial_partner_id.name,
|
||||
rec.recipient_id.name,
|
||||
rec.recipient_id.commercial_partner_id.name,
|
||||
rec.carrier_account_id.partner_id.name,
|
||||
rec.carrier_account_id.id,
|
||||
rec.valid_carrier_account_ids.ids,
|
||||
)
|
||||
|
||||
if rec.delivery_billing_mode == "collect":
|
||||
raise UserError(
|
||||
_("No carrier account should be set for no charge delivery.")
|
||||
f"Carrier account is not associated with the recipient, but billing mode is collect. Current object: {rec}"
|
||||
)
|
||||
continue
|
||||
# We allow empty carrier account for third party since we can't always
|
||||
# set it automatically.
|
||||
if (
|
||||
rec.delivery_billing_mode == "third party"
|
||||
and not rec.carrier_account_id
|
||||
):
|
||||
continue
|
||||
if rec.carrier_account_id not in rec.valid_carrier_account_ids:
|
||||
raise UserError(_("Invalid carrier account selected."))
|
||||
|
||||
def _inverse_carrier_account_id(self):
|
||||
pass
|
||||
elif rec.delivery_billing_mode in ["prepaid", "ppc", "no charge"]:
|
||||
raise UserError(
|
||||
f"Carrier account is not associated with the sender, but billing mode is prepaid, ppc or no charge. Current object: {rec}"
|
||||
)
|
||||
elif rec.delivery_billing_mode == "third party":
|
||||
raise UserError(
|
||||
f"Third party carrier account cannot belong to sender or recipient. Current object: {rec}"
|
||||
)
|
||||
|
|
@ -38,9 +38,3 @@ class DeliveryCarrierAccount(models.Model):
|
|||
def _compute_display_name(self):
|
||||
for record in self:
|
||||
record.display_name = record.account_number
|
||||
|
||||
@api.constrains("partner_id", "delivery_carrier_id")
|
||||
def _constrain_partner_carrier_same_company(self):
|
||||
for rec in self:
|
||||
if rec.partner_id.company_id != rec.delivery_carrier_id.company_id:
|
||||
raise UserError(_("Partner and Carrier must be in the same company."))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from odoo import models, fields
|
||||
from odoo import models, fields, api
|
||||
|
||||
|
||||
class Partner(models.Model):
|
||||
|
|
@ -13,6 +13,109 @@ class Partner(models.Model):
|
|||
|
||||
default_carrier_account_id = fields.Many2one(
|
||||
comodel_name="delivery.carrier.account",
|
||||
compute="_compute_logistic_defaults",
|
||||
inverse="_inverse_default_carrier_account_id",
|
||||
store=True,
|
||||
tracking=1,
|
||||
ondelete="restrict",
|
||||
)
|
||||
|
||||
property_delivery_carrier_id = fields.Many2one(
|
||||
compute="_compute_logistic_defaults",
|
||||
inverse="_inverse_property_delivery_carrier_id",
|
||||
store=True,
|
||||
)
|
||||
|
||||
def _inverse_default_carrier_account_id(self):
|
||||
pass
|
||||
|
||||
def _inverse_property_delivery_carrier_id(self):
|
||||
pass
|
||||
|
||||
@api.depends(
|
||||
"carrier_account_ids",
|
||||
"carrier_account_ids.active",
|
||||
"carrier_account_ids.delivery_carrier_id",
|
||||
)
|
||||
def _compute_logistic_defaults(self):
|
||||
# Unset default carrier account if it is archived
|
||||
for partner in self.filtered(
|
||||
lambda partner: partner.default_carrier_account_id
|
||||
and not partner.default_carrier_account_id.active
|
||||
):
|
||||
partner.default_carrier_account_id = False
|
||||
|
||||
# Unset the default carrier if no accounts are available
|
||||
for partner in self.filtered(
|
||||
lambda partner: partner.property_delivery_carrier_id
|
||||
).filtered(
|
||||
lambda partner: not partner.carrier_account_ids.filtered(
|
||||
lambda account: account.delivery_carrier_id
|
||||
== partner.property_delivery_carrier_id
|
||||
and account.active
|
||||
)
|
||||
):
|
||||
partner.property_delivery_carrier_id = False
|
||||
|
||||
# Set default carrier account if not set and accounts available
|
||||
for partner in self.filtered(
|
||||
lambda partner: not partner.default_carrier_account_id
|
||||
and partner.carrier_account_ids.filtered("active")
|
||||
):
|
||||
partner.default_carrier_account_id = partner.carrier_account_ids.filtered(
|
||||
"active"
|
||||
)[0]
|
||||
|
||||
# Set default carrier if not set and default account is set
|
||||
for partner in self.filtered(
|
||||
lambda partner: not partner.property_delivery_carrier_id
|
||||
and partner.default_carrier_account_id
|
||||
):
|
||||
partner.property_delivery_carrier_id = (
|
||||
partner.default_carrier_account_id.delivery_carrier_id
|
||||
)
|
||||
|
||||
# Reset default carrier if account is set and doesn't match
|
||||
for partner in self.filtered(
|
||||
lambda partner: partner.default_carrier_account_id
|
||||
and partner.default_carrier_account_id.delivery_carrier_id
|
||||
!= partner.property_delivery_carrier_id
|
||||
):
|
||||
partner.property_delivery_carrier_id = (
|
||||
partner.default_carrier_account_id.delivery_carrier_id
|
||||
)
|
||||
|
||||
def get_carrier_account(self, carrier):
|
||||
self.ensure_one()
|
||||
own_accounts = self.carrier_account_ids.filtered(
|
||||
lambda account: account.delivery_carrier_id == carrier
|
||||
)
|
||||
if own_accounts:
|
||||
return own_accounts[0]
|
||||
commercial_patner_accounts = (
|
||||
self.commercial_partner_id.carrier_account_ids.filtered(
|
||||
lambda account: account.delivery_carrier_id == carrier
|
||||
)
|
||||
)
|
||||
if commercial_patner_accounts:
|
||||
return commercial_patner_accounts[0]
|
||||
return self.env["delivery.carrier.account"]
|
||||
|
||||
def process_carrier_account_archiving(self):
|
||||
for partner in self:
|
||||
# Unset default carrier account if it is archived
|
||||
if (
|
||||
partner.default_carrier_account_id
|
||||
and not partner.default_carrier_account_id.active
|
||||
):
|
||||
partner.default_carrier_account_id = False
|
||||
# Unset default carrier if not more accounts available
|
||||
if (
|
||||
partner.property_delivery_carrier_id
|
||||
and not partner.carrier_account_ids.filtered(
|
||||
lambda account: account.delivery_carrier_id
|
||||
== partner.property_delivery_carrier_id
|
||||
and account.active
|
||||
)
|
||||
):
|
||||
partner.property_delivery_carrier_id = False
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
from odoo import models, fields, api, _
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SalesOrder(models.Model):
|
||||
|
|
@ -7,40 +10,50 @@ class SalesOrder(models.Model):
|
|||
|
||||
recipient_id = fields.Many2one(
|
||||
comodel_name="res.partner",
|
||||
related="partner_id",
|
||||
related="partner_shipping_id",
|
||||
)
|
||||
sender_id = fields.Many2one(
|
||||
comodel_name="res.partner",
|
||||
related="company_id.partner_id",
|
||||
related="warehouse_id.partner_id",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def write(self, values):
|
||||
res = super().write(values)
|
||||
# If carrier account ID changes for a confirmed order, change it on its
|
||||
# pending pickings as well.
|
||||
if "carrier_account_id" in values:
|
||||
for rec in self.filtered(
|
||||
lambda order: order.state not in ["draft", "sent"]
|
||||
):
|
||||
for picking in rec.picking_ids.filtered(
|
||||
lambda pick: pick.state not in ("done", "cancel")
|
||||
):
|
||||
picking.carrier_account_id = rec.carrier_account_id
|
||||
return res
|
||||
|
||||
def _create_delivery_line(self, carrier, price_unit):
|
||||
line = super()._create_delivery_line(carrier, price_unit)
|
||||
name = line.name
|
||||
delivery_billing_mode = self.delivery_billing_mode or self.env.context.get(
|
||||
"delivery_billing_mode", False
|
||||
)
|
||||
carrier_account = self.carrier_account_id or self.env.context.get(
|
||||
"carrier_account", False
|
||||
)
|
||||
delivery_billing_mode = self.delivery_billing_mode
|
||||
carrier_account = self.carrier_account_id
|
||||
if delivery_billing_mode:
|
||||
name = name + f" [{delivery_billing_mode.upper()}]"
|
||||
mode_display = delivery_billing_mode.upper()
|
||||
name = name + f" [{mode_display}]"
|
||||
if delivery_billing_mode in ["collect", "third party"] and carrier_account:
|
||||
name = name + f" #{carrier_account.account_number}"
|
||||
line.name = name
|
||||
return line
|
||||
|
||||
def _on_carrier_fields_changed(self):
|
||||
"""Propagate carrier field changes to pickings."""
|
||||
super()._on_carrier_fields_changed()
|
||||
for rec in self:
|
||||
for picking in rec.picking_ids.filtered(
|
||||
lambda pick: pick.state not in ["done", "cancel"]
|
||||
):
|
||||
picking.write(
|
||||
{
|
||||
"carrier_id": rec.carrier_id and rec.carrier_id.id,
|
||||
"delivery_billing_mode": rec.delivery_billing_mode,
|
||||
"carrier_account_id": (
|
||||
rec.carrier_account_id and rec.carrier_account_id.id
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
def action_confirm(self):
|
||||
res = super().action_confirm()
|
||||
self.picking_ids.write(
|
||||
{
|
||||
"delivery_billing_mode": self.delivery_billing_mode,
|
||||
"carrier_account_id": self.carrier_account_id
|
||||
and self.carrier_account_id.id,
|
||||
}
|
||||
)
|
||||
return res
|
||||
|
|
|
|||
|
|
@ -7,28 +7,39 @@ class Picking(models.Model):
|
|||
|
||||
recipient_id = fields.Many2one(
|
||||
comodel_name="res.partner",
|
||||
related="partner_id",
|
||||
compute="_compute_sender_recipient",
|
||||
)
|
||||
sender_id = fields.Many2one(
|
||||
comodel_name="res.partner",
|
||||
related="company_id.partner_id",
|
||||
compute="_compute_sender_recipient",
|
||||
)
|
||||
|
||||
# Override to base it on the sale order field initially and when changed
|
||||
delivery_billing_mode = fields.Selection(
|
||||
compute="_compute_delivery_billing_mode",
|
||||
inverse="_inverse_delivery_billing_mode",
|
||||
store=True,
|
||||
)
|
||||
|
||||
@api.depends("sale_id", "sale_id.delivery_billing_mode")
|
||||
def _compute_delivery_billing_mode(self):
|
||||
for rec in self:
|
||||
rec.delivery_billing_mode = rec.sale_id.delivery_billing_mode
|
||||
rec.carrier_account_id = rec.sale_id.carrier_account_id
|
||||
|
||||
def _inverse_delivery_billing_mode(self):
|
||||
pass
|
||||
def _compute_sender_recipient(self):
|
||||
for picking in self:
|
||||
dest_usage = picking.location_dest_id.usage
|
||||
src_usage = picking.location_id.usage
|
||||
match (src_usage, dest_usage):
|
||||
case ("internal", "customer") | ("internal", "supplier"):
|
||||
picking.recipient_id = picking.partner_id
|
||||
picking.sender_id = (
|
||||
picking.picking_type_id.warehouse_id.partner_id
|
||||
or picking.company_id.partner_id
|
||||
)
|
||||
case ("customer", "internal") | ("supplier", "internal"):
|
||||
picking.recipient_id = (
|
||||
picking.picking_type_id.warehouse_id.partner_id
|
||||
or picking.company_id.partner_id
|
||||
)
|
||||
picking.sender_id = picking.partner_id
|
||||
case _:
|
||||
picking.recipient_id = (
|
||||
picking.location_dest_id.warehouse_id.partner_id
|
||||
or picking.partner_id
|
||||
)
|
||||
picking.sender_id = (
|
||||
picking.location_id.warehouse_id.partner_id
|
||||
or picking.partner_id
|
||||
)
|
||||
|
||||
def _add_delivery_cost_to_so(self):
|
||||
self.ensure_one()
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ from . import test_carrier_account_common
|
|||
from . import test_carrier_account_mixin
|
||||
from . import test_choose_delivery_carrier
|
||||
from . import test_sale_order
|
||||
from . import test_res_partner
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
from .test_carrier_account_common import TestCarrierAccountCommon
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import Form
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TestCarrierAccountMixin(TestCarrierAccountCommon):
|
||||
|
|
@ -42,8 +47,9 @@ class TestCarrierAccountMixin(TestCarrierAccountCommon):
|
|||
"delivery_billing_mode": "prepaid",
|
||||
}
|
||||
)
|
||||
# No need to assert we have an account selected here. Tested elsewhere.
|
||||
picking.delivery_billing_mode = "third party"
|
||||
with Form(picking) as form: # Use a form here to trigger recomputation
|
||||
form.delivery_billing_mode = "third party"
|
||||
picking = form.record
|
||||
self.assertFalse(picking.carrier_account_id)
|
||||
|
||||
def test_changing_account_on_confirmed_sale_changes_picking(self):
|
||||
|
|
@ -110,3 +116,36 @@ class TestCarrierAccountMixin(TestCarrierAccountCommon):
|
|||
self._create_sale_order(
|
||||
"third party", self.delivery_carrier_1, self.sender_account_1
|
||||
)
|
||||
|
||||
def test_carrier_preserved_on_billing_mode_change(self):
|
||||
"""Test that changing billing mode preserves carrier when valid account exists."""
|
||||
# Create a collect account for the same carrier
|
||||
collect_account = self.env["delivery.carrier.account"].create(
|
||||
{
|
||||
"delivery_carrier_id": self.delivery_carrier_1.id,
|
||||
"account_number": "COLLECT123",
|
||||
"partner_id": self.client_partner.id,
|
||||
}
|
||||
)
|
||||
self.client_partner.write(
|
||||
{"property_delivery_carrier_id": self.delivery_carrier_2.id}
|
||||
)
|
||||
|
||||
# Create an order with prepaid billing and carrier 1
|
||||
order = self._create_sale_order(
|
||||
billing_mode=False,
|
||||
carrier=self.delivery_carrier_1,
|
||||
account=False,
|
||||
)
|
||||
self.assertEqual(order.carrier_id, self.delivery_carrier_1)
|
||||
self.assertFalse(order.delivery_billing_mode)
|
||||
|
||||
# Change billing mode to collect - carrier should stay the same
|
||||
# since there's a valid collect account for it
|
||||
with Form(order) as form:
|
||||
form.delivery_billing_mode = "collect"
|
||||
self.assertEqual(
|
||||
form.carrier_id,
|
||||
self.delivery_carrier_1,
|
||||
"Carrier should not change when switching billing mode if a valid account exists",
|
||||
)
|
||||
|
|
|
|||
148
delivery_carrier_partner_account/tests/test_res_partner.py
Normal file
148
delivery_carrier_partner_account/tests/test_res_partner.py
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
from odoo.tests import TransactionCase, tagged, Form
|
||||
from odoo import Command
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestResPartner(TransactionCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
def test_default_carrier_set_on_create(self):
|
||||
partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Partner",
|
||||
}
|
||||
)
|
||||
account = self.env["delivery.carrier.account"].create(
|
||||
{
|
||||
"partner_id": partner.id,
|
||||
"delivery_carrier_id": self.env.ref(
|
||||
"delivery.free_delivery_carrier"
|
||||
).id,
|
||||
"account_number": "1234567890",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
partner.carrier_account_ids[0], partner.default_carrier_account_id
|
||||
)
|
||||
|
||||
def test_default_carrier_set_on_update(self):
|
||||
partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Partner",
|
||||
}
|
||||
)
|
||||
partner.write(
|
||||
{
|
||||
"carrier_account_ids": [
|
||||
Command.create(
|
||||
{
|
||||
"delivery_carrier_id": self.env.ref(
|
||||
"delivery.free_delivery_carrier"
|
||||
).id,
|
||||
"account_number": "1234567890",
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
partner.carrier_account_ids[0], partner.default_carrier_account_id
|
||||
)
|
||||
|
||||
def test_no_change_to_default_account_id_on_update_if_already_set(self):
|
||||
partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Partner",
|
||||
"carrier_account_ids": [
|
||||
Command.create(
|
||||
{
|
||||
"delivery_carrier_id": self.env.ref(
|
||||
"delivery.free_delivery_carrier"
|
||||
).id,
|
||||
"account_number": "1234567890",
|
||||
}
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
new_account = self.env["delivery.carrier.account"].create(
|
||||
{
|
||||
"partner_id": partner.id,
|
||||
"delivery_carrier_id": self.env.ref(
|
||||
"delivery.delivery_local_delivery"
|
||||
).id,
|
||||
"account_number": "1234567890",
|
||||
}
|
||||
)
|
||||
self.assertNotEqual(partner.default_carrier_account_id, new_account)
|
||||
|
||||
def test_carrier_set_if_account_created_from_other_side(self):
|
||||
partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Partner",
|
||||
}
|
||||
)
|
||||
new_account = self.env["delivery.carrier.account"].create(
|
||||
{
|
||||
"partner_id": partner.id,
|
||||
"delivery_carrier_id": self.env.ref(
|
||||
"delivery.free_delivery_carrier"
|
||||
).id,
|
||||
"account_number": "1234567890",
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(partner.default_carrier_account_id, new_account)
|
||||
|
||||
def test_no_archived_default_carrier_account(self):
|
||||
partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Partner",
|
||||
}
|
||||
)
|
||||
account = self.env["delivery.carrier.account"].create(
|
||||
{
|
||||
"partner_id": partner.id,
|
||||
"delivery_carrier_id": self.env.ref(
|
||||
"delivery.free_delivery_carrier"
|
||||
).id,
|
||||
"account_number": "1234567890",
|
||||
}
|
||||
)
|
||||
|
||||
with Form(account) as account_form:
|
||||
account_form.active = False
|
||||
|
||||
self.assertFalse(partner.default_carrier_account_id)
|
||||
|
||||
def test_multiple_carrier_accounts_reset_default_on_archive(self):
|
||||
partner = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Test Partner",
|
||||
}
|
||||
)
|
||||
account1 = self.env["delivery.carrier.account"].create(
|
||||
{
|
||||
"partner_id": partner.id,
|
||||
"delivery_carrier_id": self.env.ref(
|
||||
"delivery.free_delivery_carrier"
|
||||
).id,
|
||||
"account_number": "1234567890",
|
||||
}
|
||||
)
|
||||
account2 = self.env["delivery.carrier.account"].create(
|
||||
{
|
||||
"partner_id": partner.id,
|
||||
"delivery_carrier_id": self.env.ref(
|
||||
"delivery.free_delivery_carrier"
|
||||
).id,
|
||||
"account_number": "1234567891",
|
||||
}
|
||||
)
|
||||
self.assertEqual(partner.default_carrier_account_id, account1)
|
||||
with Form(account1) as account_form:
|
||||
account_form.active = False
|
||||
self.assertEqual(partner.default_carrier_account_id, account2)
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
from .test_carrier_account_common import TestCarrierAccountCommon
|
||||
from odoo.tests import Form
|
||||
from odoo import Command, api
|
||||
|
||||
|
||||
class TestSalesOrder(TestCarrierAccountCommon):
|
||||
|
|
@ -21,12 +23,13 @@ class TestSalesOrder(TestCarrierAccountCommon):
|
|||
def test_prepaid_sale_order_line_gets_proper_name(self):
|
||||
order = self.env["sale.order"].create({"partner_id": self.client_partner.id})
|
||||
wiz = self._get_shipping_wizard(order)
|
||||
wiz.carrier_id = self.delivery_carrier_1
|
||||
wiz.delivery_billing_mode = "prepaid"
|
||||
with Form(wiz) as form:
|
||||
form.carrier_id = self.delivery_carrier_2
|
||||
form.delivery_billing_mode = "prepaid"
|
||||
wiz.button_confirm()
|
||||
self.assertEqual(
|
||||
order.order_line[0].name,
|
||||
f"{self.delivery_carrier_1.name} [PREPAID]",
|
||||
f"{self.delivery_carrier_2.name} [PREPAID]",
|
||||
)
|
||||
|
||||
def test_third_party_sale_order_line_gets_proper_name(self):
|
||||
|
|
@ -36,11 +39,67 @@ class TestSalesOrder(TestCarrierAccountCommon):
|
|||
wiz.delivery_billing_mode = "third party"
|
||||
wiz.carrier_account_id = self.third_party_account_1
|
||||
wiz.button_confirm()
|
||||
self.assertEqual(order.carrier_account_id, self.third_party_account_1)
|
||||
self.assertEqual(
|
||||
order.order_line[0].name,
|
||||
f"{self.delivery_carrier_1.name} [THIRD PARTY] #{self.third_party_account_1.account_number}",
|
||||
)
|
||||
|
||||
def test_sale_order_shipping_to_third_party_collect(self):
|
||||
# We create an order where we are shipping to a third party and the billing mode is collect
|
||||
# This should work, but was previously failing with an error that the carrier account did not belong to the recipient
|
||||
order = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.client_partner.id,
|
||||
"partner_shipping_id": self.random_partner.id,
|
||||
"order_line": [
|
||||
Command.create(
|
||||
{"product_id": self.env.ref("product.product_product_4").id}
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
# Confirming the order is important because the sender and recipient need to be in sync
|
||||
# on the sale order and delivery order. This was the point of failure previously.
|
||||
order.action_confirm()
|
||||
|
||||
wiz = self._get_shipping_wizard(order)
|
||||
with Form(wiz) as form:
|
||||
form.carrier_id = self.delivery_carrier_1
|
||||
form.delivery_billing_mode = "collect"
|
||||
self.assertIn(self.third_party_account_1, wiz.valid_carrier_account_ids)
|
||||
wiz.button_confirm()
|
||||
|
||||
self.assertEqual(
|
||||
order.carrier_account_id.partner_id,
|
||||
self.random_partner,
|
||||
"The carrier account should belong to the recipient (sale order shipping address)",
|
||||
)
|
||||
|
||||
def test_stock_picking_inherits_carrier_info(self):
|
||||
order = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.client_partner.id,
|
||||
"partner_shipping_id": self.client_partner.id,
|
||||
"order_line": [
|
||||
Command.create(
|
||||
{"product_id": self.env.ref("product.product_delivery_01").id}
|
||||
)
|
||||
],
|
||||
"carrier_id": self.delivery_carrier_1.id,
|
||||
"delivery_billing_mode": "collect",
|
||||
"carrier_account_id": self.client_account_1.id,
|
||||
}
|
||||
)
|
||||
order.action_confirm()
|
||||
self.env.flush_all()
|
||||
|
||||
picking = order.picking_ids
|
||||
self.assertEqual(picking.carrier_id, order.carrier_id)
|
||||
self.assertEqual(picking.delivery_billing_mode, order.delivery_billing_mode)
|
||||
self.assertEqual(picking.carrier_account_id, order.carrier_account_id)
|
||||
|
||||
@api.returns("delivery.carrier.wizard")
|
||||
def _get_shipping_wizard(self, order):
|
||||
wizard_action = order.action_open_delivery_wizard()
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<record id="delivery_carrier_account_view_tree" model="ir.ui.view">
|
||||
<field name="name">delivery.carrier.account.view.tree</field>
|
||||
<record id="delivery_carrier_account_view_list" model="ir.ui.view">
|
||||
<field name="name">delivery.carrier.account.view.list</field>
|
||||
<field name="model">delivery.carrier.account</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree editable="bottom" multi_edit="1">
|
||||
|
|
@ -11,4 +11,4 @@
|
|||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -26,4 +26,4 @@
|
|||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
</odoo>
|
||||
|
|
|
|||
|
|
@ -8,20 +8,32 @@ class ChooseDeliveryCarrier(models.TransientModel):
|
|||
_name = "choose.delivery.carrier"
|
||||
|
||||
sender_id = fields.Many2one(related="company_id.partner_id")
|
||||
recipient_id = fields.Many2one(related="partner_id")
|
||||
recipient_id = fields.Many2one(related="order_id.partner_shipping_id")
|
||||
|
||||
def button_confirm(self):
|
||||
res = super(
|
||||
ChooseDeliveryCarrier,
|
||||
self.with_context(
|
||||
delivery_billing_mode=self.delivery_billing_mode,
|
||||
carrier_account=self.carrier_account_id,
|
||||
),
|
||||
).button_confirm()
|
||||
extra_vals = {}
|
||||
vals = {}
|
||||
if self.delivery_billing_mode:
|
||||
extra_vals.update(delivery_billing_mode=self.delivery_billing_mode)
|
||||
vals.update(delivery_billing_mode=self.delivery_billing_mode)
|
||||
if self.carrier_account_id:
|
||||
extra_vals.update(carrier_account_id=self.carrier_account_id)
|
||||
self.order_id.write(extra_vals)
|
||||
vals.update(carrier_account_id=self.carrier_account_id.id)
|
||||
if self.carrier_id:
|
||||
vals.update(carrier_id=self.carrier_id.id)
|
||||
|
||||
# Ensure we have a valid carrier account
|
||||
if (
|
||||
self.carrier_id
|
||||
and self.delivery_billing_mode
|
||||
and not self.carrier_account_id
|
||||
):
|
||||
# Force recompute of valid carrier accounts
|
||||
self._compute_valid_carrier_account_ids()
|
||||
default_account = self._get_default_carrier_account()
|
||||
if default_account:
|
||||
vals.update(carrier_account_id=default_account.id)
|
||||
|
||||
# Write values to the order before calling super
|
||||
if vals:
|
||||
self.order_id.with_context(no_carrier_update=True).write(vals)
|
||||
|
||||
res = super().button_confirm()
|
||||
return res
|
||||
|
|
|
|||
|
|
@ -20,14 +20,35 @@
|
|||
{
|
||||
"name": "FSM Visit Confirmation",
|
||||
"version": "17.0.0.1.0",
|
||||
"summary": "Have clients confirm tentatively booked visits",
|
||||
"summary": "Enable client feedback workflow for field service tasks",
|
||||
"description": """
|
||||
This module enhances the field service management workflow by leveraging Odoo's
|
||||
built-in rating system for client task confirmations. Key features include:
|
||||
|
||||
* Automated rating requests to work order contacts when tasks reach specific stages
|
||||
* Configurable stages with rating email templates
|
||||
* Client-facing rating interface with direct links in emails
|
||||
* Support for task approval or change requests through ratings
|
||||
* Integration with existing work order contacts from bemade_fsm
|
||||
* Automatic task state updates based on client feedback
|
||||
* Real-time feedback through website messages and notifications
|
||||
* Chatter integration for client comments
|
||||
|
||||
The module helps streamline communication between field service teams and clients
|
||||
by providing a clear feedback workflow and maintaining a record of all client
|
||||
interactions and approvals through Odoo's rating system.
|
||||
""",
|
||||
"category": "Services/Field Service",
|
||||
"author": "Bemade Inc.",
|
||||
"website": "http://www.bemade.org",
|
||||
"license": "LGPL-3",
|
||||
"depends": ["industry_fsm"],
|
||||
"data": ["data/mail_templates.xml"],
|
||||
"assets": {},
|
||||
"depends": ["industry_fsm", "bemade_fsm", "rating", "http_routing"],
|
||||
"data": [
|
||||
"data/mail_templates.xml",
|
||||
"views/project_portal_project_task_templates.xml",
|
||||
"views/project_task_type_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"auto_install": False,
|
||||
"application": False,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
from . import portal
|
||||
from . import main
|
||||
|
|
|
|||
270
fsm_visit_confirmation/controllers/main.py
Normal file
270
fsm_visit_confirmation/controllers/main.py
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import werkzeug
|
||||
|
||||
from odoo import http, _
|
||||
from odoo.http import request
|
||||
from odoo.addons.base.models.ir_qweb import keep_query
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
from odoo.exceptions import AccessError, MissingError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CustomerPortalExtended(CustomerPortal):
|
||||
"""Extend the customer portal controller to handle FSM visit confirmations."""
|
||||
|
||||
@http.route("/my/task/<int:task_id>", type="http", auth="public", website=True)
|
||||
def portal_my_task(self, task_id, access_token=None, **kw):
|
||||
"""Override to allow access with token for public users."""
|
||||
try:
|
||||
task = request.env["project.task"].browse(task_id)
|
||||
task.check_access_rights("read")
|
||||
task.check_access_rights("write")
|
||||
task.check_access_rule("read")
|
||||
task.check_access_rule("write")
|
||||
except AccessError:
|
||||
task = None
|
||||
values = self._get_portal_values(task, access_token=access_token, **kw)
|
||||
if not task:
|
||||
task = values.get("task", False)
|
||||
if not task:
|
||||
return self._render_error_page(
|
||||
_("Task not found or invalid token"), task=None
|
||||
)
|
||||
|
||||
# Pass the task to the original method
|
||||
result = super(CustomerPortalExtended, self).portal_my_task(
|
||||
task_id, access_token=access_token, **kw
|
||||
)
|
||||
|
||||
# If the result is a Response object (like a redirect), return it directly
|
||||
if isinstance(result, werkzeug.wrappers.Response):
|
||||
return result
|
||||
|
||||
# Otherwise, it's a rendered template, so we need to update the qcontext
|
||||
if hasattr(result, "qcontext"):
|
||||
result.qcontext.update(values)
|
||||
|
||||
return result
|
||||
|
||||
@http.route(
|
||||
"/my/fsm_confirmation/<string:action>",
|
||||
type="http",
|
||||
auth="public",
|
||||
website=True,
|
||||
)
|
||||
def fsm_confirmation_action(self, action, **kwargs):
|
||||
"""Custom landing page for FSM visit confirmations.
|
||||
|
||||
Args:
|
||||
action: Either 'approve' or 'change'
|
||||
Kwargs:
|
||||
access_token: The access token for the task
|
||||
"""
|
||||
values = self._get_portal_values(**kwargs)
|
||||
task = values.get("task", False)
|
||||
if not task:
|
||||
return self._render_error_page(
|
||||
_("Task not found or invalid token"), task=None
|
||||
)
|
||||
# Store the language in kwargs for use in redirects
|
||||
lang = values.get("lang")
|
||||
|
||||
if action not in ("approve", "change"):
|
||||
raise werkzeug.exceptions.BadRequest(_("Invalid action"))
|
||||
|
||||
# If it's an approval, update the task state directly
|
||||
if action == "approve":
|
||||
try:
|
||||
# Update task state to approved
|
||||
task.write({"state": "03_approved"})
|
||||
|
||||
# Add a message to the chatter
|
||||
task.message_post(
|
||||
body=_("Visit approved by customer"),
|
||||
message_type="comment",
|
||||
subtype_xmlid="mail.mt_note",
|
||||
)
|
||||
|
||||
# Always redirect to the task portal page with success message
|
||||
redirect_url = "/my/task/%s?%s" % (
|
||||
task.id,
|
||||
keep_query(
|
||||
"access_token",
|
||||
visit_confirmation_status="approved",
|
||||
lang=lang,
|
||||
),
|
||||
)
|
||||
return request.redirect(redirect_url)
|
||||
except Exception as e:
|
||||
_logger.exception(_("Error approving task: %s"), e)
|
||||
return self._render_error_page(
|
||||
_("Error approving task: %s") % str(e), task=task
|
||||
)
|
||||
|
||||
# For change requests, redirect to the task portal page with change request form
|
||||
redirect_url = "/my/task/%s?%s" % (
|
||||
task.id,
|
||||
keep_query(
|
||||
"access_token",
|
||||
visit_confirmation_status="change",
|
||||
lang=lang,
|
||||
),
|
||||
)
|
||||
return request.redirect(redirect_url)
|
||||
|
||||
@http.route(
|
||||
"/my/fsm_confirmation/submit_change",
|
||||
type="http",
|
||||
auth="public",
|
||||
methods=["post"],
|
||||
website=True,
|
||||
csrf=True,
|
||||
)
|
||||
def fsm_confirmation_submit_change(self, **kwargs):
|
||||
"""Handle the submission of change request form."""
|
||||
values = self._get_portal_values(**kwargs)
|
||||
|
||||
task = values.get("task", False)
|
||||
lang = values.get("lang")
|
||||
feedback = kwargs.get("feedback", "")
|
||||
if not task:
|
||||
return self._render_error_page(
|
||||
_("Task not found or invalid token"), task=None
|
||||
)
|
||||
if not feedback:
|
||||
return self._render_error_page(
|
||||
_("No feedback provided. Please try again!"), task=task
|
||||
)
|
||||
|
||||
try:
|
||||
# Update task state to changes requested
|
||||
task.write({"state": "02_changes_requested"})
|
||||
|
||||
# Add the feedback as a message to the chatter
|
||||
task.message_post(
|
||||
body=_("Changes requested by customer: %s") % feedback,
|
||||
message_type="comment",
|
||||
subtype_xmlid="mail.mt_comment",
|
||||
)
|
||||
|
||||
# Always redirect to the task portal page with success message
|
||||
redirect_url = "/my/task/%s?%s" % (
|
||||
task.id,
|
||||
keep_query(
|
||||
access_token=kwargs.get("access_token"),
|
||||
visit_confirmation_status="change_submitted",
|
||||
lang=lang,
|
||||
),
|
||||
)
|
||||
return request.redirect(redirect_url)
|
||||
except Exception as e:
|
||||
_logger.exception(_("Error submitting change request: %s"), e)
|
||||
return self._render_error_page(
|
||||
_("Error submitting change request: %s") % str(e), task=task
|
||||
)
|
||||
|
||||
def _get_task_by_token(self, token):
|
||||
"""Get a task by its access token."""
|
||||
# First try to find the task directly by access token
|
||||
task = (
|
||||
request.env["project.task"]
|
||||
.sudo()
|
||||
.search([("access_token", "=", token)], limit=1)
|
||||
)
|
||||
if task:
|
||||
return task
|
||||
|
||||
# If not found, try to find it through the rating token
|
||||
# This is for backward compatibility with existing tokens in emails
|
||||
rating = (
|
||||
request.env["rating.rating"]
|
||||
.sudo()
|
||||
.search([("access_token", "=", token)], limit=1)
|
||||
)
|
||||
if rating and rating.res_model == "project.task":
|
||||
task = request.env["project.task"].sudo().browse(rating.res_id)
|
||||
if task.exists():
|
||||
return task
|
||||
|
||||
return None
|
||||
|
||||
def _render_error_page(self, error_message, task=None, **kwargs):
|
||||
"""Render an error page."""
|
||||
values = {
|
||||
"status_code": "400",
|
||||
"status_message": error_message,
|
||||
"lang": self._get_lang(task=task, lang=kwargs.get("lang")),
|
||||
}
|
||||
|
||||
return request.render("http_routing.http_error", values)
|
||||
|
||||
def _get_portal_values(self, task=None, **kwargs):
|
||||
"""Get common values for portal templates.
|
||||
|
||||
Args:
|
||||
task: The task record if already loaded
|
||||
**kwargs: Keyword arguments from the request
|
||||
|
||||
Returns:
|
||||
Dictionary with values for template rendering
|
||||
"""
|
||||
values = {"page_name": "task"}
|
||||
if not task:
|
||||
access_token = kwargs.get("access_token")
|
||||
if access_token:
|
||||
task = self._get_task_by_token(access_token)
|
||||
if task:
|
||||
values["task"] = task
|
||||
|
||||
# Set language and update values dictionary
|
||||
values["lang"] = self._get_lang(task=task, lang=kwargs.get("lang"))
|
||||
|
||||
# Add any additional parameters from kwargs that might be needed in templates
|
||||
for key in ["visit_confirmation_status", "error", "warning", "success"]:
|
||||
if kwargs.get(key):
|
||||
values[key] = kwargs.get(key)
|
||||
|
||||
return values
|
||||
|
||||
def _get_lang(self, task=None, lang=None):
|
||||
"""Helper method to set the language in the request environment.
|
||||
|
||||
Args:
|
||||
task: The task record (optional)
|
||||
lang: Language code (e.g., 'en_US', 'fr_FR') (optional)
|
||||
|
||||
Returns:
|
||||
The language code that was set, or None if no language was set
|
||||
"""
|
||||
# If lang is not provided, try to get it from the task
|
||||
if not lang and task:
|
||||
lang_partner = (
|
||||
task.work_order_contacts
|
||||
and task.work_order_contacts[0]
|
||||
or task.partner_id
|
||||
)
|
||||
if lang_partner and lang_partner.lang:
|
||||
lang = lang_partner.lang
|
||||
|
||||
if not lang:
|
||||
return None
|
||||
|
||||
# Get the language record
|
||||
lang_code = lang.replace("_", "-")
|
||||
lang_id = (
|
||||
request.env["res.lang"]
|
||||
.sudo()
|
||||
.search(["|", ("code", "=", lang_code), ("code", "=", lang)], limit=1)
|
||||
)
|
||||
|
||||
if lang_id:
|
||||
# Set the context language
|
||||
request.env = request.env(
|
||||
context=dict(request.env.context, lang=lang_id.code)
|
||||
)
|
||||
return lang_id.code
|
||||
|
||||
return None
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
from odoo.http import request, route
|
||||
from odoo.exceptions import AccessError, MissingError
|
||||
|
||||
|
||||
class FsmCustomerPortal(CustomerPortal):
|
||||
@route(
|
||||
"/my/tasks/approve_booking/<int:task_id>",
|
||||
type="http",
|
||||
auth="public",
|
||||
website=True,
|
||||
)
|
||||
def portal_approve_booking(self, task_id, access_token=None):
|
||||
try:
|
||||
visit_sudo = self._document_check_access(
|
||||
"project.task", task_id, access_token=access_token
|
||||
)
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect("/my")
|
||||
|
||||
visit_sudo.action_approve_booking()
|
||||
request.session["visit_confirmation_accepted"] = True
|
||||
request.redirect(f"/my/tasks/{task_id}")
|
||||
|
||||
def _task_get_page_view_values(self, task, access_token, **kwargs):
|
||||
vals = super()._task_get_page_view_values(task, access_token, **kwargs)
|
||||
if request.session.pop("visit_confirmation_accepted", False):
|
||||
vals.update(visit_confirmation_accepted=True)
|
||||
return vals
|
||||
|
||||
def _prepare_home_portal_values(self, counters):
|
||||
vals = super()._prepare_home_portal_values(counters)
|
||||
if request.session.pop("visit_confirmation_accepted", False):
|
||||
vals.update(visit_confirmation_accepted=True)
|
||||
return vals
|
||||
|
|
@ -1,47 +1,93 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="request_fsm_task_date_confirmation" model="mail.template">
|
||||
<field name="name">Field Service Visit Confirmation Request</field>
|
||||
<record id="fsm_visit_confirmation_email_template" model="mail.template">
|
||||
<field name="name">FSM Visit Confirmation</field>
|
||||
<field name="model_id" ref="project.model_project_task"/>
|
||||
<field name="subject">Please confirm the date for your upcoming Durpro service visit</field>
|
||||
<field name="email_from">{{ object.company_id.email_formatted }}</field>
|
||||
<field name="body_html">
|
||||
<table class="table table-striped">
|
||||
<tbody>
|
||||
<field name="subject">Service Visit Confirmation: {{ object.name }}</field>
|
||||
<field name="email_from">{{ user.email_formatted }}</field>
|
||||
<field name="partner_to">{{ object.work_order_contacts and object.work_order_contacts[0].id or object.partner_id.id }}</field>
|
||||
<field name="description">Send a visit confirmation email to the customer</field>
|
||||
<field name="body_html" type="html">
|
||||
<div>
|
||||
<div style="margin-bottom: 16px;">
|
||||
Dear <t t-out="object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name"/>
|
||||
,
|
||||
</div>
|
||||
<div style="margin-bottom: 16px;">
|
||||
We would like to confirm the details of your upcoming service visit:
|
||||
</div>
|
||||
<table style="width:100%;border-spacing:0;border:1px solid #e7e7e7;">
|
||||
<tr>
|
||||
<td scope="row">Summary</td>
|
||||
<td>{{ object.name }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td scope="row">Technician Arrival</td>
|
||||
<td>{{ object.planned_date_begin }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td scope="row">Intervention address</td>
|
||||
<td>
|
||||
<span t-esc="object.partner_id.street"/><br/>
|
||||
<span t-if="object.partner_id.street2" t-esc="object.partner_id.street2"/><br/>
|
||||
<span t-esc="object.partner_id.city"/><br/>
|
||||
<span t-esc="object.partner_id.state"/><br/>
|
||||
<span t-esc="object.partner_id.zip"/>
|
||||
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Task Summary</td>
|
||||
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
|
||||
<t t-out="object.name"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td scope="row">
|
||||
Assigned Technician(s):
|
||||
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Technician Arrival</td>
|
||||
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
|
||||
<t t-set="partner" t-value="object.work_order_contacts and object.work_order_contacts[0] or object.partner_id"/>
|
||||
<t t-set="partner_tz" t-value="user.tz or object.company_id.partner_id.tz or 'America/Toronto'"/>
|
||||
<span t-field="object.planned_date_begin" t-options="{'widget': 'datetime', 'timezone': partner_tz}"/>
|
||||
<div style="font-size: 12px; color: #666;">
|
||||
(Timezone: <t t-out="partner_tz"/>
|
||||
)
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<t t-foreach="object.user_ids" t-as="technician">
|
||||
<t t-esc="technician.name"/><br/>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Location</td>
|
||||
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
|
||||
<t t-out="object.partner_id.street"/>
|
||||
<br/>
|
||||
<t t-if="object.partner_id.street2">
|
||||
<t t-out="object.partner_id.street2"/>
|
||||
<br/>
|
||||
</t>
|
||||
<t t-out="object.partner_id.city"/>
|
||||
, <t t-out="object.partner_id.state_id.code"/>
|
||||
<t t-out="object.partner_id.zip"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px;background-color:#f8f9fa;">Assigned Technician(s)</td>
|
||||
<td style="padding:10px;">
|
||||
<t t-foreach="object.user_ids" t-as="user">
|
||||
<t t-out="user.name"/>
|
||||
<t t-if="not user_last">
|
||||
<br/>
|
||||
</t>
|
||||
</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>To confirm this visit, <a href="{{ user.company_id.website }}/my/tasks/approve_booking/?task_id={{ object.id }}&access_token={{ object.access_token}}">click here</a>.</p>
|
||||
<p>If you would like to propose another time for this visit, please reply to this email.</p>
|
||||
<p>Best regards,</p>
|
||||
<p>The {{ object.company_id.name }} service management team.</p>
|
||||
</table>
|
||||
<div style="margin: 16px 0px 16px 0px;">
|
||||
Please take a moment to confirm this visit by clicking one of the following options:
|
||||
</div>
|
||||
<table style="width:100%;border-spacing:0;">
|
||||
<tr>
|
||||
<td style="padding:10px;text-align:center;">
|
||||
<t t-set="base_url" t-value="object.get_base_url()"/>
|
||||
<t t-set="lang" t-value="object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'"/>
|
||||
<a t-att-href="base_url + '/my/fsm_confirmation/approve?' + keep_query(lang=lang,access_token=object.access_token)" style="background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;">
|
||||
Approve Visit
|
||||
</a>
|
||||
</td>
|
||||
<td style="padding:10px;text-align:center;">
|
||||
<a t-att-href="base_url + '/my/fsm_confirmation/change?' + keep_query(lang=lang,access_token=object.access_token)" style="background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;">
|
||||
Request Changes
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div style="margin-top: 16px;">
|
||||
<p>Best regards,</p>
|
||||
<p>The <t t-out="object.company_id.name"/>
|
||||
service management team</p>
|
||||
</div>
|
||||
</div>
|
||||
</field>
|
||||
<field name="lang">{{ object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang }}</field>
|
||||
<field name="auto_delete" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
299
fsm_visit_confirmation/i18n/fr.po
Normal file
299
fsm_visit_confirmation/i18n/fr.po
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * fsm_visit_confirmation
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 17.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-27 00:31+0000\n"
|
||||
"PO-Revision-Date: 2025-02-27 00:31+0000\n"
|
||||
"Last-Translator: Marc Durepos <marc@bemade.org>\n"
|
||||
"Language-Team: French\n"
|
||||
"Language: fr\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,body_html:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid ""
|
||||
"<div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" Dear <t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
|
||||
",\n"
|
||||
" </div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" We would like to confirm the details of your upcoming service visit:\n"
|
||||
" </div>\n"
|
||||
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Task Summary</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.name\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Technician Arrival</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
|
||||
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
|
||||
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
|
||||
" <div style=\"font-size: 12px; color: #666;\">\n"
|
||||
" (Timezone: <t t-out=\"partner_tz\"></t>\n"
|
||||
")\n"
|
||||
" </div>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Location</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.partner_id.street\"></t>\n"
|
||||
" <br>\n"
|
||||
" <t t-if=\"object.partner_id.street2\">\n"
|
||||
" <t t-out=\"object.partner_id.street2\"></t>\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" <t t-out=\"object.partner_id.city\"></t>\n"
|
||||
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
|
||||
" <t t-out=\"object.partner_id.zip\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Assigned Technician(s)</td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
|
||||
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
|
||||
" <t t-out=\"user.name\"></t>\n"
|
||||
" <t t-if=\"not user_last\">\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" </t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
|
||||
" Please take a moment to confirm this visit by clicking one of the following options:\n"
|
||||
" </div>\n"
|
||||
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
|
||||
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Approve Visit\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Request Changes\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin-top: 16px;\">\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Best regards,</p>\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">The <t t-out=\"object.company_id.name\"></t>\n"
|
||||
" service management team</p>\n"
|
||||
" </div>\n"
|
||||
" </div>\n"
|
||||
" "
|
||||
msgstr ""
|
||||
"<div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" Cher/chère<t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
|
||||
",\n"
|
||||
" </div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" Nous aimerions confirmer les détails de votre visite de service à venir.\n"
|
||||
" </div>\n"
|
||||
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Visite</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.name\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Début</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
|
||||
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
|
||||
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
|
||||
" <div style=\"font-size: 12px; color: #666;\">\n"
|
||||
" (Fuseau horaire: <t t-out=\"partner_tz\"></t>\n"
|
||||
")\n"
|
||||
" </div>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Lieu</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.partner_id.street\"></t>\n"
|
||||
" <br>\n"
|
||||
" <t t-if=\"object.partner_id.street2\">\n"
|
||||
" <t t-out=\"object.partner_id.street2\"></t>\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" <t t-out=\"object.partner_id.city\"></t>\n"
|
||||
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
|
||||
" <t t-out=\"object.partner_id.zip\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Technicien(s) assigné(s)</td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
|
||||
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
|
||||
" <t t-out=\"user.name\"></t>\n"
|
||||
" <t t-if=\"not user_last\">\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" </t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
|
||||
" Veuillez prendre un moment pour confirmer cette visite en cliquant sur une des options suivantes :\n"
|
||||
" </div>\n"
|
||||
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
|
||||
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Confirmer la visite\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Demander des changements\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin-top: 16px;\">\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Cordialement,</p>\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">L'équipe de gestion des services de <t t-out=\"object.company_id.name\"></t></p>\n"
|
||||
" </div>\n"
|
||||
" </div>\n"
|
||||
" "
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-check-circle me-2\"/>\n"
|
||||
" <strong>Success!</strong> Thank you for approving this service visit."
|
||||
msgstr ""
|
||||
"<i class=\"fa fa-check-circle me-2\"/>\n"
|
||||
" <strong>Succès!</strong> Merci d'avoir approuvé cette visite de service."
|
||||
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
|
||||
" Request Changes to Service Visit"
|
||||
msgstr ""
|
||||
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
|
||||
" Demander des changements à la visite de service"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-info-circle me-2\"/>\n"
|
||||
" <strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly."
|
||||
msgstr ""
|
||||
"<i class=\"fa fa-info-circle me-2\"/>\n"
|
||||
" <strong>Merci!</strong> Votre demande de changement a été soumise. Notre équipe examinerait cette demande rapidement."
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Changes requested by customer: %s"
|
||||
msgstr "Changements demandés par client: %s"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Error approving task: %s"
|
||||
msgstr "Erreur lors de l'approbation de la tâche: %s"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Error submitting change request: %s"
|
||||
msgstr "Erreur lors de la soumission de la demande de changement: %s"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,name:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "FSM Visit Confirmation"
|
||||
msgstr "Confirmation de visite de service"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Invalid action"
|
||||
msgstr "Action invalide"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "No feedback provided. Please try again!"
|
||||
msgstr "Aucun commentaire fourni. Veuillez réessayer!"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Please explain what changes you need..."
|
||||
msgstr "Veuillez expliquer les modifications nécessaires..."
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"Please provide details about the changes you would like to make to this "
|
||||
"service visit:"
|
||||
msgstr ""
|
||||
"Veuillez fournir des informations sur les modifications que vous souhaitez "
|
||||
"apporter à cette visite de service :"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,description:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "Send a visit confirmation email to the customer"
|
||||
msgstr "Envoyer un email de confirmation de visite au client"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,subject:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "Service Visit Confirmation: {{ object.name }}"
|
||||
msgstr "Confirmation de visite de service: {{ object.name }}"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Submit Request"
|
||||
msgstr "Soumettre la demande"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Task not found or invalid token"
|
||||
msgstr "Tâche introuvable ou jeton invalide"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Visit approved by customer"
|
||||
msgstr "Visite approuvée par le client"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Your Comments:"
|
||||
msgstr "Vos commentaires :"
|
||||
211
fsm_visit_confirmation/i18n/fsm_visit_confirmation.pot
Normal file
211
fsm_visit_confirmation/i18n/fsm_visit_confirmation.pot
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
# Translation of Odoo Server.
|
||||
# This file contains the translation of the following modules:
|
||||
# * fsm_visit_confirmation
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 17.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-02-27 00:31+0000\n"
|
||||
"PO-Revision-Date: 2025-02-27 00:31+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,body_html:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid ""
|
||||
"<div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" Dear <t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
|
||||
",\n"
|
||||
" </div>\n"
|
||||
" <div style=\"margin-bottom: 16px;\">\n"
|
||||
" We would like to confirm the details of your upcoming service visit:\n"
|
||||
" </div>\n"
|
||||
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Task Summary</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.name\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Technician Arrival</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
|
||||
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
|
||||
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
|
||||
" <div style=\"font-size: 12px; color: #666;\">\n"
|
||||
" (Timezone: <t t-out=\"partner_tz\"></t>\n"
|
||||
")\n"
|
||||
" </div>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Location</td>\n"
|
||||
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
|
||||
" <t t-out=\"object.partner_id.street\"></t>\n"
|
||||
" <br>\n"
|
||||
" <t t-if=\"object.partner_id.street2\">\n"
|
||||
" <t t-out=\"object.partner_id.street2\"></t>\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" <t t-out=\"object.partner_id.city\"></t>\n"
|
||||
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
|
||||
" <t t-out=\"object.partner_id.zip\"></t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Assigned Technician(s)</td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
|
||||
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
|
||||
" <t t-out=\"user.name\"></t>\n"
|
||||
" <t t-if=\"not user_last\">\n"
|
||||
" <br>\n"
|
||||
" </t>\n"
|
||||
" </t>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
|
||||
" Please take a moment to confirm this visit by clicking one of the following options:\n"
|
||||
" </div>\n"
|
||||
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
|
||||
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
|
||||
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Approve Visit\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
|
||||
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
|
||||
" Request Changes\n"
|
||||
" </a>\n"
|
||||
" </td>\n"
|
||||
" </tr>\n"
|
||||
" </tbody></table>\n"
|
||||
" <div style=\"margin-top: 16px;\">\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Best regards,</p>\n"
|
||||
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">The <t t-out=\"object.company_id.name\"></t>\n"
|
||||
" service management team</p>\n"
|
||||
" </div>\n"
|
||||
" </div>\n"
|
||||
" "
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-check-circle me-2\"/>\n"
|
||||
" <strong>Success!</strong> Thank you for approving this service visit."
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
|
||||
" Request Changes to Service Visit"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"<i class=\"fa fa-info-circle me-2\"/>\n"
|
||||
" <strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly."
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Changes requested by customer: %s"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Error approving task: %s"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Error submitting change request: %s"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,name:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "FSM Visit Confirmation"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Invalid action"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "No feedback provided. Please try again!"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Please explain what changes you need..."
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid ""
|
||||
"Please provide details about the changes you would like to make to this "
|
||||
"service visit:"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,description:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "Send a visit confirmation email to the customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model:mail.template,subject:fsm_visit_confirmation.fsm_visit_confirmation_email_template
|
||||
msgid "Service Visit Confirmation: {{ object.name }}"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Submit Request"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Task not found or invalid token"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#. odoo-python
|
||||
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
|
||||
#, python-format
|
||||
msgid "Visit approved by customer"
|
||||
msgstr ""
|
||||
|
||||
#. module: fsm_visit_confirmation
|
||||
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
|
||||
msgid "Your Comments:"
|
||||
msgstr ""
|
||||
|
|
@ -1 +1,2 @@
|
|||
from . import project_task_type
|
||||
from . import project_task
|
||||
|
|
|
|||
|
|
@ -1,9 +1,31 @@
|
|||
from odoo import models, fields, api
|
||||
from odoo import models
|
||||
|
||||
|
||||
class Task(models.Model):
|
||||
class ProjectTask(models.Model):
|
||||
_inherit = "project.task"
|
||||
|
||||
def action_approve_booking(self):
|
||||
self.ensure_one()
|
||||
self.state = "03_approved"
|
||||
def write(self, vals):
|
||||
# Store old stage for comparison
|
||||
old_stages = {task.id: task.stage_id for task in self}
|
||||
|
||||
# Execute original write method
|
||||
result = super().write(vals)
|
||||
|
||||
# If stage changed and new stage has an approval template, send the email
|
||||
if "stage_id" in vals:
|
||||
# Only send email for top-level tasks
|
||||
for task in self.filtered(
|
||||
lambda task: task.stage_id != old_stages[task.id]
|
||||
and task.stage_id.approval_template_id
|
||||
and not task.parent_id
|
||||
):
|
||||
task._portal_ensure_token()
|
||||
task.stage_id.approval_template_id.send_mail(
|
||||
task.id,
|
||||
force_send=True,
|
||||
email_values={
|
||||
"email_to": task.partner_id.email if task.partner_id else False
|
||||
},
|
||||
)
|
||||
|
||||
return result
|
||||
|
|
|
|||
11
fsm_visit_confirmation/models/project_task_type.py
Normal file
11
fsm_visit_confirmation/models/project_task_type.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from odoo import fields, models
|
||||
|
||||
|
||||
class ProjectTaskType(models.Model):
|
||||
_inherit = 'project.task.type'
|
||||
|
||||
approval_template_id = fields.Many2one(
|
||||
'mail.template',
|
||||
string='Approval Template',
|
||||
help='Template to use for approval emails when a task reaches this stage.'
|
||||
)
|
||||
1
fsm_visit_confirmation/tests/__init__.py
Normal file
1
fsm_visit_confirmation/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_fsm_visit_confirmation
|
||||
206
fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py
Normal file
206
fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from odoo import http
|
||||
from odoo.addons.mail.tests.common import MailCommon
|
||||
from odoo.tests import HttpCase, tagged
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tagged("post_install", "-at_install")
|
||||
class TestFSMVisitConfirmation(HttpCase, MailCommon):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Configure test company
|
||||
self.env.company.write(
|
||||
{
|
||||
"email": "company@test.example.com",
|
||||
"name": "Test Company",
|
||||
}
|
||||
)
|
||||
|
||||
# Create test users and partners
|
||||
self.fsm_user = self.env["res.users"].create(
|
||||
{
|
||||
"name": "FSM User",
|
||||
"login": "fsm_user",
|
||||
"email": "fsm@example.com",
|
||||
"groups_id": [
|
||||
(
|
||||
6,
|
||||
0,
|
||||
[
|
||||
self.env.ref("industry_fsm.group_fsm_user").id,
|
||||
],
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
# Set email on user's partner
|
||||
self.fsm_user.partner_id.email = "fsm@example.com"
|
||||
self.fsm_user.partner_id.lang = "en_US" # Set language explicitly
|
||||
|
||||
self.customer = self.env["res.partner"].create(
|
||||
{
|
||||
"name": "Customer",
|
||||
"email": "customer@example.com",
|
||||
"lang": "en_US", # Set language explicitly
|
||||
}
|
||||
)
|
||||
|
||||
# Create a project
|
||||
self.project = self.env["project.project"].create(
|
||||
{
|
||||
"name": "Test FSM Project",
|
||||
"is_fsm": True,
|
||||
"company_id": self.env.user.company_id.id,
|
||||
}
|
||||
)
|
||||
|
||||
# Create stages for the project
|
||||
self.stage_new = self.env["project.task.type"].create(
|
||||
{
|
||||
"name": "New",
|
||||
"sequence": 0,
|
||||
"project_ids": [(4, self.project.id)],
|
||||
}
|
||||
)
|
||||
|
||||
self.stage_needs_confirmation = self.env["project.task.type"].create(
|
||||
{
|
||||
"name": "Need Confirmation",
|
||||
"sequence": 1,
|
||||
"project_ids": [(4, self.project.id)],
|
||||
}
|
||||
)
|
||||
|
||||
self.stage_approved = self.env["project.task.type"].create(
|
||||
{
|
||||
"name": "Approved",
|
||||
"sequence": 2,
|
||||
"project_ids": [(4, self.project.id)],
|
||||
}
|
||||
)
|
||||
|
||||
self.stage_changes_requested = self.env["project.task.type"].create(
|
||||
{
|
||||
"name": "Changes Requested",
|
||||
"sequence": 3,
|
||||
"project_ids": [(4, self.project.id)],
|
||||
}
|
||||
)
|
||||
|
||||
# Create a task
|
||||
self.task = self.env["project.task"].create(
|
||||
{
|
||||
"name": "Test Task",
|
||||
"project_id": self.project.id,
|
||||
"partner_id": self.customer.id,
|
||||
"work_order_contacts": [(4, self.customer.id)],
|
||||
"user_ids": [(4, self.fsm_user.id)],
|
||||
"planned_date_begin": datetime.now() + timedelta(days=1),
|
||||
"stage_id": self.stage_new.id,
|
||||
"state": "01_in_progress", # Using valid state value
|
||||
}
|
||||
)
|
||||
self.assertEqual(
|
||||
self.task.stage_id, self.stage_new, "Task should start in New stage"
|
||||
)
|
||||
|
||||
# Ensure the task has an access token
|
||||
if not self.task.access_token:
|
||||
self.task._portal_ensure_token()
|
||||
self.token = self.task.access_token
|
||||
self.assertTrue(self.token, "Task should have an access token")
|
||||
|
||||
def test_01_task_approve_flow(self):
|
||||
"""Test the complete task approval flow"""
|
||||
# Test the FSM confirmation approve endpoint
|
||||
self.authenticate(None, None)
|
||||
url = f"/my/fsm_confirmation/approve?access_token={self.token}"
|
||||
response = self.url_open(url)
|
||||
self.assertEqual(
|
||||
response.status_code, 200, "FSM confirmation approve should succeed"
|
||||
)
|
||||
|
||||
# Now check that the task state was updated
|
||||
self.task.invalidate_recordset()
|
||||
self.assertEqual(
|
||||
self.task.state, "03_approved", "Task state should be updated to approved"
|
||||
)
|
||||
|
||||
# Check that a message was posted
|
||||
messages = self.env["mail.message"].search(
|
||||
[
|
||||
("model", "=", "project.task"),
|
||||
("res_id", "=", self.task.id),
|
||||
("body", "ilike", "Visit approved by customer"),
|
||||
]
|
||||
)
|
||||
self.assertTrue(messages, "A message should be posted on the task")
|
||||
|
||||
def test_03_stage_approved_sends_email(self):
|
||||
"""Test that moving a task to the approved stage sends an email"""
|
||||
# Set the approval template on the approved stage
|
||||
template = self.env.ref(
|
||||
"fsm_visit_confirmation.fsm_visit_confirmation_email_template"
|
||||
)
|
||||
self.stage_approved.approval_template_id = template
|
||||
|
||||
with self.mock_mail_gateway():
|
||||
self.task.write({"stage_id": self.stage_approved.id})
|
||||
|
||||
self.assertEqual(
|
||||
len(self._new_mails),
|
||||
1,
|
||||
"Moving task to approved stage should send an email",
|
||||
)
|
||||
self.assertEqual(self._new_mails[0].email_to, self.customer.email)
|
||||
|
||||
def test_02_task_request_changes_flow(self):
|
||||
"""Test the complete task request changes flow"""
|
||||
# Test the FSM confirmation change endpoint
|
||||
self.authenticate(None, None)
|
||||
url = f"/my/fsm_confirmation/change?access_token={self.token}"
|
||||
response = self.url_open(url)
|
||||
self.assertEqual(
|
||||
response.status_code, 200, "FSM confirmation change should succeed"
|
||||
)
|
||||
|
||||
# Now submit the change request form
|
||||
url = f"/my/fsm_confirmation/submit_change"
|
||||
response = self.url_open(
|
||||
url,
|
||||
data={
|
||||
"access_token": self.token,
|
||||
"feedback": "Need some changes",
|
||||
"csrf_token": http.Request.csrf_token(self),
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
response.status_code, 200, "Change request submission should succeed"
|
||||
)
|
||||
|
||||
# Check that the task state was updated
|
||||
self.task.invalidate_recordset()
|
||||
self.assertEqual(
|
||||
self.task.state,
|
||||
"02_changes_requested",
|
||||
"Task state should be updated to changes requested",
|
||||
)
|
||||
|
||||
# Check that a message was posted with the feedback
|
||||
messages = self.env["mail.message"].search(
|
||||
[
|
||||
("model", "=", "project.task"),
|
||||
("res_id", "=", self.task.id),
|
||||
("body", "ilike", "Need some changes"),
|
||||
]
|
||||
)
|
||||
self.assertTrue(
|
||||
messages, "A message with feedback should be posted on the task"
|
||||
)
|
||||
|
|
@ -1,10 +1,47 @@
|
|||
<?xml version="1.0" encoding="UTF-8" ?>
|
||||
<odoo>
|
||||
<template id="portal_my_task" inherit_id="project.portal_my_task">
|
||||
<xpath expr="//div[@id='task_content']" position="before">
|
||||
<div t-if="visit_confirmation_accepted" class="alert alert-success" role="alert">
|
||||
<strong>Success!</strong> We've received your confirmation. Thank you!
|
||||
<!-- Add success message when visit is approved -->
|
||||
<xpath expr="//div[@id='task_content']" position="inside">
|
||||
<div t-if="request.params.get('visit_confirmation_status') == 'approved'" class="alert alert-success" role="alert">
|
||||
<i class="fa fa-check-circle me-2"></i>
|
||||
<strong>Success!</strong> Thank you for approving this service visit.
|
||||
</div>
|
||||
<div t-if="request.params.get('visit_confirmation_status') == 'change_submitted'" class="alert alert-info" role="alert">
|
||||
<i class="fa fa-info-circle me-2"></i>
|
||||
<strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly.
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!-- Add change request form when needed -->
|
||||
<xpath expr="//div[@id='task_chat']" position="before">
|
||||
<div t-if="request.params.get('visit_confirmation_status') == 'change'" class="mb-4 mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header bg-warning text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fa fa-exclamation-triangle me-2"></i>
|
||||
Request Changes to Service Visit
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p>Please provide details about the changes you would like to make to this service visit:</p>
|
||||
<form action="/my/fsm_confirmation/submit_change" method="post">
|
||||
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
|
||||
<input type="hidden" name="access_token" t-att-value="request.params.get('access_token')"/>
|
||||
<input type="hidden" name="lang" t-att-value="request.context.get('lang') or request.params.get('lang')"/>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="feedback" class="form-label">Your Comments:</label>
|
||||
<textarea class="form-control" name="feedback" id="feedback" rows="4" required="required" placeholder="Please explain what changes you need..."></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-warning">
|
||||
Submit Request
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</xpath>
|
||||
</template>
|
||||
</odoo>
|
||||
</odoo>
|
||||
|
|
|
|||
13
fsm_visit_confirmation/views/project_task_type_views.xml
Normal file
13
fsm_visit_confirmation/views/project_task_type_views.xml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_project_task_type_form_inherit_fsm_visit_confirmation" model="ir.ui.view">
|
||||
<field name="name">project.task.type.form.inherit.fsm.visit.confirmation</field>
|
||||
<field name="model">project.task.type</field>
|
||||
<field name="inherit_id" ref="project.task_type_edit"/>
|
||||
<field name="arch" type="xml">
|
||||
<field name="mail_template_id" position="after">
|
||||
<field name="approval_template_id"/>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
1
interactive_discuss_ai/__init__.py
Normal file
1
interactive_discuss_ai/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
46
interactive_discuss_ai/__manifest__.py
Normal file
46
interactive_discuss_ai/__manifest__.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
'name': 'Interactive AI Voice Assistant',
|
||||
'version': '17.0.1.0.0',
|
||||
'category': 'Productivity/Communication',
|
||||
'summary': 'Natural Language Voice Interaction for Odoo using Open WebUI',
|
||||
'sequence': 1,
|
||||
'author': 'Benoît Vézina',
|
||||
'website': 'https://bemade.org',
|
||||
'maintainer': 'it@bemade.org',
|
||||
'license': 'LGPL-3',
|
||||
'description': """
|
||||
Interactive AI Voice Assistant for Odoo
|
||||
=====================================
|
||||
|
||||
This module enables natural language voice interaction with Odoo directly in the Discuss interface:
|
||||
- Voice input processing using Open WebUI models
|
||||
- Interactive AI chat assistant
|
||||
- Smart context-aware responses
|
||||
- Draft mode for new records creation
|
||||
""",
|
||||
'depends': [
|
||||
'base',
|
||||
'web',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'views/ai_assistant_views.xml',
|
||||
'views/ai_config_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'interactive_discuss_ai/static/src/js/ai_assistant_chat.js',
|
||||
'interactive_discuss_ai/static/src/xml/ai_assistant_chat.xml',
|
||||
],
|
||||
},
|
||||
'demo': [],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'auto_install': False,
|
||||
'external_dependencies': {
|
||||
'python': [
|
||||
'requests',
|
||||
],
|
||||
},
|
||||
}
|
||||
2
interactive_discuss_ai/models/__init__.py
Normal file
2
interactive_discuss_ai/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import ai_assistant
|
||||
from . import ai_config
|
||||
101
interactive_discuss_ai/models/ai_assistant.py
Normal file
101
interactive_discuss_ai/models/ai_assistant.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from odoo import models, fields, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class AIAssistantChannel(models.Model):
|
||||
_name = 'ai.assistant.channel'
|
||||
_description = 'AI Assistant Channel'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char(default="Assistant AI", readonly=True, tracking=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
tracking=True
|
||||
)
|
||||
active = fields.Boolean(default=True, tracking=True)
|
||||
user_id = fields.Many2one(
|
||||
'res.users',
|
||||
string='User',
|
||||
required=True,
|
||||
default=lambda self: self.env.user,
|
||||
tracking=True
|
||||
)
|
||||
message_ids = fields.One2many(
|
||||
'mail.message',
|
||||
'res_id',
|
||||
string='Messages',
|
||||
domain=lambda self: [('model', '=', self._name)],
|
||||
tracking=True
|
||||
)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
for record in records:
|
||||
# Create a dedicated discussion channel for the assistant
|
||||
channel = self.env['mail.channel'].create({
|
||||
'name': _('Assistant AI - %s', record.company_id.name),
|
||||
'channel_type': 'chat',
|
||||
'channel_partner_ids': [(4, record.user_id.partner_id.id)],
|
||||
})
|
||||
return records
|
||||
|
||||
def _get_conversation_history(self, limit=10):
|
||||
"""Get recent conversation history"""
|
||||
self.ensure_one()
|
||||
messages = self.env['mail.message'].search([
|
||||
('model', '=', self._name),
|
||||
('res_id', '=', self.id)
|
||||
], limit=limit, order='id desc')
|
||||
|
||||
history = []
|
||||
for msg in reversed(messages):
|
||||
role = "assistant" if msg.author_id == self.user_id.partner_id else "user"
|
||||
history.append({
|
||||
"role": role,
|
||||
"content": msg.body
|
||||
})
|
||||
return history
|
||||
|
||||
def process_voice_message(self, voice_input):
|
||||
"""Process voice input and respond"""
|
||||
self.ensure_one()
|
||||
if not voice_input:
|
||||
raise UserError(_('No voice input provided'))
|
||||
|
||||
# Get conversation history
|
||||
conversation_history = self._get_conversation_history()
|
||||
|
||||
# Use AI service to generate response
|
||||
ai_service = self.env['ai.service'].with_company(self.company_id)
|
||||
response = ai_service.generate_response(voice_input, conversation_history)
|
||||
|
||||
# Post response as message
|
||||
self.message_post(
|
||||
body=response,
|
||||
message_type='comment',
|
||||
subtype_xmlid='mail.mt_comment'
|
||||
)
|
||||
return True
|
||||
|
||||
@api.model
|
||||
def get_assistant_for_user(self, user_id=None):
|
||||
"""Get or create an assistant for the user in their company"""
|
||||
if not user_id:
|
||||
user_id = self.env.user.id
|
||||
|
||||
assistant = self.search([
|
||||
('user_id', '=', user_id),
|
||||
('company_id', '=', self.env.company.id),
|
||||
('active', '=', True)
|
||||
], limit=1)
|
||||
|
||||
if not assistant:
|
||||
assistant = self.create({
|
||||
'user_id': user_id,
|
||||
'company_id': self.env.company.id
|
||||
})
|
||||
|
||||
return assistant
|
||||
61
interactive_discuss_ai/models/ai_config.py
Normal file
61
interactive_discuss_ai/models/ai_config.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
from odoo import models, fields, api
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class AIConfig(models.Model):
|
||||
_name = 'ai.config'
|
||||
_description = 'AI Configuration'
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
name = fields.Char(string='Name', required=True, default='Default Config', tracking=True)
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
string='Company',
|
||||
required=True,
|
||||
default=lambda self: self.env.company,
|
||||
tracking=True
|
||||
)
|
||||
openwebui_url = fields.Char(
|
||||
string='Open WebUI URL',
|
||||
required=True,
|
||||
default='http://localhost:8080',
|
||||
tracking=True
|
||||
)
|
||||
model_name = fields.Char(
|
||||
string='Model Name',
|
||||
required=True,
|
||||
help='Nom du modèle à utiliser dans Open WebUI',
|
||||
tracking=True
|
||||
)
|
||||
system_prompt = fields.Text(
|
||||
string='System Prompt',
|
||||
default="""Tu es un assistant Odoo expert qui aide les utilisateurs à effectuer des actions
|
||||
dans le système. Analyse leurs demandes et propose des actions concrètes.""",
|
||||
tracking=True
|
||||
)
|
||||
temperature = fields.Float(
|
||||
string='Temperature',
|
||||
default=0.7,
|
||||
help='Contrôle la créativité des réponses (0.0 - 1.0)',
|
||||
tracking=True
|
||||
)
|
||||
max_tokens = fields.Integer(
|
||||
string='Max Tokens',
|
||||
default=2000,
|
||||
tracking=True
|
||||
)
|
||||
active = fields.Boolean(default=True, tracking=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('company_uniq', 'unique(company_id, active)',
|
||||
'Une seule configuration active par compagnie est autorisée!')
|
||||
]
|
||||
|
||||
@api.model
|
||||
def get_default_config(self):
|
||||
"""Récupère la configuration active pour la compagnie actuelle"""
|
||||
config = self.search([
|
||||
('active', '=', True),
|
||||
('company_id', '=', self.env.company.id)
|
||||
], limit=1)
|
||||
if not config:
|
||||
raise UserError('Aucune configuration AI active trouvée pour votre compagnie')
|
||||
62
interactive_discuss_ai/models/ai_service.py
Normal file
62
interactive_discuss_ai/models/ai_service.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import requests
|
||||
import json
|
||||
from odoo import models, tools
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class AIService(models.AbstractModel):
|
||||
_name = 'ai.service'
|
||||
_description = 'AI Service for Open WebUI Integration'
|
||||
|
||||
def _get_headers(self):
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
def _call_openwebui_api(self, endpoint, payload):
|
||||
"""Appelle l'API Open WebUI avec la configuration de la compagnie actuelle"""
|
||||
config = self.env['ai.config'].with_company(self.env.company).get_default_config()
|
||||
url = f"{config.openwebui_url}/api/v1/{endpoint}"
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self._get_headers(),
|
||||
json=payload,
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.exceptions.RequestException as e:
|
||||
raise UserError(f"Erreur de communication avec Open WebUI: {str(e)}")
|
||||
|
||||
def generate_response(self, user_input, conversation_history=None):
|
||||
"""Génère une réponse en utilisant le modèle Open WebUI de la compagnie"""
|
||||
config = self.env['ai.config'].with_company(self.env.company).get_default_config()
|
||||
|
||||
messages = []
|
||||
if config.system_prompt:
|
||||
messages.append({
|
||||
"role": "system",
|
||||
"content": config.system_prompt
|
||||
})
|
||||
|
||||
# Ajouter l'historique de conversation si disponible
|
||||
if conversation_history:
|
||||
messages.extend(conversation_history)
|
||||
|
||||
# Ajouter l'entrée utilisateur actuelle
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": user_input
|
||||
})
|
||||
|
||||
payload = {
|
||||
"model": config.model_name,
|
||||
"messages": messages,
|
||||
"temperature": config.temperature,
|
||||
"max_tokens": config.max_tokens,
|
||||
"stream": False
|
||||
}
|
||||
|
||||
response = self._call_openwebui_api('chat/completions', payload)
|
||||
return response.get('choices', [{}])[0].get('message', {}).get('content', '')
|
||||
3
interactive_discuss_ai/security/ir.model.access.csv
Normal file
3
interactive_discuss_ai/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_ai_assistant_channel_user,ai.assistant.channel.user,interactive_discuss_ai.model_ai_assistant_channel,base.group_user,1,1,1,1
|
||||
access_ai_config_user,ai.config.user,interactive_discuss_ai.model_ai_config,base.group_user,1,1,1,1
|
||||
|
52
interactive_discuss_ai/static/src/js/ai_assistant_chat.js
Normal file
52
interactive_discuss_ai/static/src/js/ai_assistant_chat.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/** @odoo-module **/
|
||||
|
||||
import { registerPatch } from '@mail/model/model_core';
|
||||
import { attr } from '@mail/model/model_field';
|
||||
import { clear } from '@mail/model/model_field_command';
|
||||
|
||||
registerPatch({
|
||||
name: 'mail.composer',
|
||||
fields: {
|
||||
isVoiceRecording: attr({
|
||||
default: false,
|
||||
}),
|
||||
},
|
||||
recordMethods: {
|
||||
async onClickVoiceRecord() {
|
||||
if (!this.isVoiceRecording) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
this.mediaRecorder = new MediaRecorder(stream);
|
||||
const audioChunks = [];
|
||||
|
||||
this.mediaRecorder.addEventListener('dataavailable', (event) => {
|
||||
audioChunks.push(event.data);
|
||||
});
|
||||
|
||||
this.mediaRecorder.addEventListener('stop', async () => {
|
||||
const audioBlob = new Blob(audioChunks);
|
||||
// Convert to base64 and send to backend
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(audioBlob);
|
||||
reader.onloadend = async () => {
|
||||
const base64data = reader.result;
|
||||
await this.env.services.rpc({
|
||||
model: 'ai.assistant.channel',
|
||||
method: 'process_voice_message',
|
||||
args: [this.thread.id, base64data],
|
||||
});
|
||||
};
|
||||
});
|
||||
|
||||
this.mediaRecorder.start();
|
||||
this.update({ isVoiceRecording: true });
|
||||
} catch (err) {
|
||||
console.error('Error accessing microphone:', err);
|
||||
}
|
||||
} else {
|
||||
this.mediaRecorder.stop();
|
||||
this.update({ isVoiceRecording: false });
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
12
interactive_discuss_ai/static/src/xml/ai_assistant_chat.xml
Normal file
12
interactive_discuss_ai/static/src/xml/ai_assistant_chat.xml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-inherit="mail.Composer" t-inherit-mode="extension">
|
||||
<xpath expr="//div[hasclass('o-mail-Composer-buttons')]" position="inside">
|
||||
<button class="btn btn-light"
|
||||
t-on-click="onClickVoiceRecord"
|
||||
t-att-class="{ 'btn-danger': isVoiceRecording }">
|
||||
<i class="fa fa-microphone" t-att-class="{ 'fa-pulse': isVoiceRecording }"/>
|
||||
</button>
|
||||
</xpath>
|
||||
</t>
|
||||
</templates>
|
||||
86
interactive_discuss_ai/views/ai_assistant_views.xml
Normal file
86
interactive_discuss_ai/views/ai_assistant_views.xml
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- AI Assistant Channel Form View -->
|
||||
<record id="view_ai_assistant_channel_form" model="ir.ui.view">
|
||||
<field name="name">ai.assistant.channel.form</field>
|
||||
<field name="model">ai.assistant.channel</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="AI Assistant Channel">
|
||||
<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>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</group>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Assistant Channel Tree View -->
|
||||
<record id="view_ai_assistant_channel_tree" model="ir.ui.view">
|
||||
<field name="name">ai.assistant.channel.tree</field>
|
||||
<field name="model">ai.assistant.channel</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree string="AI Assistant Channels">
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Assistant Channel Search View -->
|
||||
<record id="view_ai_assistant_channel_search" model="ir.ui.view">
|
||||
<field name="name">ai.assistant.channel.search</field>
|
||||
<field name="model">ai.assistant.channel</field>
|
||||
<field name="arch" type="xml">
|
||||
<search string="Search AI Assistant Channels">
|
||||
<field name="name"/>
|
||||
<field name="user_id"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<separator/>
|
||||
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- AI Assistant Channel Action -->
|
||||
<record id="action_ai_assistant_channel" model="ir.actions.act_window">
|
||||
<field name="name">AI Assistant Channels</field>
|
||||
<field name="res_model">ai.assistant.channel</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="view_ai_assistant_channel_search"/>
|
||||
<field name="help" type="html">
|
||||
<p class="o_view_nocontent_smiling_face">
|
||||
Create your first AI Assistant Channel!
|
||||
</p>
|
||||
<p>
|
||||
AI Assistant Channels allow you to interact with the AI assistant through messages.
|
||||
</p>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Menu Items -->
|
||||
<menuitem id="menu_ai_assistant"
|
||||
name="AI Assistant"
|
||||
web_icon="interactive_discuss_ai,static/description/icon.png"
|
||||
sequence="10"/>
|
||||
|
||||
<menuitem id="menu_ai_assistant_channel"
|
||||
name="Assistant Channels"
|
||||
parent="menu_ai_assistant"
|
||||
action="action_ai_assistant_channel"
|
||||
sequence="10"/>
|
||||
</odoo>
|
||||
83
interactive_discuss_ai/views/ai_config_views.xml
Normal file
83
interactive_discuss_ai/views/ai_config_views.xml
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_ai_config_form" model="ir.ui.view">
|
||||
<field name="name">ai.config.form</field>
|
||||
<field name="model">ai.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<form>
|
||||
<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"/>
|
||||
</button>
|
||||
</div>
|
||||
<group>
|
||||
<group>
|
||||
<field name="name"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="openwebui_url"/>
|
||||
<field name="model_name"/>
|
||||
<field name="active"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="temperature"/>
|
||||
<field name="max_tokens"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="System Prompt">
|
||||
<field name="system_prompt" nolabel="1"/>
|
||||
</group>
|
||||
</sheet>
|
||||
<div class="oe_chatter">
|
||||
<field name="message_follower_ids"/>
|
||||
<field name="message_ids"/>
|
||||
</div>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ai_config_tree" model="ir.ui.view">
|
||||
<field name="name">ai.config.tree</field>
|
||||
<field name="model">ai.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<tree>
|
||||
<field name="name"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="model_name"/>
|
||||
<field name="openwebui_url"/>
|
||||
<field name="active"/>
|
||||
</tree>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="view_ai_config_search" model="ir.ui.view">
|
||||
<field name="name">ai.config.search</field>
|
||||
<field name="model">ai.config</field>
|
||||
<field name="arch" type="xml">
|
||||
<search>
|
||||
<field name="name"/>
|
||||
<field name="company_id" groups="base.group_multi_company"/>
|
||||
<field name="model_name"/>
|
||||
<separator/>
|
||||
<filter string="Archivé" name="inactive" domain="[('active', '=', False)]"/>
|
||||
<group expand="0" string="Group By">
|
||||
<filter string="Compagnie" name="company" domain="[]" context="{'group_by': 'company_id'}"/>
|
||||
</group>
|
||||
</search>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_ai_config" model="ir.actions.act_window">
|
||||
<field name="name">Configuration AI</field>
|
||||
<field name="res_model">ai.config</field>
|
||||
<field name="view_mode">tree,form</field>
|
||||
<field name="search_view_id" ref="view_ai_config_search"/>
|
||||
<field name="context">{'search_default_active': 1}</field>
|
||||
</record>
|
||||
|
||||
<menuitem id="menu_ai_config"
|
||||
name="Configuration AI"
|
||||
action="action_ai_config"
|
||||
parent="base.menu_administration"
|
||||
sequence="100"/>
|
||||
</odoo>
|
||||
117
mail_loop_prevention/README.md
Normal file
117
mail_loop_prevention/README.md
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
# Mail Loop Prevention
|
||||
|
||||
## Overview
|
||||
|
||||
This module prevents communication loops that can occur when two mail servers auto-reply to each other indefinitely. This commonly happens with:
|
||||
|
||||
- Delivery receipt acknowledgements
|
||||
- Out-of-office auto-replies
|
||||
- Automated notification systems
|
||||
- Email bounce handlers
|
||||
|
||||
## How It Works
|
||||
|
||||
The module overrides `mail.thread._message_create()` to detect potential loops by:
|
||||
|
||||
1. **Content Hashing**: Creates a hash of message body and subject (unescapes HTML and normalizes whitespace)
|
||||
2. **Time Window Check**: Looks for identical messages within a configurable time window (default: 48 hours)
|
||||
3. **Threshold Detection**: Blocks messages if the number of identical messages exceeds a threshold (default: 3)
|
||||
4. **Smart Filtering**: Only checks automated messages (notification, auto_comment, email types), never blocks user comments
|
||||
|
||||
## Configuration
|
||||
|
||||
The module uses system parameters that can be configured via Settings > Technical > Parameters > System Parameters:
|
||||
|
||||
| Parameter | Default | Valid Range | Description |
|
||||
|-----------|---------|-------------|-------------|
|
||||
| `mail_loop_prevention.enabled` | `True` | `True`/`False` | Enable/disable loop prevention |
|
||||
| `mail_loop_prevention.time_window_hours` | `48` | `1` to `720` | Time window in hours to check for duplicates (max 30 days) |
|
||||
| `mail_loop_prevention.max_identical_messages` | `3` | `2` to `100` | Maximum identical messages allowed before blocking |
|
||||
|
||||
### Configuration Validation
|
||||
|
||||
The module validates all configuration parameters when they are set:
|
||||
|
||||
- **enabled**: Accepts `True`/`False`, `1`/`0`, `yes`/`no`, `on`/`off` (case-insensitive)
|
||||
- **time_window_hours**: Must be an integer between 1 and 720 (30 days)
|
||||
- **max_identical_messages**: Must be an integer between 2 and 100
|
||||
|
||||
Invalid values will raise a `ValidationError` and prevent the parameter from being saved.
|
||||
|
||||
## Example Scenario
|
||||
|
||||
**Without this module:**
|
||||
```
|
||||
Server A → Auto-reply to Server B
|
||||
Server B → Auto-reply to Server A
|
||||
Server A → Auto-reply to Server B
|
||||
Server B → Auto-reply to Server A
|
||||
... (infinite loop)
|
||||
```
|
||||
|
||||
**With this module:**
|
||||
```
|
||||
Server A → Auto-reply to Server B (1st message - allowed)
|
||||
Server B → Auto-reply to Server A (1st message - allowed)
|
||||
Server A → Auto-reply to Server B (2nd message - allowed)
|
||||
Server B → Auto-reply to Server A (2nd message - allowed)
|
||||
Server A → Auto-reply to Server B (3rd message - allowed)
|
||||
Server B → Auto-reply to Server A (3rd message - allowed)
|
||||
Server A → Auto-reply to Server B (4th message - BLOCKED)
|
||||
```
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Message Types Checked
|
||||
|
||||
The module only checks these message types for loops:
|
||||
- `notification` - System notifications
|
||||
- `auto_comment` - Automated comments
|
||||
- `email` - Email messages
|
||||
|
||||
Regular `comment` type messages (user posts) are never blocked.
|
||||
|
||||
### Duplicate Detection
|
||||
|
||||
Messages are considered identical if they have the same:
|
||||
- Body content (after unescaping HTML entities and normalizing whitespace)
|
||||
- Subject line
|
||||
|
||||
Real-world loops send **exactly the same message** repeatedly, so direct content comparison is sufficient.
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
- Only checks messages within the configured time window
|
||||
- Uses SHA-256 hashing for efficient comparison
|
||||
- Minimal database queries (uses existing message_ids relation)
|
||||
|
||||
## Testing
|
||||
|
||||
Run the test suite:
|
||||
```bash
|
||||
odoo-bin -c odoo.conf -d test_db -i mail_loop_prevention --test-enable --stop-after-init
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Legitimate messages being blocked
|
||||
|
||||
If legitimate automated messages are being blocked:
|
||||
|
||||
1. Check the logs for "Loop prevention: Blocking duplicate message" warnings
|
||||
2. Increase `mail_loop_prevention.max_identical_messages` threshold
|
||||
3. Reduce `mail_loop_prevention.time_window_hours` if the messages are spread over time
|
||||
4. Temporarily disable with `mail_loop_prevention.enabled = False` to diagnose
|
||||
|
||||
### Loops still occurring
|
||||
|
||||
If loops are still happening:
|
||||
|
||||
1. Verify the module is installed and enabled
|
||||
2. Check that `mail_loop_prevention.enabled = True`
|
||||
3. Reduce the `max_identical_messages` threshold
|
||||
4. Check if the messages have varying content (different subjects/bodies)
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
1
mail_loop_prevention/__init__.py
Normal file
1
mail_loop_prevention/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
17
mail_loop_prevention/__manifest__.py
Normal file
17
mail_loop_prevention/__manifest__.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Copyright 2025 DurPro
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
{
|
||||
"name": "Mail Loop Prevention",
|
||||
"summary": "Prevent auto-reply communication loops between mail servers",
|
||||
"version": "17.0.1.0.0",
|
||||
"license": "LGPL-3",
|
||||
"author": "Bemade Inc",
|
||||
"website": "https://github.com/durpro/durpro",
|
||||
"depends": ["mail"],
|
||||
"data": [
|
||||
"views/res_config_settings_views.xml",
|
||||
],
|
||||
"installable": True,
|
||||
"application": False,
|
||||
}
|
||||
2
mail_loop_prevention/models/__init__.py
Normal file
2
mail_loop_prevention/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import mail_thread
|
||||
from . import res_config_settings
|
||||
162
mail_loop_prevention/models/mail_thread.py
Normal file
162
mail_loop_prevention/models/mail_thread.py
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
# Copyright 2025 DurPro
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, models
|
||||
from odoo.tools import html2plaintext
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MailThread(models.AbstractModel):
|
||||
_inherit = "mail.thread"
|
||||
|
||||
@api.model
|
||||
def _get_loop_prevention_config(self):
|
||||
"""
|
||||
Get loop prevention configuration from system parameters.
|
||||
Values are validated by ir.config_parameter model on write.
|
||||
"""
|
||||
ICP = self.env["ir.config_parameter"].sudo()
|
||||
return {
|
||||
"enabled": ICP.get_param(
|
||||
"mail_loop_prevention.enabled", default="True"
|
||||
) == "True",
|
||||
"time_window_hours": int(
|
||||
ICP.get_param("mail_loop_prevention.time_window_hours", default="48")
|
||||
),
|
||||
"max_identical_messages": int(
|
||||
ICP.get_param("mail_loop_prevention.max_identical_messages", default="3")
|
||||
),
|
||||
}
|
||||
|
||||
def _compute_message_hash(self, body, subject=None):
|
||||
"""
|
||||
Compute a hash of the message content for duplicate detection.
|
||||
Converts HTML to plaintext to ignore formatting differences.
|
||||
"""
|
||||
import html
|
||||
|
||||
# Unescape HTML entities (< -> <, > -> >, etc.)
|
||||
# This is needed because message_post escapes the body before passing to _message_create
|
||||
unescaped_body = html.unescape(body or "")
|
||||
|
||||
# Convert HTML to plaintext (handles extra wrapper tags that Odoo adds)
|
||||
text_body = html2plaintext(unescaped_body).strip()
|
||||
text_subject = (subject or "").strip()
|
||||
|
||||
# Combine subject and body for hash
|
||||
content = f"{text_subject}|{text_body}"
|
||||
|
||||
# Debug logging to help troubleshoot loop detection
|
||||
if _logger.isEnabledFor(logging.DEBUG):
|
||||
_logger.debug(
|
||||
"Loop prevention: Computing hash - plaintext=%r, hash=%s",
|
||||
text_body[:100] if text_body else "",
|
||||
hashlib.sha256(content.encode("utf-8")).hexdigest()[:16],
|
||||
)
|
||||
|
||||
# Create hash
|
||||
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
||||
|
||||
def _check_message_loop(self, body, subject=None, message_type="comment"):
|
||||
"""
|
||||
Check if posting this message would create a loop.
|
||||
Returns True if the message should be blocked, False otherwise.
|
||||
"""
|
||||
config = self._get_loop_prevention_config()
|
||||
|
||||
if not config["enabled"]:
|
||||
return False
|
||||
|
||||
# Only check for automated messages (notifications, auto_comment, etc.)
|
||||
# Don't block regular user comments
|
||||
if message_type not in ("notification", "auto_comment", "email"):
|
||||
return False
|
||||
|
||||
# Compute hash of the new message
|
||||
message_hash = self._compute_message_hash(body, subject)
|
||||
|
||||
# Calculate time threshold
|
||||
time_threshold = self.env.cr.now() - timedelta(
|
||||
hours=config["time_window_hours"]
|
||||
)
|
||||
|
||||
# Search for identical messages in the time window
|
||||
identical_count = 0
|
||||
for record in self:
|
||||
if not record.message_ids:
|
||||
continue
|
||||
|
||||
identical_count = 0
|
||||
for message in record.message_ids:
|
||||
# Skip messages outside time window
|
||||
if message.date < time_threshold:
|
||||
continue
|
||||
|
||||
# Check if message content matches
|
||||
msg_hash = self._compute_message_hash(message.body, message.subject)
|
||||
if msg_hash == message_hash:
|
||||
identical_count += 1
|
||||
|
||||
# If we've hit the limit, block the message
|
||||
if identical_count >= config["max_identical_messages"]:
|
||||
_logger.warning(
|
||||
"Loop prevention: Blocking duplicate message on %s (id=%s). "
|
||||
"Found %d identical messages in the last %d hours.",
|
||||
record._name,
|
||||
record.id,
|
||||
identical_count,
|
||||
config["time_window_hours"],
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _message_create(self, values_list):
|
||||
"""
|
||||
Override _message_create to check for message loops before creating messages.
|
||||
This is safer than overriding message_post as we can cleanly filter out
|
||||
messages that would create loops.
|
||||
"""
|
||||
filtered_values_list = []
|
||||
|
||||
for values in values_list:
|
||||
body = values.get("body", "")
|
||||
subject = values.get("subject", "")
|
||||
message_type = values.get("message_type", "notification")
|
||||
model = values.get("model")
|
||||
res_id = values.get("res_id")
|
||||
|
||||
# Get the record to check its message history
|
||||
if model and res_id:
|
||||
try:
|
||||
record = self.env[model].browse(res_id)
|
||||
# All models with mail.thread mixin have _check_message_loop
|
||||
if hasattr(record, '_check_message_loop'):
|
||||
should_block = record._check_message_loop(body, subject, message_type)
|
||||
if should_block:
|
||||
_logger.warning(
|
||||
"Loop prevention: Skipping message creation on %s (id=%s) "
|
||||
"to prevent communication loop",
|
||||
model,
|
||||
res_id,
|
||||
)
|
||||
continue # Skip this message
|
||||
except Exception as e:
|
||||
# If we can't check, allow the message (fail open)
|
||||
_logger.warning(
|
||||
"Loop prevention: Error checking message loop, allowing message: %s",
|
||||
e,
|
||||
)
|
||||
|
||||
filtered_values_list.append(values)
|
||||
|
||||
# Create only the messages that passed the loop check
|
||||
if not filtered_values_list:
|
||||
return self.env["mail.message"]
|
||||
|
||||
return super()._message_create(filtered_values_list)
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue