Compare commits
52 commits
18.0
...
fix/durpro
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f951494834 | ||
|
|
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 |
205 changed files with 15640 additions and 468 deletions
|
|
@ -20,7 +20,7 @@
|
||||||
########################################################################################
|
########################################################################################
|
||||||
{
|
{
|
||||||
"name": "Improved Field Service Management",
|
"name": "Improved Field Service Management",
|
||||||
"version": "17.0.0.4.2",
|
"version": "17.0.0.4.1",
|
||||||
"summary": (
|
"summary": (
|
||||||
"Adds functionality necessary for managing field service operations at Durpro."
|
"Adds functionality necessary for managing field service operations at Durpro."
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,8 @@ class SaleOrder(models.Model):
|
||||||
_inherit = "sale.order"
|
_inherit = "sale.order"
|
||||||
|
|
||||||
valid_equipment_ids = fields.One2many(
|
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(
|
default_equipment_ids = fields.Many2many(
|
||||||
|
|
@ -50,7 +51,6 @@ class SaleOrder(models.Model):
|
||||||
store=True,
|
store=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@api.depends("order_line.task_id")
|
|
||||||
def get_relevant_order_lines(self, task_id):
|
def get_relevant_order_lines(self, task_id):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
linked_lines = self.order_line.filtered(
|
linked_lines = self.order_line.filtered(
|
||||||
|
|
|
||||||
|
|
@ -111,14 +111,16 @@ class SaleOrderLine(models.Model):
|
||||||
for t in template.subtasks:
|
for t in template.subtasks:
|
||||||
subtask = _create_task_from_template(project, t, task)
|
subtask = _create_task_from_template(project, t, task)
|
||||||
subtasks.append(subtask)
|
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
|
# We don't want to see the sub-tasks on the SO
|
||||||
task.child_ids.write(
|
if task.child_ids:
|
||||||
{
|
task.child_ids.write(
|
||||||
"sale_order_id": None,
|
{
|
||||||
"sale_line_id": None,
|
"sale_order_id": None,
|
||||||
}
|
"sale_line_id": None,
|
||||||
)
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return task
|
return task
|
||||||
|
|
||||||
def _timesheet_create_task_prepare_values_from_template(
|
def _timesheet_create_task_prepare_values_from_template(
|
||||||
|
|
@ -142,7 +144,11 @@ class SaleOrderLine(models.Model):
|
||||||
vals["tag_ids"] = template.tags.ids
|
vals["tag_ids"] = template.tags.ids
|
||||||
vals["allocated_hours"] = template.planned_hours
|
vals["allocated_hours"] = template.planned_hours
|
||||||
vals["sequence"] = template.sequence
|
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:
|
if template.equipment_ids:
|
||||||
vals["equipment_ids"] = template.equipment_ids.ids
|
vals["equipment_ids"] = template.equipment_ids.ids
|
||||||
return vals
|
return vals
|
||||||
|
|
@ -150,6 +156,9 @@ class SaleOrderLine(models.Model):
|
||||||
tmpl = self.product_id.task_template_id
|
tmpl = self.product_id.task_template_id
|
||||||
if not tmpl:
|
if not tmpl:
|
||||||
task = super()._timesheet_create_task(project)
|
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:
|
else:
|
||||||
task = _create_task_from_template(project, tmpl, None)
|
task = _create_task_from_template(project, tmpl, None)
|
||||||
self.write({"task_id": task.id})
|
self.write({"task_id": task.id})
|
||||||
|
|
@ -163,6 +172,7 @@ class SaleOrderLine(models.Model):
|
||||||
"product_name": self.product_id.name,
|
"product_name": self.product_id.name,
|
||||||
}
|
}
|
||||||
task.message_post(body=task_msg)
|
task.message_post(body=task_msg)
|
||||||
|
|
||||||
if not task.equipment_ids and self.equipment_ids:
|
if not task.equipment_ids and self.equipment_ids:
|
||||||
task.equipment_ids = self.equipment_ids.ids
|
task.equipment_ids = self.equipment_ids.ids
|
||||||
return task
|
return task
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ from odoo.addons.project.models.project_task import CLOSED_STATES
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class Task(models.Model):
|
class Task(models.Model):
|
||||||
_inherit = "project.task"
|
_inherit = "project.task"
|
||||||
|
|
||||||
|
|
@ -65,6 +67,7 @@ class Task(models.Model):
|
||||||
res = super().create(vals)
|
res = super().create(vals)
|
||||||
for rec in res:
|
for rec in res:
|
||||||
if rec.parent_id and rec.is_fsm:
|
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
|
rec.partner_id = rec.parent_id.partner_id
|
||||||
if not rec.work_order_contacts and rec.parent_id:
|
if not rec.work_order_contacts and rec.parent_id:
|
||||||
rec.work_order_contacts = rec.parent_id.work_order_contacts
|
rec.work_order_contacts = rec.parent_id.work_order_contacts
|
||||||
|
|
@ -123,7 +126,8 @@ class Task(models.Model):
|
||||||
)
|
)
|
||||||
if "partner_id" in vals:
|
if "partner_id" in vals:
|
||||||
child_vals.update(partner_id=vals["partner_id"])
|
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
|
return res
|
||||||
|
|
||||||
@api.depends("sale_order_id")
|
@api.depends("sale_order_id")
|
||||||
|
|
@ -220,3 +224,42 @@ class Task(models.Model):
|
||||||
def _compute_root_ancestor(self):
|
def _compute_root_ancestor(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
rec.root_ancestor = rec.parent_id and rec.parent_id.root_ancestor or 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-esc="doc.partner_id"
|
||||||
t-options='{
|
t-options='{
|
||||||
"widget": "contact",
|
"widget": "contact",
|
||||||
"fields": ["name", "address",]
|
"fields": ["name", "address",],
|
||||||
|
"lang": "fr_FR"
|
||||||
}'
|
}'
|
||||||
/>
|
/>
|
||||||
</t>
|
</t>
|
||||||
|
|
@ -361,11 +362,11 @@
|
||||||
>
|
>
|
||||||
<div t-if="doc.planned_date_begin"><h6>Planned start: </h6></div>
|
<div t-if="doc.planned_date_begin"><h6>Planned start: </h6></div>
|
||||||
<div class="mb-3">
|
<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>
|
||||||
<div t-if="doc.date_deadline"><h6>Planned end: </h6></div>
|
<div t-if="doc.date_deadline"><h6>Planned end: </h6></div>
|
||||||
<div class="mb-3">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -563,12 +564,9 @@
|
||||||
<template id="work_order">
|
<template id="work_order">
|
||||||
<t t-call="web.html_container">
|
<t t-call="web.html_container">
|
||||||
<t t-foreach="docs" t-as="doc">
|
<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-call="web.external_layout">
|
<t t-call="web.external_layout">
|
||||||
<t
|
<t t-call="bemade_fsm.work_order_page"/>
|
||||||
t-call="bemade_fsm.work_order_page"
|
|
||||||
t-lang="doc.partner_id.lang"
|
|
||||||
/>
|
|
||||||
</t>
|
</t>
|
||||||
</t>
|
</t>
|
||||||
</t>
|
</t>
|
||||||
|
|
@ -582,7 +580,7 @@
|
||||||
expr="//t[@t-call='industry_fsm_report.worksheet_custom_page']"
|
expr="//t[@t-call='industry_fsm_report.worksheet_custom_page']"
|
||||||
position="replace"
|
position="replace"
|
||||||
>
|
>
|
||||||
<div t-call="bemade_fsm.work_order_page" />
|
<t t-call="bemade_fsm.work_order_page" t-lang="(doc.work_order_contacts and doc.work_order_contacts[0].lang) or 'en_US'"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
</template>
|
</template>
|
||||||
</odoo>
|
</odoo>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ class BemadeFSMBaseTest(TransactionCase):
|
||||||
user_group_fsm_user = cls.env.ref("industry_fsm.group_fsm_user")
|
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_user = cls.env.ref("sales_team.group_sale_salesman")
|
||||||
user_group_sales_manager = cls.env.ref("sales_team.group_sale_manager")
|
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(
|
user_product_customer = cls.env.ref(
|
||||||
"customer_product_code.group_product_customer_code_user",
|
"customer_product_code.group_product_customer_code_user",
|
||||||
raise_if_not_found=False,
|
raise_if_not_found=False,
|
||||||
|
|
@ -53,6 +56,7 @@ class BemadeFSMBaseTest(TransactionCase):
|
||||||
user_group_fsm_user.id,
|
user_group_fsm_user.id,
|
||||||
user_group_sales_manager.id,
|
user_group_sales_manager.id,
|
||||||
user_group_sales_user.id,
|
user_group_sales_user.id,
|
||||||
|
user_group_delivery_address.id,
|
||||||
]
|
]
|
||||||
if user_product_customer:
|
if user_product_customer:
|
||||||
group_ids.append(user_product_customer.id)
|
group_ids.append(user_product_customer.id)
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ class TestEquipment(BemadeFSMBaseTest):
|
||||||
partner = self._generate_partner()
|
partner = self._generate_partner()
|
||||||
partner_2 = self._generate_partner()
|
partner_2 = self._generate_partner()
|
||||||
equipment_1 = self._generate_equipment(partner=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)
|
sale_order = self._generate_sale_order(partner=partner)
|
||||||
product = self._generate_product()
|
product = self._generate_product()
|
||||||
self.assertEqual(sale_order.valid_equipment_ids, equipment_1)
|
self.assertEqual(sale_order.valid_equipment_ids, equipment_1)
|
||||||
|
|
|
||||||
|
|
@ -142,3 +142,55 @@ class FSMVisitTest(BemadeFSMBaseTest):
|
||||||
|
|
||||||
supposed_name = "SVR12345-1 - Test Company - Test Label"
|
supposed_name = "SVR12345-1 - Test Company - Test Label"
|
||||||
self.assertEqual(task.name, supposed_name)
|
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()
|
parent = self._generate_partner()
|
||||||
child = self._generate_partner(parent=parent)
|
child = self._generate_partner(parent=parent)
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
self._generate_equipment(child)
|
self._generate_equipment(partner=child)
|
||||||
|
|
||||||
sale_order = self._generate_sale_order(partner=parent)
|
sale_order = self._generate_sale_order(partner=parent)
|
||||||
|
|
||||||
|
|
@ -139,11 +139,11 @@ class TestSalesOrder(BemadeFSMBaseTest):
|
||||||
parent = self._generate_partner()
|
parent = self._generate_partner()
|
||||||
child = self._generate_partner(parent=parent)
|
child = self._generate_partner(parent=parent)
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
self._generate_equipment(child)
|
self._generate_equipment(partner=child)
|
||||||
|
|
||||||
sale_order = self._generate_sale_order(partner=parent)
|
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):
|
def test_sale_order_resets_default_equipment_on_partner_change(self):
|
||||||
partner_1 = self._generate_partner()
|
partner_1 = self._generate_partner()
|
||||||
|
|
@ -360,4 +360,115 @@ class TestSalesOrder(BemadeFSMBaseTest):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
for task in parent_task._get_all_subtasks() | parent_task:
|
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 = \
|
line.purchase_price_actual = \
|
||||||
(stock_missing * line.purchase_price_vendor
|
(stock_missing * line.purchase_price_vendor
|
||||||
+ qty_from_stock * line.purchase_price) \
|
+ 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.gross_profit = line.price_subtotal - (
|
||||||
line.purchase_price_actual * line.product_uom_qty)
|
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:
|
def _determine_missing_stock(self) -> float:
|
||||||
""" Compute how much stock is missing to meet an order line's demand. In the
|
""" 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"),
|
("specific", "Specific Vendors"),
|
||||||
],
|
],
|
||||||
default="all",
|
default="all",
|
||||||
help=(
|
help="Choose whether to apply overdue warnings to all vendors or only to specific vendors.",
|
||||||
"Choose whether to apply overdue warnings to all vendors or only to "
|
|
||||||
"specific vendors."
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
warn_supplier_specific_ids = fields.Many2many(
|
warn_supplier_specific_ids = fields.Many2many(
|
||||||
|
|
|
||||||
|
|
@ -54,8 +54,22 @@ Technical Details
|
||||||
Change Log
|
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.
|
* Fixed an issue where synchronizing events created duplicate events on every sync.
|
||||||
* Completely revamped and synchronization of recurring events in both directions.
|
* Completely revamped and synchronization of recurring events in both directions.
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "CalDAV Synchronization",
|
"name": "CalDAV Synchronization",
|
||||||
"version": "17.0.0.6.0",
|
"version": "17.0.0.8.0",
|
||||||
"license": "LGPL-3",
|
"license": "LGPL-3",
|
||||||
"category": "Productivity",
|
"category": "Productivity",
|
||||||
"summary": "Synchronize Odoo Calendar Events with CalDAV Servers",
|
"summary": "Synchronize Odoo Calendar Events with CalDAV Servers",
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,4 @@
|
||||||
from . import calendar_event
|
from . import calendar_event
|
||||||
from . import res_users
|
from . import res_users
|
||||||
from . import calendar_recurrence
|
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
|
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
|
from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
|
||||||
import caldav
|
import caldav
|
||||||
|
from caldav.lib.error import NotFoundError
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event
|
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event, vDate
|
||||||
import re
|
import re
|
||||||
from pytz import timezone, utc
|
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
|
from markdownify import markdownify as md
|
||||||
import markdown2 as md2
|
import markdown2 as md2
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_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 = {
|
WEEKDAY_MAP = {
|
||||||
0: "MO",
|
0: "MO",
|
||||||
1: "TU",
|
1: "TU",
|
||||||
|
|
@ -26,35 +36,59 @@ WEEKDAY_MAP = {
|
||||||
6: "SU",
|
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):
|
Takes a string like "RRULE:FREQ=WEEKLY;UNTIL=20221231T000000Z;BYDAY=MO"
|
||||||
def try_to_int(part):
|
and returns a dictionary with proper types for vRecur.
|
||||||
try:
|
"""
|
||||||
return int(part)
|
from icalendar import vDDDTypes, vWeekday, vFrequency
|
||||||
except Exception:
|
|
||||||
return part
|
|
||||||
|
|
||||||
regex_str = "RRULE:(.*)$"
|
def parse_value(key: str, value: str) -> Any:
|
||||||
regex = re.compile(regex_str)
|
if key == "UNTIL":
|
||||||
params_match = regex.search(rrule_str)
|
# Convert to datetime and wrap in vDDDTypes
|
||||||
params_part = params_match.groups()[0]
|
if "T" in value:
|
||||||
params = params_part.split(";")
|
dt = datetime.strptime(value, "%Y%m%dT%H%M%S")
|
||||||
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")
|
|
||||||
else:
|
else:
|
||||||
parts[1] = datetime.strptime(parts[1], "%Y%m%d")
|
dt = datetime.strptime(value, "%Y%m%d")
|
||||||
if parts[0].upper() == "BYDAY":
|
return vDDDTypes(dt)
|
||||||
parts[1] = [part for part in parts[1].split(",")]
|
elif key in ("WKST", "BYDAY", "BYWEEKDAY"):
|
||||||
params_dict.update({parts[0]: try_to_int(parts[1])})
|
# Convert to vWeekday
|
||||||
return params_dict
|
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):
|
def _extract_vcal_email(vcal_address):
|
||||||
|
|
@ -133,19 +167,6 @@ class CalendarEvent(models.Model):
|
||||||
or not event.follow_recurrence
|
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")
|
@api.depends("recurrency", "recurrence_id", "recurrence_id.base_event_id")
|
||||||
def _compute_is_base_event(self):
|
def _compute_is_base_event(self):
|
||||||
for rec in self:
|
for rec in self:
|
||||||
|
|
@ -195,7 +216,9 @@ class CalendarEvent(models.Model):
|
||||||
try:
|
try:
|
||||||
caldav_events = event._create_in_icalendar(calendar)
|
caldav_events = event._create_in_icalendar(calendar)
|
||||||
for caldav_event in caldav_events:
|
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(
|
event.with_context(caldav_no_sync=True).write(
|
||||||
{"caldav_uid": caldav_uid}
|
{"caldav_uid": caldav_uid}
|
||||||
)
|
)
|
||||||
|
|
@ -223,6 +246,11 @@ class CalendarEvent(models.Model):
|
||||||
calendar = client.calendar(url=user.caldav_calendar_url)
|
calendar = client.calendar(url=user.caldav_calendar_url)
|
||||||
|
|
||||||
base_event = self._get_caldav_base_event_by_uid(calendar, self.caldav_uid)
|
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:
|
if self.recurrence_id:
|
||||||
tz = timezone(self.event_tz or self.env.user.tz)
|
tz = timezone(self.event_tz or self.env.user.tz)
|
||||||
start = utc.localize(self.start).astimezone(tz)
|
start = utc.localize(self.start).astimezone(tz)
|
||||||
|
|
@ -259,12 +287,14 @@ class CalendarEvent(models.Model):
|
||||||
|
|
||||||
def _update_base_caldav_event(
|
def _update_base_caldav_event(
|
||||||
self,
|
self,
|
||||||
calendar: icalendar.cal.Calendar,
|
calendar: caldav.Calendar,
|
||||||
event: caldav.Event,
|
event: caldav.Event,
|
||||||
ical_event_data: dict,
|
ical_event_data: dict,
|
||||||
):
|
):
|
||||||
if event:
|
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()
|
event.save()
|
||||||
else:
|
else:
|
||||||
calendar.add_event(**ical_event_data)
|
calendar.add_event(**ical_event_data)
|
||||||
|
|
@ -280,9 +310,11 @@ class CalendarEvent(models.Model):
|
||||||
|
|
||||||
def _get_caldav_base_event_by_uid(
|
def _get_caldav_base_event_by_uid(
|
||||||
self, calendar: caldav.Calendar, uid: str
|
self, calendar: caldav.Calendar, uid: str
|
||||||
) -> Optional[CalendarEvent]:
|
) -> Optional[caldav.Event]:
|
||||||
for event in calendar.events():
|
for event in calendar.events():
|
||||||
component = event.icalendar_component
|
component = (
|
||||||
|
event.icalendar_component
|
||||||
|
) # pyright: ignore[reportAttributeAccessIssue]
|
||||||
event_uid = self._extract_component_text(component, "uid")
|
event_uid = self._extract_component_text(component, "uid")
|
||||||
if event_uid == uid and not component.get("recurrence-id"):
|
if event_uid == uid and not component.get("recurrence-id"):
|
||||||
return event
|
return event
|
||||||
|
|
@ -315,6 +347,7 @@ class CalendarEvent(models.Model):
|
||||||
calendar = client.calendar(url=user.caldav_calendar_url)
|
calendar = client.calendar(url=user.caldav_calendar_url)
|
||||||
try:
|
try:
|
||||||
caldav_event = calendar.event_by_uid(self.caldav_uid)
|
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:
|
if not delete_all and self.recurrence_id and not self.is_base_event:
|
||||||
index = self._get_subcomponent_index_for_recurrence(
|
index = self._get_subcomponent_index_for_recurrence(
|
||||||
caldav_event
|
caldav_event
|
||||||
|
|
@ -329,14 +362,16 @@ class CalendarEvent(models.Model):
|
||||||
# of the event matches.
|
# of the event matches.
|
||||||
if delete_all or self._matches_caldav_start(caldav_event):
|
if delete_all or self._matches_caldav_start(caldav_event):
|
||||||
caldav_event.delete()
|
caldav_event.delete()
|
||||||
except caldav.error.NotFoundError:
|
except NotFoundError:
|
||||||
# No worries - it just didn't exist so nothing to sync
|
# No worries - it just didn't exist so nothing to sync
|
||||||
pass
|
pass
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.error(f"Failed to remove event from CalDAV server: {e}")
|
_logger.error(f"Failed to remove event from CalDAV server: {e}")
|
||||||
|
|
||||||
def _matches_caldav_start(self, caldav_event: caldav.Event) -> bool:
|
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
|
tz = event_start.tzinfo
|
||||||
self_start = utc.localize(self.start).astimezone(tz)
|
self_start = utc.localize(self.start).astimezone(tz)
|
||||||
return self_start == event_start
|
return self_start == event_start
|
||||||
|
|
@ -453,7 +488,7 @@ class CalendarEvent(models.Model):
|
||||||
|
|
||||||
def _add_event_dates(self, event_data: Dict) -> None:
|
def _add_event_dates(self, event_data: Dict) -> None:
|
||||||
"""Add pertinent dates to event data, based on self."""
|
"""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_tz = timezone(tz)
|
||||||
event_data["last-modified"] = vDatetime(
|
event_data["last-modified"] = vDatetime(
|
||||||
utc.localize(self.write_date).astimezone(event_tz)
|
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["dtstart"] = vDatetime(utc.localize(self.start).astimezone(event_tz))
|
||||||
event_data["dtend"] = vDatetime(utc.localize(self.stop).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:
|
def _add_event_recurrence_id(self, event_data: Dict) -> None:
|
||||||
"""Add the recurrence-id parameter to event data if self is linked
|
"""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."""
|
synchronize them with their Odoo calendar."""
|
||||||
all_users = self.env["res.users"].search([("is_caldav_enabled", "=", True)])
|
all_users = self.env["res.users"].search([("is_caldav_enabled", "=", True)])
|
||||||
for user in all_users:
|
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
|
@api.model
|
||||||
def _poll_user_caldav_server(self, user) -> None:
|
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,
|
# There are some events remaining in this recurrence series,
|
||||||
# so we have synchronized them individually.
|
# so we have synchronized them individually.
|
||||||
pass
|
pass
|
||||||
except caldav.error.NotFoundError:
|
except NotFoundError:
|
||||||
# There are no more events with this UID, so we need to clear
|
# There are no more events with this UID, so we need to clear
|
||||||
# out the whole recurrence chain from the Odoo side.
|
# out the whole recurrence chain from the Odoo side.
|
||||||
ctx = {"caldav_no_sync": True}
|
ctx = {"caldav_no_sync": True}
|
||||||
|
|
@ -582,9 +617,8 @@ class CalendarEvent(models.Model):
|
||||||
).with_user(user).unlink()
|
).with_user(user).unlink()
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _sync_event_from_ical(
|
@api.returns("calendar.event")
|
||||||
self, ical_event: icalendar.cal.Event, user: User
|
def _sync_event_from_ical(self, ical_event: icalendar.cal.Event, user: User):
|
||||||
) -> CalendarEvent:
|
|
||||||
"""Given an iCalendar event, compare the event with any existing
|
"""Given an iCalendar event, compare the event with any existing
|
||||||
Odoo event that it matches and synchronize the changes. If no event
|
Odoo event that it matches and synchronize the changes. If no event
|
||||||
exists, create one.
|
exists, create one.
|
||||||
|
|
@ -610,9 +644,16 @@ class CalendarEvent(models.Model):
|
||||||
owned = (
|
owned = (
|
||||||
existing_instance and existing_instance.partner_id == user.partner_id
|
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)
|
recurrency_vals = self._get_recurrency_values_from_ical_event(component)
|
||||||
if not existing_instance:
|
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,
|
# If we're creating an instance and it doesn't follow the recurrence,
|
||||||
# just scrap the recurrency vals, they're not useful
|
# just scrap the recurrency vals, they're not useful
|
||||||
if not recurrency_vals.get("follow_recurrence"):
|
if not recurrency_vals.get("follow_recurrence"):
|
||||||
|
|
@ -646,7 +687,8 @@ class CalendarEvent(models.Model):
|
||||||
return synced_events
|
return synced_events
|
||||||
|
|
||||||
@api.model
|
@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,
|
"""Find the Odoo calendar.event record matching uid and,
|
||||||
if set, recurrence_id.
|
if set, recurrence_id.
|
||||||
"""
|
"""
|
||||||
|
|
@ -685,7 +727,7 @@ class CalendarEvent(models.Model):
|
||||||
|
|
||||||
return instance or self.env["calendar.event"].search(
|
return instance or self.env["calendar.event"].search(
|
||||||
[
|
[
|
||||||
("caldav_uid", "=", "uid"),
|
("caldav_uid", "=", uid),
|
||||||
("recurrence_id", "=", False),
|
("recurrence_id", "=", False),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
@ -718,14 +760,20 @@ class CalendarEvent(models.Model):
|
||||||
"recurrence_update": "self_only",
|
"recurrence_update": "self_only",
|
||||||
}
|
}
|
||||||
|
|
||||||
if not rrule:
|
if not rrule or not isinstance(rrule, vRecur):
|
||||||
return {}
|
return {}
|
||||||
if rrule.get("until"):
|
until = rrule.get("until")
|
||||||
rrule["until"] = rrule.get("until")[0].astimezone(utc)
|
if until and isinstance(until, list):
|
||||||
|
until = until[0].astimezone(utc)
|
||||||
rrule_str = rrule.to_ical() and rrule.to_ical().decode("utf-8")
|
rrule_str = rrule.to_ical() and rrule.to_ical().decode("utf-8")
|
||||||
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
|
if rrule_str:
|
||||||
"RRULE:" + rrule_str, component.get("dtstart").dt.astimezone(utc)
|
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 = {
|
vals = {
|
||||||
"recurrency": True,
|
"recurrency": True,
|
||||||
"follow_recurrence": True,
|
"follow_recurrence": True,
|
||||||
|
|
@ -743,8 +791,9 @@ class CalendarEvent(models.Model):
|
||||||
vals.update(end_type="count")
|
vals.update(end_type="count")
|
||||||
if not vals.get("count"):
|
if not vals.get("count"):
|
||||||
vals.update(count=MAX_RECURRENT_EVENT)
|
vals.update(count=MAX_RECURRENT_EVENT)
|
||||||
if vals.get("until"):
|
until = vals.get("until")
|
||||||
until_day = vals.get("until").date()
|
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.update(until=until_day)
|
||||||
vals.pop("count", None)
|
vals.pop("count", None)
|
||||||
return vals
|
return vals
|
||||||
|
|
@ -813,8 +862,8 @@ class CalendarEvent(models.Model):
|
||||||
def _get_outdated(
|
def _get_outdated(
|
||||||
self,
|
self,
|
||||||
component: icalendar.cal.Component,
|
component: icalendar.cal.Component,
|
||||||
existing_instance: CalendarEvent,
|
existing_instance: OdooCalendarEvent,
|
||||||
synced_events: CalendarEvent,
|
synced_events: OdooCalendarEvent,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Check whether a component from the CalDAV server (typically an
|
"""Check whether a component from the CalDAV server (typically an
|
||||||
event) is outdated when compared to its existing Odoo calendar.event
|
event) is outdated when compared to its existing Odoo calendar.event
|
||||||
|
|
@ -840,14 +889,15 @@ class CalendarEvent(models.Model):
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def _get_values_from_ical_component(
|
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:
|
) -> Dict:
|
||||||
"""Get the dictionary representing calendar.event field values from
|
"""Get the dictionary representing calendar.event field values from
|
||||||
an iCalendar event.
|
an iCalendar event.
|
||||||
|
|
||||||
:param component: The iCalendar component from which to extract
|
:param component: The iCalendar component from which to extract values.
|
||||||
values.
|
|
||||||
:param user: The res.users record the event will belong to.
|
: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."""
|
:return: The dictionary of values to construct a calendar.event."""
|
||||||
start = component.get("dtstart") and component.decoded("dtstart")
|
start = component.get("dtstart") and component.decoded("dtstart")
|
||||||
if isinstance(start, datetime):
|
if isinstance(start, datetime):
|
||||||
|
|
@ -855,8 +905,11 @@ class CalendarEvent(models.Model):
|
||||||
end = component.get("dtend") and component.decoded("dtend")
|
end = component.get("dtend") and component.decoded("dtend")
|
||||||
if isinstance(end, datetime):
|
if isinstance(end, datetime):
|
||||||
end = end.astimezone(utc).replace(tzinfo=None)
|
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)
|
attendee_ids = self._get_attendee_partners(component, user.partner_id.email)
|
||||||
|
|
||||||
|
# Basic values that apply to both creation and updates
|
||||||
values = {
|
values = {
|
||||||
"name": str(component.get("summary")),
|
"name": str(component.get("summary")),
|
||||||
"start": start,
|
"start": start,
|
||||||
|
|
@ -868,9 +921,33 @@ class CalendarEvent(models.Model):
|
||||||
"videocall_location": self._extract_component_text(component, "conference"),
|
"videocall_location": self._extract_component_text(component, "conference"),
|
||||||
"caldav_uid": str(component.get("uid")),
|
"caldav_uid": str(component.get("uid")),
|
||||||
"partner_ids": [(6, 0, attendee_ids.ids)],
|
"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
|
return values
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
|
|
@ -886,6 +963,13 @@ class CalendarEvent(models.Model):
|
||||||
matching Odoo event belongs to.
|
matching Odoo event belongs to.
|
||||||
:return: The res.partner records who are attendees for the event."""
|
:return: The res.partner records who are attendees for the event."""
|
||||||
attendee_emails = self._get_ical_attendee_emails(component)
|
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:
|
if current_user_email not in attendee_emails:
|
||||||
attendee_emails.append(current_user_email)
|
attendee_emails.append(current_user_email)
|
||||||
existing_partners = self.env["res.partner"].search(
|
existing_partners = self.env["res.partner"].search(
|
||||||
|
|
@ -896,14 +980,24 @@ class CalendarEvent(models.Model):
|
||||||
for email in attendee_emails
|
for email in attendee_emails
|
||||||
if email not in [partner.email for partner in existing_partners]
|
if email not in [partner.email for partner in existing_partners]
|
||||||
]
|
]
|
||||||
added_partners = self.env["res.partner"].create(
|
# Create new partners without triggering notifications
|
||||||
[
|
added_partners = (
|
||||||
{
|
self.env["res.partner"]
|
||||||
"name": email,
|
.with_context(
|
||||||
"email": email,
|
mail_notify_author=False, # Don't notify the author
|
||||||
}
|
mail_notify_force_send=False, # Don't force send notifications
|
||||||
for email in missing_emails
|
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 = {}
|
final_attendees = {}
|
||||||
all_partners = existing_partners | added_partners
|
all_partners = existing_partners | added_partners
|
||||||
|
|
@ -933,11 +1027,29 @@ class CalendarEvent(models.Model):
|
||||||
|
|
||||||
organizer = component.get("organizer")
|
organizer = component.get("organizer")
|
||||||
if organizer:
|
if organizer:
|
||||||
partner = self.env["res.partner"].search(
|
email = _extract_vcal_email(organizer)
|
||||||
[("email", "=", _extract_vcal_email(organizer))]
|
_logger.info("Organizer email: %s", email)
|
||||||
)
|
partner = self.env["res.partner"].search([("email", "=", email)], limit=1)
|
||||||
# TODO: prioritize partner with a user if there is one
|
_logger.info("Found partner: %s", partner)
|
||||||
return partner[0] if partner else partner # partner[0] in case many matches
|
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:
|
else:
|
||||||
return self.env["res.partner"]
|
return self.env["res.partner"]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,34 @@ class ResUsers(models.Model):
|
||||||
caldav_password = fields.Char(string="CalDAV Password")
|
caldav_password = fields.Char(string="CalDAV Password")
|
||||||
is_caldav_enabled = fields.Boolean(compute="_compute_is_caldav_enabled", store=True)
|
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")
|
@api.depends("caldav_username", "caldav_password", "caldav_calendar_url")
|
||||||
def _compute_is_caldav_enabled(self):
|
def _compute_is_caldav_enabled(self):
|
||||||
"""This is a bit of an odd way of computing the field, but it works since any
|
"""Compute whether CalDAV is enabled for each user by validating their credentials.
|
||||||
failed attempt to get the events from the server should mark the user as
|
We only check if we can connect to the server and access the principal, without
|
||||||
CalDAV being disabled. We just make sure to mute the logger in the case that
|
fetching any events to avoid timeouts with large calendars."""
|
||||||
an exception is raised because we are just computing the field, not actually
|
|
||||||
attempting to synchronize anything."""
|
|
||||||
for rec in self:
|
for rec in self:
|
||||||
with mute_logger("odoo.addons.caldav_sync.models.res_users"):
|
# If any required field is empty, CalDAV is disabled
|
||||||
rec._get_caldav_events()
|
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):
|
def _get_caldav_client(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
from . import test_res_users
|
from . import test_res_users
|
||||||
from . import test_calendar
|
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 collections.abc import Iterable
|
||||||
from odoo.tests import TransactionCase
|
from odoo.tests import TransactionCase, tagged
|
||||||
from odoo import Command
|
from odoo import Command
|
||||||
from unittest.mock import patch, MagicMock, DEFAULT
|
from unittest.mock import patch, MagicMock, DEFAULT
|
||||||
import icalendar
|
import icalendar
|
||||||
|
|
@ -25,24 +25,25 @@ def _get_ics_path(filename):
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
|
def _patch_caldav_with_events_from_ics(
|
||||||
with (
|
ics_paths, user, last_modified=None, futurize=True
|
||||||
patch("caldav.DAVClient") as MockDAVClient,
|
):
|
||||||
patch("caldav.Calendar") as MockCalendar,
|
with patch("caldav.DAVClient") as MockDAVClient:
|
||||||
):
|
|
||||||
mock_client = MockDAVClient.return_value
|
mock_client = MockDAVClient.return_value
|
||||||
mock_calendar = MockCalendar.return_value
|
|
||||||
mock_client.calendar = mock_calendar
|
|
||||||
mock_calendars = {}
|
mock_calendars = {}
|
||||||
|
|
||||||
def calendar_side_effect(url):
|
def calendar_side_effect(url):
|
||||||
if url not in mock_calendars:
|
if url not in mock_calendars:
|
||||||
mock_calendars[url] = MockCalendar()
|
mock_cal = MagicMock()
|
||||||
if url == user.caldav_calendar_url:
|
mock_cal.events = MagicMock(return_value=[])
|
||||||
return mock_calendars[url]
|
mock_cal.event_by_uid = MagicMock()
|
||||||
raise Exception("Calendar does not exist.")
|
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):
|
def event_by_uid_side_effect(self, uid):
|
||||||
for event in self.events():
|
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":
|
if subcomponent.name == "VEVENT":
|
||||||
subcomponent["last-modified"] = icalendar.vDate(last_modified)
|
subcomponent["last-modified"] = icalendar.vDate(last_modified)
|
||||||
subcomponent["dtstamp"] = 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")]
|
base_events = [event for event in ical_events if not event.get("recurrence-id")]
|
||||||
for base_event in base_events:
|
for base_event in base_events:
|
||||||
|
|
@ -83,17 +102,33 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@tagged("post_install", "-at_install")
|
||||||
class TestCalendarEvent(TransactionCase, CaldavTestCommon):
|
class TestCalendarEvent(TransactionCase, CaldavTestCommon):
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
cls.env["res.users"].search([])._compute_is_caldav_enabled()
|
cls.env["res.users"].search([])._compute_is_caldav_enabled()
|
||||||
cls.user_1_url = "https://mycaldav.test.com/test1calendar"
|
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_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_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):
|
def test_basic_event_from_server_create(self):
|
||||||
user = self.user_1
|
user = self.user_1
|
||||||
|
|
@ -110,17 +145,29 @@ class TestCalendarEvent(TransactionCase, CaldavTestCommon):
|
||||||
ics_path = _get_ics_path("basic.ics")
|
ics_path = _get_ics_path("basic.ics")
|
||||||
with _patch_caldav_with_events_from_ics(ics_path, user):
|
with _patch_caldav_with_events_from_ics(ics_path, user):
|
||||||
self.env["calendar.event"].poll_caldav_server()
|
self.env["calendar.event"].poll_caldav_server()
|
||||||
|
|
||||||
|
# Verify the event was created correctly
|
||||||
event = self.env["calendar.event"].search([("user_id", "=", user.id)])
|
event = self.env["calendar.event"].search([("user_id", "=", user.id)])
|
||||||
|
self.assertEqual(event.name, "Test")
|
||||||
orig_start = event.start
|
orig_start = event.start
|
||||||
orig_stop = event.stop
|
orig_stop = event.stop
|
||||||
|
|
||||||
|
# Now update the event with the updated ICS data
|
||||||
ics_path = _get_ics_path("basic_updated.ics")
|
ics_path = _get_ics_path("basic_updated.ics")
|
||||||
with _patch_caldav_with_events_from_ics(
|
with _patch_caldav_with_events_from_ics(
|
||||||
ics_path,
|
ics_path,
|
||||||
user,
|
user,
|
||||||
last_modified=(datetime.now(UTC)),
|
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()
|
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)])
|
event = self.env["calendar.event"].search([("user_id", "=", user.id)])
|
||||||
|
|
||||||
|
# Verify the event was updated correctly
|
||||||
self.assertEqual(event.name, "Test Updated")
|
self.assertEqual(event.name, "Test Updated")
|
||||||
# This next one is just lazy avoiding the HTML stripping
|
# This next one is just lazy avoiding the HTML stripping
|
||||||
self.assertIn("Some note ...", event.description)
|
self.assertIn("Some note ...", event.description)
|
||||||
|
|
@ -224,21 +271,25 @@ class TestCalendarEvent(TransactionCase, CaldavTestCommon):
|
||||||
self.env["calendar.event"].poll_caldav_server()
|
self.env["calendar.event"].poll_caldav_server()
|
||||||
with _patch_caldav_with_events_from_ics(ics_path, user3):
|
with _patch_caldav_with_events_from_ics(ics_path, user3):
|
||||||
self.env["calendar.event"].poll_caldav_server()
|
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
|
# Now update it to remove one attendee
|
||||||
# Shuffle the user polling order just to test more robustly
|
# Shuffle the user polling order just to test more robustly
|
||||||
ics_path = _get_ics_path("test_multi_user_update.ics")
|
ics_path = _get_ics_path("test_multi_user_update.ics")
|
||||||
with _patch_caldav_with_events_from_ics(
|
with _patch_caldav_with_events_from_ics(
|
||||||
ics_path, user2, last_modified=datetime.now(UTC)
|
ics_path, user2, last_modified=datetime.now(UTC)
|
||||||
):
|
), patch(notification_method) as mock_notification_method:
|
||||||
self.env["calendar.event"].poll_caldav_server()
|
self.env["calendar.event"].poll_caldav_server()
|
||||||
|
mock_notification_method.assert_not_called()
|
||||||
with _patch_caldav_with_events_from_ics(
|
with _patch_caldav_with_events_from_ics(
|
||||||
ics_path, user3, last_modified=datetime.now(UTC)
|
ics_path, user3, last_modified=datetime.now(UTC)
|
||||||
):
|
), patch(notification_method) as mock_notification_method:
|
||||||
self.env["calendar.event"].poll_caldav_server()
|
self.env["calendar.event"].poll_caldav_server()
|
||||||
|
mock_notification_method.assert_not_called()
|
||||||
with _patch_caldav_with_events_from_ics(
|
with _patch_caldav_with_events_from_ics(
|
||||||
ics_path, user1, last_modified=datetime.now(UTC)
|
ics_path, user1, last_modified=datetime.now(UTC)
|
||||||
):
|
), patch(notification_method) as mock_notification_method:
|
||||||
self.env["calendar.event"].poll_caldav_server()
|
self.env["calendar.event"].poll_caldav_server()
|
||||||
|
mock_notification_method.assert_not_called()
|
||||||
event = self.env["calendar.event"].search(
|
event = self.env["calendar.event"].search(
|
||||||
[("caldav_uid", "=", "2495546B-5C9A-4632-AAD3-A179EF83CF20")]
|
[("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 odoo.tests import TransactionCase
|
||||||
from unittest.mock import patch, MagicMock
|
from unittest.mock import patch, MagicMock
|
||||||
from .common import CaldavTestCommon
|
from .common import CaldavTestCommon
|
||||||
|
import caldav
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TestUsers(TransactionCase, CaldavTestCommon):
|
class TestUsers(TransactionCase, CaldavTestCommon):
|
||||||
|
|
@ -9,24 +13,67 @@ class TestUsers(TransactionCase, CaldavTestCommon):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
|
|
||||||
def test_caldav_enabled_false_without_url(self):
|
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)
|
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")
|
@patch("caldav.DAVClient")
|
||||||
def test_caldav_connection_succeeds_but_not_calendar(self, MockDAVClient):
|
def test_caldav_enabled_success(self, MockDAVClient):
|
||||||
user = self._generate_user("test", "test", "https://example.com/abc123")
|
"""Test that is_caldav_enabled is True when connection succeeds."""
|
||||||
# Create a mock client and mock calendar
|
# 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_client = MockDAVClient.return_value
|
||||||
mock_calendar = MagicMock()
|
mock_principal = MagicMock()
|
||||||
mock_client.calendar.return_value = mock_calendar
|
mock_client.principal.return_value = mock_principal
|
||||||
|
|
||||||
# Mock the events method to raise an exception
|
# Compute should succeed and set is_caldav_enabled to True
|
||||||
mock_calendar.events.side_effect = Exception("Failed to get events")
|
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
|
@patch("caldav.DAVClient")
|
||||||
# users' calendars. Instead the exception message is simply logged to error.
|
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"):
|
with self.assertLogs("odoo.addons.caldav_sync.models.res_users", "ERROR"):
|
||||||
user._get_caldav_events()
|
user._compute_is_caldav_enabled()
|
||||||
|
|
||||||
# Ensure the is_caldav_enabled is set to False
|
|
||||||
self.assertFalse(user.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 **/
|
/** @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";
|
import {patch} from "@web/core/utils/patch";
|
||||||
|
|
||||||
patch(Many2OneField.prototype, {
|
patch(Many2OneField.prototype, {
|
||||||
get Many2XAutocompleteProps() {
|
/**
|
||||||
const props = super.Many2XAutocompleteProps;
|
* The original Many2OneField already has a quickCreate method and an openConfirmationDialog method.
|
||||||
props.quickCreate = this.openConfirmationDialog.bind(this);
|
* The quickCreate method directly updates the record, while openConfirmationDialog shows a dialog
|
||||||
return props;
|
* 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 models
|
||||||
|
from . import wizards
|
||||||
|
|
@ -1,15 +1,45 @@
|
||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Customer Itch Cycle Management',
|
'name': 'Customer Itch Cycle',
|
||||||
'version': '1.0',
|
'version': '17.0.1.0.0',
|
||||||
'depends': ['base', 'sale'],
|
'category': 'Sales',
|
||||||
'author': 'Your Name',
|
'summary': 'Manage customer product cycles and predict future sales',
|
||||||
'category': 'Sales Management',
|
'description': """
|
||||||
'description': "Manage customer itch cycles by product for proactive sales engagement.",
|
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': [
|
'data': [
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'data/month_data.xml',
|
||||||
|
'wizards/apply_to_child_categories.xml',
|
||||||
'views/itch_cycle_product_partner_view.xml',
|
'views/itch_cycle_product_partner_view.xml',
|
||||||
'views/res_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,
|
'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 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 odoo import models, fields, api
|
||||||
from datetime import timedelta, datetime
|
from odoo.exceptions import ValidationError, UserError
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class ItchCycleProductPartner(models.Model):
|
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.
|
||||||
|
|
||||||
partner_id = fields.Many2one('res.partner', string="Client", required=True, ondelete='cascade')
|
Tracks purchase patterns, predicts future orders, and manages related
|
||||||
product_id = fields.Many2one('product.product', string="Produit", required=True)
|
opportunities.
|
||||||
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)
|
|
||||||
|
|
||||||
@api.depends('last_purchase_date', 'itch_cycle_duration')
|
Attributes:
|
||||||
def _compute_next_follow_up_date(self):
|
_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"]
|
||||||
|
|
||||||
|
_sql_constraints = [
|
||||||
|
(
|
||||||
|
"positive_cycle_duration",
|
||||||
|
"CHECK(cycle_duration_override >= 0)",
|
||||||
|
"The forced cycle duration must be positive, zero or FALSE."
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
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:
|
for record in self:
|
||||||
if record.last_purchase_date and record.itch_cycle_duration > 0:
|
record.name = f"{record.partner_id.name}/{record.product_id.name}"
|
||||||
record.next_follow_up_date = record.last_purchase_date + timedelta(days=record.itch_cycle_duration)
|
|
||||||
|
@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:
|
else:
|
||||||
record.next_follow_up_date = False
|
dates = sorted(
|
||||||
|
record.sale_order_line_ids.mapped('order_id.date_order')
|
||||||
|
)
|
||||||
|
|
||||||
class ResPartner(models.Model):
|
if len(dates) > 1:
|
||||||
_inherit = 'res.partner'
|
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")
|
@api.constrains("cycle_duration_override", "date_expected_override")
|
||||||
itch_next_delay = fields.Date(string="Prochaine Date de Suivi (Itch-Cycle Min)", compute="_compute_itch_next_delay", store=True)
|
def _check_cycle_constraints(self):
|
||||||
|
"""Validate cycle constraints.
|
||||||
|
|
||||||
@api.depends('itch_cycle_product_ids.next_follow_up_date')
|
Ensures forced cycle duration is positive
|
||||||
def _compute_itch_next_delay(self):
|
and expected date is not in past.
|
||||||
for partner in self:
|
"""
|
||||||
follow_up_dates = partner.itch_cycle_product_ids.mapped('next_follow_up_date')
|
for record in self:
|
||||||
partner.itch_next_delay = min(follow_up_dates) if follow_up_dates else False
|
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.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>
|
<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="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">
|
<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="partner_id"/>
|
||||||
<field name="product_id"/>
|
<field name="product_id"/>
|
||||||
<field name="last_purchase_date"/>
|
<field name="state"/>
|
||||||
<field name="itch_cycle_duration"/>
|
<separator/>
|
||||||
<field name="next_follow_up_date"/>
|
<filter string="Archivé" name="inactive" domain="[('active', '=', False)]"/>
|
||||||
</tree>
|
<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>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|
||||||
<record id="view_itch_cycle_product_partner_form" model="ir.ui.view">
|
<record id="view_itch_cycle_product_partner_form" model="ir.ui.view">
|
||||||
<field name="name">itch.cycle.product.partner.form</field>
|
<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">
|
<field name="arch" type="xml">
|
||||||
<form>
|
<form>
|
||||||
|
<header>
|
||||||
|
<field name="state" widget="statusbar" statusbar_visible="new,on_time,delayed,archived"/>
|
||||||
|
</header>
|
||||||
<sheet>
|
<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>
|
<group>
|
||||||
<field name="partner_id"/>
|
<group string="Partenaire" col="2">
|
||||||
<field name="product_id"/>
|
<field name="partner_id"
|
||||||
<field name="last_purchase_date"/>
|
context="{'form_view_ref': 'base.view_partner_form'}"
|
||||||
<field name="itch_cycle_duration"/>
|
options="{'always_reload': true}"
|
||||||
<field name="next_follow_up_date"/>
|
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>
|
||||||
|
<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>
|
</sheet>
|
||||||
|
<div class="oe_chatter">
|
||||||
|
<field name="message_follower_ids"/>
|
||||||
|
<field name="activity_ids"/>
|
||||||
|
<field name="message_ids"/>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</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>
|
</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>
|
<odoo>
|
||||||
<record id="view_partner_form_inherit_itch_cycle" model="ir.ui.view">
|
<record id="view_partner_form_inherit_itch_cycle" model="ir.ui.view">
|
||||||
<field name="name">res.partner.form.itch.cycle</field>
|
<field name="name">res.partner.form.itch.cycle</field>
|
||||||
<field name="model">res.partner</field>
|
<field name="model">res.partner</field>
|
||||||
<field name="inherit_id" ref="base.view_partner_form"/>
|
<field name="inherit_id" ref="base.view_partner_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<sheet position="before">
|
<notebook position="inside">
|
||||||
<group string="Itch Cycle Information">
|
<page string="Product Cycles" name="product_cycles">
|
||||||
<field name="itch_next_delay" readonly="1"/>
|
<field name="itch_cycle_product_ids">
|
||||||
<field name="itch_cycle_product_ids" readonly="1">
|
|
||||||
<tree>
|
<tree>
|
||||||
<field name="product_id"/>
|
<field name="product_id"/>
|
||||||
<field name="last_purchase_date"/>
|
<field name="quantity_mean_ordered"/>
|
||||||
<field name="itch_cycle_duration"/>
|
<field name="date_expected"/>
|
||||||
<field name="next_follow_up_date"/>
|
<field name="active"/>
|
||||||
</tree>
|
</tree>
|
||||||
</field>
|
</field>
|
||||||
</group>
|
</page>
|
||||||
</sheet>
|
</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>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
</odoo>
|
</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>
|
||||||
|
|
@ -20,14 +20,35 @@
|
||||||
{
|
{
|
||||||
"name": "FSM Visit Confirmation",
|
"name": "FSM Visit Confirmation",
|
||||||
"version": "17.0.0.1.0",
|
"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",
|
"category": "Services/Field Service",
|
||||||
"author": "Bemade Inc.",
|
"author": "Bemade Inc.",
|
||||||
"website": "http://www.bemade.org",
|
"website": "http://www.bemade.org",
|
||||||
"license": "LGPL-3",
|
"license": "LGPL-3",
|
||||||
"depends": ["industry_fsm"],
|
"depends": ["industry_fsm", "bemade_fsm", "rating", "http_routing"],
|
||||||
"data": ["data/mail_templates.xml"],
|
"data": [
|
||||||
"assets": {},
|
"data/mail_templates.xml",
|
||||||
|
"views/project_portal_project_task_templates.xml",
|
||||||
|
"views/project_task_type_views.xml",
|
||||||
|
],
|
||||||
"installable": True,
|
"installable": True,
|
||||||
"auto_install": False,
|
"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>
|
<odoo>
|
||||||
<record id="request_fsm_task_date_confirmation" model="mail.template">
|
<record id="fsm_visit_confirmation_email_template" model="mail.template">
|
||||||
<field name="name">Field Service Visit Confirmation Request</field>
|
<field name="name">FSM Visit Confirmation</field>
|
||||||
<field name="model_id" ref="project.model_project_task"/>
|
<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="subject">Service Visit Confirmation: {{ object.name }}</field>
|
||||||
<field name="email_from">{{ object.company_id.email_formatted }}</field>
|
<field name="email_from">{{ user.email_formatted }}</field>
|
||||||
<field name="body_html">
|
<field name="partner_to">{{ object.work_order_contacts and object.work_order_contacts[0].id or object.partner_id.id }}</field>
|
||||||
<table class="table table-striped">
|
<field name="description">Send a visit confirmation email to the customer</field>
|
||||||
<tbody>
|
<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>
|
<tr>
|
||||||
<td scope="row">Summary</td>
|
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Task Summary</td>
|
||||||
<td>{{ object.name }}</td>
|
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
|
||||||
</tr>
|
<t t-out="object.name"/>
|
||||||
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td scope="row">
|
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Technician Arrival</td>
|
||||||
Assigned Technician(s):
|
<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>
|
||||||
<td>
|
</tr>
|
||||||
<t t-foreach="object.user_ids" t-as="technician">
|
<tr>
|
||||||
<t t-esc="technician.name"/><br/>
|
<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>
|
</t>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
<div style="margin: 16px 0px 16px 0px;">
|
||||||
<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>
|
Please take a moment to confirm this visit by clicking one of the following options:
|
||||||
<p>If you would like to propose another time for this visit, please reply to this email.</p>
|
</div>
|
||||||
<p>Best regards,</p>
|
<table style="width:100%;border-spacing:0;">
|
||||||
<p>The {{ object.company_id.name }} service management team.</p>
|
<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>
|
||||||
|
<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>
|
</record>
|
||||||
</odoo>
|
</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
|
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"
|
_inherit = "project.task"
|
||||||
|
|
||||||
def action_approve_booking(self):
|
def write(self, vals):
|
||||||
self.ensure_one()
|
# Store old stage for comparison
|
||||||
self.state = "03_approved"
|
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,9 +1,46 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" ?>
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
<odoo>
|
<odoo>
|
||||||
<template id="portal_my_task" inherit_id="project.portal_my_task">
|
<template id="portal_my_task" inherit_id="project.portal_my_task">
|
||||||
<xpath expr="//div[@id='task_content']" position="before">
|
<!-- Add success message when visit is approved -->
|
||||||
<div t-if="visit_confirmation_accepted" class="alert alert-success" role="alert">
|
<xpath expr="//div[@id='task_content']" position="inside">
|
||||||
<strong>Success!</strong> We've received your confirmation. Thank you!
|
<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>
|
</div>
|
||||||
</xpath>
|
</xpath>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
||||||
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>
|
||||||
1
nuke_mid_task/__init__.py
Normal file
1
nuke_mid_task/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import models
|
||||||
27
nuke_mid_task/__manifest__.py
Normal file
27
nuke_mid_task/__manifest__.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
'name': 'Task Nuker',
|
||||||
|
'version': '17.0.1.0.0',
|
||||||
|
'category': 'Project',
|
||||||
|
'summary': 'Add ability to nuke intermediate tasks while preserving hierarchy',
|
||||||
|
'description': """
|
||||||
|
This module adds a 'Nuke' button on tasks that have both parent and child tasks.
|
||||||
|
When activated:
|
||||||
|
- Requests confirmation
|
||||||
|
- Reassigns all child tasks to the parent task
|
||||||
|
- Deletes the intermediate task
|
||||||
|
- Logs the action in the chatter of both parent and child tasks
|
||||||
|
""",
|
||||||
|
'author': 'DurPro',
|
||||||
|
'website': 'https://www.durpro.com',
|
||||||
|
'depends': [
|
||||||
|
'project',
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
'views/project_task_views.xml',
|
||||||
|
],
|
||||||
|
'assets': {},
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'installable': True,
|
||||||
|
'application': False,
|
||||||
|
'auto_install': False,
|
||||||
|
}
|
||||||
1
nuke_mid_task/models/__init__.py
Normal file
1
nuke_mid_task/models/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import project_task
|
||||||
75
nuke_mid_task/models/project_task.py
Normal file
75
nuke_mid_task/models/project_task.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
from odoo import models, api, fields, _
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
class ProjectTask(models.Model):
|
||||||
|
_inherit = 'project.task'
|
||||||
|
|
||||||
|
can_be_nuked = fields.Boolean(
|
||||||
|
compute='_compute_can_be_nuked',
|
||||||
|
help="Technical field to determine if task can be nuked"
|
||||||
|
)
|
||||||
|
|
||||||
|
@api.depends('parent_id', 'child_ids')
|
||||||
|
def _compute_can_be_nuked(self):
|
||||||
|
"""Compute if the task can be nuked based on parent and children presence"""
|
||||||
|
for task in self:
|
||||||
|
task.can_be_nuked = bool(task.parent_id and task.child_ids)
|
||||||
|
|
||||||
|
def action_nuke_task(self):
|
||||||
|
"""
|
||||||
|
Nuke the current task:
|
||||||
|
1. Verify the task can be nuked
|
||||||
|
2. Get parent and children
|
||||||
|
3. Reassign all children to the parent
|
||||||
|
4. Update children names to include nuked task name
|
||||||
|
5. Post messages in the chatter
|
||||||
|
6. Delete the current task
|
||||||
|
7. Return action to redirect to parent task
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.can_be_nuked:
|
||||||
|
raise UserError(_("This task cannot be nuked. It must have both a parent and child tasks."))
|
||||||
|
|
||||||
|
parent = self.parent_id
|
||||||
|
children = self.child_ids
|
||||||
|
nuked_name = self.name
|
||||||
|
|
||||||
|
# Post message to parent about the upcoming changes
|
||||||
|
parent.message_post(
|
||||||
|
body=_(
|
||||||
|
"The following task has been nuked: %(task)s\n"
|
||||||
|
"%(count)d child tasks have been reassigned to this task."
|
||||||
|
) % {
|
||||||
|
'task': nuked_name,
|
||||||
|
'count': len(children)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reassign all children to the parent, update their names and notify them
|
||||||
|
for child in children:
|
||||||
|
old_name = child.name
|
||||||
|
# Update name to include nuked task's name
|
||||||
|
child.write({
|
||||||
|
'name': f"[{nuked_name}] {old_name}",
|
||||||
|
'parent_id': parent.id
|
||||||
|
})
|
||||||
|
child.message_post(
|
||||||
|
body=_("This task has been reassigned to %(new_parent)s due to the removal of %(old_parent)s.\nTask name updated from '%(old_name)s' to '%(new_name)s'") % {
|
||||||
|
'new_parent': parent.name,
|
||||||
|
'old_parent': nuked_name,
|
||||||
|
'old_name': old_name,
|
||||||
|
'new_name': child.name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete the current task
|
||||||
|
self.unlink()
|
||||||
|
|
||||||
|
# Return action to redirect to parent task
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.act_window',
|
||||||
|
'res_model': 'project.task',
|
||||||
|
'res_id': parent.id,
|
||||||
|
'view_mode': 'form',
|
||||||
|
'target': 'current',
|
||||||
|
}
|
||||||
22
nuke_mid_task/views/project_task_views.xml
Normal file
22
nuke_mid_task/views/project_task_views.xml
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<record id="view_task_form_inherit_nuke" model="ir.ui.view">
|
||||||
|
<field name="name">project.task.form.inherit.nuke</field>
|
||||||
|
<field name="model">project.task</field>
|
||||||
|
<field name="inherit_id" ref="project.view_task_form2"/>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<xpath expr="//field[@name='recurrence_id']" position="after">
|
||||||
|
<field name="can_be_nuked" invisible="1"/>
|
||||||
|
</xpath>
|
||||||
|
<xpath expr="//header" position="inside">
|
||||||
|
<button
|
||||||
|
name="action_nuke_task"
|
||||||
|
string="Nuke Task"
|
||||||
|
type="object"
|
||||||
|
confirm="Are you sure you want to nuke this task? All child tasks will be reassigned to the parent task."
|
||||||
|
invisible="not can_be_nuked"
|
||||||
|
groups="project.group_project_manager"/>
|
||||||
|
</xpath>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
</odoo>
|
||||||
90
odoo_proxmox_manager/README.md
Normal file
90
odoo_proxmox_manager/README.md
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Odoo Proxmox Manager
|
||||||
|
|
||||||
|
This module allows you to manage your Proxmox servers and virtual machines directly from Odoo 17.0.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Manage multiple Proxmox servers and clusters
|
||||||
|
- Monitor server status and resources
|
||||||
|
- View and manage virtual machines
|
||||||
|
- Start, stop, restart, suspend, and resume VMs
|
||||||
|
- Group servers into clusters for better organization
|
||||||
|
- Multi-company support
|
||||||
|
- Role-based access control
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
1. Odoo 17.0
|
||||||
|
2. Python package requirements:
|
||||||
|
```
|
||||||
|
proxmoxer
|
||||||
|
requests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Steps
|
||||||
|
|
||||||
|
1. Clone this repository into your Odoo addons directory
|
||||||
|
2. Install the required Python packages:
|
||||||
|
```bash
|
||||||
|
pip install proxmoxer requests
|
||||||
|
```
|
||||||
|
3. Update your Odoo addons list
|
||||||
|
4. Install the module through Odoo's Apps menu
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
1. Create an API Token in your Proxmox server:
|
||||||
|
- Log in to your Proxmox web interface
|
||||||
|
- Go to Datacenter -> Permissions -> API Tokens
|
||||||
|
- Create a new token and note down the Token ID and Secret
|
||||||
|
|
||||||
|
2. In Odoo:
|
||||||
|
- Go to the Proxmox menu
|
||||||
|
- Create a new cluster (optional)
|
||||||
|
- Create a new server:
|
||||||
|
- Enter the server hostname
|
||||||
|
- Enter your API token information
|
||||||
|
- Test the connection
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Managing Servers
|
||||||
|
|
||||||
|
1. Navigate to Proxmox -> Servers
|
||||||
|
2. Create or select a server
|
||||||
|
3. Click "Sync VMs" to synchronize virtual machines
|
||||||
|
4. View server status and resource information
|
||||||
|
|
||||||
|
### Managing Virtual Machines
|
||||||
|
|
||||||
|
1. Navigate to Proxmox -> Virtual Machines
|
||||||
|
2. View all VMs across your servers
|
||||||
|
3. Use the action buttons to:
|
||||||
|
- Start VMs
|
||||||
|
- Stop VMs
|
||||||
|
- Restart VMs
|
||||||
|
- Suspend VMs
|
||||||
|
- Resume VMs
|
||||||
|
|
||||||
|
### Managing Clusters
|
||||||
|
|
||||||
|
1. Navigate to Proxmox -> Clusters
|
||||||
|
2. Create a new cluster
|
||||||
|
3. Add servers to the cluster
|
||||||
|
4. Use "Sync All Servers" to update all servers in the cluster
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
The module includes two user groups:
|
||||||
|
- Proxmox User: Can view servers and VMs
|
||||||
|
- Proxmox Manager: Can manage servers and VMs
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For bugs or feature requests, please create an issue in the repository.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LGPL-3
|
||||||
3
odoo_proxmox_manager/__init__.py
Normal file
3
odoo_proxmox_manager/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
from . import wizard
|
||||||
35
odoo_proxmox_manager/__manifest__.py
Normal file
35
odoo_proxmox_manager/__manifest__.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
{
|
||||||
|
'name': 'Proxmox Manager',
|
||||||
|
'version': '17.0.1.0.0',
|
||||||
|
'category': 'Administration',
|
||||||
|
'summary': 'Manage Proxmox servers and clusters from Odoo',
|
||||||
|
'sequence': 1,
|
||||||
|
'author': 'DurPro',
|
||||||
|
'website': 'https://www.durpro.com',
|
||||||
|
'license': 'LGPL-3',
|
||||||
|
'icon': '/odoo_proxmox_manager/static/description/icon.png',
|
||||||
|
'depends': [
|
||||||
|
'base',
|
||||||
|
'web',
|
||||||
|
'mail'
|
||||||
|
],
|
||||||
|
'data': [
|
||||||
|
'security/proxmox_security.xml',
|
||||||
|
'security/ir.model.access.csv',
|
||||||
|
'views/proxmox_server_views.xml',
|
||||||
|
'views/proxmox_cluster_views.xml',
|
||||||
|
'views/proxmox_vm_views.xml',
|
||||||
|
'views/proxmox_menus.xml',
|
||||||
|
],
|
||||||
|
'assets': {
|
||||||
|
'web.assets_backend': [
|
||||||
|
'odoo_proxmox_manager/static/src/components/**/*',
|
||||||
|
'odoo_proxmox_manager/static/src/js/dashboard_view.js',
|
||||||
|
'odoo_proxmox_manager/static/src/scss/dashboard.scss',
|
||||||
|
'odoo_proxmox_manager/static/src/xml/dashboard.xml',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'application': True,
|
||||||
|
'installable': True,
|
||||||
|
'auto_install': False
|
||||||
|
}
|
||||||
1
odoo_proxmox_manager/controllers/__init__.py
Normal file
1
odoo_proxmox_manager/controllers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
from . import main
|
||||||
9
odoo_proxmox_manager/controllers/main.py
Normal file
9
odoo_proxmox_manager/controllers/main.py
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
|
||||||
|
class ProxmoxController(http.Controller):
|
||||||
|
|
||||||
|
@http.route('/proxmox/dashboard/data', type='json', auth='user')
|
||||||
|
def get_dashboard_data(self):
|
||||||
|
"""Get dashboard data for the Proxmox overview"""
|
||||||
|
return request.env['proxmox.server'].get_dashboard_data()
|
||||||
3
odoo_proxmox_manager/models/__init__.py
Normal file
3
odoo_proxmox_manager/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
from . import proxmox_server
|
||||||
|
from . import proxmox_cluster
|
||||||
|
from . import proxmox_vm
|
||||||
37
odoo_proxmox_manager/models/proxmox_cluster.py
Normal file
37
odoo_proxmox_manager/models/proxmox_cluster.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
class ProxmoxCluster(models.Model):
|
||||||
|
_name = 'proxmox.cluster'
|
||||||
|
_description = 'Proxmox Cluster'
|
||||||
|
_order = 'name'
|
||||||
|
_inherit = ['mail.thread']
|
||||||
|
|
||||||
|
name = fields.Char(string='Name', required=True, tracking=True)
|
||||||
|
description = fields.Text(string='Description', tracking=True)
|
||||||
|
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
|
||||||
|
server_ids = fields.One2many('proxmox.server', 'cluster_id', string='Servers')
|
||||||
|
server_count = fields.Integer(string='Server Count', compute='_compute_server_count')
|
||||||
|
total_vms = fields.Integer(string='Total VMs', compute='_compute_total_vms')
|
||||||
|
active_servers = fields.Integer(string='Active Servers', compute='_compute_active_servers')
|
||||||
|
|
||||||
|
@api.depends('server_ids')
|
||||||
|
def _compute_server_count(self):
|
||||||
|
for cluster in self:
|
||||||
|
cluster.server_count = len(cluster.server_ids)
|
||||||
|
|
||||||
|
@api.depends('server_ids.vm_ids')
|
||||||
|
def _compute_total_vms(self):
|
||||||
|
for cluster in self:
|
||||||
|
cluster.total_vms = sum(server.vm_count for server in cluster.server_ids)
|
||||||
|
|
||||||
|
@api.depends('server_ids.state')
|
||||||
|
def _compute_active_servers(self):
|
||||||
|
for cluster in self:
|
||||||
|
cluster.active_servers = len(cluster.server_ids.filtered(lambda s: s.state == 'online'))
|
||||||
|
|
||||||
|
def action_sync_all_servers(self):
|
||||||
|
"""Synchronize all servers in the cluster"""
|
||||||
|
self.ensure_one()
|
||||||
|
for server in self.server_ids:
|
||||||
|
server.action_sync_vms()
|
||||||
|
return True
|
||||||
164
odoo_proxmox_manager/models/proxmox_server.py
Normal file
164
odoo_proxmox_manager/models/proxmox_server.py
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
from odoo import models, fields, api
|
||||||
|
from proxmoxer import ProxmoxAPI
|
||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ProxmoxServer(models.Model):
|
||||||
|
_name = 'proxmox.server'
|
||||||
|
_description = 'Proxmox Server'
|
||||||
|
_order = 'name'
|
||||||
|
_inherit = ['mail.thread']
|
||||||
|
|
||||||
|
name = fields.Char(string='Name', required=True, tracking=True)
|
||||||
|
hostname = fields.Char(string='Hostname', required=True, tracking=True)
|
||||||
|
port = fields.Integer(string='Port', default=8006, tracking=True)
|
||||||
|
cluster_id = fields.Many2one('proxmox.cluster', string='Cluster', tracking=True)
|
||||||
|
username = fields.Char(string='Username', required=True, tracking=True)
|
||||||
|
token_name = fields.Char(string='API Token Name', tracking=True)
|
||||||
|
token_value = fields.Char(string='API Token Value', tracking=True)
|
||||||
|
verify_ssl = fields.Boolean(string='Verify SSL', default=True, tracking=True)
|
||||||
|
company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
|
||||||
|
state = fields.Selection([
|
||||||
|
('offline', 'Offline'),
|
||||||
|
('online', 'Online')
|
||||||
|
], string='Status', default='offline', compute='_compute_state', store=True, tracking=True)
|
||||||
|
vm_ids = fields.One2many('proxmox.vm', 'server_id', string='Virtual Machines')
|
||||||
|
vm_count = fields.Integer(string='VM Count', compute='_compute_vm_count')
|
||||||
|
node_info = fields.Text(string='Node Information', compute='_compute_node_info')
|
||||||
|
|
||||||
|
def _get_proxmox_connection(self):
|
||||||
|
try:
|
||||||
|
if not self.token_name or not self.token_value:
|
||||||
|
raise ValueError("API Token is required")
|
||||||
|
|
||||||
|
return ProxmoxAPI(
|
||||||
|
self.hostname,
|
||||||
|
port=self.port,
|
||||||
|
user=self.username,
|
||||||
|
token_name=self.token_name,
|
||||||
|
token_value=self.token_value,
|
||||||
|
verify_ssl=self.verify_ssl
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Failed to connect to Proxmox server {self.name}: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@api.depends('hostname', 'port', 'token_name', 'token_value')
|
||||||
|
def _compute_state(self):
|
||||||
|
for server in self:
|
||||||
|
try:
|
||||||
|
proxmox = server._get_proxmox_connection()
|
||||||
|
if proxmox:
|
||||||
|
# Test connection by getting version info
|
||||||
|
proxmox.version.get()
|
||||||
|
server.state = 'online'
|
||||||
|
else:
|
||||||
|
server.state = 'offline'
|
||||||
|
except:
|
||||||
|
server.state = 'offline'
|
||||||
|
|
||||||
|
@api.depends('vm_ids')
|
||||||
|
def _compute_vm_count(self):
|
||||||
|
for server in self:
|
||||||
|
server.vm_count = len(server.vm_ids)
|
||||||
|
|
||||||
|
def _compute_node_info(self):
|
||||||
|
for server in self:
|
||||||
|
try:
|
||||||
|
proxmox = server._get_proxmox_connection()
|
||||||
|
if proxmox:
|
||||||
|
node_info = proxmox.nodes(server.hostname).status.get()
|
||||||
|
server.node_info = str(node_info)
|
||||||
|
else:
|
||||||
|
server.node_info = "Unable to connect to server"
|
||||||
|
except Exception as e:
|
||||||
|
server.node_info = f"Error getting node info: {str(e)}"
|
||||||
|
|
||||||
|
def action_sync_vms(self):
|
||||||
|
"""Synchronize VMs from Proxmox server"""
|
||||||
|
self.ensure_one()
|
||||||
|
proxmox = self._get_proxmox_connection()
|
||||||
|
if not proxmox:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Get all VMs from the server
|
||||||
|
vms = proxmox.nodes(self.hostname).qemu.get()
|
||||||
|
|
||||||
|
# Update or create VM records
|
||||||
|
for vm in vms:
|
||||||
|
vm_vals = {
|
||||||
|
'name': vm.get('name'),
|
||||||
|
'vmid': vm.get('vmid'),
|
||||||
|
'status': vm.get('status'),
|
||||||
|
'server_id': self.id,
|
||||||
|
}
|
||||||
|
existing_vm = self.env['proxmox.vm'].search([
|
||||||
|
('server_id', '=', self.id),
|
||||||
|
('vmid', '=', vm.get('vmid'))
|
||||||
|
])
|
||||||
|
if existing_vm:
|
||||||
|
existing_vm.write(vm_vals)
|
||||||
|
else:
|
||||||
|
self.env['proxmox.vm'].create(vm_vals)
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Failed to sync VMs for server {self.name}: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
@api.model
|
||||||
|
def get_dashboard_data(self):
|
||||||
|
"""Get dashboard data for the Proxmox overview"""
|
||||||
|
servers = self.search([])
|
||||||
|
clusters = self.env['proxmox.cluster'].search([])
|
||||||
|
vms = self.env['proxmox.vm'].search([])
|
||||||
|
|
||||||
|
server_data = []
|
||||||
|
for server in servers:
|
||||||
|
try:
|
||||||
|
proxmox = server._get_proxmox_connection()
|
||||||
|
if proxmox:
|
||||||
|
node_info = proxmox.nodes(server.hostname).status.get()
|
||||||
|
memory_total = node_info.get('memory', {}).get('total', 0)
|
||||||
|
memory_used = node_info.get('memory', {}).get('used', 0)
|
||||||
|
memory_usage = round((memory_used / memory_total) * 100, 2) if memory_total else 0
|
||||||
|
|
||||||
|
cpu_usage = round(node_info.get('cpu', 0) * 100, 2)
|
||||||
|
|
||||||
|
server_data.append({
|
||||||
|
'id': server.id,
|
||||||
|
'name': server.name,
|
||||||
|
'state': server.state,
|
||||||
|
'vm_count': server.vm_count,
|
||||||
|
'memory_usage': memory_usage,
|
||||||
|
'cpu_usage': cpu_usage,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
server_data.append({
|
||||||
|
'id': server.id,
|
||||||
|
'name': server.name,
|
||||||
|
'state': 'offline',
|
||||||
|
'vm_count': server.vm_count,
|
||||||
|
'memory_usage': 0,
|
||||||
|
'cpu_usage': 0,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error(f"Error getting data for server {server.name}: {str(e)}")
|
||||||
|
server_data.append({
|
||||||
|
'id': server.id,
|
||||||
|
'name': server.name,
|
||||||
|
'state': 'offline',
|
||||||
|
'vm_count': server.vm_count,
|
||||||
|
'memory_usage': 0,
|
||||||
|
'cpu_usage': 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
'server_count': len(servers),
|
||||||
|
'cluster_count': len(clusters),
|
||||||
|
'vm_count': len(vms),
|
||||||
|
'servers': server_data,
|
||||||
|
}
|
||||||
87
odoo_proxmox_manager/models/proxmox_vm.py
Normal file
87
odoo_proxmox_manager/models/proxmox_vm.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
class ProxmoxVM(models.Model):
|
||||||
|
_name = 'proxmox.vm'
|
||||||
|
_description = 'Proxmox Virtual Machine'
|
||||||
|
_order = 'name'
|
||||||
|
_inherit = ['mail.thread']
|
||||||
|
|
||||||
|
name = fields.Char(string='Name', required=True, tracking=True)
|
||||||
|
vmid = fields.Integer(string='VM ID', required=True, tracking=True)
|
||||||
|
server_id = fields.Many2one('proxmox.server', string='Server', required=True, tracking=True)
|
||||||
|
company_id = fields.Many2one('res.company', string='Company', required=True,
|
||||||
|
related='server_id.company_id', store=True, readonly=True)
|
||||||
|
status = fields.Selection([
|
||||||
|
('running', 'Running'),
|
||||||
|
('stopped', 'Stopped'),
|
||||||
|
('suspended', 'Suspended')
|
||||||
|
], string='Status', default='stopped', tracking=True)
|
||||||
|
memory = fields.Integer(string='Memory (MB)')
|
||||||
|
cpus = fields.Integer(string='vCPUs')
|
||||||
|
disk_size = fields.Float(string='Disk Size (GB)')
|
||||||
|
ip_address = fields.Char(string='IP Address')
|
||||||
|
|
||||||
|
def action_start(self):
|
||||||
|
"""Start the virtual machine"""
|
||||||
|
self.ensure_one()
|
||||||
|
proxmox = self.server_id._get_proxmox_connection()
|
||||||
|
if proxmox:
|
||||||
|
try:
|
||||||
|
proxmox.nodes(self.server_id.hostname).qemu(self.vmid).status.start.post()
|
||||||
|
self.status = 'running'
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def action_stop(self):
|
||||||
|
"""Stop the virtual machine"""
|
||||||
|
self.ensure_one()
|
||||||
|
proxmox = self.server_id._get_proxmox_connection()
|
||||||
|
if proxmox:
|
||||||
|
try:
|
||||||
|
proxmox.nodes(self.server_id.hostname).qemu(self.vmid).status.stop.post()
|
||||||
|
self.status = 'stopped'
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def action_restart(self):
|
||||||
|
"""Restart the virtual machine"""
|
||||||
|
self.ensure_one()
|
||||||
|
proxmox = self.server_id._get_proxmox_connection()
|
||||||
|
if proxmox:
|
||||||
|
try:
|
||||||
|
proxmox.nodes(self.server_id.hostname).qemu(self.vmid).status.reset.post()
|
||||||
|
self.status = 'running'
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def action_suspend(self):
|
||||||
|
"""Suspend the virtual machine"""
|
||||||
|
self.ensure_one()
|
||||||
|
proxmox = self.server_id._get_proxmox_connection()
|
||||||
|
if proxmox:
|
||||||
|
try:
|
||||||
|
proxmox.nodes(self.server_id.hostname).qemu(self.vmid).status.suspend.post()
|
||||||
|
self.status = 'suspended'
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def action_resume(self):
|
||||||
|
"""Resume the virtual machine"""
|
||||||
|
self.ensure_one()
|
||||||
|
proxmox = self.server_id._get_proxmox_connection()
|
||||||
|
if proxmox:
|
||||||
|
try:
|
||||||
|
proxmox.nodes(self.server_id.hostname).qemu(self.vmid).status.resume.post()
|
||||||
|
self.status = 'running'
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
2
odoo_proxmox_manager/requirements.txt
Normal file
2
odoo_proxmox_manager/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
proxmoxer>=1.3.1
|
||||||
|
requests>=2.31.0
|
||||||
9
odoo_proxmox_manager/security/ir.model.access.csv
Normal file
9
odoo_proxmox_manager/security/ir.model.access.csv
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||||
|
access_proxmox_server_user,proxmox.server.user,model_proxmox_server,group_proxmox_user,1,0,0,0
|
||||||
|
access_proxmox_server_manager,proxmox.server.manager,model_proxmox_server,group_proxmox_manager,1,1,1,1
|
||||||
|
access_proxmox_cluster_user,proxmox.cluster.user,model_proxmox_cluster,group_proxmox_user,1,0,0,0
|
||||||
|
access_proxmox_cluster_manager,proxmox.cluster.manager,model_proxmox_cluster,group_proxmox_manager,1,1,1,1
|
||||||
|
access_proxmox_vm_user,proxmox.vm.user,model_proxmox_vm,group_proxmox_user,1,1,0,0
|
||||||
|
access_proxmox_vm_manager,proxmox.vm.manager,model_proxmox_vm,group_proxmox_manager,1,1,1,1
|
||||||
|
access_proxmox_server_wizard,proxmox.server.wizard,model_proxmox_server_wizard,base.group_user,1,1,1,0
|
||||||
|
access_proxmox_server_wizard_node,proxmox.server.wizard.node,model_proxmox_server_wizard_node,base.group_user,1,1,1,0
|
||||||
|
39
odoo_proxmox_manager/security/proxmox_security.xml
Normal file
39
odoo_proxmox_manager/security/proxmox_security.xml
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<data>
|
||||||
|
<record id="module_proxmox_category" model="ir.module.category">
|
||||||
|
<field name="name">Proxmox Management</field>
|
||||||
|
<field name="description">Manage Proxmox servers and virtual machines</field>
|
||||||
|
<field name="sequence">20</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_proxmox_user" model="res.groups">
|
||||||
|
<field name="name">User</field>
|
||||||
|
<field name="category_id" ref="module_proxmox_category"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="group_proxmox_manager" model="res.groups">
|
||||||
|
<field name="name">Manager</field>
|
||||||
|
<field name="category_id" ref="module_proxmox_category"/>
|
||||||
|
<field name="implied_ids" eval="[(4, ref('group_proxmox_user'))]"/>
|
||||||
|
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
|
||||||
|
<data noupdate="1">
|
||||||
|
<record id="proxmox_server_rule" model="ir.rule">
|
||||||
|
<field name="name">Proxmox Server Multi-Company</field>
|
||||||
|
<field name="model_id" ref="model_proxmox_server"/>
|
||||||
|
<field name="global" eval="True"/>
|
||||||
|
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<record id="proxmox_cluster_rule" model="ir.rule">
|
||||||
|
<field name="name">Proxmox Cluster Multi-Company</field>
|
||||||
|
<field name="model_id" ref="model_proxmox_cluster"/>
|
||||||
|
<field name="global" eval="True"/>
|
||||||
|
<field name="domain_force">['|', ('company_id', '=', False), ('company_id', 'in', company_ids)]</field>
|
||||||
|
</record>
|
||||||
|
</data>
|
||||||
|
</odoo>
|
||||||
BIN
odoo_proxmox_manager/static/description/icon.png
Normal file
BIN
odoo_proxmox_manager/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 833 B |
|
|
@ -0,0 +1,32 @@
|
||||||
|
/** @odoo-module **/
|
||||||
|
|
||||||
|
import { ListController } from "@web/views/list/list_controller";
|
||||||
|
import { registry } from "@web/core/registry";
|
||||||
|
import { listView } from "@web/views/list/list_view";
|
||||||
|
|
||||||
|
class ProxmoxServerListController extends ListController {
|
||||||
|
setup() {
|
||||||
|
super.setup();
|
||||||
|
}
|
||||||
|
|
||||||
|
async onAddServer() {
|
||||||
|
await this.env.services.action.doAction({
|
||||||
|
type: 'ir.actions.act_window',
|
||||||
|
res_model: 'proxmox.server.wizard',
|
||||||
|
view_mode: 'form',
|
||||||
|
view_type: 'form',
|
||||||
|
views: [[false, 'form']],
|
||||||
|
target: 'new',
|
||||||
|
context: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProxmoxServerListController.template = 'odoo_proxmox_manager.ProxmoxServerListView.Buttons';
|
||||||
|
|
||||||
|
export const proxmoxServerList = {
|
||||||
|
...listView,
|
||||||
|
Controller: ProxmoxServerListController,
|
||||||
|
};
|
||||||
|
|
||||||
|
registry.category("views").add("proxmox_server_list", proxmoxServerList);
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue