Compare commits

...

52 commits

Author SHA1 Message Date
mathis
72ee664967 Fix Work Order Report translations by ensuring proper translation tags and context 2025-06-26 14:29:22 -04:00
mathis
8775cec248 Fix timezones printing in UTC instead of client`s timezone in FSM work orders 2025-06-17 10:27:14 -04:00
mathis
b90466786d Fixed FSM work orders printing with UTC times 2025-06-13 11:13:08 -04:00
mathis
67fcffb37a Durpro inheritance mixin editing and work order print depending on language of work order contact 2025-06-13 09:35:32 -04:00
Marc Durepos
20e4f6c8b3 caldav_sync: v0.8.0 - disable notifications when polling server
- 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.
2025-06-10 10:13:45 -04:00
Marc Durepos
401818765a confirm_many2one_create: simplify js 2025-05-16 11:01:56 -04:00
Marc Durepos
d1e97e3448 fix confirm_many2one_create JS 2025-05-16 10:57:10 -04:00
Marc Durepos
0b5a791e1f add tests for caldav_sync, new module preamble_on_quotation 2025-05-16 09:52:07 -04:00
Marc Durepos
4b19f8262e caldav_sync: further fixes for organizer issues 2025-05-16 09:52:07 -04:00
Marc Durepos
3ebadcb969 caldav_sync: fix for incorrect organizer setting
Prior to this fix, the organizer on events was being incorrectly set to
the database's admin user in some cases, when synchronizing an event
from the CalDAV server.
2025-05-16 09:52:07 -04:00
Marc Durepos
db10db822c caldav_sync bug fixes:
* Fixed an issue where accepting events from an external organizer would send emails
  to the all event attendees upon synchronization.
* Corrected the data model for events where multiple Odoo users are attendees for the
  same event. Now, only one event is created in Odoo, with all the attendees on the
  same event. This conforms to Odoo's existing calendar event data model.
* Updated the setting of the organizer field (user_id) on the calendar events upon
  synchronization. Previously, the organizer field was always set to the current user
  whose events were being synchronized. Now, it is set based on the ORGANIZER parameter
  of the iCalendar event, if present. If not present, it defaults to the current synchronizing
  user. In the case that the organizer is external, the user_id field is left blank.
2025-05-16 09:51:42 -04:00
Marc Durepos
9fb835db34 [FIX] bemade_fsm: correctly set partner on subtasks for visits
Prior to this commit, subtasks that were added (from task templates) as
part of a visit (from sales orders), were having their partner_id set to
False by a built-in Odoo _compute_partner_id method.

This fix (and previous commits) introduce a test that correctly failed
when this behaviour was broken as well as modifications to the behaviour
of sale order lines and tasks to ensure that all tasks created from a
sale order for an FSM project correctly get their partner_id set to the
sale order's delivery address.
2025-04-24 08:49:33 -04:00
Marc Durepos
512bf58da8 Merge commit '873a7ba7' into 17.0 2025-04-24 08:34:24 -04:00
Marc Durepos
873a7ba71a bemade_fsm: add a failing test to demonstrate partner assignment bug 2025-04-24 08:34:03 -04:00
Marc Durepos
b19da588cd bemade_fsm: correct partner_id on task to use partner_shipping_id from sale order 2025-04-24 08:30:16 -04:00
Marc Durepos
ab60babd7d fix division by zero in vendor pricelist 2025-04-23 15:05:48 -04:00
Marc Durepos
055ceede53 fsm_visit_confirmation: only send email for top-level tasks 2025-02-27 14:52:16 -05:00
Marc Durepos
9859a3cf96 fsm_visit_confirmation: don't auto-delete email from chatter 2025-02-27 14:39:31 -05:00
Marc Durepos
57b401ca1a fsm_visit_confirmation: ensure token before sending email 2025-02-27 14:36:19 -05:00
Marc Durepos
2322dbaae1 fsm_visit_confirmation: completely separated from ratings now 2025-02-26 21:25:37 -05:00
Marc Durepos
81a4a904c7 fsm_visit_confirmation: translate template, move away from ratings 2025-02-26 20:58:14 -05:00
Marc Durepos
95a95298bb fsm_visit_confirmation: new module good to go 2025-02-26 14:52:17 -05:00
Marc Durepos
f9f32aab34 new module fsm_visit_confirmation 2025-02-26 10:12:33 -05:00
Marc Durepos
65fbcb9b33 caldav_sync bug fixes:
* Fixed an issue where accepting events from an external organizer would send emails
  to the all event attendees upon synchronization.
* Corrected the data model for events where multiple Odoo users are attendees for the
  same event. Now, only one event is created in Odoo, with all the attendees on the
  same event. This conforms to Odoo's existing calendar event data model.
* Updated the setting of the organizer field (user_id) on the calendar events upon
  synchronization. Previously, the organizer field was always set to the current user
  whose events were being synchronized. Now, it is set based on the ORGANIZER parameter
  of the iCalendar event, if present. If not present, it defaults to the current synchronizing
  user. In the case that the organizer is external, the user_id field is left blank.
2025-02-07 12:59:29 -05:00
Benoît Vézina
799e944112 fix and validated date_deadline 2025-02-06 10:19:48 -05:00
Benoît Vézina
7ab530bd29 Fixe date deadline cascade 2025-02-05 16:13:34 -05:00
xtremxpert
b1a625a0e4 wip 2025-02-05 13:14:42 -05:00
Marc Durepos
45c1bdf440 bemade_fsm: fix a bug where changing the delivery address on a canceled/re-drafted SO screwed up subtasks 2025-01-27 11:00:09 -05:00
Marc Durepos
370ebae08b bemade_fsm: Fix broken tests 2025-01-27 08:42:37 -05:00
Marc Durepos
d334b63696 bemade_fsm: use commercial partner id to calculate valid equipments on SO 2025-01-17 10:39:35 -05:00
Marc Durepos
424e9e5159 nuke_mid_task: Get rid of unnecessary security folder 2025-01-13 13:12:06 +00:00
Benoît Vézina
1db303d0a5 nuke task 2025-01-10 11:38:33 -05:00
xtremxpert
5050839056 Unifi to test 2025-01-06 08:02:03 -05:00
Benoît Vézina
010752b01f itch work? 2025-01-02 08:06:56 -05:00
Benoît Vézina
d644841f99 js work 2024-12-23 11:40:07 -05:00
xtremxpert
3ba182fe0e fick js 2024-12-19 11:20:44 -05:00
Benoît Vézina
b06e9c4569 dawn js 2024-12-19 10:16:30 -05:00
Marc Durepos
f3a7072314 price_update_notifications: add mail.activity.mixin to notices 2024-12-17 16:11:06 -05:00
Benoît Vézina
57c938bb8d itch 2024-12-17 15:40:14 -05:00
xtremxpert
fa4f1b2b19 Merge branch '17.0' of git.bemade.org:bemade/bemade-addons into 17.0 2024-12-17 12:51:57 -05:00
xtremxpert
536f10b61e itch et unifi 2024-12-17 12:51:45 -05:00
Marc Durepos
f6ab94e0b3 caldav_sync: add SELF_WRITEABLE_FIELDS for users 2024-12-17 10:43:10 -05:00
Benoît Vézina
9578594628 update 2024-12-14 06:53:23 -05:00
Marc Durepos
5a97745d42 [FIX] set timezone on context if empty in event and user
Prior to this commit, synchronizing an event to the CalDAV server could
break in the _add_event_dates method if both self.event_tz and
self.env.user.tz were empty (False). This should be a rare occurrence,
but adding a fallback of self._context.get('tz') should help.
2024-12-12 08:45:11 -05:00
Marc Durepos
a0ff56ade2 fix product_template_id field ref on sale order line for price update notif 2024-12-10 09:34:10 -05:00
Marc Durepos
8ce49de715 fix to translation for price update notice 2024-12-10 09:27:43 -05:00
Marc Durepos
3f6f452fe1 fix to translation for price update notice 2024-12-10 09:26:04 -05:00
Marc Durepos
8c1758dacd Merge commit '58e49f0d' into 17.0 2024-12-10 09:12:43 -05:00
Marc Durepos
58e49f0db5 bug fix for price update 2024-12-10 09:12:26 -05:00
xtremxpert
5b18adb083 Merge branch '17.0' of git.bemade.org:bemade/bemade-addons into 17.0 2024-12-09 09:36:06 -05:00
xtremxpert
fb057ada9a more on that 2024-12-09 09:35:56 -05:00
xtremxpert
7783b04e73 wip 2024-12-02 15:12:08 -05:00
206 changed files with 15655 additions and 480 deletions

View file

@ -20,7 +20,7 @@
########################################################################################
{
"name": "Improved Field Service Management",
"version": "17.0.0.4.2",
"version": "17.0.0.4.1",
"summary": (
"Adds functionality necessary for managing field service operations at Durpro."
),
@ -39,6 +39,7 @@
"fsm_equipment",
"bemade_partner_root_ancestor",
"mail",
"durpro_tag_inheritance",
],
"data": [
"data/fsm_data.xml",

View file

@ -1068,16 +1068,18 @@ msgstr "Travaux exécutés"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.workorder_page_info_block
msgid ""
"Work Order\n"
" Contacts:"
msgstr ""
"Contacts recevant le bon de travail :"
msgid "Work Order Contacts:"
msgstr "Contact recevant le bon de travail : "
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__work_order_contacts
msgid "Work Order Contacts"
msgstr "Contacts recevant le bon de travail"
#: model_terms:ir.ui.view,arch_db:bemade_fsm.work_order_page
msgid "Phone:"
msgstr "Téléphone:"
#. module: bemade_fsm
#: model_terms:ir.ui.view,arch_db:bemade_fsm.work_order_page
msgid "Email:"
msgstr "Courriel:"
#. module: bemade_fsm
#: model:ir.model.fields,field_description:bemade_fsm.field_project_task__work_order_number

View file

@ -5,7 +5,8 @@ class SaleOrder(models.Model):
_inherit = "sale.order"
valid_equipment_ids = fields.One2many(
comodel_name="fsm.equipment", related="partner_id.owned_equipment_ids"
comodel_name="fsm.equipment",
related="partner_id.commercial_partner_id.owned_equipment_ids",
)
default_equipment_ids = fields.Many2many(
@ -50,7 +51,6 @@ class SaleOrder(models.Model):
store=True,
)
@api.depends("order_line.task_id")
def get_relevant_order_lines(self, task_id):
self.ensure_one()
linked_lines = self.order_line.filtered(

View file

@ -111,14 +111,16 @@ class SaleOrderLine(models.Model):
for t in template.subtasks:
subtask = _create_task_from_template(project, t, task)
subtasks.append(subtask)
# task.write({"child_ids": [Command.set([t.id for t in subtasks])]})
# We don't want to see the sub-tasks on the SO
task.child_ids.write(
{
"sale_order_id": None,
"sale_line_id": None,
}
)
if task.child_ids:
task.child_ids.write(
{
"sale_order_id": None,
"sale_line_id": None,
}
)
return task
def _timesheet_create_task_prepare_values_from_template(
@ -142,7 +144,11 @@ class SaleOrderLine(models.Model):
vals["tag_ids"] = template.tags.ids
vals["allocated_hours"] = template.planned_hours
vals["sequence"] = template.sequence
vals["partner_id"] = self.order_id.partner_id.id
# Use shipping address for FSM tasks for consistency
if project and project.is_fsm:
vals["partner_id"] = self.order_id.partner_shipping_id.id
else:
vals["partner_id"] = self.order_id.partner_id.id
if template.equipment_ids:
vals["equipment_ids"] = template.equipment_ids.ids
return vals
@ -150,6 +156,9 @@ class SaleOrderLine(models.Model):
tmpl = self.product_id.task_template_id
if not tmpl:
task = super()._timesheet_create_task(project)
# For FSM tasks without a template, update partner_id to use shipping address
if project.is_fsm and task:
task.partner_id = self.order_id.partner_shipping_id.id
else:
task = _create_task_from_template(project, tmpl, None)
self.write({"task_id": task.id})
@ -163,6 +172,8 @@ class SaleOrderLine(models.Model):
"product_name": self.product_id.name,
}
task.message_post(body=task_msg)
task._inherit_tags_from(self.order_id)
if not task.equipment_ids and self.equipment_ids:
task.equipment_ids = self.equipment_ids.ids
return task

View file

@ -3,6 +3,8 @@ from odoo.addons.project.models.project_task import CLOSED_STATES
import re
class Task(models.Model):
_inherit = "project.task"
@ -65,6 +67,7 @@ class Task(models.Model):
res = super().create(vals)
for rec in res:
if rec.parent_id and rec.is_fsm:
# Always ensure FSM subtasks have a partner_id set from their parent
rec.partner_id = rec.parent_id.partner_id
if not rec.work_order_contacts and rec.parent_id:
rec.work_order_contacts = rec.parent_id.work_order_contacts
@ -123,7 +126,8 @@ class Task(models.Model):
)
if "partner_id" in vals:
child_vals.update(partner_id=vals["partner_id"])
rec.child_ids.write(child_vals)
if child_vals:
rec.child_ids.write(child_vals)
return res
@api.depends("sale_order_id")
@ -220,3 +224,42 @@ class Task(models.Model):
def _compute_root_ancestor(self):
for rec in self:
rec.root_ancestor = rec.parent_id and rec.parent_id.root_ancestor or self
@api.depends(
"partner_id",
"sale_line_id.order_partner_id",
"parent_id.sale_line_id",
"project_id.sale_line_id",
"milestone_id.sale_line_id",
"allow_billable",
)
def _compute_sale_line(self):
"""Override to prevent subtasks from inheriting parent's sale_line_id.
In the base implementation, if a task and its parent share the same commercial partner,
the task will inherit the parent's sale_line_id. This causes issues with our FSM tasks
where we explicitly want subtasks to NOT have a sale_line_id set.
"""
# Only run on root tasks
subtasks = self.filtered("parent_id")
(subtasks - subtasks.filtered("sale_line_id")).sale_line_id = False
super(Task, self - subtasks)._compute_sale_line()
@api.depends("parent_id.partner_id", "project_id")
def _compute_partner_id(self):
"""Override to prevent clearing partner_id for FSM tasks.
In the base implementation, if a task has a partner_id but no project_id or parent_id,
the partner_id is cleared. This causes issues with our FSM tasks where we want to
preserve the partner_id even if project_id or parent_id is temporarily not set.
"""
# Only run the standard logic on non-FSM tasks
non_fsm_tasks = self.filtered(lambda t: not t.is_fsm)
super(Task, non_fsm_tasks)._compute_partner_id()
# For FSM tasks, only set partner_id if it's not already set
fsm_tasks = self - non_fsm_tasks
for task in fsm_tasks:
if not task.partner_id:
task.partner_id = self._get_default_partner_id(task.project_id, task.parent_id)

View file

@ -350,7 +350,8 @@
t-esc="doc.partner_id"
t-options='{
"widget": "contact",
"fields": ["name", "address",]
"fields": ["name", "address",],
"lang": "fr_FR"
}'
/>
</t>
@ -361,11 +362,11 @@
>
<div t-if="doc.planned_date_begin"><h6>Planned start: </h6></div>
<div class="mb-3">
<div t-out="doc.planned_date_begin.strftime('%Y-%m-%d %H:%M')" />
<div t-esc="context_timestamp(doc.planned_date_begin).strftime('%Y-%m-%d %H:%M')" />
</div>
<div t-if="doc.date_deadline"><h6>Planned end: </h6></div>
<div class="mb-3">
<div t-out="doc.date_deadline.strftime('%Y-%m-%d %H:%M')" />
<div t-out="context_timestamp(doc.date_deadline).strftime('%Y-%m-%d %H:%M')" />
</div>
</div>
</div>
@ -373,7 +374,7 @@
<div
t-attf-class="{{'col-6' if report_type == 'pdf' else 'col-md-6 col-12'}}"
>
<div t-if="doc.site_contacts"><h6>Site Contacts: </h6></div>
<div t-if="doc.site_contacts"><h6>Site Contacts:</h6></div>
<t t-foreach="doc.site_contacts" t-as="contact">
<div class="mb-3">
<div
@ -389,8 +390,7 @@
<div
t-attf-class="{{'col-6' if report_type == 'pdf' else 'col-md-6 col-12'}}"
>
<div t-if="doc.work_order_contacts"><h6>Work Order
Contacts: </h6></div>
<div t-if="doc.work_order_contacts"><h6>Work Order Contacts:</h6></div>
<t t-foreach="doc.work_order_contacts" t-as="contact">
<div class="mb-3">
<div
@ -563,12 +563,9 @@
<template id="work_order">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-set="doc" t-value="doc.root_ancestor" t-if="doc.parent_id" />
<t t-call="web.external_layout">
<t
t-call="bemade_fsm.work_order_page"
t-lang="doc.partner_id.lang"
/>
<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-lang="(doc.work_order_contacts and doc.work_order_contacts[0].lang) or 'en_US'">
<t t-call="bemade_fsm.work_order_page" t-lang="(doc.work_order_contacts and doc.work_order_contacts[0].lang) or 'en_US'"/>
</t>
</t>
</t>
@ -582,7 +579,7 @@
expr="//t[@t-call='industry_fsm_report.worksheet_custom_page']"
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>
</template>
</odoo>

View file

@ -42,6 +42,9 @@ class BemadeFSMBaseTest(TransactionCase):
user_group_fsm_user = cls.env.ref("industry_fsm.group_fsm_user")
user_group_sales_user = cls.env.ref("sales_team.group_sale_salesman")
user_group_sales_manager = cls.env.ref("sales_team.group_sale_manager")
user_group_delivery_address = cls.env.ref(
"account.group_delivery_invoice_address"
)
user_product_customer = cls.env.ref(
"customer_product_code.group_product_customer_code_user",
raise_if_not_found=False,
@ -53,6 +56,7 @@ class BemadeFSMBaseTest(TransactionCase):
user_group_fsm_user.id,
user_group_sales_manager.id,
user_group_sales_user.id,
user_group_delivery_address.id,
]
if user_product_customer:
group_ids.append(user_product_customer.id)

View file

@ -10,7 +10,7 @@ class TestEquipment(BemadeFSMBaseTest):
partner = self._generate_partner()
partner_2 = self._generate_partner()
equipment_1 = self._generate_equipment(partner=partner)
equipment_2 = self._generate_equipment(partner_2)
equipment_2 = self._generate_equipment(partner=partner_2)
sale_order = self._generate_sale_order(partner=partner)
product = self._generate_product()
self.assertEqual(sale_order.valid_equipment_ids, equipment_1)

View file

@ -142,3 +142,55 @@ class FSMVisitTest(BemadeFSMBaseTest):
supposed_name = "SVR12345-1 - Test Company - Test Label"
self.assertEqual(task.name, supposed_name)
def test_subtasks_inherit_partner_from_parent_task(self):
"""Test that subtasks of tasks created from FSM sales orders have their partner_id set correctly."""
# Create a sale order with a shipping address different from the billing address
partner = self._generate_partner(name="Customer")
shipping_partner = self.env['res.partner'].create({
'name': 'Shipping Address',
'parent_id': partner.id,
'type': 'delivery',
})
# Create a sale order with the customer and shipping address
so = self._generate_sale_order(partner=partner)
so.partner_shipping_id = shipping_partner
# Create a visit
visit = self._generate_visit(sale_order=so)
# Create a product with a task template that has subtasks
parent_template = self._generate_task_template(
structure=[1],
names=["Parent Task", "Child Task"],
)
product = self._generate_product(task_template=parent_template)
# Add the product to the sale order
sol = self._generate_sale_order_line(sale_order=so, product=product)
# Set the sequence to ensure proper ordering
visit.so_section_id.sequence = 1
sol.sequence = 2
# Confirm the sale order to create tasks
so.action_confirm()
# Get the created tasks
parent_task = sol.task_id
self.assertTrue(parent_task, "Parent task should be created")
# Check that the parent task has a partner_id set
self.assertTrue(parent_task.partner_id, "Parent task should have a partner set")
# Check that the subtask exists
self.assertTrue(parent_task.child_ids, "Parent task should have subtasks")
child_task = parent_task.child_ids[0]
# The key test: Check that the subtask has a partner_id set
self.assertTrue(child_task.partner_id, "Subtask should have a partner_id set")
# Check that the subtask has the same partner as the parent task
self.assertEqual(child_task.partner_id, parent_task.partner_id,
"Subtask should have the same partner as its parent task")

View file

@ -127,7 +127,7 @@ class TestSalesOrder(BemadeFSMBaseTest):
parent = self._generate_partner()
child = self._generate_partner(parent=parent)
for i in range(3):
self._generate_equipment(child)
self._generate_equipment(partner=child)
sale_order = self._generate_sale_order(partner=parent)
@ -139,11 +139,11 @@ class TestSalesOrder(BemadeFSMBaseTest):
parent = self._generate_partner()
child = self._generate_partner(parent=parent)
for i in range(4):
self._generate_equipment(child)
self._generate_equipment(partner=child)
sale_order = self._generate_sale_order(partner=parent)
self.assertEqual(sale_order.default_equipment_ids, parent.owned_equipment_ids)
self.assertEqual(sale_order.default_equipment_ids, self.env["fsm.equipment"])
def test_sale_order_resets_default_equipment_on_partner_change(self):
partner_1 = self._generate_partner()
@ -360,4 +360,115 @@ class TestSalesOrder(BemadeFSMBaseTest):
}
)
for task in parent_task._get_all_subtasks() | parent_task:
self.assertEqual(so.partner_shipping_id, task.partner_id)
self.assertEqual(
so.partner_shipping_id,
task.partner_id,
f"{task.name} has a different partner than the SO",
)
def test_task_hierarchy_maintained_after_cancel_reconfirm(self):
"""Test that task hierarchy and project assignments are maintained when canceling
and reconfirming a sale order with a templated FSM product."""
self.env.user.groups_id |= self.env.ref(
"account.group_delivery_invoice_address"
)
# Create a task template with subtasks
parent_template = self._generate_task_template(
structure=[2], # Two subtasks
names=["Main Service", "Subtask"],
planned_hours=8,
)
# Create FSM product with template
product = self._generate_product(task_template=parent_template)
# Create and confirm sale order
partner = self._generate_partner()
partner_2 = self._generate_partner(parent=partner, company_type="person")
self.assertEqual(partner_2.commercial_partner_id, partner)
so = self._generate_sale_order(partner=partner)
sol = self._generate_sale_order_line(so, product=product)
so.action_confirm()
# Get initial tasks and verify setup
main_task = sol.task_id
self.assertTrue(main_task, "Main task should be created")
self.assertTrue(main_task.project_id, "Main task should have a project")
subtasks = main_task.child_ids
self.assertEqual(len(subtasks), 2, "Should have created 2 subtasks")
self.assertEqual(so.tasks_count, 1, "Should have only 1 task on confirmation")
# Verify initial task hierarchy
initial_project = main_task.project_id
for subtask in subtasks:
self.assertEqual(
subtask.project_id,
initial_project,
"Subtask should have same project as main task",
)
self.assertFalse(
subtask.sale_order_id, "Subtask should not be linked to sale order"
)
self.assertFalse(
subtask.sale_line_id, "Subtask should not be linked to sale order line"
)
# Store initial names for comparison
initial_subtask_names = subtasks.mapped("name")
original_task_names = (main_task | main_task._get_all_subtasks()).mapped("name")
# Cancel and reconfirm the sale order
so.with_context(disable_cancel_warning=True).action_cancel()
so.action_draft()
so.write({"partner_shipping_id": partner_2.id})
# Get new tasks
new_main_task = sol.task_id
self.assertEqual(
new_main_task, main_task, "New main task should be same as old"
)
new_subtasks = new_main_task.child_ids
new_subtasks._compute_sale_line()
self.assertEqual(
len(new_subtasks), 2, "Should still have 2 subtasks after reconfirmation"
)
self.assertFalse(
new_subtasks.sale_line_id,
"Subtasks should not be linked to Sale Order Line",
)
new_task_names = (new_main_task | new_main_task._get_all_subtasks()).mapped(
"name"
)
self.assertEqual(
new_task_names, original_task_names, "New task names should be the same"
)
# Verify task hierarchy is maintained
self.assertEqual(
new_main_task.project_id,
initial_project,
"New main task should have same project",
)
for subtask in new_subtasks:
self.assertEqual(
subtask.project_id,
initial_project,
"New subtask should maintain same project as main task",
)
self.assertFalse(
subtask.sale_order_id, "New subtask should not be linked to sale order"
)
self.assertFalse(
subtask.sale_line_id,
"New subtask should not be linked to sale order line",
)
# Verify subtask names are maintained
self.assertEqual(
sorted(new_subtasks.mapped("name")),
sorted(initial_subtask_names),
"Subtask names should be maintained after reconfirmation",
)

View file

@ -69,10 +69,10 @@ class SaleOrderLine(models.Model):
line.purchase_price_actual = \
(stock_missing * line.purchase_price_vendor
+ qty_from_stock * line.purchase_price) \
/ line.product_uom_qty
/ line.product_uom_qty if line.product_uom_qty != 0 else 0
line.gross_profit = line.price_subtotal - (
line.purchase_price_actual * line.product_uom_qty)
line.gross_profit_percent = line.price_subtotal and line.gross_profit / line.price_subtotal
line.gross_profit_percent = line.price_subtotal and line.gross_profit / line.price_subtotal if line.price_subtotal != 0 else 0
def _determine_missing_stock(self) -> float:
""" Compute how much stock is missing to meet an order line's demand. In the

View file

@ -33,10 +33,7 @@ class Company(models.Model):
("specific", "Specific Vendors"),
],
default="all",
help=(
"Choose whether to apply overdue warnings to all vendors or only to "
"specific vendors."
),
help="Choose whether to apply overdue warnings to all vendors or only to specific vendors.",
)
warn_supplier_specific_ids = fields.Many2many(

View file

@ -54,8 +54,22 @@ Technical Details
Change Log
----------
17.0.0.6.0
^^^^^^^^^^
0.8.0
^^^^^
* Disable sending of notification emails when events are created or updated
in Odoo during a CalDAV server synchronization.
* General code cleanup with improved type hints.
0.7.0
^^^^^
* Stopped the import of past events when synchronizing from the CalDAV server.
This should help with performance, timeouts and avoid importing events that
are not relevant to the user.
0.6.0
^^^^^
* Fixed an issue where synchronizing events created duplicate events on every sync.
* Completely revamped and synchronization of recurring events in both directions.

View file

@ -8,7 +8,7 @@
{
"name": "CalDAV Synchronization",
"version": "17.0.0.6.0",
"version": "17.0.0.8.0",
"license": "LGPL-3",
"category": "Productivity",
"summary": "Synchronize Odoo Calendar Events with CalDAV Servers",

View file

@ -2,3 +2,4 @@
from . import calendar_event
from . import res_users
from . import calendar_recurrence
from . import calendar_attendee

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

View file

@ -2,20 +2,30 @@ import uuid
import icalendar.cal
from odoo import models, api, fields
from odoo import models, api, fields, _
from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
import caldav
from caldav.lib.error import NotFoundError
import logging
from datetime import datetime
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event
from icalendar import vCalAddress, vText, vDatetime, vRecur, Event, vDate
import re
from pytz import timezone, utc
from typing import List, Dict, TypeVar, Optional
from typing import List, Dict, Optional, Any, TYPE_CHECKING
from markdownify import markdownify as md
import markdown2 as md2
_logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from odoo.addons.base.models.res_users import Users as User
from odoo.addons.base.models.res_partner import Partner
from odoo.addons.calendar.models.calendar_event import Meeting as OdooCalendarEvent
else:
User = models.Model
Partner = models.Model
OdooCalendarEvent = models.Model
WEEKDAY_MAP = {
0: "MO",
1: "TU",
@ -26,35 +36,59 @@ WEEKDAY_MAP = {
6: "SU",
}
CalendarEvent = TypeVar("calendar.event", bound=models.Model)
User = TypeVar("res.users", bound=models.Model)
Partner = TypeVar("res.partner", bound=models.Model)
def _parse_rrule_string(rrule_str: str) -> Dict[str, Any]:
"""Parse a string representing an RRULE into a dictionary of its parts.
def _parse_rrule_string(rrule_str):
def try_to_int(part):
try:
return int(part)
except Exception:
return part
Takes a string like "RRULE:FREQ=WEEKLY;UNTIL=20221231T000000Z;BYDAY=MO"
and returns a dictionary with proper types for vRecur.
"""
from icalendar import vDDDTypes, vWeekday, vFrequency
regex_str = "RRULE:(.*)$"
regex = re.compile(regex_str)
params_match = regex.search(rrule_str)
params_part = params_match.groups()[0]
params = params_part.split(";")
params_dict = {}
for param in params:
parts = param.split("=")
if parts[0].upper() == "UNTIL":
if "T" in parts[1]:
parts[1] = datetime.strptime(parts[1], "%Y%m%dT%H%M%S")
def parse_value(key: str, value: str) -> Any:
if key == "UNTIL":
# Convert to datetime and wrap in vDDDTypes
if "T" in value:
dt = datetime.strptime(value, "%Y%m%dT%H%M%S")
else:
parts[1] = datetime.strptime(parts[1], "%Y%m%d")
if parts[0].upper() == "BYDAY":
parts[1] = [part for part in parts[1].split(",")]
params_dict.update({parts[0]: try_to_int(parts[1])})
return params_dict
dt = datetime.strptime(value, "%Y%m%d")
return vDDDTypes(dt)
elif key in ("WKST", "BYDAY", "BYWEEKDAY"):
# Convert to vWeekday
return [vWeekday(day) for day in value.split(",")]
elif key == "FREQ":
# vFrequency will handle the conversion
return vFrequency(value)
elif key in (
"COUNT",
"INTERVAL",
"BYSECOND",
"BYMINUTE",
"BYHOUR",
"BYWEEKNO",
"BYMONTHDAY",
"BYYEARDAY",
"BYMONTH",
"BYSETPOS",
):
# Convert to int or list of ints
if "," in value:
return [int(v) for v in value.split(",")]
return int(value)
return value
if not rrule_str.startswith("RRULE:"):
return {}
params = rrule_str[6:] # Remove 'RRULE:'
result = {}
for param in params.split(";"):
if "=" in param:
key, value = param.split("=", 1)
key = key.upper()
result[key] = parse_value(key, value)
return result
def _extract_vcal_email(vcal_address):
@ -133,19 +167,6 @@ class CalendarEvent(models.Model):
or not event.follow_recurrence
)
@api.depends("is_base_event")
def _compute_update_all_recurrence(self):
for rec in self:
rec.update_all_recurrence = (
rec.recurrency
and rec.is_base_event
and (
rec.recurrence_update == "all_events"
or not rec.recurrence_update
or rec.recurrence_id.calendar_event_ids == rec
)
)
@api.depends("recurrency", "recurrence_id", "recurrence_id.base_event_id")
def _compute_is_base_event(self):
for rec in self:
@ -195,7 +216,9 @@ class CalendarEvent(models.Model):
try:
caldav_events = event._create_in_icalendar(calendar)
for caldav_event in caldav_events:
caldav_uid = caldav_event.vobject_instance.vevent.uid.value
caldav_uid = (
caldav_event.vobject_instance.vevent.uid.value
) # pyright: ignore[reportAttributeAccessIssue]
event.with_context(caldav_no_sync=True).write(
{"caldav_uid": caldav_uid}
)
@ -223,6 +246,11 @@ class CalendarEvent(models.Model):
calendar = client.calendar(url=user.caldav_calendar_url)
base_event = self._get_caldav_base_event_by_uid(calendar, self.caldav_uid)
if not base_event:
_logger.warning(
f"Failed to find base event for {self} on CalDAV server."
)
return
if self.recurrence_id:
tz = timezone(self.event_tz or self.env.user.tz)
start = utc.localize(self.start).astimezone(tz)
@ -259,12 +287,14 @@ class CalendarEvent(models.Model):
def _update_base_caldav_event(
self,
calendar: icalendar.cal.Calendar,
calendar: caldav.Calendar,
event: caldav.Event,
ical_event_data: dict,
):
if event:
self._update_ical_event_values(event.icalendar_component, ical_event_data)
self._update_ical_event_values(
event.icalendar_component, ical_event_data
) # pyright: ignore[reportAttributeAccessIssue]
event.save()
else:
calendar.add_event(**ical_event_data)
@ -280,9 +310,11 @@ class CalendarEvent(models.Model):
def _get_caldav_base_event_by_uid(
self, calendar: caldav.Calendar, uid: str
) -> Optional[CalendarEvent]:
) -> Optional[caldav.Event]:
for event in calendar.events():
component = event.icalendar_component
component = (
event.icalendar_component
) # pyright: ignore[reportAttributeAccessIssue]
event_uid = self._extract_component_text(component, "uid")
if event_uid == uid and not component.get("recurrence-id"):
return event
@ -315,6 +347,7 @@ class CalendarEvent(models.Model):
calendar = client.calendar(url=user.caldav_calendar_url)
try:
caldav_event = calendar.event_by_uid(self.caldav_uid)
assert isinstance(caldav_event, caldav.Event)
if not delete_all and self.recurrence_id and not self.is_base_event:
index = self._get_subcomponent_index_for_recurrence(
caldav_event
@ -329,14 +362,16 @@ class CalendarEvent(models.Model):
# of the event matches.
if delete_all or self._matches_caldav_start(caldav_event):
caldav_event.delete()
except caldav.error.NotFoundError:
except NotFoundError:
# No worries - it just didn't exist so nothing to sync
pass
except Exception as e:
_logger.error(f"Failed to remove event from CalDAV server: {e}")
def _matches_caldav_start(self, caldav_event: caldav.Event) -> bool:
event_start = caldav_event.icalendar_component.get("dtstart").dt
event_start = caldav_event.icalendar_component.get(
"dtstart"
).dt # pyright: ignore[reportAttributeAccessIssue]
tz = event_start.tzinfo
self_start = utc.localize(self.start).astimezone(tz)
return self_start == event_start
@ -453,7 +488,7 @@ class CalendarEvent(models.Model):
def _add_event_dates(self, event_data: Dict) -> None:
"""Add pertinent dates to event data, based on self."""
tz = self.event_tz or self.env.user.tz
tz = self.event_tz or self.env.user.tz or self._context.get('tz')
event_tz = timezone(tz)
event_data["last-modified"] = vDatetime(
utc.localize(self.write_date).astimezone(event_tz)
@ -463,7 +498,6 @@ class CalendarEvent(models.Model):
)
event_data["dtstart"] = vDatetime(utc.localize(self.start).astimezone(event_tz))
event_data["dtend"] = vDatetime(utc.localize(self.stop).astimezone(event_tz))
return event_data
def _add_event_recurrence_id(self, event_data: Dict) -> None:
"""Add the recurrence-id parameter to event data if self is linked
@ -531,7 +565,8 @@ class CalendarEvent(models.Model):
synchronize them with their Odoo calendar."""
all_users = self.env["res.users"].search([("is_caldav_enabled", "=", True)])
for user in all_users:
self._poll_user_caldav_server(user)
self.with_context(dont_notify=True)._poll_user_caldav_server(user)
# self._poll_user_caldav_server(user)
@api.model
def _poll_user_caldav_server(self, user) -> None:
@ -568,7 +603,7 @@ class CalendarEvent(models.Model):
# There are some events remaining in this recurrence series,
# so we have synchronized them individually.
pass
except caldav.error.NotFoundError:
except NotFoundError:
# There are no more events with this UID, so we need to clear
# out the whole recurrence chain from the Odoo side.
ctx = {"caldav_no_sync": True}
@ -582,9 +617,8 @@ class CalendarEvent(models.Model):
).with_user(user).unlink()
@api.model
def _sync_event_from_ical(
self, ical_event: icalendar.cal.Event, user: User
) -> CalendarEvent:
@api.returns("calendar.event")
def _sync_event_from_ical(self, ical_event: icalendar.cal.Event, user: User):
"""Given an iCalendar event, compare the event with any existing
Odoo event that it matches and synchronize the changes. If no event
exists, create one.
@ -610,9 +644,16 @@ class CalendarEvent(models.Model):
owned = (
existing_instance and existing_instance.partner_id == user.partner_id
)
values = self._get_values_from_ical_component(component, user)
# Pass for_creation=True only when creating a new event
values = self._get_values_from_ical_component(
component, user, for_creation=not existing_instance
)
recurrency_vals = self._get_recurrency_values_from_ical_event(component)
if not existing_instance:
# If the event is in the past, we just ignore it.
stop = values.get("stop")
if stop and stop < datetime.now(tz=None):
continue
# If we're creating an instance and it doesn't follow the recurrence,
# just scrap the recurrency vals, they're not useful
if not recurrency_vals.get("follow_recurrence"):
@ -646,7 +687,8 @@ class CalendarEvent(models.Model):
return synced_events
@api.model
def _get_existing_instance(self, uid, recurrence_id: datetime) -> CalendarEvent:
@api.returns("calendar.event")
def _get_existing_instance(self, uid, recurrence_id: Optional[datetime]):
"""Find the Odoo calendar.event record matching uid and,
if set, recurrence_id.
"""
@ -685,7 +727,7 @@ class CalendarEvent(models.Model):
return instance or self.env["calendar.event"].search(
[
("caldav_uid", "=", "uid"),
("caldav_uid", "=", uid),
("recurrence_id", "=", False),
]
)
@ -718,14 +760,20 @@ class CalendarEvent(models.Model):
"recurrence_update": "self_only",
}
if not rrule:
if not rrule or not isinstance(rrule, vRecur):
return {}
if rrule.get("until"):
rrule["until"] = rrule.get("until")[0].astimezone(utc)
until = rrule.get("until")
if until and isinstance(until, list):
until = until[0].astimezone(utc)
rrule_str = rrule.to_ical() and rrule.to_ical().decode("utf-8")
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
"RRULE:" + rrule_str, component.get("dtstart").dt.astimezone(utc)
)
if rrule_str:
rrule_params = self.env["calendar.recurrence"]._rrule_parse(
"RRULE:" + rrule_str, component.get("dtstart").dt.astimezone(utc)
)
else:
_logger.warning(f"Could not convert RRULE to string: {rrule}")
return {}
vals = {
"recurrency": True,
"follow_recurrence": True,
@ -743,8 +791,9 @@ class CalendarEvent(models.Model):
vals.update(end_type="count")
if not vals.get("count"):
vals.update(count=MAX_RECURRENT_EVENT)
if vals.get("until"):
until_day = vals.get("until").date()
until = vals.get("until")
if until and (isinstance(until, vDatetime) or isinstance(until, vDate)):
until_day = until.dt if isinstance(until, vDatetime) else until.dt
vals.update(until=until_day)
vals.pop("count", None)
return vals
@ -813,8 +862,8 @@ class CalendarEvent(models.Model):
def _get_outdated(
self,
component: icalendar.cal.Component,
existing_instance: CalendarEvent,
synced_events: CalendarEvent,
existing_instance: OdooCalendarEvent,
synced_events: OdooCalendarEvent,
) -> bool:
"""Check whether a component from the CalDAV server (typically an
event) is outdated when compared to its existing Odoo calendar.event
@ -840,14 +889,15 @@ class CalendarEvent(models.Model):
@api.model
def _get_values_from_ical_component(
self, component: icalendar.cal.Component, user: User
self, component: icalendar.cal.Component, user: User, for_creation: bool = False
) -> Dict:
"""Get the dictionary representing calendar.event field values from
an iCalendar event.
:param component: The iCalendar component from which to extract
values.
:param component: The iCalendar component from which to extract values.
:param user: The res.users record the event will belong to.
:param for_creation: Whether these values are for creating a new event (True)
or updating an existing one (False).
:return: The dictionary of values to construct a calendar.event."""
start = component.get("dtstart") and component.decoded("dtstart")
if isinstance(start, datetime):
@ -855,8 +905,11 @@ class CalendarEvent(models.Model):
end = component.get("dtend") and component.decoded("dtend")
if isinstance(end, datetime):
end = end.astimezone(utc).replace(tzinfo=None)
organizer = self._get_organizer_partner(component)
# Get attendees regardless of creation/update
attendee_ids = self._get_attendee_partners(component, user.partner_id.email)
# Basic values that apply to both creation and updates
values = {
"name": str(component.get("summary")),
"start": start,
@ -868,9 +921,33 @@ class CalendarEvent(models.Model):
"videocall_location": self._extract_component_text(component, "conference"),
"caldav_uid": str(component.get("uid")),
"partner_ids": [(6, 0, attendee_ids.ids)],
"partner_id": organizer.id if organizer else user.partner_id.id,
"user_id": user.id,
}
# Only set user_id and partner_id during creation
if for_creation:
organizer_partner = self._get_organizer_partner(component)
if organizer_partner:
# Get the Odoo user ID associated with the organizer partner
organizer = (
organizer_partner.user_ids[0].id
if organizer_partner.user_ids
else False
)
values.update(
{
"partner_id": organizer_partner.id,
"user_id": organizer,
}
)
else:
# For new events without an organizer, use the current user
values.update(
{
"partner_id": user.partner_id.id,
"user_id": user.id,
}
)
return values
@api.model
@ -886,6 +963,13 @@ class CalendarEvent(models.Model):
matching Odoo event belongs to.
:return: The res.partner records who are attendees for the event."""
attendee_emails = self._get_ical_attendee_emails(component)
# Add organizer to attendees if present
organizer = component.get("organizer")
if organizer:
organizer_email = _extract_vcal_email(organizer)
if organizer_email not in attendee_emails:
attendee_emails.append(organizer_email)
# Add current user if not already in attendees
if current_user_email not in attendee_emails:
attendee_emails.append(current_user_email)
existing_partners = self.env["res.partner"].search(
@ -896,14 +980,24 @@ class CalendarEvent(models.Model):
for email in attendee_emails
if email not in [partner.email for partner in existing_partners]
]
added_partners = self.env["res.partner"].create(
[
{
"name": email,
"email": email,
}
for email in missing_emails
]
# Create new partners without triggering notifications
added_partners = (
self.env["res.partner"]
.with_context(
mail_notify_author=False, # Don't notify the author
mail_notify_force_send=False, # Don't force send notifications
tracking_disable=True, # Disable tracking which can trigger notifications
no_reset_password=True, # Don't trigger password reset emails
)
.create(
[
{
"name": email,
"email": email,
}
for email in missing_emails
]
)
)
final_attendees = {}
all_partners = existing_partners | added_partners
@ -933,11 +1027,29 @@ class CalendarEvent(models.Model):
organizer = component.get("organizer")
if organizer:
partner = self.env["res.partner"].search(
[("email", "=", _extract_vcal_email(organizer))]
)
# TODO: prioritize partner with a user if there is one
return partner[0] if partner else partner # partner[0] in case many matches
email = _extract_vcal_email(organizer)
_logger.info("Organizer email: %s", email)
partner = self.env["res.partner"].search([("email", "=", email)], limit=1)
_logger.info("Found partner: %s", partner)
if not partner:
# Create new partner without triggering notifications
partner = (
self.env["res.partner"]
.with_context(
mail_notify_author=False,
mail_notify_force_send=False,
tracking_disable=True,
no_reset_password=True,
)
.create(
{
"name": email,
"email": email,
}
)
)
_logger.info("Created partner: %s", partner)
return partner
else:
return self.env["res.partner"]

View file

@ -15,16 +15,34 @@ class ResUsers(models.Model):
caldav_password = fields.Char(string="CalDAV Password")
is_caldav_enabled = fields.Boolean(compute="_compute_is_caldav_enabled", store=True)
@property
def SELF_WRITEABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS + [
"caldav_calendar_url",
"caldav_username",
"caldav_password",
]
@api.depends("caldav_username", "caldav_password", "caldav_calendar_url")
def _compute_is_caldav_enabled(self):
"""This is a bit of an odd way of computing the field, but it works since any
failed attempt to get the events from the server should mark the user as
CalDAV being disabled. We just make sure to mute the logger in the case that
an exception is raised because we are just computing the field, not actually
attempting to synchronize anything."""
"""Compute whether CalDAV is enabled for each user by validating their credentials.
We only check if we can connect to the server and access the principal, without
fetching any events to avoid timeouts with large calendars."""
for rec in self:
with mute_logger("odoo.addons.caldav_sync.models.res_users"):
rec._get_caldav_events()
# If any required field is empty, CalDAV is disabled
if not (
rec.caldav_username and rec.caldav_password and rec.caldav_calendar_url
):
rec.is_caldav_enabled = False
continue
try:
client = rec._get_caldav_client()
# Just try to access the principal, which is a lightweight operation
client.principal()
rec.is_caldav_enabled = True
except Exception as e:
rec.is_caldav_enabled = False
_logger.error("Failed to validate CalDAV credentials: %s", e)
def _get_caldav_client(self):
self.ensure_one()

View file

@ -1,2 +1,3 @@
from . import test_res_users
from . import test_calendar
from . import test_external_organizer

File diff suppressed because it is too large Load diff

View 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

View 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

View 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

View file

@ -1,5 +1,5 @@
from collections.abc import Iterable
from odoo.tests import TransactionCase
from odoo.tests import TransactionCase, tagged
from odoo import Command
from unittest.mock import patch, MagicMock, DEFAULT
import icalendar
@ -25,24 +25,25 @@ def _get_ics_path(filename):
@contextmanager
def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
with (
patch("caldav.DAVClient") as MockDAVClient,
patch("caldav.Calendar") as MockCalendar,
):
def _patch_caldav_with_events_from_ics(
ics_paths, user, last_modified=None, futurize=True
):
with patch("caldav.DAVClient") as MockDAVClient:
mock_client = MockDAVClient.return_value
mock_calendar = MockCalendar.return_value
mock_client.calendar = mock_calendar
mock_calendars = {}
def calendar_side_effect(url):
if url not in mock_calendars:
mock_calendars[url] = MockCalendar()
if url == user.caldav_calendar_url:
return mock_calendars[url]
raise Exception("Calendar does not exist.")
mock_cal = MagicMock()
mock_cal.events = MagicMock(return_value=[])
mock_cal.event_by_uid = MagicMock()
mock_calendars[url] = mock_cal
return mock_calendars[url]
mock_calendar.side_effect = calendar_side_effect
mock_client.calendar = calendar_side_effect
# Get or create the mock calendar for this user
mock_calendar = calendar_side_effect(user.caldav_calendar_url)
def event_by_uid_side_effect(self, uid):
for event in self.events():
@ -64,6 +65,24 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
if subcomponent.name == "VEVENT":
subcomponent["last-modified"] = icalendar.vDate(last_modified)
subcomponent["dtstamp"] = icalendar.vDate(last_modified)
if futurize:
for event in ical_events:
for subcomponent in event.subcomponents:
if subcomponent.name == "VEVENT":
start = subcomponent.get("dtstart") and subcomponent.decoded(
"dtstart"
)
end = subcomponent.get("dtend") and subcomponent.decoded(
"dtend"
)
if isinstance(start, datetime) and isinstance(end, datetime):
duration = end - start
else:
duration = timedelta(hours=1)
subcomponent["dtstart"] = icalendar.vDDDTypes(datetime.now())
subcomponent["dtend"] = icalendar.vDDDTypes(
datetime.now() + duration
)
base_events = [event for event in ical_events if not event.get("recurrence-id")]
for base_event in base_events:
@ -83,17 +102,33 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
yield
@tagged("post_install", "-at_install")
class TestCalendarEvent(TransactionCase, CaldavTestCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.env["res.users"].search([])._compute_is_caldav_enabled()
cls.user_1_url = "https://mycaldav.test.com/test1calendar"
cls.user_1 = cls._generate_user("test1", "test1", cls.user_1_url)
cls.user_1 = cls._generate_user(
"test1",
caldav_username="user1",
caldav_password="pass1",
caldav_url=cls.user_1_url,
)
cls.user_2_url = "https://mycaldav.test.com/test2calendar"
cls.user_2 = cls._generate_user("test2", "test2", cls.user_2_url)
cls.user_2 = cls._generate_user(
"test2",
caldav_username="user2",
caldav_password="pass2",
caldav_url=cls.user_2_url,
)
cls.user_3_url = "https://mycaldav.test.com/test3calendar"
cls.user_3 = cls._generate_user("test3", "test3", cls.user_3_url)
cls.user_3 = cls._generate_user(
"test3",
caldav_username="user3",
caldav_password="pass3",
caldav_url=cls.user_3_url,
)
def test_basic_event_from_server_create(self):
user = self.user_1
@ -110,17 +145,29 @@ class TestCalendarEvent(TransactionCase, CaldavTestCommon):
ics_path = _get_ics_path("basic.ics")
with _patch_caldav_with_events_from_ics(ics_path, user):
self.env["calendar.event"].poll_caldav_server()
# Verify the event was created correctly
event = self.env["calendar.event"].search([("user_id", "=", user.id)])
self.assertEqual(event.name, "Test")
orig_start = event.start
orig_stop = event.stop
# Now update the event with the updated ICS data
ics_path = _get_ics_path("basic_updated.ics")
with _patch_caldav_with_events_from_ics(
ics_path,
user,
last_modified=(datetime.now(UTC)),
):
# Clear any caches to ensure fresh data
self.env["calendar.event"].invalidate_model()
self.env["calendar.event"].poll_caldav_server()
# Refresh the event from the database to get updated values
event.invalidate_recordset()
event = self.env["calendar.event"].search([("user_id", "=", user.id)])
# Verify the event was updated correctly
self.assertEqual(event.name, "Test Updated")
# This next one is just lazy avoiding the HTML stripping
self.assertIn("Some note ...", event.description)
@ -224,21 +271,25 @@ class TestCalendarEvent(TransactionCase, CaldavTestCommon):
self.env["calendar.event"].poll_caldav_server()
with _patch_caldav_with_events_from_ics(ics_path, user3):
self.env["calendar.event"].poll_caldav_server()
notification_method = "odoo.addons.calendar.models.calendar_attendee.Attendee._send_mail_to_attendees"
# Now update it to remove one attendee
# Shuffle the user polling order just to test more robustly
ics_path = _get_ics_path("test_multi_user_update.ics")
with _patch_caldav_with_events_from_ics(
ics_path, user2, last_modified=datetime.now(UTC)
):
), patch(notification_method) as mock_notification_method:
self.env["calendar.event"].poll_caldav_server()
mock_notification_method.assert_not_called()
with _patch_caldav_with_events_from_ics(
ics_path, user3, last_modified=datetime.now(UTC)
):
), patch(notification_method) as mock_notification_method:
self.env["calendar.event"].poll_caldav_server()
mock_notification_method.assert_not_called()
with _patch_caldav_with_events_from_ics(
ics_path, user1, last_modified=datetime.now(UTC)
):
), patch(notification_method) as mock_notification_method:
self.env["calendar.event"].poll_caldav_server()
mock_notification_method.assert_not_called()
event = self.env["calendar.event"].search(
[("caldav_uid", "=", "2495546B-5C9A-4632-AAD3-A179EF83CF20")]
)

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

View file

@ -1,6 +1,10 @@
from odoo.tests import TransactionCase
from unittest.mock import patch, MagicMock
from .common import CaldavTestCommon
import caldav
import logging
_logger = logging.getLogger(__name__)
class TestUsers(TransactionCase, CaldavTestCommon):
@ -9,24 +13,67 @@ class TestUsers(TransactionCase, CaldavTestCommon):
super().setUpClass()
def test_caldav_enabled_false_without_url(self):
user = self._generate_user("test", "test")
# Create user with no CalDAV credentials
user = self._generate_user("test")
self.assertFalse(user.is_caldav_enabled)
def test_caldav_enabled_false_without_credentials(self):
"""Test that is_caldav_enabled is False when any required field is missing."""
# Test with missing URL - has username and password only
user1 = self._generate_user(
"test1", caldav_username="user1", caldav_password="pass1"
)
self.assertFalse(user1.is_caldav_enabled)
# Test with missing username - has password and URL only
user2 = self._generate_user(
"test2", caldav_password="pass2", caldav_url="https://example.com"
)
self.assertFalse(user2.is_caldav_enabled)
# Test with missing password - has username and URL only
user3 = self._generate_user(
"test3", caldav_username="user3", caldav_url="https://example.com"
)
self.assertFalse(user3.is_caldav_enabled)
@patch("caldav.DAVClient")
def test_caldav_connection_succeeds_but_not_calendar(self, MockDAVClient):
user = self._generate_user("test", "test", "https://example.com/abc123")
# Create a mock client and mock calendar
def test_caldav_enabled_success(self, MockDAVClient):
"""Test that is_caldav_enabled is True when connection succeeds."""
# Create user with name 'test' and set CalDAV credentials
user = self._generate_user(
"test",
caldav_username="user",
caldav_password="pass",
caldav_url="https://example.com/abc123",
)
# Mock successful connection
mock_client = MockDAVClient.return_value
mock_calendar = MagicMock()
mock_client.calendar.return_value = mock_calendar
mock_principal = MagicMock()
mock_client.principal.return_value = mock_principal
# Mock the events method to raise an exception
mock_calendar.events.side_effect = Exception("Failed to get events")
# Compute should succeed and set is_caldav_enabled to True
user._compute_is_caldav_enabled()
self.assertTrue(user.is_caldav_enabled)
# An exception should not be raised because we need to continue to sync other
# users' calendars. Instead the exception message is simply logged to error.
@patch("caldav.DAVClient")
def test_caldav_enabled_connection_fails(self, MockDAVClient):
"""Test that is_caldav_enabled is False when connection fails."""
user = self._generate_user(
"test",
caldav_username="user",
caldav_password="pass",
caldav_url="https://example.com/abc123",
)
# Mock failed connection
mock_client = MockDAVClient.return_value
mock_client.principal.side_effect = caldav.error.AuthorizationError(
"Invalid credentials"
)
# Should handle the error gracefully and set is_caldav_enabled to False
with self.assertLogs("odoo.addons.caldav_sync.models.res_users", "ERROR"):
user._get_caldav_events()
# Ensure the is_caldav_enabled is set to False
user._compute_is_caldav_enabled()
self.assertFalse(user.is_caldav_enabled)

View file

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

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

View file

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

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

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

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

View file

@ -1,12 +1,29 @@
/** @odoo-module **/
import {Many2OneField} from "@web/views/fields/many2one/many2one_field";
import {Many2OneField, m2oTupleFromData} from "@web/views/fields/many2one/many2one_field";
import {patch} from "@web/core/utils/patch";
patch(Many2OneField.prototype, {
get Many2XAutocompleteProps() {
const props = super.Many2XAutocompleteProps;
props.quickCreate = this.openConfirmationDialog.bind(this);
return props;
},
/**
* The original Many2OneField already has a quickCreate method and an openConfirmationDialog method.
* The quickCreate method directly updates the record, while openConfirmationDialog shows a dialog
* before calling quickCreate.
*
* We just need to modify the Many2XAutocompleteProps getter to use openConfirmationDialog
* instead of quickCreate for the quickCreate property.
*/
get Many2XAutocompleteProps() {
const props = super.Many2XAutocompleteProps;
// If quickCreate is defined, replace it with openConfirmationDialog
if (props.quickCreate && this.openConfirmationDialog) {
// Store the original quickCreate function
const originalQuickCreate = props.quickCreate;
// Replace with openConfirmationDialog
props.quickCreate = (name) => this.openConfirmationDialog(name);
}
return props;
},
});

View file

@ -1,2 +1,2 @@
from . import models
from . import wizards

View file

@ -1,15 +1,45 @@
{
'name': 'Customer Itch Cycle Management',
'version': '1.0',
'depends': ['base', 'sale'],
'author': 'Your Name',
'category': 'Sales Management',
'description': "Manage customer itch cycles by product for proactive sales engagement.",
'name': 'Customer Itch Cycle',
'version': '17.0.1.0.0',
'category': 'Sales',
'summary': 'Manage customer product cycles and predict future sales',
'description': """
This module helps track and manage customer purchasing cycles:
* Track product purchase cycles per customer
* Predict next purchase dates
* Generate opportunities based on predictions
* Monitor delayed purchases
""",
'author': 'DurPro',
'website': 'https://www.durpro.com',
'depends': [
'base',
'sale',
'sale_management',
'crm',
'sale_crm',
'base_automation',
],
'data': [
'security/ir.model.access.csv',
'data/month_data.xml',
'wizards/apply_to_child_categories.xml',
'views/itch_cycle_product_partner_view.xml',
'views/res_partner_view.xml',
'views/itch_cycle_actions.xml',
'views/itch_cycle_menu.xml',
'views/crm_lead_view.xml',
'views/product_category_view.xml',
'data/ir_cron_data.xml',
# 'data/crm_automation_data.xml',
],
'assets': {
'web.assets_backend': [
'customer_itch_cycle/static/src/js/cycle_product_partner_list_view.js',
'customer_itch_cycle/static/src/xml/process_history_button.xml',
],
},
'installable': True,
'application': False,
'application': True,
'license': 'LGPL-3',
}

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

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

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

View file

@ -1,3 +1,5 @@
from . import itch_cycle_product_partner
from . import sale_order
from . import sale_order_line
from . import product_category
from . import res_partner
from . import crm_lead

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

View file

@ -1,33 +1,903 @@
from datetime import timedelta, date
import logging
import numpy as np
from odoo import models, fields, api
from datetime import timedelta, datetime
from odoo.exceptions import ValidationError, UserError
_logger = logging.getLogger(__name__)
class ItchCycleProductPartner(models.Model):
_name = 'itch_cycle_product_partner'
_description = 'Itch Cycle by Product and Partner'
"""
Model representing the purchase cycle between a product and a partner.
Tracks purchase patterns, predicts future orders, and manages related
opportunities.
Attributes:
_name (str): Model technical name
_description (str): Model description
_inherit (list): Inherited models
"""
_name = "itch.cycle.product.partner"
_description = "Product/Partner Purchase Cycle"
_inherit = ["mail.thread", "mail.activity.mixin"]
partner_id = fields.Many2one('res.partner', string="Client", required=True, ondelete='cascade')
product_id = fields.Many2one('product.product', string="Produit", required=True)
last_purchase_date = fields.Date(string="Dernière Date d'Achat")
itch_cycle_duration = fields.Integer(string="Durée du Itch-Cycle (jours)", default=0)
next_follow_up_date = fields.Date(string="Prochaine Date de Suivi", compute="_compute_next_follow_up_date", store=True)
_sql_constraints = [
(
"positive_cycle_duration",
"CHECK(cycle_duration_override >= 0)",
"The forced cycle duration must be positive, zero or FALSE."
)
]
@api.depends('last_purchase_date', 'itch_cycle_duration')
def _compute_next_follow_up_date(self):
active = fields.Boolean(
string="Active",
default=True,
help="If unchecked, this cycle will be considered archived",
tracking=True
)
partner_id = fields.Many2one(
comodel_name="res.partner",
string="Customer",
help="Customer associated with this cycle",
required=True,
tracking=True
)
partner_email = fields.Char(
related="partner_id.email",
string="Partner Email"
)
partner_phone = fields.Char(
related="partner_id.phone",
string="Partner Phone"
)
partner_mobile = fields.Char(
related="partner_id.mobile",
string="Partner Mobile"
)
partner_city = fields.Char(
related="partner_id.city",
string="Partner City"
)
partner_country_id = fields.Many2one(
related="partner_id.country_id",
string="Partner Country"
)
product_id = fields.Many2one(
comodel_name="product.product",
string="Product",
help="Product associated with this cycle",
required=True,
tracking=True
)
product_categ_id = fields.Many2one(
related="product_id.categ_id",
string="Product Category"
)
product_type = fields.Selection(
related="product_id.type",
string="Product Type"
)
product_lst_price = fields.Float(
related="product_id.lst_price",
string="Product List Price"
)
product_qty_available = fields.Float(
related="product_id.qty_available",
string="Product Quantity Available"
)
sale_order_line_ids = fields.One2many(
comodel_name="sale.order.line",
inverse_name="itch_cycle_id",
string="Sale Order Lines",
help="Historical sales order lines for this product/partner combination"
)
opportunity_ids = fields.Many2many(
comodel_name="crm.lead",
string="Related Opportunities",
domain=[("type", "=", "opportunity")],
help="Opportunities automatically generated from cycle predictions"
)
opportunity_count = fields.Integer(
string="Number of Opportunities",
compute="_compute_opportunity_count",
help="Count of related opportunities"
)
quantity_total_ordered = fields.Float(
string="Total Quantity Ordered",
compute="_compute_sale_order_line_related_fields",
help="Total quantity ordered by the customer for this product",
store=True
)
quantity_of_orders = fields.Integer(
string="Number of Orders",
compute="_compute_sale_order_line_related_fields",
help="Number of orders placed by the customer for this product",
store=True
)
quantity_min_ordered = fields.Float(
string="Minimum Quantity Ordered",
compute="_compute_sale_order_line_related_fields",
help="Minimum quantity ordered by the customer for this product",
store=True
)
quantity_max_ordered = fields.Float(
string="Maximum Quantity Ordered",
compute="_compute_sale_order_line_related_fields",
help="Maximum quantity ordered by the customer for this product",
store=True
)
quantity_mean_ordered = fields.Float(
string="Average Quantity Ordered",
compute="_compute_sale_order_line_related_fields",
help="Average quantity ordered by the customer for this product",
store=True
)
quantity_manual_override = fields.Float(
string="Manual Quantity Override",
help="Manually defined quantity",
tracking=True
)
quantity_planned = fields.Float(
string="Planned Quantity",
help="Planned quantity for the next order",
compute="_compute_quantity_planned",
store=True,
tracking=True
)
cycle_duration_calculated = fields.Integer(
string="Calculated Average Cycle (days)",
compute="_compute_average_cycle",
help="Calculated average cycle in days",
store=True
)
cycle_duration_override = fields.Integer(
string="Cycle Duration (forced)",
help="Cycle duration in days (forced value)",
default=0,
tracking=True
)
cycle_duration = fields.Integer(
string="Average Cycle (days)",
compute="_compute_itch_cycle_duration",
help="Average cycle in days",
store=True
)
date_expected_evaluated = fields.Date(
string="Next Expected Sale by Calculation",
compute="_compute_date_expected_evaluated",
store=True
)
date_expected_override = fields.Date(
string="Next Expected Sale Manual Override",
help="Manually defined next expected sale date",
tracking=True
)
date_expected = fields.Date(
string="Expected Date",
help="Expected date for the next order",
compute="_compute_date_expected",
store=True,
tracking=True
)
date_next_follow_up = fields.Date(
string="Follow-up Date",
help="Planned follow-up date",
compute="_compute_next_follow_up_date",
store=True,
readonly=False,
tracking=True
)
date_last_purchase = fields.Date(
string="Last Purchase Date",
compute="_compute_last_purchase_date",
help="Date of last purchase",
store=True
)
state = fields.Selection(
selection=[
("new", "New"),
("pending", "Pending"),
("on_time", "On Time"),
("upcoming", "Upcoming"),
("delayed", "Delayed"),
("critical", "Critical"),
("archived", "Archived"),
],
string="State",
help="Current cycle state",
compute="_compute_state",
store=True
)
notes = fields.Text(
string="Notes",
help="Additional notes about this cycle",
tracking=True
)
deviation_percent = fields.Float(
string="Average Deviation (%)",
compute="_compute_deviation",
help="Percentage deviation from average orders",
store=True
)
name = fields.Char(
string='Name',
compute='_compute_name',
store=True,
)
@api.depends('partner_id.name', 'product_id.name')
def _compute_name(self):
for record in self:
if record.last_purchase_date and record.itch_cycle_duration > 0:
record.next_follow_up_date = record.last_purchase_date + timedelta(days=record.itch_cycle_duration)
record.name = f"{record.partner_id.name}/{record.product_id.name}"
@api.depends("cycle_duration_override", "cycle_duration_calculated")
def _compute_itch_cycle_duration(self):
"""Compute the cycle duration.
If a cycle is manually defined, it is used instead.
Otherwise, the calculated average cycle is used.
"""
for record in self:
record.cycle_duration = (
record.cycle_duration_override or
record.cycle_duration_calculated)
@api.depends("quantity_manual_override", "quantity_mean_ordered")
def _compute_quantity_planned(self):
"""Compute the planned quantity for the next order.
If a quantity is manually defined, it is used.
Otherwise, the average quantity is used.
"""
for record in self:
record.quantity_planned = (
record.quantity_manual_override or
record.quantity_mean_ordered
)
@api.depends("sale_order_line_ids")
def _compute_last_purchase_date(self):
"""Update the last purchase date."""
for record in self:
orders = record.sale_order_line_ids.mapped('order_id')
last_order = orders.sorted(key=lambda o: o.date_order, reverse=True)
record.date_last_purchase = (
last_order[0].date_order if last_order else None
)
@api.depends("date_expected")
def _compute_next_follow_up_date(self):
"""Compute the follow-up date.
Can be based on `next_expected_date`,
but modifiable by the user.
"""
for record in self:
record.date_next_follow_up = (
record.date_expected - timedelta(days=7)
if record.date_expected
else None
)
@api.depends("sale_order_line_ids", "product_id.categ_id")
def _compute_average_cycle(self):
"""Calculate the average cycle in days.
Takes into account seasonal factors
if defined in product category.
"""
for record in self:
product_category = record.product_id.categ_id
if (product_category.seasonal_factor and
product_category.season_months):
active_months = list(map(
int,
product_category.season_months.split(',')
))
dates = sorted([
line.order_id.date_order
for line in record.sale_order_line_ids
if line.order_id.date_order.month in active_months
])
else:
record.next_follow_up_date = False
dates = sorted(
record.sale_order_line_ids.mapped('order_id.date_order')
)
class ResPartner(models.Model):
_inherit = 'res.partner'
if len(dates) > 1:
intervals = [
(dates[i + 1] - dates[i]).days
for i in range(len(dates) - 1)
]
record.cycle_duration_calculated = (
int(np.mean(intervals)) if intervals else 0
)
else:
record.cycle_duration_calculated = 0
itch_cycle_product_ids = fields.One2many('itch_cycle_product_partner', 'partner_id', string="Itch Cycles Produits")
itch_next_delay = fields.Date(string="Prochaine Date de Suivi (Itch-Cycle Min)", compute="_compute_itch_next_delay", store=True)
@api.constrains("cycle_duration_override", "date_expected_override")
def _check_cycle_constraints(self):
"""Validate cycle constraints.
Ensures forced cycle duration is positive
and expected date is not in past.
"""
for record in self:
if record.cycle_duration_override and record.cycle_duration_override < 0:
raise ValidationError('The forced cycle duration must be positive.')
if record.date_expected_override and record.date_expected_override < fields.Date.today():
raise ValidationError('The forced expected date cannot be in the past.')
@api.depends('itch_cycle_product_ids.next_follow_up_date')
def _compute_itch_next_delay(self):
for partner in self:
follow_up_dates = partner.itch_cycle_product_ids.mapped('next_follow_up_date')
partner.itch_next_delay = min(follow_up_dates) if follow_up_dates else False
@api.constrains('product_id')
def _check_product_category_tracked(self):
"""Ensure cycles can only be created for products in tracked categories.
Raises ValidationError if product category
is not configured for cycle tracking.
"""
for record in self:
if not record.product_id.categ_id.is_cycle_tracked:
msg = (
f"Cannot create cycle for product '{record.product_id.name}' "
f"because its category '{record.product_id.categ_id.name}' "
"is not configured for cycle tracking. Enable 'Track Sales Cycle' "
"in the product category settings first."
)
raise ValidationError(msg)
@api.depends(
'cycle_duration',
'date_last_purchase'
)
def _compute_date_expected_evaluated(self):
"""Calculate next sale date based on average cycle and last purchase date.
Handles edge cases and logs warnings for invalid data.
"""
for record in self:
try:
# Check if cycle_duration is valid
if not isinstance(record.cycle_duration, int):
record.date_expected_evaluated = None
_logger.warning(
"Invalid cycle duration type for record %s: "
"Expected int, got %s",
record.id, type(record.cycle_duration)
)
continue
if record.cycle_duration < 0:
record.date_expected_evaluated = None
_logger.warning(
"Invalid cycle duration value for record %s: "
"Must be positive, got %s",
record.id, record.cycle_duration
)
continue
# Check if date_last_purchase is valid
if not isinstance(record.date_last_purchase, date):
record.date_expected_evaluated = None
_logger.warning(
"Invalid last purchase date type for record %s: "
"Expected date, got %s",
record.id, type(record.date_last_purchase)
)
continue
# Calculate expected date
record.date_expected_evaluated = (
record.date_last_purchase +
timedelta(days=record.cycle_duration)
)
_logger.info(
"Successfully computed date_expected_evaluated for record %s: "
"Last purchase: %s, Cycle duration: %s days, "
"Expected date: %s",
record.id, record.date_last_purchase,
record.cycle_duration, record.date_expected_evaluated
)
except Exception as e:
record.date_expected_evaluated = None
_logger.error(
"Error computing date_expected_evaluated for record %s: %s",
record.id, str(e)
)
@api.depends(
'date_expected_evaluated',
'date_expected_override',
'cycle_duration',
'date_last_purchase'
)
def _compute_date_expected(self):
"""Calculate expected date considering:
- The override date if defined
- The evaluated date if future
- The last order date + cycle if a cycle is defined
- Otherwise None
"""
for record in self:
if (record.date_expected_override and
record.date_expected_override > fields.Date.today()):
record.date_expected = record.date_expected_override
elif record.date_expected_evaluated:
record.date_expected = record.date_expected_evaluated
else:
record.date_expected = None
@api.depends('date_expected', 'active', 'quantity_of_orders')
def _compute_state(self):
"""Determine current state based on various conditions.
Possible states: new, pending, on_time,
upcoming, delayed, critical, archived.
"""
today = fields.Date.today()
for record in self:
if not record.active:
record.state = 'archived'
continue
if record.quantity_of_orders < 2:
record.state = 'new'
elif not record.date_expected:
record.state = 'pending'
elif record.date_expected < today:
days_late = (today - record.date_expected).days
record.state = 'critical' if days_late > 30 else 'delayed'
elif (record.date_expected - today).days <= 7:
record.state = 'upcoming'
else:
record.state = 'on_time'
@api.model
def populate_from_past_orders(self):
"""Process historical sales data to create or update product cycles.
Analyzes past orders to calculate
cycle durations and expected dates.
"""
_logger.info("Starting historical sales data processing...")
sale_lines = self.env['sale.order.line'].search([
('state', 'in', ['sale', 'done']),
('order_id.state', 'in', ['sale', 'done']),
('order_id.date_order', '!=', False),
('product_id', '!=', False),
('order_id.partner_id', '!=', False),
('qty_delivered', '!=', 0)
])
partner_product_lines = {}
total_lines = len(sale_lines)
for i, line in enumerate(sale_lines, 1):
if i % (total_lines // 10 or 1) == 0:
progress = (i / total_lines) * 100
_logger.info(f"Progress: {progress:.0f}%")
if not (line.product_id and line.order_id.partner_id and line.order_id.date_order):
continue
key = (line.order_id.partner_id.id, line.product_id.id)
if key not in partner_product_lines:
partner_product_lines[key] = []
partner_product_lines[key].append(line)
notifications = []
processed_count = 0
error_count = 0
total_combinations = len(partner_product_lines)
for (partner_id, product_id), lines in partner_product_lines.items():
cr = self.env.cr
try:
with cr.savepoint():
if not partner_id or not product_id:
continue
sorted_lines = sorted(
lines,
key=lambda line: line.order_id.date_order or fields.Datetime.now()
)
total_quantity = sum(line.product_uom_qty for line in sorted_lines)
mean_quantity = total_quantity / len(sorted_lines)
last_order_date = sorted_lines[-1].order_id.date_order if sorted_lines else False
if len(sorted_lines) > 1:
time_diffs = [
(sorted_lines[i].order_id.date_order -
sorted_lines[i-1].order_id.date_order).days
for i in range(1, len(sorted_lines))
if sorted_lines[i].order_id.date_order and
sorted_lines[i-1].order_id.date_order
]
if time_diffs:
cycle_duration = sum(time_diffs) / len(time_diffs)
if cycle_duration < 1:
cycle_duration = 1
else:
cycle_duration = 30
else:
cycle_duration = 30
date_expected = last_order_date + timedelta(days=cycle_duration) if last_order_date else False
cycle = self.with_context(active_test=False).search([
('partner_id', '=', partner_id),
('product_id', '=', product_id)
])
values = {
'quantity_mean_ordered': mean_quantity,
'quantity_total_ordered': total_quantity,
'cycle_duration': cycle_duration,
'sale_order_line_ids': [(6, 0, [line.id for line in sorted_lines])],
'date_expected': date_expected,
'date_last_purchase': last_order_date,
'active': True,
}
if cycle:
cycle.write(values)
else:
values.update({
'partner_id': partner_id,
'product_id': product_id,
})
self.create(values)
processed_count += 1
if processed_count % (total_combinations // 10 or 1) == 0:
progress = (processed_count / total_combinations) * 100
_logger.info(f"Progress: {progress:.0f}%")
except Exception as e:
error_count += 1
_logger.error(f"Error processing partner {partner_id} and product {product_id}: {str(e)}")
notifications.append({
'params': {
'message': f"Error processing partner {partner_id} and product {product_id}: {str(e)}",
'type': 'warning',
'sticky': False
}
})
continue
# Add final notification
status = 'warning' if error_count > 0 else 'success'
message = f"Processed {processed_count} combinations"
if error_count > 0:
message += f" with {error_count} errors"
notifications.append({
'params': {
'message': message,
'type': status,
'sticky': False
}
})
return {
'tag': 'display_notifications',
'params': {'notifications': notifications}
}
@api.depends(
'sale_order_line_ids'
)
def _compute_sale_order_line_related_fields(self):
"""Compute statistics from related sale order lines.
Calculates:
- Total quantity ordered
- Number of orders
- Minimum quantity ordered
- Maximum quantity ordered
- Average quantity ordered
"""
for record in self:
quantities = record.sale_order_line_ids.mapped('product_uom_qty')
record.quantity_of_orders = len(quantities)
if quantities:
record.quantity_total_ordered = sum(quantities)
record.quantity_min_ordered = min(quantities)
record.quantity_max_ordered = max(quantities)
record.quantity_mean_ordered = sum(quantities) / len(quantities)
else:
record.quantity_total_ordered = 0
record.quantity_min_ordered = 0
record.quantity_max_ordered = 0
record.quantity_mean_ordered = 0
@api.constrains(
'quantity_manual_override'
)
def _check_quantity_manual_override(self):
"""Validate manual quantity override.
Ensures manual quantity override is positive.
Raises ValidationError if negative value
is provided.
"""
for record in self:
if record.quantity_manual_override < 0:
raise ValidationError('The manual quantity override must be positive.')
@api.depends(
'quantity_planned',
'quantity_mean_ordered'
)
def _compute_deviation(self):
"""Calculate deviation between planned and average quantity.
Returns percentage difference between
planned quantity and historical average.
"""
for record in self:
if record.quantity_mean_ordered and record.quantity_planned:
record.deviation_percent = ((record.quantity_planned - record.quantity_mean_ordered) / record.quantity_mean_ordered) * 100
else:
record.deviation_percent = 0
@api.depends(
'opportunity_ids'
)
def _compute_opportunity_count(self):
"""Count related opportunities.
Returns number of opportunities
linked to this cycle.
"""
for record in self:
record.opportunity_count = len(record.opportunity_ids)
def action_view_opportunities(self):
"""Open CRM opportunities view.
Displays all opportunities related
to this cycle in pipeline view.
"""
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("crm.crm_lead_action_pipeline")
action['domain'] = [('id', 'in', self.opportunity_ids.ids)]
action['context'] = {
'default_partner_id': self.partner_id.id,
'default_expected_revenue': self.product_lst_price * self.quantity_mean_ordered,
}
return action
def _create_opportunity_data(self):
"""Prepare data for opportunity creation.
Returns:
dict: Dictionary containing opportunity data
"""
self.ensure_one()
expected_date = self.date_expected or fields.Date.today()
expected_revenue = self.product_lst_price * self.quantity_mean_ordered
return {
'name': f"Predicted Sale: {self.product_id.name}",
'partner_id': self.partner_id.id,
'type': 'opportunity',
'expected_revenue': expected_revenue,
'date_deadline': expected_date,
'itch_cycle_id': self.id,
'description': f"""
<h3>Automatically generated from sales cycle prediction</h3>
<ul>
<li><strong>Product:</strong> {self.product_id.name}</li>
<li><strong>Expected Quantity:</strong> {self.quantity_mean_ordered}</li>
<li><strong>Cycle Duration:</strong> {self.cycle_duration} days</li>
<li><strong>Previous Order Date:</strong> {self.date_last_purchase}</li>
</ul>
""",
}
def create_opportunity(self):
"""Create new opportunity from cycle prediction.
Generates opportunity with expected revenue
and deadline based on cycle data.
"""
self.ensure_one()
# Create opportunity using prepared data
opportunity = self.env['crm.lead'].create(
self._create_opportunity_data()
)
# Link the opportunity to this cycle
self.write({
'opportunity_ids': [(4, opportunity.id)]
})
return {
'type': 'ir.actions.act_window',
'res_model': 'crm.lead',
'res_id': opportunity.id,
'view_mode': 'form',
'target': 'current',
}
def action_create_opportunity(self):
"""Create opportunity from button click.
Wrapper method for create_opportunity()
to handle button actions.
"""
return self.create_opportunity()
def action_view_latest_opportunity(self):
"""View most recent opportunity.
Opens form view of the latest created
opportunity for this cycle.
"""
self.ensure_one()
if not self.opportunity_ids:
raise UserError("Aucune opportunité associée à ce cycle.")
latest_opportunity = self.opportunity_ids.sorted(key=lambda o: o.create_date, reverse=True)[0]
return {
'type': 'ir.actions.act_window',
'res_model': 'crm.lead',
'res_id': latest_opportunity.id,
'view_mode': 'form',
'target': 'current',
}
def action_create_batch_opportunities(self):
"""
Create opportunities in batch for selected cycles.
Returns:
dict: Action result to display notification
"""
created_count = 0
error_count = 0
for cycle in self:
try:
# Check if there's already an open opportunity for this cycle
existing_opportunities = cycle.opportunity_ids.filtered(
lambda o: not o.stage_id.is_won
)
if existing_opportunities:
continue
# Create new opportunity using prepared data
opportunity = self.env['crm.lead'].create(
cycle._create_opportunity_data()
)
cycle.write({
'opportunity_ids': [(4, opportunity.id)]
})
created_count += 1
except Exception as e:
error_count += 1
_logger.error(
f"Error creating opportunity for cycle {cycle.id}: {str(e)}"
)
continue
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Batch Opportunity Creation',
'message': f"Created {created_count} opportunities, {error_count} errors",
'sticky': False,
'type': 'success' if error_count == 0 else 'warning',
}
}
def _cron_create_opportunities(self):
"""Automatically create opportunities for upcoming predicted sales.
This cron job will:
- Find all active cycles with expected dates in the next 30 days
- Create new opportunities for cycles without existing open opportunities
- Log the results of the operation
"""
_logger.info("Starting automatic opportunity creation...")
# Find upcoming cycles in the next 30 days
today = fields.Date.today()
upcoming_cycles = self.search([
('date_expected', '!=', False),
('date_expected', '>=', today),
('date_expected', '<=', today + timedelta(days=30)),
('active', '=', True),
])
created_count = 0
skipped_count = 0
error_count = 0
for cycle in upcoming_cycles:
try:
# Check if there's already an open opportunity for this cycle
existing_opportunities = cycle.opportunity_ids.filtered(
lambda o: not o.stage_id.is_won and
o.date_deadline >= today
)
if existing_opportunities:
skipped_count += 1
continue
# Create new opportunity using prepared data
opportunity = self.env['crm.lead'].create(
cycle._create_opportunity_data()
)
cycle.write({
'opportunity_ids': [(4, opportunity.id)]
})
created_count += 1
except Exception as e:
error_count += 1
_logger.error(
f"Error creating opportunity for cycle {cycle.id}: {str(e)}"
)
continue
# Log final results
_logger.info(
f"Opportunity creation completed: "
f"{created_count} created, "
f"{skipped_count} skipped, "
f"{error_count} errors"
)
return {
'created_count': created_count,
'skipped_count': skipped_count,
'error_count': error_count,
}

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

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

View file

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

View 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

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_itch_cycle_product_partner itch.cycle.product.partner access model_itch_cycle_product_partner base.group_user 1 1 1 1
3 access_itch_month_user itch.month access user model_itch_month base.group_user 1 0 0 0
4 access_itch_month_manager itch.month access manager model_itch_month sales_team.group_sale_manager 1 1 1 1
5 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

View file

@ -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",
});

View file

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

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

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

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

View file

@ -1,44 +1,191 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_itch_cycle_product_partner_tree" model="ir.ui.view">
<record id="action_itch_cycle_product_partner" model="ir.actions.act_window">
<field name="name">Cycles Produit/Partenaire</field>
<field name="res_model">itch.cycle.product.partner</field>
<field name="view_mode">tree,form</field>
<field name="display_name">name_get</field>
</record>
<record id="action_create_batch_opportunities" model="ir.actions.server">
<field name="name">Create Opportunities</field>
<field name="model_id" ref="customer_itch_cycle.model_itch_cycle_product_partner"/>
<field name="state">code</field>
<field name="code">records.action_create_batch_opportunities()</field>
<field name="binding_model_id" ref="customer_itch_cycle.model_itch_cycle_product_partner"/>
<field name="binding_type">action</field>
<field name="binding_view_types">tree,form</field>
</record>
<record id="view_cycle_product_partner_tree" model="ir.ui.view">
<field name="name">itch.cycle.product.partner.tree</field>
<field name="model">itch_cycle_product_partner</field>
<field name="model">itch.cycle.product.partner</field>
<field name="arch" type="xml">
<tree>
<tree js_class="cycle_product_partner_list" decoration-bf="state == 'delayed'" decoration-it="state == 'archived'">
<field name="partner_id" widget="many2one_avatar"/>
<field name="partner_city"/>
<field name="partner_country_id" widget="country_flag" optional="hide"/>
<field name="product_id" widget="many2one_avatar"/>
<field name="product_categ_id" optional="hide"/>
<field name="quantity_planned" sum="Total prévu"/>
<field name="quantity_of_orders" sum="Total commandé"/>
<field name="cycle_duration" widget="duration"/>
<field name="date_last_purchase" optional="hide"/>
<field name="date_expected" optional="show" string="Date attendue"/>
<field name="date_next_follow_up" optional="show" string="Prochain suivi"/>
<field name="state" widget="badge" options="{'classes': {'new': 'bg-info', 'on_time': 'bg-success', 'delayed': 'bg-danger', 'archived': 'bg-secondary'}}"/>
<field name="active" invisible="1"/>
</tree>
</field>
</record>
<record id="view_itch_cycle_product_partner_search" model="ir.ui.view">
<field name="name">itch.cycle.product.partner.search</field>
<field name="model">itch.cycle.product.partner</field>
<field name="arch" type="xml">
<search>
<field name="partner_id"/>
<field name="product_id"/>
<field name="last_purchase_date"/>
<field name="itch_cycle_duration"/>
<field name="next_follow_up_date"/>
</tree>
<field name="state"/>
<separator/>
<filter string="Archivé" name="inactive" domain="[('active', '=', False)]"/>
<separator/>
<filter string="Suivi cette semaine" name="follow_up_this_week"
domain="[('date_next_follow_up', '>=', context_today().strftime('%Y-%m-%d')),
('date_next_follow_up', '&lt;=', (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', '&lt;=', (context_today() + datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<group expand="0" string="Grouper par">
<filter string="État" name="group_by_state" context="{'group_by': 'state'}"/>
<filter string="Partenaire" name="group_by_partner" context="{'group_by': 'partner_id'}"/>
<filter string="Ville" name="group_by_city" context="{'group_by': 'partner_id:city'}"/>
<filter string="Pays" name="group_by_country" context="{'group_by': 'partner_id:country_id'}"/>
<filter string="Produit" name="group_by_product" context="{'group_by': 'product_id'}"/>
<filter string="Catégorie de produit" name="group_by_product_categ" context="{'group_by': 'product_id:categ_id'}"/>
<filter string="Date de suivi" name="group_by_follow_up" context="{'group_by': 'date_next_follow_up:week'}"/>
<filter string="Date attendue" name="group_by_expected" context="{'group_by': 'date_expected:week'}"/>
</group>
</search>
</field>
</record>
<record id="view_itch_cycle_product_partner_form" model="ir.ui.view">
<field name="name">itch.cycle.product.partner.form</field>
<field name="model">itch_cycle_product_partner</field>
<field name="model">itch.cycle.product.partner</field>
<field name="arch" type="xml">
<form>
<header>
<field name="state" widget="statusbar" statusbar_visible="new,on_time,delayed,archived"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<widget name="web_ribbon" title="Archivé" bg_color="bg-secondary" invisible="active"/>
<button name="toggle_active"
type="object"
class="oe_stat_button"
icon="fa-archive"
help="Archiver/Désarchiver cet enregistrement">
<field name="active" widget="boolean_button" options="{'terminology': 'archive'}"/>
</button>
<button name="action_create_opportunity"
type="object"
string="Créer une opportunité"
class="oe_stat_button"
icon="fa-plus"
help="Créer une nouvelle opportunité pour ce partenaire et ce produit"/>
<button name="action_view_latest_opportunity"
type="object"
string="Dernière opportunité"
class="oe_stat_button"
icon="fa-eye"
help="Afficher la dernière opportunité créée pour ce partenaire et ce produit"/>
</div>
<group>
<field name="partner_id"/>
<field name="product_id"/>
<field name="last_purchase_date"/>
<field name="itch_cycle_duration"/>
<field name="next_follow_up_date"/>
<group string="Partenaire" col="2">
<field name="partner_id"
context="{'form_view_ref': 'base.view_partner_form'}"
options="{'always_reload': true}"
help="Partenaire associé à ce cycle"/>
<field name="partner_email" help="Adresse e-mail principale du partenaire"/>
<field name="partner_phone" help="Téléphone principal du partenaire"/>
<field name="partner_mobile" help="Téléphone mobile du partenaire"/>
<field name="partner_city" help="Ville du partenaire"/>
<field name="partner_country_id" help="Pays du partenaire"/>
</group>
<group string="Produit" col="2">
<field name="product_id"
context="{'form_view_ref': 'product.product_normal_form_view'}"
options="{'always_reload': true}"
help="Produit associé à ce cycle"/>
<field name="product_categ_id" help="Catégorie de produit"/>
<field name="product_type" help="Type de produit (stockable, consommable, service)"/>
<field name="product_lst_price" widget="monetary" help="Prix de vente du produit"/>
<field name="product_qty_available" help="Quantité disponible en stock"/>
</group>
</group>
<group>
<group string="Remplacement manuel" col="2">
<field name="cycle_duration_override" help="Remplacement manuel de la durée du cycle"/>
<field name="quantity_manual_override" help="Remplacement manuel de la quantité prévue"/>
<field name="date_expected_override" help="Remplacement manuel de la date attendue"/>
</group>
<group string="Statut" col="2">
<field name="date_expected" readonly="1" widget="date" help="Date attendue calculée"/>
<field name="cycle_duration" readonly="1" widget="duration" help="Durée du cycle calculée"/>
<field name="quantity_planned" readonly="1" widget="float_time" help="Quantité prévue calculée"/>
<field name="state" readonly="1" widget="badge" help="Statut actuel du cycle"/>
<field name="active" invisible="1"/>
</group>
</group>
<group>
<group string="Statistiques de commande" col="2">
<field name="quantity_of_orders" readonly="1" help="Nombre total de commandes"/>
<field name="quantity_total_ordered" readonly="1" help="Quantité totale commandée"/>
<field name="quantity_min_ordered" readonly="1" help="Quantité minimale commandée"/>
<field name="quantity_max_ordered" readonly="1" help="Quantité maximale commandée"/>
<field name="quantity_mean_ordered" readonly="1" help="Quantité moyenne commandée"/>
</group>
<group string="Statistiques de cycle" col="2">
<field name="date_last_purchase" readonly="1" widget="date" help="Date de la dernière commande"/>
<field name="date_next_follow_up" widget="date" options="{'datepicker': {'minDate': context_today().strftime('%Y-%m-%d')}}" help="Date du prochain suivi"/>
<field name="cycle_duration_calculated" readonly="1" widget="duration" help="Durée du cycle calculée"/>
<field name="date_expected_evaluated" readonly="1" widget="date" help="Date attendue évaluée"/>
</group>
</group>
<notebook>
<page string="Lignes de commande associées">
<field name="sale_order_line_ids" readonly="1">
<tree editable="bottom">
<field name="order_id"/>
<field name="product_id"/>
<field name="price_unit"/>
<field name="product_uom_qty"/>
<field name="sale_date"/>
<field name="discount"/>
</tree>
</field>
</page>
<page string="Opportunités associées">
<field name="opportunity_ids" readonly="1">
<tree>
<field name="name"/>
<field name="partner_id"/>
<field name="expected_revenue"/>
<field name="probability"/>
<field name="stage_id"/>
<field name="create_date"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<menuitem id="menu_itch_cycle_product_partner" name="Itch Cycles"
parent="sale.sale_order_menu"
action="action_itch_cycle_product_partner"/>
<record id="action_itch_cycle_product_partner" model="ir.actions.act_window">
<field name="name">Itch Cycles</field>
<field name="res_model">itch_cycle_product_partner</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>

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

View file

@ -1,23 +1,89 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_partner_form_inherit_itch_cycle" model="ir.ui.view">
<field name="name">res.partner.form.itch.cycle</field>
<field name="model">res.partner</field>
<field name="inherit_id" ref="base.view_partner_form"/>
<field name="arch" type="xml">
<sheet position="before">
<group string="Itch Cycle Information">
<field name="itch_next_delay" readonly="1"/>
<field name="itch_cycle_product_ids" readonly="1">
<notebook position="inside">
<page string="Product Cycles" name="product_cycles">
<field name="itch_cycle_product_ids">
<tree>
<field name="product_id"/>
<field name="last_purchase_date"/>
<field name="itch_cycle_duration"/>
<field name="next_follow_up_date"/>
<field name="quantity_mean_ordered"/>
<field name="date_expected"/>
<field name="active"/>
</tree>
</field>
</group>
</sheet>
</page>
</notebook>
</field>
</record>
<record id="view_cycle_product_partner_form" model="ir.ui.view">
<field name="name">itch.cycle.product.partner.form</field>
<field name="model">itch.cycle.product.partner</field>
<field name="arch" type="xml">
<form>
<header>
<button name="create_opportunity"
string="Create Opportunity"
type="object"
class="oe_highlight"
invisible="not active"/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_opportunities"
type="object"
class="oe_stat_button"
icon="fa-star">
<field name="opportunity_count" widget="statinfo" string="Opportunities"/>
</button>
</div>
<group>
<group>
<field name="partner_id"/>
<field name="product_id"/>
<field name="active"/>
</group>
<group>
<field name="date_expected"/>
<field name="cycle_duration"/>
<field name="quantity_mean_ordered"/>
<field name="quantity_total_ordered"/>
</group>
</group>
<notebook>
<page string="Opportunities" name="opportunities">
<field name="opportunity_ids">
<tree>
<field name="name"/>
<field name="date_deadline"/>
<field name="expected_revenue"/>
<field name="stage_id"/>
</tree>
</field>
</page>
<page string="Sales History" name="sales_history">
<field name="sale_order_line_ids">
<tree>
<field name="order_id"/>
<field name="product_uom_qty"/>
<field name="price_unit"/>
<field name="price_subtotal"/>
<field name="state"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids" groups="base.group_user"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
</odoo>

View file

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

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

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

View file

@ -20,14 +20,35 @@
{
"name": "FSM Visit Confirmation",
"version": "17.0.0.1.0",
"summary": "Have clients confirm tentatively booked visits",
"summary": "Enable client feedback workflow for field service tasks",
"description": """
This module enhances the field service management workflow by leveraging Odoo's
built-in rating system for client task confirmations. Key features include:
* Automated rating requests to work order contacts when tasks reach specific stages
* Configurable stages with rating email templates
* Client-facing rating interface with direct links in emails
* Support for task approval or change requests through ratings
* Integration with existing work order contacts from bemade_fsm
* Automatic task state updates based on client feedback
* Real-time feedback through website messages and notifications
* Chatter integration for client comments
The module helps streamline communication between field service teams and clients
by providing a clear feedback workflow and maintaining a record of all client
interactions and approvals through Odoo's rating system.
""",
"category": "Services/Field Service",
"author": "Bemade Inc.",
"website": "http://www.bemade.org",
"license": "LGPL-3",
"depends": ["industry_fsm"],
"data": ["data/mail_templates.xml"],
"assets": {},
"depends": ["industry_fsm", "bemade_fsm", "rating", "http_routing"],
"data": [
"data/mail_templates.xml",
"views/project_portal_project_task_templates.xml",
"views/project_task_type_views.xml",
],
"installable": True,
"auto_install": False,
"application": False,
}

View file

@ -1 +1 @@
from . import portal
from . import main

View 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

View file

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

View file

@ -1,47 +1,93 @@
<?xml version="1.0" encoding="UTF-8" ?>
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="request_fsm_task_date_confirmation" model="mail.template">
<field name="name">Field Service Visit Confirmation Request</field>
<record id="fsm_visit_confirmation_email_template" model="mail.template">
<field name="name">FSM Visit Confirmation</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="subject">Please confirm the date for your upcoming Durpro service visit</field>
<field name="email_from">{{ object.company_id.email_formatted }}</field>
<field name="body_html">
<table class="table table-striped">
<tbody>
<field name="subject">Service Visit Confirmation: {{ object.name }}</field>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="partner_to">{{ object.work_order_contacts and object.work_order_contacts[0].id or object.partner_id.id }}</field>
<field name="description">Send a visit confirmation email to the customer</field>
<field name="body_html" type="html">
<div>
<div style="margin-bottom: 16px;">
Dear <t t-out="object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name"/>
,
</div>
<div style="margin-bottom: 16px;">
We would like to confirm the details of your upcoming service visit:
</div>
<table style="width:100%;border-spacing:0;border:1px solid #e7e7e7;">
<tr>
<td scope="row">Summary</td>
<td>{{ object.name }}</td>
</tr>
<tr>
<td scope="row">Technician Arrival</td>
<td>{{ object.planned_date_begin }}</td>
</tr>
<tr>
<td scope="row">Intervention address</td>
<td>
<span t-esc="object.partner_id.street"/><br/>
<span t-if="object.partner_id.street2" t-esc="object.partner_id.street2"/><br/>
<span t-esc="object.partner_id.city"/><br/>
<span t-esc="object.partner_id.state"/><br/>
<span t-esc="object.partner_id.zip"/>
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Task Summary</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
<t t-out="object.name"/>
</td>
</tr>
<tr>
<td scope="row">
Assigned Technician(s):
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Technician Arrival</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
<t t-set="partner" t-value="object.work_order_contacts and object.work_order_contacts[0] or object.partner_id"/>
<t t-set="partner_tz" t-value="user.tz or object.company_id.partner_id.tz or 'America/Toronto'"/>
<span t-field="object.planned_date_begin" t-options="{'widget': 'datetime', 'timezone': partner_tz}"/>
<div style="font-size: 12px; color: #666;">
(Timezone: <t t-out="partner_tz"/>
)
</div>
</td>
<td>
<t t-foreach="object.user_ids" t-as="technician">
<t t-esc="technician.name"/><br/>
</tr>
<tr>
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Location</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
<t t-out="object.partner_id.street"/>
<br/>
<t t-if="object.partner_id.street2">
<t t-out="object.partner_id.street2"/>
<br/>
</t>
<t t-out="object.partner_id.city"/>
, <t t-out="object.partner_id.state_id.code"/>
<t t-out="object.partner_id.zip"/>
</td>
</tr>
<tr>
<td style="padding:10px;background-color:#f8f9fa;">Assigned Technician(s)</td>
<td style="padding:10px;">
<t t-foreach="object.user_ids" t-as="user">
<t t-out="user.name"/>
<t t-if="not user_last">
<br/>
</t>
</t>
</td>
</tr>
</tbody>
</table>
<p>To confirm this visit, <a href="{{ user.company_id.website }}/my/tasks/approve_booking/?task_id={{ object.id }}&amp;access_token={{ object.access_token}}">click here</a>.</p>
<p>If you would like to propose another time for this visit, please reply to this email.</p>
<p>Best regards,</p>
<p>The {{ object.company_id.name }} service management team.</p>
</table>
<div style="margin: 16px 0px 16px 0px;">
Please take a moment to confirm this visit by clicking one of the following options:
</div>
<table style="width:100%;border-spacing:0;">
<tr>
<td style="padding:10px;text-align:center;">
<t t-set="base_url" t-value="object.get_base_url()"/>
<t t-set="lang" t-value="object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'"/>
<a t-att-href="base_url + '/my/fsm_confirmation/approve?' + keep_query(lang=lang,access_token=object.access_token)" style="background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;">
Approve Visit
</a>
</td>
<td style="padding:10px;text-align:center;">
<a t-att-href="base_url + '/my/fsm_confirmation/change?' + keep_query(lang=lang,access_token=object.access_token)" style="background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;">
Request Changes
</a>
</td>
</tr>
</table>
<div style="margin-top: 16px;">
<p>Best regards,</p>
<p>The <t t-out="object.company_id.name"/>
service management team</p>
</div>
</div>
</field>
<field name="lang">{{ object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang }}</field>
<field name="auto_delete" eval="False"/>
</record>
</odoo>

View 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 + '&amp;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 + '&amp;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 + '&amp;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 + '&amp;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 :"

View 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 + '&amp;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 + '&amp;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 ""

View file

@ -1 +1,2 @@
from . import project_task_type
from . import project_task

View file

@ -1,9 +1,31 @@
from odoo import models, fields, api
from odoo import models
class Task(models.Model):
class ProjectTask(models.Model):
_inherit = "project.task"
def action_approve_booking(self):
self.ensure_one()
self.state = "03_approved"
def write(self, vals):
# Store old stage for comparison
old_stages = {task.id: task.stage_id for task in self}
# Execute original write method
result = super().write(vals)
# If stage changed and new stage has an approval template, send the email
if "stage_id" in vals:
# Only send email for top-level tasks
for task in self.filtered(
lambda task: task.stage_id != old_stages[task.id]
and task.stage_id.approval_template_id
and not task.parent_id
):
task._portal_ensure_token()
task.stage_id.approval_template_id.send_mail(
task.id,
force_send=True,
email_values={
"email_to": task.partner_id.email if task.partner_id else False
},
)
return result

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

View file

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

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

View file

@ -1,10 +1,47 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="portal_my_task" inherit_id="project.portal_my_task">
<xpath expr="//div[@id='task_content']" position="before">
<div t-if="visit_confirmation_accepted" class="alert alert-success" role="alert">
<strong>Success!</strong> We've received your confirmation. Thank you!
<!-- Add success message when visit is approved -->
<xpath expr="//div[@id='task_content']" position="inside">
<div t-if="request.params.get('visit_confirmation_status') == 'approved'" class="alert alert-success" role="alert">
<i class="fa fa-check-circle me-2"></i>
<strong>Success!</strong> Thank you for approving this service visit.
</div>
<div t-if="request.params.get('visit_confirmation_status') == 'change_submitted'" class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-2"></i>
<strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly.
</div>
</xpath>
<!-- Add change request form when needed -->
<xpath expr="//div[@id='task_chat']" position="before">
<div t-if="request.params.get('visit_confirmation_status') == 'change'" class="mb-4 mt-4">
<div class="card">
<div class="card-header bg-warning text-white">
<h5 class="mb-0">
<i class="fa fa-exclamation-triangle me-2"></i>
Request Changes to Service Visit
</h5>
</div>
<div class="card-body">
<p>Please provide details about the changes you would like to make to this service visit:</p>
<form action="/my/fsm_confirmation/submit_change" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="access_token" t-att-value="request.params.get('access_token')"/>
<input type="hidden" name="lang" t-att-value="request.context.get('lang') or request.params.get('lang')"/>
<div class="form-group mb-3">
<label for="feedback" class="form-label">Your Comments:</label>
<textarea class="form-control" name="feedback" id="feedback" rows="4" required="required" placeholder="Please explain what changes you need..."></textarea>
</div>
<button type="submit" class="btn btn-warning">
Submit Request
</button>
</form>
</div>
</div>
</div>
</xpath>
</template>
</odoo>
</odoo>

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

View file

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

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

View file

@ -0,0 +1,2 @@
from . import ai_assistant
from . import ai_config

View 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

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

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

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ai_assistant_channel_user ai.assistant.channel.user interactive_discuss_ai.model_ai_assistant_channel base.group_user 1 1 1 1
3 access_ai_config_user ai.config.user interactive_discuss_ai.model_ai_config base.group_user 1 1 1 1

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

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

View 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="{&quot;terminology&quot;: &quot;archive&quot;}"/>
</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>

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

View file

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

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

View file

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

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

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

View 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

View file

@ -0,0 +1,3 @@
from . import models
from . import controllers
from . import wizard

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

View file

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

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

View file

@ -0,0 +1,3 @@
from . import proxmox_server
from . import proxmox_cluster
from . import proxmox_vm

View 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

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

View 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

View file

@ -0,0 +1,2 @@
proxmoxer>=1.3.1
requests>=2.31.0

View 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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_proxmox_server_user proxmox.server.user model_proxmox_server group_proxmox_user 1 0 0 0
3 access_proxmox_server_manager proxmox.server.manager model_proxmox_server group_proxmox_manager 1 1 1 1
4 access_proxmox_cluster_user proxmox.cluster.user model_proxmox_cluster group_proxmox_user 1 0 0 0
5 access_proxmox_cluster_manager proxmox.cluster.manager model_proxmox_cluster group_proxmox_manager 1 1 1 1
6 access_proxmox_vm_user proxmox.vm.user model_proxmox_vm group_proxmox_user 1 1 0 0
7 access_proxmox_vm_manager proxmox.vm.manager model_proxmox_vm group_proxmox_manager 1 1 1 1
8 access_proxmox_server_wizard proxmox.server.wizard model_proxmox_server_wizard base.group_user 1 1 1 0
9 access_proxmox_server_wizard_node proxmox.server.wizard.node model_proxmox_server_wizard_node base.group_user 1 1 1 0

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 833 B

Some files were not shown because too many files have changed in this diff Show more