Compare commits

...

77 commits
18.0 ... 17.0

Author SHA1 Message Date
Marc Durepos
7156cb440e new module mail_loop_prevention 2025-10-07 10:06:10 -04:00
Marc Durepos
18dc17a3bb Fix UI issues for stock_quant_reserved_fix 2025-09-03 16:03:35 -04:00
Marc Durepos
8c8cb76c3f [ADD] New module stock_quant_reserved_fix (admin powers)
Adds a new module to allow admins to fix the stock quant reserved
quantity manually from the Physical Inventories list (or other
stock.quant list views).

This is necessary because the reserved quantity sometimes goes out of
whack with stock transfers cancelling or otherwise changing but not
updating the stock quant to unreserve the quantities they had previously
reserved. This is, of course, less ideal than fixing the issue at its
core, but it helps to have a tool for intervention when necessary.
2025-09-03 14:20:09 -04:00
Marc Durepos
3a4fc5762c remove durpro dependency from bemade_fsm 2025-08-26 10:36:30 -04:00
Marc Durepos
6af14a2f60 bemade_fsm: fix work order translations 2025-08-26 08:52:55 -04:00
Marc Durepos
b5df3a9735 properly translate work orders 2025-08-25 11:34:49 -04:00
Marc Durepos
b5e4b2d257 back-port delivery_carrier_partner_account 2025-07-04 09:26:36 -04:00
Marc Durepos
4840d9ac9e delivery_carrier_partner_account: fix and refactoring
- Fix: partners can no longer have default carrier accounts that are
archived.
- Refactor: remove write/create overrides to replace them with
  computed/stored fields.
2025-07-03 07:18:05 -04:00
Marc Durepos
8561aeca57 delivery_carrier_partner_account: transfer carrier account to delivery order on SO confirmation 2025-07-03 07:18:05 -04:00
Marc Durepos
6fa8068be5 [FIX] delivery_carrier_partner_account - failing collect SO
Fixes a bug where sale orders with a partner_shipping_id not within the
same commercial entity as their partner_id could not have collect
carrier accounts set after being confirmed.

The issue stemmed from the fact that the sale order's recipient_id field
was being set to partner_id, while the created delivery order had
recipient_id set to its partner_id, which is the partner_shipping_id of
the sale order. In other words, the sale order recipient was incorrectly
set to the main partner instead of the shipping address.

This commit adds a test that was previously failing in this scenario. It
also properly sets the recipient_id on the transport selection wizard
and on sale orders themselves, fixing the issue.
2025-07-03 07:18:05 -04:00
Marc Durepos
c08f4779d8 debugging carrier account issue with logging 2025-07-03 07:18:05 -04:00
Marc Durepos
31892a892a attempt fix to delivery carrier account - selecting collect account raising error 2025-07-03 07:18:05 -04:00
Marc Durepos
1da36c1e1c attempt fix to delivery carrier account - selecting collect account raising error 2025-07-03 07:18:05 -04:00
Marc Durepos
b627b1f57c new module sale_mandatory_customer_reference and small fix to delivery_carrier_partner_account 2025-07-03 07:18:05 -04:00
Marc Durepos
3c9835270e Fix to delivery_carrier_partner_account + test to validate 2025-07-03 07:18:05 -04:00
Marc Durepos
852c0723b9 fix a dependency issue for customer carrier accounts 2025-07-03 07:18:05 -04:00
Marc Durepos
4a778d7d81 remove debug logging for delivery_carrier_partner_account 2025-07-03 07:18:05 -04:00
Marc Durepos
4563b8b8b1 Significant rework of delivery_carrier_account.
carrier_id, carrier_account_id and delivery_billing_mode fields are all computed and have a single inverse method that can be overriden by subclasses.
2025-07-03 07:18:05 -04:00
xtremxpert
f45cd3a633 fix for _ help 2025-07-03 07:18:05 -04:00
Marc Durepos
a7eb187202 changes to delivery_carrier_partner_account 2025-07-03 07:18:05 -04:00
Marc Durepos
f4c6d0c21e Add better carrier linking for purchasing
delivery_carrier_partner_account and purchase_delivery_carrier
2025-07-03 07:18:05 -04:00
Marc Durepos
bcd92d5039 purchase_delivery_carrier: integrate with delivery_carrier_partner_account 2025-07-03 07:18:05 -04:00
Marc Durepos
c8e492b481 delivery_carrier_partner_account: added some test cases and fixed some bugs relating default accounts 2025-07-03 07:18:05 -04:00
Marc Durepos
6e61c065a5 make sure carrier_account_ids is set before indexing at 0 2025-07-03 07:18:05 -04:00
Marc Durepos
a273cde7ba delivery_carrier_partner_account: make default the first account added to a partner 2025-07-03 07:18:05 -04:00
Marc Durepos
69faaec42f delivery_carrier_partner_account fixes 2025-07-03 07:18:05 -04:00
Marc Durepos
8093509995 revert tag inheritance in bemade_fsm from sale order to task 2025-06-26 10:07:28 -04:00
Marc Durepos
1e57afd3ca Merge branch '17.0-changes' into '17.0'
Durpro inheritance mixin editing and work order print depending on language of work order contact

See merge request bemade/bemade-addons!107
2025-06-19 13:07:15 +00:00
Mathis Ouimet-Masson
7c38f76dea Durpro inheritance mixin editing and work order print depending on language of work order contact 2025-06-19 13:07:15 +00: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
238 changed files with 17569 additions and 647 deletions

View file

@ -20,7 +20,7 @@
########################################################################################
{
"name": "Improved Field Service Management",
"version": "17.0.0.4.2",
"version": "17.0.0.4.3",
"summary": (
"Adds functionality necessary for managing field service operations at Durpro."
),

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,7 @@ class SaleOrderLine(models.Model):
"product_name": self.product_id.name,
}
task.message_post(body=task_msg)
if not task.equipment_ids and self.equipment_ids:
task.equipment_ids = self.equipment_ids.ids
return task

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>
@ -563,12 +564,10 @@
<template id="work_order">
<t t-call="web.html_container">
<t t-foreach="docs" t-as="doc">
<t t-set="doc" t-value="doc.root_ancestor" t-if="doc.parent_id" />
<t t-set="doc" t-value="doc.root_ancestor.with_context(tz=doc.partner_id.tz)" t-if="doc.parent_id"/>
<t t-set="lang" t-value="(doc.work_order_contacts and doc.work_order_contacts[0].lang) or (doc.partner_id and doc.partner_id.lang) or (doc.user_id and doc.user_id.lang) or user.lang"/>
<t t-call="web.external_layout">
<t
t-call="bemade_fsm.work_order_page"
t-lang="doc.partner_id.lang"
/>
<t t-call="bemade_fsm.work_order_page" t-lang="lang"/>
</t>
</t>
</t>
@ -578,11 +577,11 @@
inherit_id="industry_fsm_report.worksheet_custom"
priority="100"
>
<xpath
expr="//t[@t-call='industry_fsm_report.worksheet_custom_page']"
position="replace"
>
<div t-call="bemade_fsm.work_order_page" />
<xpath expr="//t[@t-call='web.external_layout']" position="replace">
<t t-set="lang" t-value="(doc.work_order_contacts and doc.work_order_contacts[0].lang) or (doc.partner_id and doc.partner_id.lang) or (doc.user_id and doc.user_id.lang) or user.lang"/>
<t t-call="web.external_layout" t-lang="lang">
<t t-call="bemade_fsm.work_order_page" t-lang="lang"/>
</t>
</xpath>
</template>
</odoo>

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

@ -19,7 +19,7 @@
#
{
"name": "Carrier Accounts by Partner",
"version": "17.0.0.1.1",
"version": "17.0.0.1.4",
"summary": "Add one or many carrier accounts per partner",
"category": "Delivery",
"author": "Bemade Inc.",

View file

@ -3,7 +3,7 @@
<record id="action_open_delivery_carrier_accounts" model="ir.actions.act_window">
<field name="name">Carrier Accounts</field>
<field name="res_model">delivery.carrier.account</field>
<field name="view_mode">tree,form</field>
<field name="view_mode">list,form</field>
<field name="target">current</field>
<field name="domain">[('delivery_carrier_id', '=', context.get("carrier_id"))]</field>
<field name="context">{'default_delivery_carrier_id': "active_id"}</field>
@ -11,7 +11,7 @@
<record id="action_open_all_delivery_carrier_accounts" model="ir.actions.act_window">
<field name="name">Carrier Accounts</field>
<field name="res_model">delivery.carrier.account</field>
<field name="view_mode">tree,form</field>
<field name="view_mode">list,form</field>
<field name="target">current</field>
</record>
<menuitem id="delivery_carrier_accounts"
@ -21,4 +21,4 @@
name="Carrier Accounts"
/>
/>
</odoo>
</odoo>

View file

@ -1,5 +1,8 @@
from odoo import models, fields, api, _
from odoo import models, fields, api
from odoo.exceptions import UserError
import logging
_logger = logging.getLogger(__name__)
class CarrierAccountMixin(models.AbstractModel):
@ -23,7 +26,17 @@ class CarrierAccountMixin(models.AbstractModel):
sender_id = fields.Many2one(comodel_name="res.partner", string="Sender")
recipient_id = fields.Many2one(comodel_name="res.partner", string="Recipient")
carrier_id = fields.Many2one(comodel_name="delivery.carrier", string="Carrier")
carrier_id = fields.Many2one(
comodel_name="delivery.carrier",
string="Carrier",
compute="_compute_carrier_id",
store=True,
inverse="_on_carrier_fields_changed",
compute_sudo=True,
)
_default_carrier_field = "property_delivery_carrier_id"
_default_carrier_account_field = "default_carrier_account_id"
delivery_billing_mode = fields.Selection(
[
@ -33,7 +46,7 @@ class CarrierAccountMixin(models.AbstractModel):
("collect", "Collect"),
("third party", "Third Party"),
],
help=_(
help=(
"""
Prepaid: The shipper will pay the carrier and the client pays the estimate.
Prepaid & Charge: The shipper will pay the carrier and bill the client based on the actual price paid.
@ -42,16 +55,20 @@ class CarrierAccountMixin(models.AbstractModel):
"""
),
string="Delivery Billing Mode",
compute="_compute_delivery_billing_mode",
inverse="_on_carrier_fields_changed",
store=True,
compute_sudo=True,
)
carrier_account_id = fields.Many2one(
comodel_name="delivery.carrier.account",
ondelete="restrict",
string="Carrier Account",
compute="_compute_carrier_account_id",
inverse="_inverse_carrier_account_id",
inverse="_on_carrier_fields_changed",
store=True,
compute_sudo=True,
string="Carrier Account",
)
carrier_account_owner_id = fields.Many2one(
@ -67,111 +84,202 @@ class CarrierAccountMixin(models.AbstractModel):
string="Valid Carrier Accounts",
)
@api.depends("delivery_billing_mode", "carrier_id", "recipient_id", "sender_id")
valid_carrier_ids = fields.One2many(
comodel_name="delivery.carrier",
compute="_compute_valid_carrier_ids",
compute_sudo=True,
)
def _on_carrier_fields_changed(self):
"""Hook for subclasses to perform additional actions when carrier fields change."""
pass
def _get_valid_carrier_partners(self):
"""Get partners that can have valid carrier accounts for the current billing mode.
Returns:
Tuple containing:
- partners: recordset of partners that can have valid accounts
"""
self.ensure_one()
invalid_partners = self.env["res.partner"]
match self.delivery_billing_mode:
case "collect":
return self.recipient_id | self.recipient_id.commercial_partner_id
case "prepaid" | "ppc" | "no charge":
return self.sender_id | self.sender_id.commercial_partner_id
case "third party":
invalid_partners = (
self.recipient_id | self.recipient_id.commercial_partner_id
) | (self.sender_id | self.sender_id.commercial_partner_id)
return self.env["res.partner"].search(
[("id", "not in", invalid_partners.ids)]
)
case _:
return self.env["res.partner"]
@api.depends(
"sender_id",
"recipient_id",
"delivery_billing_mode",
)
def _compute_carrier_id(self):
for rec in self:
if not rec.carrier_id:
rec.carrier_id = rec._get_default_carrier()
elif (
rec.delivery_billing_mode
and rec.carrier_id not in rec.valid_carrier_ids
and not rec._has_valid_account_for_carrier()
):
rec.carrier_id = rec._get_default_carrier()
def _has_valid_account_for_carrier(self):
"""Check if there's a valid account for the current carrier and billing mode."""
self.ensure_one()
if not self.carrier_id or not self.delivery_billing_mode:
return False
partners = self._get_valid_carrier_partners()
valid_accounts = partners.mapped("carrier_account_ids").filtered(
lambda account: account.delivery_carrier_id == self.carrier_id
)
return bool(valid_accounts)
def _get_default_carrier(self):
self.ensure_one()
recipient = self.recipient_id
sender = self.sender_id
def_car = self._default_carrier_field
match self.delivery_billing_mode:
case "collect":
return getattr(recipient, def_car) or getattr(
recipient.commercial_partner_id, def_car
)
case "ppc" | "prepaid" | "no charge":
return getattr(sender, def_car) or getattr(
sender.commercial_partner_id, def_car
)
case _:
return False
@api.depends(
"sender_id",
"recipient_id",
"delivery_billing_mode",
"carrier_id",
"valid_carrier_account_ids",
)
def _compute_carrier_account_id(self):
for rec in self.filtered(
lambda rec: not rec.carrier_account_id
or rec.carrier_account_id not in rec.valid_carrier_account_ids
):
rec.carrier_account_id = rec._get_default_carrier_account()
def _get_default_carrier_account(self):
self.ensure_one()
match self.delivery_billing_mode:
case "collect":
default_acct = getattr(
self.recipient_id, self._default_carrier_account_field
) or getattr(
self.recipient_id.commercial_partner_id,
self._default_carrier_account_field,
)
if default_acct and default_acct.delivery_carrier_id == self.carrier_id:
return default_acct
return self.recipient_id.get_carrier_account(self.carrier_id)
case "ppc" | "prepaid" | "no charge":
default_acct = getattr(
self.sender_id, self._default_carrier_account_field
) or getattr(
self.sender_id.commercial_partner_id,
self._default_carrier_account_field,
)
if default_acct and default_acct.delivery_carrier_id == self.carrier_id:
return default_acct
return self.sender_id.get_carrier_account(self.carrier_id)
case _:
return False
@api.depends("carrier_account_id")
def _compute_delivery_billing_mode(self):
for rec in self.filtered(lambda rec: not rec.delivery_billing_mode):
if not rec.carrier_account_id:
rec.delivery_billing_mode = False
continue
account_partner = rec.carrier_account_id.partner_id
if account_partner in (
rec.recipient_id | rec.recipient_id.commercial_partner_id
):
rec.delivery_billing_mode = "collect"
elif account_partner in (
rec.sender_id | rec.sender_id.commercial_partner_id
):
rec.delivery_billing_mode = "ppc"
else:
rec.delivery_billing_mode = "third party"
@api.depends(
"delivery_billing_mode",
"carrier_id",
"recipient_id",
"sender_id",
"carrier_account_id",
)
def _compute_valid_carrier_account_ids(self):
for rec in self:
if rec.delivery_billing_mode == "collect":
rec.valid_carrier_account_ids = (
(rec.recipient_id | rec.recipient_id.commercial_partner_id)
.mapped("carrier_account_ids")
.filtered(
lambda account: account.delivery_carrier_id == rec.carrier_id
)
partners = rec._get_valid_carrier_partners()
if rec.carrier_id and partners:
rec.valid_carrier_account_ids = partners.mapped(
"carrier_account_ids"
).filtered(
lambda account: account.delivery_carrier_id == rec.carrier_id
)
if rec.delivery_billing_mode == "third party":
rec.valid_carrier_account_ids = self.env[
"delivery.carrier.account"
].search(
[
("delivery_carrier_id", "=", rec.carrier_id.id),
(
"partner_id",
"not in",
[
rec.sender_id.id,
rec.recipient_id.id,
rec.recipient_id.commercial_partner_id.id,
],
),
]
)
if rec.delivery_billing_mode in ["prepaid", "ppc"]:
rec.valid_carrier_account_ids = (
rec.sender_id.carrier_account_ids.filtered(
lambda account: account.delivery_carrier_id == rec.carrier_id
)
)
if rec.delivery_billing_mode == "no charge":
rec.valid_carrier_account_ids = self.env["delivery.carrier.account"]
if not rec.delivery_billing_mode:
rec.valid_carrier_account_ids = self.env["delivery.carrier.account"]
else:
rec.valid_carrier_account_ids = partners.mapped("carrier_account_ids")
@api.depends("delivery_billing_mode", "carrier_id", "valid_carrier_account_ids")
def _compute_carrier_account_id(self):
"""Compute the carrier account to use for this record if one is not set or if
the current one doesn't match the carrier_id selected.
When delivery_billing_mode is collect, we need to choose a carrier account that
matches both the carrier_id and the partner_id or its commercial partner.
When it is third party, any account matching the carrier_id is fine.
When it is prepaid or ppc, we select the company's account.
"""
@api.depends("delivery_billing_mode", "sender_id", "recipient_id")
def _compute_valid_carrier_ids(self):
"""Compute all valid carriers for the current billing mode and partners."""
for rec in self:
if rec.delivery_billing_mode == "collect":
if rec.carrier_account_id not in rec.valid_carrier_account_ids:
if (
rec.recipient_id.default_carrier_account_id.delivery_carrier_id
== rec.carrier_id
):
rec.carrier_account_id = (
rec.recipient_id.default_carrier_account_id
)
elif rec.valid_carrier_account_ids:
rec.carrier_account_id = rec.valid_carrier_account_ids[0]
else:
raise UserError(
"The client does not have an account with the selected carrier."
)
if rec.delivery_billing_mode == "third party":
if rec.carrier_account_id not in rec.valid_carrier_account_ids:
rec.carrier_account_id = False
if rec.delivery_billing_mode in ["prepaid", "ppc"]:
rec.carrier_account_id = (
self.env["delivery.carrier.account"]
.search([("partner_id", "=", rec.sender_id.id)])
.filtered(
lambda account: account.delivery_carrier_id == rec.carrier_id
)
)
if (
rec.delivery_billing_mode == "no charge"
or not rec.delivery_billing_mode
):
rec.carrier_account_id = False
partners = rec._get_valid_carrier_partners()
rec.valid_carrier_ids = partners.mapped(
"carrier_account_ids.delivery_carrier_id"
)
@api.constrains("carrier_account_id")
def _check_account_id(self):
@api.constrains("delivery_billing_mode", "carrier_id", "carrier_account_id")
def _check_carrier_account(self):
for rec in self:
if (
not rec.delivery_billing_mode
or rec.delivery_billing_mode == "no charge"
rec.carrier_account_id
and rec.delivery_billing_mode
and rec.valid_carrier_account_ids # Use the computed field directly
and rec.carrier_account_id not in rec.valid_carrier_account_ids
):
if rec.carrier_account_id:
_logger.warning(
"Billing mode: %s, sender: %s (commercial: %s), recipient: %s (commercial: %s), account: %s, id: %s, valid: %s",
rec.delivery_billing_mode,
rec.sender_id.name,
rec.sender_id.commercial_partner_id.name,
rec.recipient_id.name,
rec.recipient_id.commercial_partner_id.name,
rec.carrier_account_id.partner_id.name,
rec.carrier_account_id.id,
rec.valid_carrier_account_ids.ids,
)
if rec.delivery_billing_mode == "collect":
raise UserError(
_("No carrier account should be set for no charge delivery.")
f"Carrier account is not associated with the recipient, but billing mode is collect. Current object: {rec}"
)
continue
# We allow empty carrier account for third party since we can't always
# set it automatically.
if (
rec.delivery_billing_mode == "third party"
and not rec.carrier_account_id
):
continue
if rec.carrier_account_id not in rec.valid_carrier_account_ids:
raise UserError(_("Invalid carrier account selected."))
def _inverse_carrier_account_id(self):
pass
elif rec.delivery_billing_mode in ["prepaid", "ppc", "no charge"]:
raise UserError(
f"Carrier account is not associated with the sender, but billing mode is prepaid, ppc or no charge. Current object: {rec}"
)
elif rec.delivery_billing_mode == "third party":
raise UserError(
f"Third party carrier account cannot belong to sender or recipient. Current object: {rec}"
)

View file

@ -38,9 +38,3 @@ class DeliveryCarrierAccount(models.Model):
def _compute_display_name(self):
for record in self:
record.display_name = record.account_number
@api.constrains("partner_id", "delivery_carrier_id")
def _constrain_partner_carrier_same_company(self):
for rec in self:
if rec.partner_id.company_id != rec.delivery_carrier_id.company_id:
raise UserError(_("Partner and Carrier must be in the same company."))

View file

@ -1,4 +1,4 @@
from odoo import models, fields
from odoo import models, fields, api
class Partner(models.Model):
@ -13,6 +13,109 @@ class Partner(models.Model):
default_carrier_account_id = fields.Many2one(
comodel_name="delivery.carrier.account",
compute="_compute_logistic_defaults",
inverse="_inverse_default_carrier_account_id",
store=True,
tracking=1,
ondelete="restrict",
)
property_delivery_carrier_id = fields.Many2one(
compute="_compute_logistic_defaults",
inverse="_inverse_property_delivery_carrier_id",
store=True,
)
def _inverse_default_carrier_account_id(self):
pass
def _inverse_property_delivery_carrier_id(self):
pass
@api.depends(
"carrier_account_ids",
"carrier_account_ids.active",
"carrier_account_ids.delivery_carrier_id",
)
def _compute_logistic_defaults(self):
# Unset default carrier account if it is archived
for partner in self.filtered(
lambda partner: partner.default_carrier_account_id
and not partner.default_carrier_account_id.active
):
partner.default_carrier_account_id = False
# Unset the default carrier if no accounts are available
for partner in self.filtered(
lambda partner: partner.property_delivery_carrier_id
).filtered(
lambda partner: not partner.carrier_account_ids.filtered(
lambda account: account.delivery_carrier_id
== partner.property_delivery_carrier_id
and account.active
)
):
partner.property_delivery_carrier_id = False
# Set default carrier account if not set and accounts available
for partner in self.filtered(
lambda partner: not partner.default_carrier_account_id
and partner.carrier_account_ids.filtered("active")
):
partner.default_carrier_account_id = partner.carrier_account_ids.filtered(
"active"
)[0]
# Set default carrier if not set and default account is set
for partner in self.filtered(
lambda partner: not partner.property_delivery_carrier_id
and partner.default_carrier_account_id
):
partner.property_delivery_carrier_id = (
partner.default_carrier_account_id.delivery_carrier_id
)
# Reset default carrier if account is set and doesn't match
for partner in self.filtered(
lambda partner: partner.default_carrier_account_id
and partner.default_carrier_account_id.delivery_carrier_id
!= partner.property_delivery_carrier_id
):
partner.property_delivery_carrier_id = (
partner.default_carrier_account_id.delivery_carrier_id
)
def get_carrier_account(self, carrier):
self.ensure_one()
own_accounts = self.carrier_account_ids.filtered(
lambda account: account.delivery_carrier_id == carrier
)
if own_accounts:
return own_accounts[0]
commercial_patner_accounts = (
self.commercial_partner_id.carrier_account_ids.filtered(
lambda account: account.delivery_carrier_id == carrier
)
)
if commercial_patner_accounts:
return commercial_patner_accounts[0]
return self.env["delivery.carrier.account"]
def process_carrier_account_archiving(self):
for partner in self:
# Unset default carrier account if it is archived
if (
partner.default_carrier_account_id
and not partner.default_carrier_account_id.active
):
partner.default_carrier_account_id = False
# Unset default carrier if not more accounts available
if (
partner.property_delivery_carrier_id
and not partner.carrier_account_ids.filtered(
lambda account: account.delivery_carrier_id
== partner.property_delivery_carrier_id
and account.active
)
):
partner.property_delivery_carrier_id = False

View file

@ -1,4 +1,7 @@
from odoo import models, fields, api, _
import logging
_logger = logging.getLogger(__name__)
class SalesOrder(models.Model):
@ -7,40 +10,50 @@ class SalesOrder(models.Model):
recipient_id = fields.Many2one(
comodel_name="res.partner",
related="partner_id",
related="partner_shipping_id",
)
sender_id = fields.Many2one(
comodel_name="res.partner",
related="company_id.partner_id",
related="warehouse_id.partner_id",
)
@api.model
def write(self, values):
res = super().write(values)
# If carrier account ID changes for a confirmed order, change it on its
# pending pickings as well.
if "carrier_account_id" in values:
for rec in self.filtered(
lambda order: order.state not in ["draft", "sent"]
):
for picking in rec.picking_ids.filtered(
lambda pick: pick.state not in ("done", "cancel")
):
picking.carrier_account_id = rec.carrier_account_id
return res
def _create_delivery_line(self, carrier, price_unit):
line = super()._create_delivery_line(carrier, price_unit)
name = line.name
delivery_billing_mode = self.delivery_billing_mode or self.env.context.get(
"delivery_billing_mode", False
)
carrier_account = self.carrier_account_id or self.env.context.get(
"carrier_account", False
)
delivery_billing_mode = self.delivery_billing_mode
carrier_account = self.carrier_account_id
if delivery_billing_mode:
name = name + f" [{delivery_billing_mode.upper()}]"
mode_display = delivery_billing_mode.upper()
name = name + f" [{mode_display}]"
if delivery_billing_mode in ["collect", "third party"] and carrier_account:
name = name + f" #{carrier_account.account_number}"
line.name = name
return line
def _on_carrier_fields_changed(self):
"""Propagate carrier field changes to pickings."""
super()._on_carrier_fields_changed()
for rec in self:
for picking in rec.picking_ids.filtered(
lambda pick: pick.state not in ["done", "cancel"]
):
picking.write(
{
"carrier_id": rec.carrier_id and rec.carrier_id.id,
"delivery_billing_mode": rec.delivery_billing_mode,
"carrier_account_id": (
rec.carrier_account_id and rec.carrier_account_id.id
),
}
)
def action_confirm(self):
res = super().action_confirm()
self.picking_ids.write(
{
"delivery_billing_mode": self.delivery_billing_mode,
"carrier_account_id": self.carrier_account_id
and self.carrier_account_id.id,
}
)
return res

View file

@ -7,28 +7,39 @@ class Picking(models.Model):
recipient_id = fields.Many2one(
comodel_name="res.partner",
related="partner_id",
compute="_compute_sender_recipient",
)
sender_id = fields.Many2one(
comodel_name="res.partner",
related="company_id.partner_id",
compute="_compute_sender_recipient",
)
# Override to base it on the sale order field initially and when changed
delivery_billing_mode = fields.Selection(
compute="_compute_delivery_billing_mode",
inverse="_inverse_delivery_billing_mode",
store=True,
)
@api.depends("sale_id", "sale_id.delivery_billing_mode")
def _compute_delivery_billing_mode(self):
for rec in self:
rec.delivery_billing_mode = rec.sale_id.delivery_billing_mode
rec.carrier_account_id = rec.sale_id.carrier_account_id
def _inverse_delivery_billing_mode(self):
pass
def _compute_sender_recipient(self):
for picking in self:
dest_usage = picking.location_dest_id.usage
src_usage = picking.location_id.usage
match (src_usage, dest_usage):
case ("internal", "customer") | ("internal", "supplier"):
picking.recipient_id = picking.partner_id
picking.sender_id = (
picking.picking_type_id.warehouse_id.partner_id
or picking.company_id.partner_id
)
case ("customer", "internal") | ("supplier", "internal"):
picking.recipient_id = (
picking.picking_type_id.warehouse_id.partner_id
or picking.company_id.partner_id
)
picking.sender_id = picking.partner_id
case _:
picking.recipient_id = (
picking.location_dest_id.warehouse_id.partner_id
or picking.partner_id
)
picking.sender_id = (
picking.location_id.warehouse_id.partner_id
or picking.partner_id
)
def _add_delivery_cost_to_so(self):
self.ensure_one()

View file

@ -2,3 +2,4 @@ from . import test_carrier_account_common
from . import test_carrier_account_mixin
from . import test_choose_delivery_carrier
from . import test_sale_order
from . import test_res_partner

View file

@ -1,5 +1,10 @@
from .test_carrier_account_common import TestCarrierAccountCommon
from odoo.exceptions import UserError
from odoo.tests import Form
import logging
_logger = logging.getLogger(__name__)
class TestCarrierAccountMixin(TestCarrierAccountCommon):
@ -42,8 +47,9 @@ class TestCarrierAccountMixin(TestCarrierAccountCommon):
"delivery_billing_mode": "prepaid",
}
)
# No need to assert we have an account selected here. Tested elsewhere.
picking.delivery_billing_mode = "third party"
with Form(picking) as form: # Use a form here to trigger recomputation
form.delivery_billing_mode = "third party"
picking = form.record
self.assertFalse(picking.carrier_account_id)
def test_changing_account_on_confirmed_sale_changes_picking(self):
@ -110,3 +116,36 @@ class TestCarrierAccountMixin(TestCarrierAccountCommon):
self._create_sale_order(
"third party", self.delivery_carrier_1, self.sender_account_1
)
def test_carrier_preserved_on_billing_mode_change(self):
"""Test that changing billing mode preserves carrier when valid account exists."""
# Create a collect account for the same carrier
collect_account = self.env["delivery.carrier.account"].create(
{
"delivery_carrier_id": self.delivery_carrier_1.id,
"account_number": "COLLECT123",
"partner_id": self.client_partner.id,
}
)
self.client_partner.write(
{"property_delivery_carrier_id": self.delivery_carrier_2.id}
)
# Create an order with prepaid billing and carrier 1
order = self._create_sale_order(
billing_mode=False,
carrier=self.delivery_carrier_1,
account=False,
)
self.assertEqual(order.carrier_id, self.delivery_carrier_1)
self.assertFalse(order.delivery_billing_mode)
# Change billing mode to collect - carrier should stay the same
# since there's a valid collect account for it
with Form(order) as form:
form.delivery_billing_mode = "collect"
self.assertEqual(
form.carrier_id,
self.delivery_carrier_1,
"Carrier should not change when switching billing mode if a valid account exists",
)

View file

@ -0,0 +1,148 @@
from odoo.tests import TransactionCase, tagged, Form
from odoo import Command
@tagged("post_install", "-at_install")
class TestResPartner(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
def test_default_carrier_set_on_create(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
}
)
account = self.env["delivery.carrier.account"].create(
{
"partner_id": partner.id,
"delivery_carrier_id": self.env.ref(
"delivery.free_delivery_carrier"
).id,
"account_number": "1234567890",
}
)
self.assertEqual(
partner.carrier_account_ids[0], partner.default_carrier_account_id
)
def test_default_carrier_set_on_update(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
}
)
partner.write(
{
"carrier_account_ids": [
Command.create(
{
"delivery_carrier_id": self.env.ref(
"delivery.free_delivery_carrier"
).id,
"account_number": "1234567890",
}
)
]
}
)
self.assertEqual(
partner.carrier_account_ids[0], partner.default_carrier_account_id
)
def test_no_change_to_default_account_id_on_update_if_already_set(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
"carrier_account_ids": [
Command.create(
{
"delivery_carrier_id": self.env.ref(
"delivery.free_delivery_carrier"
).id,
"account_number": "1234567890",
}
)
],
}
)
new_account = self.env["delivery.carrier.account"].create(
{
"partner_id": partner.id,
"delivery_carrier_id": self.env.ref(
"delivery.delivery_local_delivery"
).id,
"account_number": "1234567890",
}
)
self.assertNotEqual(partner.default_carrier_account_id, new_account)
def test_carrier_set_if_account_created_from_other_side(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
}
)
new_account = self.env["delivery.carrier.account"].create(
{
"partner_id": partner.id,
"delivery_carrier_id": self.env.ref(
"delivery.free_delivery_carrier"
).id,
"account_number": "1234567890",
}
)
self.assertEqual(partner.default_carrier_account_id, new_account)
def test_no_archived_default_carrier_account(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
}
)
account = self.env["delivery.carrier.account"].create(
{
"partner_id": partner.id,
"delivery_carrier_id": self.env.ref(
"delivery.free_delivery_carrier"
).id,
"account_number": "1234567890",
}
)
with Form(account) as account_form:
account_form.active = False
self.assertFalse(partner.default_carrier_account_id)
def test_multiple_carrier_accounts_reset_default_on_archive(self):
partner = self.env["res.partner"].create(
{
"name": "Test Partner",
}
)
account1 = self.env["delivery.carrier.account"].create(
{
"partner_id": partner.id,
"delivery_carrier_id": self.env.ref(
"delivery.free_delivery_carrier"
).id,
"account_number": "1234567890",
}
)
account2 = self.env["delivery.carrier.account"].create(
{
"partner_id": partner.id,
"delivery_carrier_id": self.env.ref(
"delivery.free_delivery_carrier"
).id,
"account_number": "1234567891",
}
)
self.assertEqual(partner.default_carrier_account_id, account1)
with Form(account1) as account_form:
account_form.active = False
self.assertEqual(partner.default_carrier_account_id, account2)

View file

@ -1,4 +1,6 @@
from .test_carrier_account_common import TestCarrierAccountCommon
from odoo.tests import Form
from odoo import Command, api
class TestSalesOrder(TestCarrierAccountCommon):
@ -21,12 +23,13 @@ class TestSalesOrder(TestCarrierAccountCommon):
def test_prepaid_sale_order_line_gets_proper_name(self):
order = self.env["sale.order"].create({"partner_id": self.client_partner.id})
wiz = self._get_shipping_wizard(order)
wiz.carrier_id = self.delivery_carrier_1
wiz.delivery_billing_mode = "prepaid"
with Form(wiz) as form:
form.carrier_id = self.delivery_carrier_2
form.delivery_billing_mode = "prepaid"
wiz.button_confirm()
self.assertEqual(
order.order_line[0].name,
f"{self.delivery_carrier_1.name} [PREPAID]",
f"{self.delivery_carrier_2.name} [PREPAID]",
)
def test_third_party_sale_order_line_gets_proper_name(self):
@ -36,11 +39,67 @@ class TestSalesOrder(TestCarrierAccountCommon):
wiz.delivery_billing_mode = "third party"
wiz.carrier_account_id = self.third_party_account_1
wiz.button_confirm()
self.assertEqual(order.carrier_account_id, self.third_party_account_1)
self.assertEqual(
order.order_line[0].name,
f"{self.delivery_carrier_1.name} [THIRD PARTY] #{self.third_party_account_1.account_number}",
)
def test_sale_order_shipping_to_third_party_collect(self):
# We create an order where we are shipping to a third party and the billing mode is collect
# This should work, but was previously failing with an error that the carrier account did not belong to the recipient
order = self.env["sale.order"].create(
{
"partner_id": self.client_partner.id,
"partner_shipping_id": self.random_partner.id,
"order_line": [
Command.create(
{"product_id": self.env.ref("product.product_product_4").id}
)
],
}
)
# Confirming the order is important because the sender and recipient need to be in sync
# on the sale order and delivery order. This was the point of failure previously.
order.action_confirm()
wiz = self._get_shipping_wizard(order)
with Form(wiz) as form:
form.carrier_id = self.delivery_carrier_1
form.delivery_billing_mode = "collect"
self.assertIn(self.third_party_account_1, wiz.valid_carrier_account_ids)
wiz.button_confirm()
self.assertEqual(
order.carrier_account_id.partner_id,
self.random_partner,
"The carrier account should belong to the recipient (sale order shipping address)",
)
def test_stock_picking_inherits_carrier_info(self):
order = self.env["sale.order"].create(
{
"partner_id": self.client_partner.id,
"partner_shipping_id": self.client_partner.id,
"order_line": [
Command.create(
{"product_id": self.env.ref("product.product_delivery_01").id}
)
],
"carrier_id": self.delivery_carrier_1.id,
"delivery_billing_mode": "collect",
"carrier_account_id": self.client_account_1.id,
}
)
order.action_confirm()
self.env.flush_all()
picking = order.picking_ids
self.assertEqual(picking.carrier_id, order.carrier_id)
self.assertEqual(picking.delivery_billing_mode, order.delivery_billing_mode)
self.assertEqual(picking.carrier_account_id, order.carrier_account_id)
@api.returns("delivery.carrier.wizard")
def _get_shipping_wizard(self, order):
wizard_action = order.action_open_delivery_wizard()
return (

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="delivery_carrier_account_view_tree" model="ir.ui.view">
<field name="name">delivery.carrier.account.view.tree</field>
<record id="delivery_carrier_account_view_list" model="ir.ui.view">
<field name="name">delivery.carrier.account.view.list</field>
<field name="model">delivery.carrier.account</field>
<field name="arch" type="xml">
<tree editable="bottom" multi_edit="1">
@ -11,4 +11,4 @@
</tree>
</field>
</record>
</odoo>
</odoo>

View file

@ -26,4 +26,4 @@
</xpath>
</field>
</record>
</odoo>
</odoo>

View file

@ -8,20 +8,32 @@ class ChooseDeliveryCarrier(models.TransientModel):
_name = "choose.delivery.carrier"
sender_id = fields.Many2one(related="company_id.partner_id")
recipient_id = fields.Many2one(related="partner_id")
recipient_id = fields.Many2one(related="order_id.partner_shipping_id")
def button_confirm(self):
res = super(
ChooseDeliveryCarrier,
self.with_context(
delivery_billing_mode=self.delivery_billing_mode,
carrier_account=self.carrier_account_id,
),
).button_confirm()
extra_vals = {}
vals = {}
if self.delivery_billing_mode:
extra_vals.update(delivery_billing_mode=self.delivery_billing_mode)
vals.update(delivery_billing_mode=self.delivery_billing_mode)
if self.carrier_account_id:
extra_vals.update(carrier_account_id=self.carrier_account_id)
self.order_id.write(extra_vals)
vals.update(carrier_account_id=self.carrier_account_id.id)
if self.carrier_id:
vals.update(carrier_id=self.carrier_id.id)
# Ensure we have a valid carrier account
if (
self.carrier_id
and self.delivery_billing_mode
and not self.carrier_account_id
):
# Force recompute of valid carrier accounts
self._compute_valid_carrier_account_ids()
default_account = self._get_default_carrier_account()
if default_account:
vals.update(carrier_account_id=default_account.id)
# Write values to the order before calling super
if vals:
self.order_id.with_context(no_carrier_update=True).write(vals)
res = super().button_confirm()
return res

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,117 @@
# Mail Loop Prevention
## Overview
This module prevents communication loops that can occur when two mail servers auto-reply to each other indefinitely. This commonly happens with:
- Delivery receipt acknowledgements
- Out-of-office auto-replies
- Automated notification systems
- Email bounce handlers
## How It Works
The module overrides `mail.thread._message_create()` to detect potential loops by:
1. **Content Hashing**: Creates a hash of message body and subject (unescapes HTML and normalizes whitespace)
2. **Time Window Check**: Looks for identical messages within a configurable time window (default: 48 hours)
3. **Threshold Detection**: Blocks messages if the number of identical messages exceeds a threshold (default: 3)
4. **Smart Filtering**: Only checks automated messages (notification, auto_comment, email types), never blocks user comments
## Configuration
The module uses system parameters that can be configured via Settings > Technical > Parameters > System Parameters:
| Parameter | Default | Valid Range | Description |
|-----------|---------|-------------|-------------|
| `mail_loop_prevention.enabled` | `True` | `True`/`False` | Enable/disable loop prevention |
| `mail_loop_prevention.time_window_hours` | `48` | `1` to `720` | Time window in hours to check for duplicates (max 30 days) |
| `mail_loop_prevention.max_identical_messages` | `3` | `2` to `100` | Maximum identical messages allowed before blocking |
### Configuration Validation
The module validates all configuration parameters when they are set:
- **enabled**: Accepts `True`/`False`, `1`/`0`, `yes`/`no`, `on`/`off` (case-insensitive)
- **time_window_hours**: Must be an integer between 1 and 720 (30 days)
- **max_identical_messages**: Must be an integer between 2 and 100
Invalid values will raise a `ValidationError` and prevent the parameter from being saved.
## Example Scenario
**Without this module:**
```
Server A → Auto-reply to Server B
Server B → Auto-reply to Server A
Server A → Auto-reply to Server B
Server B → Auto-reply to Server A
... (infinite loop)
```
**With this module:**
```
Server A → Auto-reply to Server B (1st message - allowed)
Server B → Auto-reply to Server A (1st message - allowed)
Server A → Auto-reply to Server B (2nd message - allowed)
Server B → Auto-reply to Server A (2nd message - allowed)
Server A → Auto-reply to Server B (3rd message - allowed)
Server B → Auto-reply to Server A (3rd message - allowed)
Server A → Auto-reply to Server B (4th message - BLOCKED)
```
## Technical Details
### Message Types Checked
The module only checks these message types for loops:
- `notification` - System notifications
- `auto_comment` - Automated comments
- `email` - Email messages
Regular `comment` type messages (user posts) are never blocked.
### Duplicate Detection
Messages are considered identical if they have the same:
- Body content (after unescaping HTML entities and normalizing whitespace)
- Subject line
Real-world loops send **exactly the same message** repeatedly, so direct content comparison is sufficient.
### Performance Considerations
- Only checks messages within the configured time window
- Uses SHA-256 hashing for efficient comparison
- Minimal database queries (uses existing message_ids relation)
## Testing
Run the test suite:
```bash
odoo-bin -c odoo.conf -d test_db -i mail_loop_prevention --test-enable --stop-after-init
```
## Troubleshooting
### Legitimate messages being blocked
If legitimate automated messages are being blocked:
1. Check the logs for "Loop prevention: Blocking duplicate message" warnings
2. Increase `mail_loop_prevention.max_identical_messages` threshold
3. Reduce `mail_loop_prevention.time_window_hours` if the messages are spread over time
4. Temporarily disable with `mail_loop_prevention.enabled = False` to diagnose
### Loops still occurring
If loops are still happening:
1. Verify the module is installed and enabled
2. Check that `mail_loop_prevention.enabled = True`
3. Reduce the `max_identical_messages` threshold
4. Check if the messages have varying content (different subjects/bodies)
## License
AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)

View file

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

View file

@ -0,0 +1,17 @@
# Copyright 2025 DurPro
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
{
"name": "Mail Loop Prevention",
"summary": "Prevent auto-reply communication loops between mail servers",
"version": "17.0.1.0.0",
"license": "LGPL-3",
"author": "Bemade Inc",
"website": "https://github.com/durpro/durpro",
"depends": ["mail"],
"data": [
"views/res_config_settings_views.xml",
],
"installable": True,
"application": False,
}

View file

@ -0,0 +1,2 @@
from . import mail_thread
from . import res_config_settings

View file

@ -0,0 +1,162 @@
# Copyright 2025 DurPro
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
import hashlib
import logging
from datetime import timedelta
from odoo import api, models
from odoo.tools import html2plaintext
_logger = logging.getLogger(__name__)
class MailThread(models.AbstractModel):
_inherit = "mail.thread"
@api.model
def _get_loop_prevention_config(self):
"""
Get loop prevention configuration from system parameters.
Values are validated by ir.config_parameter model on write.
"""
ICP = self.env["ir.config_parameter"].sudo()
return {
"enabled": ICP.get_param(
"mail_loop_prevention.enabled", default="True"
) == "True",
"time_window_hours": int(
ICP.get_param("mail_loop_prevention.time_window_hours", default="48")
),
"max_identical_messages": int(
ICP.get_param("mail_loop_prevention.max_identical_messages", default="3")
),
}
def _compute_message_hash(self, body, subject=None):
"""
Compute a hash of the message content for duplicate detection.
Converts HTML to plaintext to ignore formatting differences.
"""
import html
# Unescape HTML entities (&lt; -> <, &gt; -> >, etc.)
# This is needed because message_post escapes the body before passing to _message_create
unescaped_body = html.unescape(body or "")
# Convert HTML to plaintext (handles extra wrapper tags that Odoo adds)
text_body = html2plaintext(unescaped_body).strip()
text_subject = (subject or "").strip()
# Combine subject and body for hash
content = f"{text_subject}|{text_body}"
# Debug logging to help troubleshoot loop detection
if _logger.isEnabledFor(logging.DEBUG):
_logger.debug(
"Loop prevention: Computing hash - plaintext=%r, hash=%s",
text_body[:100] if text_body else "",
hashlib.sha256(content.encode("utf-8")).hexdigest()[:16],
)
# Create hash
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _check_message_loop(self, body, subject=None, message_type="comment"):
"""
Check if posting this message would create a loop.
Returns True if the message should be blocked, False otherwise.
"""
config = self._get_loop_prevention_config()
if not config["enabled"]:
return False
# Only check for automated messages (notifications, auto_comment, etc.)
# Don't block regular user comments
if message_type not in ("notification", "auto_comment", "email"):
return False
# Compute hash of the new message
message_hash = self._compute_message_hash(body, subject)
# Calculate time threshold
time_threshold = self.env.cr.now() - timedelta(
hours=config["time_window_hours"]
)
# Search for identical messages in the time window
identical_count = 0
for record in self:
if not record.message_ids:
continue
identical_count = 0
for message in record.message_ids:
# Skip messages outside time window
if message.date < time_threshold:
continue
# Check if message content matches
msg_hash = self._compute_message_hash(message.body, message.subject)
if msg_hash == message_hash:
identical_count += 1
# If we've hit the limit, block the message
if identical_count >= config["max_identical_messages"]:
_logger.warning(
"Loop prevention: Blocking duplicate message on %s (id=%s). "
"Found %d identical messages in the last %d hours.",
record._name,
record.id,
identical_count,
config["time_window_hours"],
)
return True
return False
def _message_create(self, values_list):
"""
Override _message_create to check for message loops before creating messages.
This is safer than overriding message_post as we can cleanly filter out
messages that would create loops.
"""
filtered_values_list = []
for values in values_list:
body = values.get("body", "")
subject = values.get("subject", "")
message_type = values.get("message_type", "notification")
model = values.get("model")
res_id = values.get("res_id")
# Get the record to check its message history
if model and res_id:
try:
record = self.env[model].browse(res_id)
# All models with mail.thread mixin have _check_message_loop
if hasattr(record, '_check_message_loop'):
should_block = record._check_message_loop(body, subject, message_type)
if should_block:
_logger.warning(
"Loop prevention: Skipping message creation on %s (id=%s) "
"to prevent communication loop",
model,
res_id,
)
continue # Skip this message
except Exception as e:
# If we can't check, allow the message (fail open)
_logger.warning(
"Loop prevention: Error checking message loop, allowing message: %s",
e,
)
filtered_values_list.append(values)
# Create only the messages that passed the loop check
if not filtered_values_list:
return self.env["mail.message"]
return super()._message_create(filtered_values_list)

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