Compare commits

...

48 commits

Author SHA1 Message Date
xtremxpert
2750f9268a devben 2025-07-11 10:42:05 -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
219 changed files with 17026 additions and 386 deletions

View file

@ -20,7 +20,7 @@
########################################################################################
{
"name": "Improved Field Service Management",
"version": "17.0.0.4.2",
"version": "17.0.0.4.1",
"summary": (
"Adds functionality necessary for managing field service operations at Durpro."
),
@ -49,6 +49,7 @@
"views/menus.xml",
"views/task_views.xml",
"views/sale_order_views.xml",
"views/sale_order_template_views.xml",
"reports/worksheet_custom_report_templates.xml",
"reports/worksheet_custom_reports.xml",
"wizard/new_task_from_template.xml",

View file

@ -2,6 +2,7 @@ from . import task_template
from . import product_template
from . import sale_order_line
from . import sale_order
from . import sale_order_template
from . import task
from . import res_partner
from . import fsm_visit

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(
@ -161,3 +161,65 @@ class SaleOrder(models.Model):
for rec in self:
rec.tasks_ids.write({"partner_id": rec.partner_shipping_id.id})
return res
@api.onchange('sale_order_template_id')
def _onchange_fsm_sale_order_template_id(self):
"""Ajoute le support des champs FSM lors de l'utilisation d'un modèle de devis.
Cette méthode est appelée après le traitement standard du modèle de devis.
"""
if not self.sale_order_template_id:
return
# Vérifier si le modèle a les attributs FSM (pour éviter les erreurs si module non installé)
if hasattr(self.sale_order_template_id, 'is_fsm') and self.sale_order_template_id.is_fsm:
# Copier les contacts du modèle de devis
if hasattr(self.sale_order_template_id, 'site_contacts'):
self.site_contacts = self.sale_order_template_id.site_contacts
if hasattr(self.sale_order_template_id, 'work_order_contacts'):
self.work_order_contacts = self.sale_order_template_id.work_order_contacts
# Copier les équipements par défaut du modèle
if hasattr(self.sale_order_template_id, 'default_equipment_ids') and self.sale_order_template_id.default_equipment_ids:
self.default_equipment_ids = self.sale_order_template_id.default_equipment_ids
# Traiter les templates de visite après que toutes les lignes ont été créées
self._process_fsm_visit_templates()
def _process_fsm_visit_templates(self):
"""Créer des visites à partir des templates de visite du modèle de devis."""
if not self.sale_order_template_id or not hasattr(self.sale_order_template_id, 'visit_template_ids'):
return
for visit_template in self.sale_order_template_id.visit_template_ids:
# Créer une nouvelle visite pour chaque template
visit = self.env["bemade_fsm.visit"].create({
"label": visit_template.name,
"sale_order_id": self.id,
})
# Dans le contexte d'un modèle de commande, nous devons associer les lignes aux visites
# d'une façon différente, puisque le lien direct entre les lignes de commande
# et les lignes de modèle n'est pas disponible
# Si nous avons une section définie dans le template, nous associons la visite
# à la première section correspondante de la commande
if visit_template.so_section_template_id:
# Trouver une ligne de section avec un nom similaire
for line in self.order_line.filtered(lambda l: l.display_type == 'line_section'):
if line.name == visit_template.so_section_template_id.name:
visit.so_section_id = line
break
# Pour les produits, associer au mieux les équipements par correspondance
# avec le nom ou la description des produits
for line in self.order_line.filtered(lambda l: not l.display_type):
# Associer les lignes à la visite si c'est pertinent
# La logique exacte dépendra de l'implémentation de votre système
if visit.so_section_id and line.sequence > visit.so_section_id.sequence:
# Si la ligne suit la section de la visite, l'associer à cette visite
line.visit_id = visit.id
# Appliquer les équipements du modèle de devis par défaut
if self.default_equipment_ids:
line.equipment_ids = self.default_equipment_ids

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

View file

@ -0,0 +1,78 @@
# -*- coding: utf-8 -*-
from odoo import fields, models, api
class SaleOrderTemplate(models.Model):
_inherit = "sale.order.template"
# Champs pour les équipements
default_equipment_ids = fields.Many2many(
comodel_name="fsm.equipment",
string="Default Equipment to Service",
help="The default equipment to service for template lines.",
)
# Contacts du site et destinataires des ordres de travail
site_contacts = fields.Many2many(
comodel_name="res.partner",
relation="sale_order_template_site_contacts_rel",
string="Site Contacts",
)
work_order_contacts = fields.Many2many(
comodel_name="res.partner",
relation="sale_order_template_work_order_contacts_rel",
string="Work Order Recipients",
)
# Visites
visit_template_ids = fields.One2many(
comodel_name="bemade_fsm.visit.template",
inverse_name="sale_order_template_id",
string="Visit Templates",
copy=True,
)
is_fsm = fields.Boolean(
string="Is FSM",
compute="_compute_is_fsm",
store=True,
)
@api.depends("sale_order_template_line_ids.product_id.is_field_service")
def _compute_is_fsm(self):
for rec in self:
rec.is_fsm = any([line.product_id.is_field_service for line in rec.sale_order_template_line_ids if not line.display_type])
class SaleOrderTemplateLine(models.Model):
_inherit = "sale.order.template.line"
equipment_ids = fields.Many2many(
string="Equipment to Service",
comodel_name="fsm.equipment",
relation="bemade_fsm_equipment_sale_template_line_rel",
column1="sale_template_line_id",
column2="equipment_id",
)
visit_template_id = fields.Many2one(
comodel_name="bemade_fsm.visit.template",
string="Visit Template",
)
class BemadeFsmVisitTemplate(models.Model):
_name = "bemade_fsm.visit.template"
_description = "Template for FSM Visits"
name = fields.Char(string="Label", required=True)
sale_order_template_id = fields.Many2one(
comodel_name="sale.order.template",
string="Quotation Template",
ondelete="cascade",
)
so_section_template_id = fields.Many2one(
comodel_name="sale.order.template.line",
string="Section Line",
)

View file

@ -65,6 +65,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 +124,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 +222,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

@ -2,4 +2,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_bemade_fsm_task_template_manager,bemade_fsm_task_template,model_project_task_template,project.group_project_manager,1,1,1,1
access_bemade_fsm_task_template_user,bemade_fsm_task_template,model_project_task_template,project.group_project_user,1,1,1,1
access_bemade_fsm_visit,access_bemade_fsm_visit,model_bemade_fsm_visit,base.group_user,1,1,1,1
access_bemade_fsm_visit_template,access_bemade_fsm_visit_template,model_bemade_fsm_visit_template,base.group_user,1,1,1,1
access_bemade_fsm_task_wizard,access_bemade_fsm_task_wizard,model_project_task_from_template_wizard,project.group_project_user,1,1,1,1

1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_bemade_fsm_task_template_manager bemade_fsm_task_template model_project_task_template project.group_project_manager 1 1 1 1
3 access_bemade_fsm_task_template_user bemade_fsm_task_template model_project_task_template project.group_project_user 1 1 1 1
4 access_bemade_fsm_visit access_bemade_fsm_visit model_bemade_fsm_visit base.group_user 1 1 1 1
5 access_bemade_fsm_visit_template access_bemade_fsm_visit_template model_bemade_fsm_visit_template base.group_user 1 1 1 1
6 access_bemade_fsm_task_wizard access_bemade_fsm_task_wizard model_project_task_from_template_wizard project.group_project_user 1 1 1 1

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

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sale_order_template_view_form_fsm" model="ir.ui.view">
<field name="name">sale.order.template.form.fsm</field>
<field name="model">sale.order.template</field>
<field name="inherit_id" ref="sale_management.sale_order_template_view_form"/>
<field name="arch" type="xml">
<!-- Ajouter un onglet pour les champs FSM -->
<notebook position="inside">
<page string="Field Service">
<group>
<field name="default_equipment_ids" widget="many2many_tags"/>
<field name="site_contacts" widget="many2many_tags"/>
<field name="work_order_contacts" widget="many2many_tags"/>
</group>
<!-- Section pour les templates de visite -->
<separator string="Visit Templates"/>
<field name="visit_template_ids">
<tree editable="bottom">
<field name="name"/>
<field name="so_section_template_id"/>
</tree>
</field>
</page>
</notebook>
<!-- Ajouter champs équipements dans les lignes de modèle -->
<xpath expr="//field[@name='sale_order_template_line_ids']/tree" position="inside">
<field name="equipment_ids" widget="many2many_tags"/>
<field name="visit_template_id"/>
</xpath>
</field>
</record>
</odoo>

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

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

View file

@ -2,7 +2,8 @@ import uuid
import icalendar.cal
from odoo import models, api, fields
from odoo import models, api, fields, _
from odoo.tools.misc import _logger
from odoo.addons.calendar.models.calendar_recurrence import MAX_RECURRENT_EVENT
import caldav
import logging
@ -453,7 +454,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)
@ -610,7 +611,8 @@ 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 we're creating an instance and it doesn't follow the recurrence,
@ -646,7 +648,7 @@ class CalendarEvent(models.Model):
return synced_events
@api.model
def _get_existing_instance(self, uid, recurrence_id: datetime) -> CalendarEvent:
def _get_existing_instance(self, uid, recurrence_id: Optional[datetime]) -> CalendarEvent:
"""Find the Odoo calendar.event record matching uid and,
if set, recurrence_id.
"""
@ -685,7 +687,7 @@ class CalendarEvent(models.Model):
return instance or self.env["calendar.event"].search(
[
("caldav_uid", "=", "uid"),
("caldav_uid", "=", uid),
("recurrence_id", "=", False),
]
)
@ -840,14 +842,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 +858,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 +874,25 @@ 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 +908,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 +925,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 +972,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
@ -31,18 +31,20 @@ def _patch_caldav_with_events_from_ics(ics_paths, user, last_modified=None):
patch("caldav.Calendar") as MockCalendar,
):
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():
@ -83,17 +85,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 +128,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)

View file

@ -0,0 +1,81 @@
from odoo.tests import TransactionCase
from unittest.mock import patch, MagicMock
from .common import CaldavTestCommon
from .test_calendar import _get_ics_path, _patch_caldav_with_events_from_ics
class TestExternalOrganizer(TransactionCase, CaldavTestCommon):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.user = cls._generate_user(
"test",
caldav_username="test",
caldav_password="test",
caldav_url="https://example.com/calendar",
)
def test_external_organizer_event_sync(self):
"""Test that events with external organizers are handled correctly."""
# Setup mock for CalDAV client with our test ICS file
with _patch_caldav_with_events_from_ics(
[_get_ics_path("test_external_organizer.ics")], self.user
):
# Ensure caldav is enabled
self.user._compute_is_caldav_enabled()
# Sync events from the mock server
self.env["calendar.event"].poll_caldav_server()
# Find the synced event
event = self.env["calendar.event"].search(
[("caldav_uid", "=", "external-organizer-test-123")]
)
# Verify event was created
self.assertTrue(event, "Event should be created")
# Verify user_id is False for external organizer
self.assertFalse(
event.user_id,
"Event with external organizer should have user_id set to False",
)
# Verify the organizer's email is preserved in attendees
external_attendee = event.attendee_ids.filtered(
lambda a: a.email == "external.person@otherdomain.com"
)
self.assertTrue(
external_attendee,
"External organizer should be present in attendees",
)
# The external organizer should be in the attendees list
self.assertTrue(
external_attendee,
"External organizer should be present in attendees list",
)
def test_no_organizer_external_event_sync(self):
"""Test that events without organizers are assigned to the calendar's user."""
# Setup mock for CalDAV client with our test ICS file
with _patch_caldav_with_events_from_ics(
[_get_ics_path("test_external_no_organizer.ics")], self.user
):
self.user._compute_is_caldav_enabled()
# Make sure that the current user is not the admin user
self.assertNotEqual(self.user.id, self.env.ref("base.user_admin").id)
self.env["calendar.event"].poll_caldav_server()
# Find the synced event
event = self.env["calendar.event"].search(
[("caldav_uid", "=", "external-organizer-test-123")]
)
# Verify event was created
self.assertTrue(event, "Event should be created")
# Verify user_id is set to the calendar's user
self.assertEqual(
event.user_id,
self.user,
"Event without organizer should have user_id set to calendar's user",
)

View file

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

View file

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

View file

@ -0,0 +1,20 @@
{
'name': 'ChatGPT Assistant for Discuss',
'version': '1.0',
'author': 'Bemade Inc.',
'category': 'Tools',
'summary': 'Integrate ChatGPT into Odoo Discuss',
'depends': ['mail'],
'data': [
# 'views/assets.xml',
],
'assets': {
'web.assets_backend': [
# '/chatgpt_assistant/static/src/js/discuss_extension.js',
],
},
'installable': True,
'application': False,
'license': 'LGPL-3',
}

View file

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

View file

@ -0,0 +1,23 @@
from odoo import http
from odoo.http import request
import openai
import pandas as pd
class DiscussChatGPT(http.Controller):
@http.route('/discuss/chatgpt', type='json', auth='user')
def chatgpt_respond(self, message, attachment_id=None):
openai.api_key = request.env['ir.config_parameter'].sudo().get_param('chatgpt.api_key')
if attachment_id:
attachment = request.env['ir.attachment'].sudo().browse(attachment_id)
if attachment.mimetype == 'text/csv':
data = pd.read_csv(attachment._full_path(attachment.store_fname))
processed_data = data.describe().to_string()
return {'response': f"Analyse des données : \n{processed_data}"}
response = openai.ChatCompletion.create(
model="gpt-4",
messages=[{"role": "user", "content": message}]
)
return {'response': response['choices'][0]['message']['content']}

View file

@ -0,0 +1,28 @@
odoo.define('chatgpt_assistant.discuss_extension', [], function (Discuss, utils) {
"use strict";
const { patch } = utils;
patch(Discuss.prototype, 'chatgpt_assistant.discuss_extension', {
async _onChatGPTSendMessage(event) {
const message = this.inputValue || '';
if (!message.trim()) {
return;
}
try {
const result = await this.env.services.rpc({
route: '/discuss/chatgpt',
params: { message },
});
if (result.response) {
this._insertMessage(result.response);
}
} catch (error) {
console.error('Erreur lors de l\'appel à ChatGPT:', error);
this._insertMessage('Erreur : Impossible de contacter ChatGPT.');
}
},
});
});

View file

@ -0,0 +1,8 @@
<odoo>
<template id="assets_backend" name="chatgpt assistant assets" inherit_id="web.assets_backend">
<xpath expr="." position="inside">
<script type="text/javascript" src="/chatgpt_assistant/static/src/js/discuss_extension.js"></script>
</xpath>
</template>
</odoo>

View file

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

View file

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

View file

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

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_reprocess_all_itch_cycles" model="ir.actions.server">
<field name="name">Reprocess All Cycles</field>
<field name="model_id" ref="model_itch_cycle_product_partner"/>
<field name="binding_model_id" ref="model_itch_cycle_product_partner"/>
<field name="binding_view_types">list,form</field>
<field name="state">code</field>
<field name="code">
env['itch.cycle.product.partner'].populate_from_past_orders()
</field>
</record>
</odoo>

View file

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Automated Task for Creating Opportunities -->
<record id="ir_cron_create_opportunities" model="ir.cron">
<field name="name">Sales Cycles: Create Opportunities</field>
<field name="model_id" ref="model_itch_cycle_product_partner"/>
<field name="state">code</field>
<field name="code">model._cron_create_opportunities()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="priority">5</field>
<field name="active" eval="True"/>
</record>
<!-- Automated Task for Processing History -->
<record id="ir_cron_process_history" model="ir.cron">
<field name="name">Sales Cycles: Process History</field>
<field name="model_id" ref="model_itch_cycle_product_partner"/>
<field name="state">code</field>
<field name="code">model.populate_from_past_orders()</field>
<field name="user_id" ref="base.user_root"/>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="doall" eval="False"/>
<field name="priority">10</field>
<field name="active" eval="True"/>
</record>
</data>
</odoo>

View file

@ -0,0 +1,66 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Initialize months -->
<record id="month_01" model="itch.month">
<field name="name">January</field>
<field name="sequence">1</field>
<field name="number">1</field>
</record>
<record id="month_02" model="itch.month">
<field name="name">February</field>
<field name="sequence">2</field>
<field name="number">2</field>
</record>
<record id="month_03" model="itch.month">
<field name="name">March</field>
<field name="sequence">3</field>
<field name="number">3</field>
</record>
<record id="month_04" model="itch.month">
<field name="name">April</field>
<field name="sequence">4</field>
<field name="number">4</field>
</record>
<record id="month_05" model="itch.month">
<field name="name">May</field>
<field name="sequence">5</field>
<field name="number">5</field>
</record>
<record id="month_06" model="itch.month">
<field name="name">June</field>
<field name="sequence">6</field>
<field name="number">6</field>
</record>
<record id="month_07" model="itch.month">
<field name="name">July</field>
<field name="sequence">7</field>
<field name="number">7</field>
</record>
<record id="month_08" model="itch.month">
<field name="name">August</field>
<field name="sequence">8</field>
<field name="number">8</field>
</record>
<record id="month_09" model="itch.month">
<field name="name">September</field>
<field name="sequence">9</field>
<field name="number">9</field>
</record>
<record id="month_10" model="itch.month">
<field name="name">October</field>
<field name="sequence">10</field>
<field name="number">10</field>
</record>
<record id="month_11" model="itch.month">
<field name="name">November</field>
<field name="sequence">11</field>
<field name="number">11</field>
</record>
<record id="month_12" model="itch.month">
<field name="name">December</field>
<field name="sequence">12</field>
<field name="number">12</field>
</record>
</data>
</odoo>

View file

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

View file

@ -0,0 +1,42 @@
from odoo import models, fields
class CrmLead(models.Model):
"""
Extend CRM Lead model to add purchase cycle information.
Adds a link to the purchase cycle associated with the opportunity.
This allows tracking customer purchasing patterns and forecasting
future opportunities based on historical data.
"""
_inherit = 'crm.lead'
itch_cycle_id = fields.Many2one(
comodel_name='itch.cycle.product.partner',
string='Purchase Cycle',
ondelete='set null',
help="""
Linked purchase cycle for this opportunity.
Used to track the customer's purchasing patterns.
"""
)
date_last_purchase = fields.Date(
related='itch_cycle_id.date_last_purchase',
string='Last Purchase Date',
readonly=True
)
cycle_duration = fields.Integer(
related='itch_cycle_id.cycle_duration',
string='Cycle Duration (days)',
readonly=True
)
cycle_sales = fields.One2many(
comodel_name='sale.order.line',
related='itch_cycle_id.sale_order_line_ids',
string='Cycle Sales',
readonly=True
)

View file

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

View file

@ -0,0 +1,124 @@
from odoo import models, fields, api
from odoo.exceptions import ValidationError
import logging
_logger = logging.getLogger(__name__)
class Month(models.Model):
"""
Model to represent months for seasonal tracking.
This allows for better organization and selection of active months
for seasonal product categories.
"""
_name = 'itch.month'
_description = 'Month for Seasonal Tracking'
_order = 'sequence'
name = fields.Char(string='Name', required=True, translate=True)
sequence = fields.Integer(string='Sequence', required=True)
number = fields.Integer(string='Month Number', required=True)
_sql_constraints = [
('unique_number', 'unique(number)', 'Month number must be unique!'),
('number_range', 'CHECK(number >= 1 AND number <= 12)', 'Month number must be between 1 and 12!')
]
class ProductCategory(models.Model):
"""
Extends the product category model to add seasonal behavior functionality.
This is used in the sales cycle calculation to better predict next sales
by taking into account seasonal patterns.
"""
_inherit = 'product.category'
is_cycle_tracked = fields.Boolean(
string="Track Sales Cycle",
help="If enabled, products in this category will be tracked by the sales cycle system",
default=True,
)
temp_is_cycle_tracked = fields.Boolean(
string="Temporary Track Sales Cycle",
help="Technical field to track changes in is_cycle_tracked",
default=True,
)
@api.onchange('is_cycle_tracked')
def _onchange_is_cycle_tracked(self):
"""Store the new value temporarily and restore the original value"""
if self.id and self.is_cycle_tracked != self.temp_is_cycle_tracked:
self.temp_is_cycle_tracked = self.is_cycle_tracked
self.is_cycle_tracked = not self.is_cycle_tracked
return {
'warning': {
'title': 'Confirmation Required',
'message': 'Please use the "Apply Changes" button to change tracking status.'
}
}
def action_apply_cycle_tracked(self):
"""Open wizard to apply cycle tracked changes"""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Apply to Child Categories',
'res_model': 'itch.apply.to.child.categories',
'view_mode': 'form',
'target': 'new',
'context': {
'default_category_id': self.id,
'default_new_value': self.temp_is_cycle_tracked,
}
}
def write(self, vals):
"""
Override write to handle changes in is_cycle_tracked field.
If a category is no longer tracked, we archive all related cycles.
"""
if 'is_cycle_tracked' in vals and not vals['is_cycle_tracked']:
# Find all cycles for products in this category
cycles = self.env['itch.cycle.product.partner'].search([
('product_id.categ_id', 'in', self.ids)
])
if cycles:
# Archive the cycles
cycles.write({
'active': False,
'notes': f'{fields.Date.today()}: Archived automatically - Category no longer tracked'
})
# Log the action
_logger.info(
f"Archived {len(cycles)} cycles due to category {self.name} "
f"being marked as not tracked"
)
return super().write(vals)
seasonal_factor = fields.Boolean(
string="Seasonal Category",
help="Enable this if the products in this category have seasonal sales patterns",
default=False,
)
season_months = fields.Many2many(
'itch.month',
string="Active Months",
help="Select the months when this category is typically active",
)
@api.constrains('season_months')
def _check_season_months(self):
"""
Validate the selection of season_months field.
Rules:
- Cannot be empty if seasonal_factor is True
"""
for record in self:
if record.seasonal_factor and not record.season_months:
raise ValidationError("Active months must be specified for seasonal categories")

View file

@ -0,0 +1,53 @@
from odoo import api, fields, models
class ResPartner(models.Model):
"""
Extends the partner model to add sales cycle tracking functionality.
"""
_inherit = 'res.partner'
itch_cycle_product_ids = fields.One2many(
comodel_name='itch.cycle.product.partner',
inverse_name='partner_id',
string="Product Sales Cycles",
help="List of all product sales cycles associated with this customer",
)
reorder_cycle_product_ids = fields.One2many(
comodel_name='itch.cycle.product.partner',
inverse_name='partner_id',
string="Active Sales Cycles",
domain=[('quantity_of_orders', '>', 1)],
help="List of product sales cycles with established patterns",
)
itch_next_delay = fields.Date(
string="Next Follow-up Date",
compute="_compute_itch_next_delay",
store=True,
help="Earliest follow-up date",
)
@api.depends(
'itch_cycle_product_ids',
'itch_cycle_product_ids.date_next_follow_up'
)
def _compute_itch_next_delay(self):
"""
Compute the earliest follow-up date.
The date is calculated from all associated product cycles.
"""
for partner in self:
cycles = partner.reorder_cycle_product_ids
dates = cycles.mapped('date_next_follow_up')
partner.itch_next_delay = min(dates) if dates else False
def action_populate_itch_cycles(self):
"""
Initialize or update product cycles.
The cycles are created from historical order data.
"""
self.env['itch.cycle.product.partner'].populate_from_past_orders()

View file

@ -1,44 +0,0 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from datetime import datetime
class SaleOrder(models.Model):
_inherit = 'sale.order'
def action_confirm(self):
super(SaleOrder, self).action_confirm()
for line in self.order_line:
partner_id = self.partner_id
product_id = line.product_id
itch_cycle_record = self.env['itch_cycle_product_partner'].search([
('partner_id', '=', partner_id.id),
('product_id', '=', product_id.id)
], limit=1)
if itch_cycle_record:
if itch_cycle_record.last_purchase_date:
days_since_last_purchase = (datetime.now().date() - itch_cycle_record.last_purchase_date).days
if itch_cycle_record.itch_cycle_duration == 0:
raise UserError(_(
f"Veuillez définir la durée du itch-cycle pour le produit '{product_id.name}' "
f"pour le client '{partner_id.name}'.\n\n"
f"Il y a eu {days_since_last_purchase} jours depuis le dernier achat."
))
else:
itch_cycle_record.last_purchase_date = datetime.now().date()
itch_cycle_record.itch_cycle_duration = days_since_last_purchase
else:
itch_cycle_record.last_purchase_date = datetime.now().date()
else:
new_record = self.env['itch_cycle_product_partner'].create({
'partner_id': partner_id.id,
'product_id': product_id.id,
'last_purchase_date': datetime.now().date(),
'itch_cycle_duration': 0
})
raise UserError(_(
f"Il n'y a pas encore de itch-cycle défini pour le produit '{product_id.name}' "
f"avec le client '{partner_id.name}'.\n\nVeuillez entrer une durée pour ce cycle."
))

View file

@ -0,0 +1,51 @@
from odoo import models, fields, api
class SaleOrderLine(models.Model):
"""
Extends sale order line to add itch cycle tracking.
"""
_inherit = 'sale.order.line'
itch_cycle_id = fields.Many2one(
comodel_name='itch.cycle.product.partner',
string="Cycle")
stock_move_ids = fields.One2many(
comodel_name='stock.move',
inverse_name='sale_line_id',
string="Movements")
sale_date = fields.Datetime(
string="Date",
related='order_id.date_order',
store=True)
@api.model_create_multi
def create(self, vals_list):
"""
Create sale order lines and associate them with their itch cycle.
If the product is cycle tracked and no existing cycle exists for the
product/partner combination, a new cycle is created.
"""
lines = super().create(vals_list)
for line in lines:
if (line.product_id and line.order_id and
line.product_id.categ_id.is_cycle_tracked):
partner_id = line.order_id.partner_id.id
itch_cycle = self.env['itch.cycle.product.partner'].search([
('partner_id', '=', partner_id),
('product_id', '=', line.product_id.id)
], limit=1)
if not itch_cycle:
cycle_vals = {
'partner_id': partner_id,
'product_id': line.product_id.id,
}
itch_cycle = self.env['itch.cycle.product.partner'].create(
cycle_vals)
line.itch_cycle_id = itch_cycle.id
return lines

View file

@ -0,0 +1,5 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_itch_cycle_product_partner,itch.cycle.product.partner access,model_itch_cycle_product_partner,base.group_user,1,1,1,1
access_itch_month_user,itch.month access user,model_itch_month,base.group_user,1,0,0,0
access_itch_month_manager,itch.month access manager,model_itch_month,sales_team.group_sale_manager,1,1,1,1
access_itch_apply_to_child_categories,itch.apply.to.child.categories access,model_itch_apply_to_child_categories,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_itch_cycle_product_partner itch.cycle.product.partner access model_itch_cycle_product_partner base.group_user 1 1 1 1
3 access_itch_month_user itch.month access user model_itch_month base.group_user 1 0 0 0
4 access_itch_month_manager itch.month access manager model_itch_month sales_team.group_sale_manager 1 1 1 1
5 access_itch_apply_to_child_categories itch.apply.to.child.categories access model_itch_apply_to_child_categories base.group_user 1 1 1 1

View file

@ -0,0 +1,56 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { listView } from "@web/views/list/list_view";
import { ListController } from "@web/views/list/list_controller";
import { useService } from "@web/core/utils/hooks";
/**
* Custom List Controller for Product/Partner Cycle Management
* Extends standard list view to add history processing functionality
*/
class CycleProductPartnerListController extends ListController {
setup() {
super.setup();
this.orm = useService("orm");
this.notification = useService("notification");
}
/**
* Process historical sales data to update cycles
* Shows notifications for processing status
*/
async ProcessHistoryButton() {
try {
this.notification.add("Starting history processing...", {
type: "info",
sticky: false,
});
const result = await this.orm.call("itch.cycle.product.partner", "populate_from_past_orders", []);
if (result && result.tag === 'display_notifications' && result.params.notifications) {
for (const notif of result.params.notifications) {
this.notification.add(notif.params.message, {
type: notif.params.type,
sticky: notif.params.sticky,
});
await new Promise(resolve => setTimeout(resolve, 300));
}
}
await this.model.load();
} catch (error) {
this.notification.add(`An error occurred: ${error.message}`, {
type: "danger",
});
}
}
}
// Register the custom list view for cycle product partner
registry.category("views").add("cycle_product_partner_list", {
...listView,
Controller: CycleProductPartnerListController,
buttonTemplate: "customer_itch_cycle.ListButtons",
});

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates id="template" xml:space="preserve">
<t t-name="customer_itch_cycle.ListButtons" name="ProcessHistoryButton">
<div>
<button class="btn btn-primary" t-on-click="ProcessHistoryButton">
Update from Sales History
</button>
</div>
</t>
</templates>

View file

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<!-- Add itch cycle field to CRM lead form -->
<record id="view_crm_lead_form_inherit_itch_cycle" model="ir.ui.view">
<field name="name">crm.lead.form.inherit.itch.cycle</field>
<field name="model">crm.lead</field>
<field name="inherit_id" ref="crm.crm_lead_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="after">
<field name="itch_cycle_id" widget="many2one"/>
</xpath>
</field>
</record>
<!-- Add itch cycle tab to CRM lead form -->
<record id="view_crm_lead_form_inherit_itch_cycle_tab" model="ir.ui.view">
<field name="name">crm.lead.form.inherit.itch.cycle.tab</field>
<field name="model">crm.lead</field>
<field name="inherit_id" ref="crm.crm_lead_view_form"/>
<field name="arch" type="xml">
<xpath expr="//notebook" position="inside">
<page string="Purchase Cycle">
<field name="itch_cycle_id" readonly="1"/>
</page>
<page string="Opportunities">
<tree>
<field name="expected_revenue"/>
<field name="probability"/>
<field name="date_deadline"/>
<field name="stage_id"/>
<field name="tag_ids"/>
</tree>
<field name="description" widget="html"/>
</page>
</xpath>
</field>
</record>
<!-- Add itch cycle filter to CRM lead search -->
<record id="view_crm_lead_search_inherit_itch_cycle" model="ir.ui.view">
<field name="name">crm.lead.search.inherit.itch.cycle</field>
<field name="model">crm.lead</field>
<field name="inherit_id" ref="crm.view_crm_case_opportunities_filter"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='assigned_to_me']" position="after">
<filter string="Purchase Cycle" name="itch_cycle"
domain="[('itch_cycle_id','!=',False)]"/>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_cycle_product_partner" model="ir.actions.act_window">
<field name="name">Product Cycle Management</field>
<field name="res_model">itch.cycle.product.partner</field>
<field name="view_mode">tree,form</field>
<field name="view_id" ref="view_cycle_product_partner_tree"/>
<field name="domain">[('state', '!=', 'new'), ('active', '=', True)]
</field>
</record>
</odoo>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<menuitem id="menu_itch_cycle_product_root"
name="Product Cycle"
sequence="5"
parent="sale.menu_sale_report"
action="action_cycle_product_partner"/>
</odoo>

View file

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

View file

@ -0,0 +1,30 @@
<odoo>
<record id="view_product_category_form" model="ir.ui.view">
<field name="name">product.category.form</field>
<field name="model">product.category</field>
<field name="inherit_id" ref="product.product_category_form_view"/>
<field name="arch" type="xml">
<xpath expr="//form[1]/sheet[1]" position="inside">
<group string="Sales Cycle">
<group>
<field name="is_cycle_tracked"/>
<field name="temp_is_cycle_tracked" invisible="1"/>
<button name="action_apply_cycle_tracked"
string="Apply Changes"
type="object"
class="btn btn-primary"
invisible="is_cycle_tracked == temp_is_cycle_tracked"/>
<field name="seasonal_factor"
help="Factor used to adjust sales predictions based on seasonal patterns"/>
<field name="season_months"
widget="many2many_tags"
options="{'no_create': True}"
help="Months where this product category has peak sales"
invisible="not seasonal_factor"
required="seasonal_factor"/>
</group>
</group>
</xpath>
</field>
</record>
</odoo>

View file

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

View file

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

View file

@ -0,0 +1,48 @@
from odoo import models, fields, api
class ApplyToChildCategories(models.TransientModel):
"""
Wizard to apply cycle tracking settings to child categories
This wizard is shown when changing is_cycle_tracked on a product category
"""
_name = 'itch.apply.to.child.categories'
_description = 'Apply Settings to Child Categories'
category_id = fields.Many2one('product.category', string='Category', required=True)
new_value = fields.Boolean(string='New Tracking Value')
apply_to_children = fields.Boolean(
string='Apply to Child Categories',
help='If checked, the cycle tracking settings will be applied to all child categories',
default=True
)
child_count = fields.Integer(
string='Number of Child Categories',
compute='_compute_child_count'
)
@api.depends('category_id')
def _compute_child_count(self):
"""Compute the number of child categories that would be affected"""
for wizard in self:
wizard.child_count = self.env['product.category'].search_count([
('id', 'child_of', wizard.category_id.id),
('id', '!=', wizard.category_id.id)
])
def apply_settings(self):
"""Apply the settings to the selected categories"""
self.ensure_one()
if self.apply_to_children:
categories = self.env['product.category'].search([
('id', 'child_of', self.category_id.id)
])
else:
categories = self.category_id
# Apply the settings
values = {
'is_cycle_tracked': self.new_value,
'temp_is_cycle_tracked': self.new_value,
}
categories.write(values)
return {'type': 'ir.actions.act_window_close'}

View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_apply_to_child_categories_form" model="ir.ui.view">
<field name="name">itch.apply.to.child.categories.form</field>
<field name="model">itch.apply.to.child.categories</field>
<field name="arch" type="xml">
<form string="Apply to Child Categories">
<group>
<field name="category_id" invisible="1"/>
<field name="child_count"/>
<field name="apply_to_children"/>
</group>
<footer>
<button string="Apply" name="apply_settings" type="object" class="btn-primary" data-hotkey="q"/>
<button string="Cancel" class="btn-secondary" special="cancel" data-hotkey="z"/>
</footer>
</form>
</field>
</record>
</odoo>

View file

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

View file

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

View file

@ -0,0 +1,270 @@
# -*- coding: utf-8 -*-
import logging
import werkzeug
from odoo import http, _
from odoo.http import request
from odoo.addons.base.models.ir_qweb import keep_query
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.exceptions import AccessError, MissingError
_logger = logging.getLogger(__name__)
class CustomerPortalExtended(CustomerPortal):
"""Extend the customer portal controller to handle FSM visit confirmations."""
@http.route("/my/task/<int:task_id>", type="http", auth="public", website=True)
def portal_my_task(self, task_id, access_token=None, **kw):
"""Override to allow access with token for public users."""
try:
task = request.env["project.task"].browse(task_id)
task.check_access_rights("read")
task.check_access_rights("write")
task.check_access_rule("read")
task.check_access_rule("write")
except AccessError:
task = None
values = self._get_portal_values(task, access_token=access_token, **kw)
if not task:
task = values.get("task", False)
if not task:
return self._render_error_page(
_("Task not found or invalid token"), task=None
)
# Pass the task to the original method
result = super(CustomerPortalExtended, self).portal_my_task(
task_id, access_token=access_token, **kw
)
# If the result is a Response object (like a redirect), return it directly
if isinstance(result, werkzeug.wrappers.Response):
return result
# Otherwise, it's a rendered template, so we need to update the qcontext
if hasattr(result, "qcontext"):
result.qcontext.update(values)
return result
@http.route(
"/my/fsm_confirmation/<string:action>",
type="http",
auth="public",
website=True,
)
def fsm_confirmation_action(self, action, **kwargs):
"""Custom landing page for FSM visit confirmations.
Args:
action: Either 'approve' or 'change'
Kwargs:
access_token: The access token for the task
"""
values = self._get_portal_values(**kwargs)
task = values.get("task", False)
if not task:
return self._render_error_page(
_("Task not found or invalid token"), task=None
)
# Store the language in kwargs for use in redirects
lang = values.get("lang")
if action not in ("approve", "change"):
raise werkzeug.exceptions.BadRequest(_("Invalid action"))
# If it's an approval, update the task state directly
if action == "approve":
try:
# Update task state to approved
task.write({"state": "03_approved"})
# Add a message to the chatter
task.message_post(
body=_("Visit approved by customer"),
message_type="comment",
subtype_xmlid="mail.mt_note",
)
# Always redirect to the task portal page with success message
redirect_url = "/my/task/%s?%s" % (
task.id,
keep_query(
"access_token",
visit_confirmation_status="approved",
lang=lang,
),
)
return request.redirect(redirect_url)
except Exception as e:
_logger.exception(_("Error approving task: %s"), e)
return self._render_error_page(
_("Error approving task: %s") % str(e), task=task
)
# For change requests, redirect to the task portal page with change request form
redirect_url = "/my/task/%s?%s" % (
task.id,
keep_query(
"access_token",
visit_confirmation_status="change",
lang=lang,
),
)
return request.redirect(redirect_url)
@http.route(
"/my/fsm_confirmation/submit_change",
type="http",
auth="public",
methods=["post"],
website=True,
csrf=True,
)
def fsm_confirmation_submit_change(self, **kwargs):
"""Handle the submission of change request form."""
values = self._get_portal_values(**kwargs)
task = values.get("task", False)
lang = values.get("lang")
feedback = kwargs.get("feedback", "")
if not task:
return self._render_error_page(
_("Task not found or invalid token"), task=None
)
if not feedback:
return self._render_error_page(
_("No feedback provided. Please try again!"), task=task
)
try:
# Update task state to changes requested
task.write({"state": "02_changes_requested"})
# Add the feedback as a message to the chatter
task.message_post(
body=_("Changes requested by customer: %s") % feedback,
message_type="comment",
subtype_xmlid="mail.mt_comment",
)
# Always redirect to the task portal page with success message
redirect_url = "/my/task/%s?%s" % (
task.id,
keep_query(
access_token=kwargs.get("access_token"),
visit_confirmation_status="change_submitted",
lang=lang,
),
)
return request.redirect(redirect_url)
except Exception as e:
_logger.exception(_("Error submitting change request: %s"), e)
return self._render_error_page(
_("Error submitting change request: %s") % str(e), task=task
)
def _get_task_by_token(self, token):
"""Get a task by its access token."""
# First try to find the task directly by access token
task = (
request.env["project.task"]
.sudo()
.search([("access_token", "=", token)], limit=1)
)
if task:
return task
# If not found, try to find it through the rating token
# This is for backward compatibility with existing tokens in emails
rating = (
request.env["rating.rating"]
.sudo()
.search([("access_token", "=", token)], limit=1)
)
if rating and rating.res_model == "project.task":
task = request.env["project.task"].sudo().browse(rating.res_id)
if task.exists():
return task
return None
def _render_error_page(self, error_message, task=None, **kwargs):
"""Render an error page."""
values = {
"status_code": "400",
"status_message": error_message,
"lang": self._get_lang(task=task, lang=kwargs.get("lang")),
}
return request.render("http_routing.http_error", values)
def _get_portal_values(self, task=None, **kwargs):
"""Get common values for portal templates.
Args:
task: The task record if already loaded
**kwargs: Keyword arguments from the request
Returns:
Dictionary with values for template rendering
"""
values = {"page_name": "task"}
if not task:
access_token = kwargs.get("access_token")
if access_token:
task = self._get_task_by_token(access_token)
if task:
values["task"] = task
# Set language and update values dictionary
values["lang"] = self._get_lang(task=task, lang=kwargs.get("lang"))
# Add any additional parameters from kwargs that might be needed in templates
for key in ["visit_confirmation_status", "error", "warning", "success"]:
if kwargs.get(key):
values[key] = kwargs.get(key)
return values
def _get_lang(self, task=None, lang=None):
"""Helper method to set the language in the request environment.
Args:
task: The task record (optional)
lang: Language code (e.g., 'en_US', 'fr_FR') (optional)
Returns:
The language code that was set, or None if no language was set
"""
# If lang is not provided, try to get it from the task
if not lang and task:
lang_partner = (
task.work_order_contacts
and task.work_order_contacts[0]
or task.partner_id
)
if lang_partner and lang_partner.lang:
lang = lang_partner.lang
if not lang:
return None
# Get the language record
lang_code = lang.replace("_", "-")
lang_id = (
request.env["res.lang"]
.sudo()
.search(["|", ("code", "=", lang_code), ("code", "=", lang)], limit=1)
)
if lang_id:
# Set the context language
request.env = request.env(
context=dict(request.env.context, lang=lang_id.code)
)
return lang_id.code
return None

View file

@ -1,35 +0,0 @@
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.http import request, route
from odoo.exceptions import AccessError, MissingError
class FsmCustomerPortal(CustomerPortal):
@route(
"/my/tasks/approve_booking/<int:task_id>",
type="http",
auth="public",
website=True,
)
def portal_approve_booking(self, task_id, access_token=None):
try:
visit_sudo = self._document_check_access(
"project.task", task_id, access_token=access_token
)
except (AccessError, MissingError):
return request.redirect("/my")
visit_sudo.action_approve_booking()
request.session["visit_confirmation_accepted"] = True
request.redirect(f"/my/tasks/{task_id}")
def _task_get_page_view_values(self, task, access_token, **kwargs):
vals = super()._task_get_page_view_values(task, access_token, **kwargs)
if request.session.pop("visit_confirmation_accepted", False):
vals.update(visit_confirmation_accepted=True)
return vals
def _prepare_home_portal_values(self, counters):
vals = super()._prepare_home_portal_values(counters)
if request.session.pop("visit_confirmation_accepted", False):
vals.update(visit_confirmation_accepted=True)
return vals

View file

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

View file

@ -0,0 +1,299 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * fsm_visit_confirmation
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 17.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-27 00:31+0000\n"
"PO-Revision-Date: 2025-02-27 00:31+0000\n"
"Last-Translator: Marc Durepos <marc@bemade.org>\n"
"Language-Team: French\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#. module: fsm_visit_confirmation
#: model:mail.template,body_html:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid ""
"<div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" Dear <t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
",\n"
" </div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" We would like to confirm the details of your upcoming service visit:\n"
" </div>\n"
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Task Summary</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.name\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Technician Arrival</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
" <div style=\"font-size: 12px; color: #666;\">\n"
" (Timezone: <t t-out=\"partner_tz\"></t>\n"
")\n"
" </div>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Location</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.partner_id.street\"></t>\n"
" <br>\n"
" <t t-if=\"object.partner_id.street2\">\n"
" <t t-out=\"object.partner_id.street2\"></t>\n"
" <br>\n"
" </t>\n"
" <t t-out=\"object.partner_id.city\"></t>\n"
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
" <t t-out=\"object.partner_id.zip\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Assigned Technician(s)</td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
" <t t-out=\"user.name\"></t>\n"
" <t t-if=\"not user_last\">\n"
" <br>\n"
" </t>\n"
" </t>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
" Please take a moment to confirm this visit by clicking one of the following options:\n"
" </div>\n"
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Approve Visit\n"
" </a>\n"
" </td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Request Changes\n"
" </a>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin-top: 16px;\">\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Best regards,</p>\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">The <t t-out=\"object.company_id.name\"></t>\n"
" service management team</p>\n"
" </div>\n"
" </div>\n"
" "
msgstr ""
"<div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" Cher/chère<t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
",\n"
" </div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" Nous aimerions confirmer les détails de votre visite de service à venir.\n"
" </div>\n"
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Visite</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.name\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Début</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
" <div style=\"font-size: 12px; color: #666;\">\n"
" (Fuseau horaire: <t t-out=\"partner_tz\"></t>\n"
")\n"
" </div>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Lieu</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.partner_id.street\"></t>\n"
" <br>\n"
" <t t-if=\"object.partner_id.street2\">\n"
" <t t-out=\"object.partner_id.street2\"></t>\n"
" <br>\n"
" </t>\n"
" <t t-out=\"object.partner_id.city\"></t>\n"
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
" <t t-out=\"object.partner_id.zip\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Technicien(s) assigné(s)</td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
" <t t-out=\"user.name\"></t>\n"
" <t t-if=\"not user_last\">\n"
" <br>\n"
" </t>\n"
" </t>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
" Veuillez prendre un moment pour confirmer cette visite en cliquant sur une des options suivantes :\n"
" </div>\n"
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Confirmer la visite\n"
" </a>\n"
" </td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Demander des changements\n"
" </a>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin-top: 16px;\">\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Cordialement,</p>\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">L'équipe de gestion des services de <t t-out=\"object.company_id.name\"></t></p>\n"
" </div>\n"
" </div>\n"
" "
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-check-circle me-2\"/>\n"
" <strong>Success!</strong> Thank you for approving this service visit."
msgstr ""
"<i class=\"fa fa-check-circle me-2\"/>\n"
" <strong>Succès!</strong> Merci d'avoir approuvé cette visite de service."
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
" Request Changes to Service Visit"
msgstr ""
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
" Demander des changements à la visite de service"
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-info-circle me-2\"/>\n"
" <strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly."
msgstr ""
"<i class=\"fa fa-info-circle me-2\"/>\n"
" <strong>Merci!</strong> Votre demande de changement a été soumise. Notre équipe examinerait cette demande rapidement."
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Changes requested by customer: %s"
msgstr "Changements demandés par client: %s"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Error approving task: %s"
msgstr "Erreur lors de l'approbation de la tâche: %s"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Error submitting change request: %s"
msgstr "Erreur lors de la soumission de la demande de changement: %s"
#. module: fsm_visit_confirmation
#: model:mail.template,name:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "FSM Visit Confirmation"
msgstr "Confirmation de visite de service"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Invalid action"
msgstr "Action invalide"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "No feedback provided. Please try again!"
msgstr "Aucun commentaire fourni. Veuillez réessayer!"
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Please explain what changes you need..."
msgstr "Veuillez expliquer les modifications nécessaires..."
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"Please provide details about the changes you would like to make to this "
"service visit:"
msgstr ""
"Veuillez fournir des informations sur les modifications que vous souhaitez "
"apporter à cette visite de service :"
#. module: fsm_visit_confirmation
#: model:mail.template,description:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "Send a visit confirmation email to the customer"
msgstr "Envoyer un email de confirmation de visite au client"
#. module: fsm_visit_confirmation
#: model:mail.template,subject:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "Service Visit Confirmation: {{ object.name }}"
msgstr "Confirmation de visite de service: {{ object.name }}"
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Submit Request"
msgstr "Soumettre la demande"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Task not found or invalid token"
msgstr "Tâche introuvable ou jeton invalide"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Visit approved by customer"
msgstr "Visite approuvée par le client"
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Your Comments:"
msgstr "Vos commentaires :"

View file

@ -0,0 +1,211 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * fsm_visit_confirmation
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 17.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-27 00:31+0000\n"
"PO-Revision-Date: 2025-02-27 00:31+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: fsm_visit_confirmation
#: model:mail.template,body_html:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid ""
"<div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" Dear <t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
",\n"
" </div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" We would like to confirm the details of your upcoming service visit:\n"
" </div>\n"
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Task Summary</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.name\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Technician Arrival</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
" <div style=\"font-size: 12px; color: #666;\">\n"
" (Timezone: <t t-out=\"partner_tz\"></t>\n"
")\n"
" </div>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Location</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.partner_id.street\"></t>\n"
" <br>\n"
" <t t-if=\"object.partner_id.street2\">\n"
" <t t-out=\"object.partner_id.street2\"></t>\n"
" <br>\n"
" </t>\n"
" <t t-out=\"object.partner_id.city\"></t>\n"
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
" <t t-out=\"object.partner_id.zip\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Assigned Technician(s)</td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
" <t t-out=\"user.name\"></t>\n"
" <t t-if=\"not user_last\">\n"
" <br>\n"
" </t>\n"
" </t>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
" Please take a moment to confirm this visit by clicking one of the following options:\n"
" </div>\n"
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Approve Visit\n"
" </a>\n"
" </td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Request Changes\n"
" </a>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin-top: 16px;\">\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Best regards,</p>\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">The <t t-out=\"object.company_id.name\"></t>\n"
" service management team</p>\n"
" </div>\n"
" </div>\n"
" "
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-check-circle me-2\"/>\n"
" <strong>Success!</strong> Thank you for approving this service visit."
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
" Request Changes to Service Visit"
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-info-circle me-2\"/>\n"
" <strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly."
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Changes requested by customer: %s"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Error approving task: %s"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Error submitting change request: %s"
msgstr ""
#. module: fsm_visit_confirmation
#: model:mail.template,name:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "FSM Visit Confirmation"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Invalid action"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "No feedback provided. Please try again!"
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Please explain what changes you need..."
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"Please provide details about the changes you would like to make to this "
"service visit:"
msgstr ""
#. module: fsm_visit_confirmation
#: model:mail.template,description:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "Send a visit confirmation email to the customer"
msgstr ""
#. module: fsm_visit_confirmation
#: model:mail.template,subject:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "Service Visit Confirmation: {{ object.name }}"
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Submit Request"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Task not found or invalid token"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Visit approved by customer"
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Your Comments:"
msgstr ""

View file

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

View file

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

View file

@ -0,0 +1,11 @@
from odoo import fields, models
class ProjectTaskType(models.Model):
_inherit = 'project.task.type'
approval_template_id = fields.Many2one(
'mail.template',
string='Approval Template',
help='Template to use for approval emails when a task reaches this stage.'
)

View file

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

View file

@ -0,0 +1,206 @@
# -*- coding: utf-8 -*-
import logging
from datetime import datetime, timedelta
from odoo import http
from odoo.addons.mail.tests.common import MailCommon
from odoo.tests import HttpCase, tagged
_logger = logging.getLogger(__name__)
@tagged("post_install", "-at_install")
class TestFSMVisitConfirmation(HttpCase, MailCommon):
def setUp(self):
super().setUp()
# Configure test company
self.env.company.write(
{
"email": "company@test.example.com",
"name": "Test Company",
}
)
# Create test users and partners
self.fsm_user = self.env["res.users"].create(
{
"name": "FSM User",
"login": "fsm_user",
"email": "fsm@example.com",
"groups_id": [
(
6,
0,
[
self.env.ref("industry_fsm.group_fsm_user").id,
],
)
],
}
)
# Set email on user's partner
self.fsm_user.partner_id.email = "fsm@example.com"
self.fsm_user.partner_id.lang = "en_US" # Set language explicitly
self.customer = self.env["res.partner"].create(
{
"name": "Customer",
"email": "customer@example.com",
"lang": "en_US", # Set language explicitly
}
)
# Create a project
self.project = self.env["project.project"].create(
{
"name": "Test FSM Project",
"is_fsm": True,
"company_id": self.env.user.company_id.id,
}
)
# Create stages for the project
self.stage_new = self.env["project.task.type"].create(
{
"name": "New",
"sequence": 0,
"project_ids": [(4, self.project.id)],
}
)
self.stage_needs_confirmation = self.env["project.task.type"].create(
{
"name": "Need Confirmation",
"sequence": 1,
"project_ids": [(4, self.project.id)],
}
)
self.stage_approved = self.env["project.task.type"].create(
{
"name": "Approved",
"sequence": 2,
"project_ids": [(4, self.project.id)],
}
)
self.stage_changes_requested = self.env["project.task.type"].create(
{
"name": "Changes Requested",
"sequence": 3,
"project_ids": [(4, self.project.id)],
}
)
# Create a task
self.task = self.env["project.task"].create(
{
"name": "Test Task",
"project_id": self.project.id,
"partner_id": self.customer.id,
"work_order_contacts": [(4, self.customer.id)],
"user_ids": [(4, self.fsm_user.id)],
"planned_date_begin": datetime.now() + timedelta(days=1),
"stage_id": self.stage_new.id,
"state": "01_in_progress", # Using valid state value
}
)
self.assertEqual(
self.task.stage_id, self.stage_new, "Task should start in New stage"
)
# Ensure the task has an access token
if not self.task.access_token:
self.task._portal_ensure_token()
self.token = self.task.access_token
self.assertTrue(self.token, "Task should have an access token")
def test_01_task_approve_flow(self):
"""Test the complete task approval flow"""
# Test the FSM confirmation approve endpoint
self.authenticate(None, None)
url = f"/my/fsm_confirmation/approve?access_token={self.token}"
response = self.url_open(url)
self.assertEqual(
response.status_code, 200, "FSM confirmation approve should succeed"
)
# Now check that the task state was updated
self.task.invalidate_recordset()
self.assertEqual(
self.task.state, "03_approved", "Task state should be updated to approved"
)
# Check that a message was posted
messages = self.env["mail.message"].search(
[
("model", "=", "project.task"),
("res_id", "=", self.task.id),
("body", "ilike", "Visit approved by customer"),
]
)
self.assertTrue(messages, "A message should be posted on the task")
def test_03_stage_approved_sends_email(self):
"""Test that moving a task to the approved stage sends an email"""
# Set the approval template on the approved stage
template = self.env.ref(
"fsm_visit_confirmation.fsm_visit_confirmation_email_template"
)
self.stage_approved.approval_template_id = template
with self.mock_mail_gateway():
self.task.write({"stage_id": self.stage_approved.id})
self.assertEqual(
len(self._new_mails),
1,
"Moving task to approved stage should send an email",
)
self.assertEqual(self._new_mails[0].email_to, self.customer.email)
def test_02_task_request_changes_flow(self):
"""Test the complete task request changes flow"""
# Test the FSM confirmation change endpoint
self.authenticate(None, None)
url = f"/my/fsm_confirmation/change?access_token={self.token}"
response = self.url_open(url)
self.assertEqual(
response.status_code, 200, "FSM confirmation change should succeed"
)
# Now submit the change request form
url = f"/my/fsm_confirmation/submit_change"
response = self.url_open(
url,
data={
"access_token": self.token,
"feedback": "Need some changes",
"csrf_token": http.Request.csrf_token(self),
},
)
self.assertEqual(
response.status_code, 200, "Change request submission should succeed"
)
# Check that the task state was updated
self.task.invalidate_recordset()
self.assertEqual(
self.task.state,
"02_changes_requested",
"Task state should be updated to changes requested",
)
# Check that a message was posted with the feedback
messages = self.env["mail.message"].search(
[
("model", "=", "project.task"),
("res_id", "=", self.task.id),
("body", "ilike", "Need some changes"),
]
)
self.assertTrue(
messages, "A message with feedback should be posted on the task"
)

View file

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

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_project_task_type_form_inherit_fsm_visit_confirmation" model="ir.ui.view">
<field name="name">project.task.type.form.inherit.fsm.visit.confirmation</field>
<field name="model">project.task.type</field>
<field name="inherit_id" ref="project.task_type_edit"/>
<field name="arch" type="xml">
<field name="mail_template_id" position="after">
<field name="approval_template_id"/>
</field>
</field>
</record>
</odoo>

View file

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

View file

@ -0,0 +1,46 @@
{
'name': 'Interactive AI Voice Assistant',
'version': '17.0.1.0.0',
'category': 'Productivity/Communication',
'summary': 'Natural Language Voice Interaction for Odoo using Open WebUI',
'sequence': 1,
'author': 'Benoît Vézina',
'website': 'https://bemade.org',
'maintainer': 'it@bemade.org',
'license': 'LGPL-3',
'description': """
Interactive AI Voice Assistant for Odoo
=====================================
This module enables natural language voice interaction with Odoo directly in the Discuss interface:
- Voice input processing using Open WebUI models
- Interactive AI chat assistant
- Smart context-aware responses
- Draft mode for new records creation
""",
'depends': [
'base',
'web',
'mail',
],
'data': [
'security/ir.model.access.csv',
'views/ai_assistant_views.xml',
'views/ai_config_views.xml',
],
'assets': {
'web.assets_backend': [
'interactive_discuss_ai/static/src/js/ai_assistant_chat.js',
'interactive_discuss_ai/static/src/xml/ai_assistant_chat.xml',
],
},
'demo': [],
'installable': True,
'application': True,
'auto_install': False,
'external_dependencies': {
'python': [
'requests',
],
},
}

View file

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

View file

@ -0,0 +1,101 @@
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class AIAssistantChannel(models.Model):
_name = 'ai.assistant.channel'
_description = 'AI Assistant Channel'
_inherit = ['mail.thread']
name = fields.Char(default="Assistant AI", readonly=True, tracking=True)
company_id = fields.Many2one(
'res.company',
string='Company',
required=True,
default=lambda self: self.env.company,
tracking=True
)
active = fields.Boolean(default=True, tracking=True)
user_id = fields.Many2one(
'res.users',
string='User',
required=True,
default=lambda self: self.env.user,
tracking=True
)
message_ids = fields.One2many(
'mail.message',
'res_id',
string='Messages',
domain=lambda self: [('model', '=', self._name)],
tracking=True
)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for record in records:
# Create a dedicated discussion channel for the assistant
channel = self.env['mail.channel'].create({
'name': _('Assistant AI - %s', record.company_id.name),
'channel_type': 'chat',
'channel_partner_ids': [(4, record.user_id.partner_id.id)],
})
return records
def _get_conversation_history(self, limit=10):
"""Get recent conversation history"""
self.ensure_one()
messages = self.env['mail.message'].search([
('model', '=', self._name),
('res_id', '=', self.id)
], limit=limit, order='id desc')
history = []
for msg in reversed(messages):
role = "assistant" if msg.author_id == self.user_id.partner_id else "user"
history.append({
"role": role,
"content": msg.body
})
return history
def process_voice_message(self, voice_input):
"""Process voice input and respond"""
self.ensure_one()
if not voice_input:
raise UserError(_('No voice input provided'))
# Get conversation history
conversation_history = self._get_conversation_history()
# Use AI service to generate response
ai_service = self.env['ai.service'].with_company(self.company_id)
response = ai_service.generate_response(voice_input, conversation_history)
# Post response as message
self.message_post(
body=response,
message_type='comment',
subtype_xmlid='mail.mt_comment'
)
return True
@api.model
def get_assistant_for_user(self, user_id=None):
"""Get or create an assistant for the user in their company"""
if not user_id:
user_id = self.env.user.id
assistant = self.search([
('user_id', '=', user_id),
('company_id', '=', self.env.company.id),
('active', '=', True)
], limit=1)
if not assistant:
assistant = self.create({
'user_id': user_id,
'company_id': self.env.company.id
})
return assistant

View file

@ -0,0 +1,61 @@
from odoo import models, fields, api
from odoo.exceptions import UserError
class AIConfig(models.Model):
_name = 'ai.config'
_description = 'AI Configuration'
_inherit = ['mail.thread']
name = fields.Char(string='Name', required=True, default='Default Config', tracking=True)
company_id = fields.Many2one(
'res.company',
string='Company',
required=True,
default=lambda self: self.env.company,
tracking=True
)
openwebui_url = fields.Char(
string='Open WebUI URL',
required=True,
default='http://localhost:8080',
tracking=True
)
model_name = fields.Char(
string='Model Name',
required=True,
help='Nom du modèle à utiliser dans Open WebUI',
tracking=True
)
system_prompt = fields.Text(
string='System Prompt',
default="""Tu es un assistant Odoo expert qui aide les utilisateurs à effectuer des actions
dans le système. Analyse leurs demandes et propose des actions concrètes.""",
tracking=True
)
temperature = fields.Float(
string='Temperature',
default=0.7,
help='Contrôle la créativité des réponses (0.0 - 1.0)',
tracking=True
)
max_tokens = fields.Integer(
string='Max Tokens',
default=2000,
tracking=True
)
active = fields.Boolean(default=True, tracking=True)
_sql_constraints = [
('company_uniq', 'unique(company_id, active)',
'Une seule configuration active par compagnie est autorisée!')
]
@api.model
def get_default_config(self):
"""Récupère la configuration active pour la compagnie actuelle"""
config = self.search([
('active', '=', True),
('company_id', '=', self.env.company.id)
], limit=1)
if not config:
raise UserError('Aucune configuration AI active trouvée pour votre compagnie')

View file

@ -0,0 +1,62 @@
import requests
import json
from odoo import models, tools
from odoo.exceptions import UserError
class AIService(models.AbstractModel):
_name = 'ai.service'
_description = 'AI Service for Open WebUI Integration'
def _get_headers(self):
return {
'Content-Type': 'application/json',
}
def _call_openwebui_api(self, endpoint, payload):
"""Appelle l'API Open WebUI avec la configuration de la compagnie actuelle"""
config = self.env['ai.config'].with_company(self.env.company).get_default_config()
url = f"{config.openwebui_url}/api/v1/{endpoint}"
try:
response = requests.post(
url,
headers=self._get_headers(),
json=payload,
timeout=30
)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
raise UserError(f"Erreur de communication avec Open WebUI: {str(e)}")
def generate_response(self, user_input, conversation_history=None):
"""Génère une réponse en utilisant le modèle Open WebUI de la compagnie"""
config = self.env['ai.config'].with_company(self.env.company).get_default_config()
messages = []
if config.system_prompt:
messages.append({
"role": "system",
"content": config.system_prompt
})
# Ajouter l'historique de conversation si disponible
if conversation_history:
messages.extend(conversation_history)
# Ajouter l'entrée utilisateur actuelle
messages.append({
"role": "user",
"content": user_input
})
payload = {
"model": config.model_name,
"messages": messages,
"temperature": config.temperature,
"max_tokens": config.max_tokens,
"stream": False
}
response = self._call_openwebui_api('chat/completions', payload)
return response.get('choices', [{}])[0].get('message', {}).get('content', '')

View file

@ -0,0 +1,3 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_ai_assistant_channel_user,ai.assistant.channel.user,interactive_discuss_ai.model_ai_assistant_channel,base.group_user,1,1,1,1
access_ai_config_user,ai.config.user,interactive_discuss_ai.model_ai_config,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_ai_assistant_channel_user ai.assistant.channel.user interactive_discuss_ai.model_ai_assistant_channel base.group_user 1 1 1 1
3 access_ai_config_user ai.config.user interactive_discuss_ai.model_ai_config base.group_user 1 1 1 1

View file

@ -0,0 +1,52 @@
/** @odoo-module **/
import { registerPatch } from '@mail/model/model_core';
import { attr } from '@mail/model/model_field';
import { clear } from '@mail/model/model_field_command';
registerPatch({
name: 'mail.composer',
fields: {
isVoiceRecording: attr({
default: false,
}),
},
recordMethods: {
async onClickVoiceRecord() {
if (!this.isVoiceRecording) {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.mediaRecorder = new MediaRecorder(stream);
const audioChunks = [];
this.mediaRecorder.addEventListener('dataavailable', (event) => {
audioChunks.push(event.data);
});
this.mediaRecorder.addEventListener('stop', async () => {
const audioBlob = new Blob(audioChunks);
// Convert to base64 and send to backend
const reader = new FileReader();
reader.readAsDataURL(audioBlob);
reader.onloadend = async () => {
const base64data = reader.result;
await this.env.services.rpc({
model: 'ai.assistant.channel',
method: 'process_voice_message',
args: [this.thread.id, base64data],
});
};
});
this.mediaRecorder.start();
this.update({ isVoiceRecording: true });
} catch (err) {
console.error('Error accessing microphone:', err);
}
} else {
this.mediaRecorder.stop();
this.update({ isVoiceRecording: false });
}
},
},
});

View file

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-inherit="mail.Composer" t-inherit-mode="extension">
<xpath expr="//div[hasclass('o-mail-Composer-buttons')]" position="inside">
<button class="btn btn-light"
t-on-click="onClickVoiceRecord"
t-att-class="{ 'btn-danger': isVoiceRecording }">
<i class="fa fa-microphone" t-att-class="{ 'fa-pulse': isVoiceRecording }"/>
</button>
</xpath>
</t>
</templates>

View file

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- AI Assistant Channel Form View -->
<record id="view_ai_assistant_channel_form" model="ir.ui.view">
<field name="name">ai.assistant.channel.form</field>
<field name="model">ai.assistant.channel</field>
<field name="arch" type="xml">
<form string="AI Assistant Channel">
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button" options="{&quot;terminology&quot;: &quot;archive&quot;}"/>
</button>
</div>
<group>
<group>
<field name="name"/>
<field name="user_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<!-- AI Assistant Channel Tree View -->
<record id="view_ai_assistant_channel_tree" model="ir.ui.view">
<field name="name">ai.assistant.channel.tree</field>
<field name="model">ai.assistant.channel</field>
<field name="arch" type="xml">
<tree string="AI Assistant Channels">
<field name="name"/>
<field name="user_id"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<!-- AI Assistant Channel Search View -->
<record id="view_ai_assistant_channel_search" model="ir.ui.view">
<field name="name">ai.assistant.channel.search</field>
<field name="model">ai.assistant.channel</field>
<field name="arch" type="xml">
<search string="Search AI Assistant Channels">
<field name="name"/>
<field name="user_id"/>
<field name="company_id" groups="base.group_multi_company"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
<!-- AI Assistant Channel Action -->
<record id="action_ai_assistant_channel" model="ir.actions.act_window">
<field name="name">AI Assistant Channels</field>
<field name="res_model">ai.assistant.channel</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_ai_assistant_channel_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first AI Assistant Channel!
</p>
<p>
AI Assistant Channels allow you to interact with the AI assistant through messages.
</p>
</field>
</record>
<!-- Menu Items -->
<menuitem id="menu_ai_assistant"
name="AI Assistant"
web_icon="interactive_discuss_ai,static/description/icon.png"
sequence="10"/>
<menuitem id="menu_ai_assistant_channel"
name="Assistant Channels"
parent="menu_ai_assistant"
action="action_ai_assistant_channel"
sequence="10"/>
</odoo>

View file

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_ai_config_form" model="ir.ui.view">
<field name="name">ai.config.form</field>
<field name="model">ai.config</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="toggle_active" type="object" class="oe_stat_button" icon="fa-archive">
<field name="active" widget="boolean_button"/>
</button>
</div>
<group>
<group>
<field name="name"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="openwebui_url"/>
<field name="model_name"/>
<field name="active"/>
</group>
<group>
<field name="temperature"/>
<field name="max_tokens"/>
</group>
</group>
<group string="System Prompt">
<field name="system_prompt" nolabel="1"/>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="view_ai_config_tree" model="ir.ui.view">
<field name="name">ai.config.tree</field>
<field name="model">ai.config</field>
<field name="arch" type="xml">
<tree>
<field name="name"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="model_name"/>
<field name="openwebui_url"/>
<field name="active"/>
</tree>
</field>
</record>
<record id="view_ai_config_search" model="ir.ui.view">
<field name="name">ai.config.search</field>
<field name="model">ai.config</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<field name="company_id" groups="base.group_multi_company"/>
<field name="model_name"/>
<separator/>
<filter string="Archivé" name="inactive" domain="[('active', '=', False)]"/>
<group expand="0" string="Group By">
<filter string="Compagnie" name="company" domain="[]" context="{'group_by': 'company_id'}"/>
</group>
</search>
</field>
</record>
<record id="action_ai_config" model="ir.actions.act_window">
<field name="name">Configuration AI</field>
<field name="res_model">ai.config</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="view_ai_config_search"/>
<field name="context">{'search_default_active': 1}</field>
</record>
<menuitem id="menu_ai_config"
name="Configuration AI"
action="action_ai_config"
parent="base.menu_administration"
sequence="100"/>
</odoo>

View file

@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import models

View file

@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
{
"name": "IA pour messages perdus",
"version": "17.0.1.0.0",
"category": "Discuss",
"summary": "Analyse IA des messages non routés pour suggestion de routage intelligent",
"description": """
Ce module utilise l'intelligence artificielle pour analyser les messages non routés
capturés par le module mail_manual_routing et suggérer des destinations appropriées.
Fonctionnalités:
- Analyse automatique des messages non routés
- Extraction d'informations des pièces jointes
- Suggestions intelligentes de routage
- Interface utilisateur intuitive pour les suggestions
- Apprentissage continu basé sur les retours utilisateurs
""",
"author": "BeMade",
"website": "https://www.bemade.org",
"depends": [
"mail_manual_routing",
"base", # En attendant un module hypothétique base_ai
],
"data": [
"security/ir.model.access.csv",
"views/mail_message_view.xml",
"views/res_config_settings_view.xml",
"views/mail_message_ai_suggestion_view.xml",
"views/menu.xml",
],
"demo": [],
"installable": True,
"application": False,
"auto_install": False,
"license": "LGPL-3",
}

View file

@ -0,0 +1,258 @@
# Analyse d'un module IA pour améliorer `mail_manual_routing`
## Introduction
Ce document présente une analyse conceptuelle d'un module complémentaire nommé `mail_manual_routing_ai` qui utiliserait l'intelligence artificielle pour améliorer la gestion des messages non routés capturés par le module `mail_manual_routing`. L'objectif principal serait d'analyser automatiquement le contenu des messages perdus pour suggérer ou même automatiser leur routage vers les objets appropriés dans Odoo.
## Cas d'utilisation principal
Le cas d'utilisation typique serait celui où un client (M. X) envoie un courriel à un employé (M. Y), qui répond en incluant des informations importantes comme une soumission ou une référence à un projet existant. Comme ce courriel ne contient pas de référence directe à un thread Odoo existant (thread_id), il serait normalement classé comme "perdu" par le module `mail_manual_routing`. Le module IA interviendrait alors pour analyser le contenu et proposer un routage intelligent.
## Architecture proposée
### 1. Intégration avec `mail_manual_routing`
Le module `mail_manual_routing_ai` dépendrait du module `mail_manual_routing` et étendrait ses fonctionnalités :
```python
# __manifest__.py
{
"name": "IA pour messages perdus",
"version": "17.0.1.0.0",
"category": "Discuss",
"depends": [
"mail_manual_routing",
"base_ai", # Module hypothétique pour les fonctionnalités IA de base
],
# ...
}
```
### 2. Modèles de données
Le module ajouterait de nouveaux modèles pour gérer les suggestions d'IA :
```python
# models/ai_suggestion.py
class MailMessageAISuggestion(models.Model):
_name = "mail.message.ai.suggestion"
_description = "Suggestions IA pour messages perdus"
message_id = fields.Many2one('mail.message', string="Message", required=True)
suggested_model = fields.Char(string="Modèle suggéré")
suggested_record_id = fields.Integer(string="ID d'enregistrement suggéré")
confidence_score = fields.Float(string="Score de confiance", help="Score de confiance de l'IA (0-1)")
suggestion_reason = fields.Text(string="Explication", help="Explication de la suggestion par l'IA")
is_applied = fields.Boolean(string="Appliqué", default=False)
user_feedback = fields.Selection([
('positive', 'Correcte'),
('negative', 'Incorrecte'),
], string="Retour utilisateur")
```
### 3. Processus d'analyse IA
Le module implémenterait un processus d'analyse en plusieurs étapes :
1. **Déclenchement automatique** : Lorsqu'un nouveau message est classé comme "perdu" par `mail_manual_routing`
2. **Extraction d'informations** : Analyse du contenu du message, des pièces jointes et des métadonnées
3. **Recherche de correspondances** : Identification des références potentielles à des objets existants
4. **Génération de suggestions** : Création de suggestions avec scores de confiance
```python
# models/mail_message.py
class MailMessage(models.Model):
_inherit = "mail.message"
ai_suggestion_ids = fields.One2many('mail.message.ai.suggestion', 'message_id', string="Suggestions IA")
ai_suggestion_count = fields.Integer(compute='_compute_ai_suggestion_count')
ai_analyzed = fields.Boolean(string="Analysé par IA", default=False)
ai_top_suggestion_id = fields.Many2one('mail.message.ai.suggestion', compute='_compute_top_suggestion')
def action_analyze_with_ai(self):
"""Déclenche l'analyse IA du message"""
for message in self.filtered(lambda m: m.is_unattached):
self.env['mail.message.ai.analyzer'].analyze_message(message)
return True
@api.depends('ai_suggestion_ids')
def _compute_ai_suggestion_count(self):
for message in self:
message.ai_suggestion_count = len(message.ai_suggestion_ids)
@api.depends('ai_suggestion_ids.confidence_score')
def _compute_top_suggestion(self):
for message in self:
suggestions = message.ai_suggestion_ids.sorted(key=lambda s: s.confidence_score, reverse=True)
message.ai_top_suggestion_id = suggestions[0] if suggestions else False
```
### 4. Techniques d'IA utilisées
Le module pourrait utiliser plusieurs techniques d'IA pour améliorer la précision des suggestions :
1. **Traitement du langage naturel (NLP)** pour extraire des entités nommées, des références de projets, des numéros de client, etc.
2. **Reconnaissance optique de caractères (OCR)** pour analyser le texte dans les pièces jointes (PDF, images)
3. **Apprentissage automatique** pour améliorer les suggestions basées sur les retours utilisateurs
4. **Analyse sémantique** pour comprendre le contexte et l'intention du message
### 5. Interface utilisateur
L'interface utilisateur serait enrichie pour présenter les suggestions de l'IA de manière intuitive :
```xml
<!-- views/mail_message_view.xml -->
<record id="mail_message_view_form_ai_enhanced" model="ir.ui.view">
<field name="model">mail.message</field>
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button string="Analyser avec IA"
name="action_analyze_with_ai"
type="object"
class="btn-primary"
attrs="{'invisible': ['|', ('is_unattached', '=', False), ('ai_analyzed', '=', True)]}"/>
</xpath>
<xpath expr="//field[@name='lost_comments']" position="after">
<field name="ai_analyzed" invisible="1"/>
<field name="ai_suggestion_count" invisible="1"/>
<div class="alert alert-info" attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}">
<div class="d-flex align-items-center">
<i class="fa fa-robot mr-2"/>
<span>L'IA suggère de router ce message vers : </span>
<field name="ai_top_suggestion_id" readonly="1" options="{'no_open': True}"/>
<button string="Appliquer"
name="action_apply_ai_suggestion"
type="object"
class="btn btn-sm btn-primary ml-2"
attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}"/>
<button string="Voir toutes les suggestions"
name="action_view_ai_suggestions"
type="object"
class="btn btn-sm btn-secondary ml-2"
attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}"/>
</div>
<div attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}">
<field name="ai_top_suggestion_id.suggestion_reason" readonly="1" class="text-muted"/>
</div>
</div>
</xpath>
</field>
</record>
```
## Fonctionnalités avancées
### 1. Apprentissage continu
Le module pourrait implémenter un système d'apprentissage continu basé sur les retours des utilisateurs :
- Lorsqu'un utilisateur accepte ou rejette une suggestion, le système enregistre cette décision
- Ces données sont utilisées pour améliorer les futures suggestions
- Le modèle d'IA est régulièrement réentraîné avec ces nouvelles données
### 2. Routage automatique
Pour les suggestions avec un score de confiance élevé (configurable), le module pourrait proposer un routage automatique :
```python
# models/res_config_settings.py
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
ai_auto_routing_threshold = fields.Float(
string="Seuil de routage automatique",
config_parameter="mail_manual_routing_ai.auto_routing_threshold",
default=0.95,
help="Score de confiance minimum pour le routage automatique (0-1)"
)
ai_auto_routing_enabled = fields.Boolean(
string="Activer le routage automatique",
config_parameter="mail_manual_routing_ai.auto_routing_enabled",
default=False
)
```
### 3. Analyse des pièces jointes
Un aspect particulierèrement utile serait l'analyse des pièces jointes pour extraire des informations pertinentes :
- Extraction de texte des PDF, images et documents Office
- Reconnaissance de formulaires et documents standard (factures, bons de commande, etc.)
- Identification de références de projet, numéros de client ou autres identifiants
```python
# models/mail_message_ai_analyzer.py
class MailMessageAIAnalyzer(models.AbstractModel):
_name = "mail.message.ai.analyzer"
_description = "Analyseur IA pour messages"
def analyze_message(self, message):
# Analyse du corps du message
body_text = html2text(message.body) if message.body else ""
body_entities = self._extract_entities(body_text)
# Analyse des pièces jointes
attachment_entities = []
for attachment in message.attachment_ids:
attachment_text = self._extract_text_from_attachment(attachment)
if attachment_text:
attachment_entities.extend(self._extract_entities(attachment_text))
# Recherche de correspondances dans la base de données
all_entities = body_entities + attachment_entities
suggestions = self._find_matching_records(all_entities)
# Création des suggestions
for suggestion in suggestions:
self.env['mail.message.ai.suggestion'].create({
'message_id': message.id,
'suggested_model': suggestion['model'],
'suggested_record_id': suggestion['record_id'],
'confidence_score': suggestion['score'],
'suggestion_reason': suggestion['reason'],
})
message.ai_analyzed = True
return True
```
## Bénéfices attendus
1. **Réduction du temps de traitement** : Les utilisateurs passent moins de temps à déterminer manuellement où router les messages
2. **Amélioration de la précision** : L'IA peut identifier des relations que les utilisateurs pourraient manquer
3. **Expérience utilisateur améliorée** : Interface intuitive avec explications des suggestions
4. **Réduction des erreurs** : Moins de risques d'associer un message au mauvais enregistrement
5. **Apprentissage continu** : Le système s'améliore avec le temps grâce aux retours utilisateurs
## Considérations techniques
### 1. Performance et scalabilité
L'analyse IA peut être intensive en ressources, surtout pour les pièces jointes volumineuses. Des stratégies pourraient être mises en place :
- Traitement asynchrone via des tâches planifiées (queue.job)
- Limitation de la taille des pièces jointes à analyser
- Mise en cache des résultats d'analyse pour les documents similaires
### 2. Confidentialité et sécurité
L'analyse de contenu par IA soulève des questions de confidentialité :
- Option pour traiter les données localement sans les envoyer à des services externes
- Paramètres de configuration pour limiter les types de données analysées
- Journal d'audit des analyses effectuées
### 3. Intégration avec des services IA
Le module pourrait s'intégrer avec différents services d'IA :
- Services internes (modèles locaux)
- Services cloud (OpenAI, Google AI, Azure AI)
- Combinaison hybride selon les besoins de confidentialité et de performance
## Conclusion
Un module `mail_manual_routing_ai` offrirait une amélioration significative du processus de gestion des messages non routés dans Odoo. En utilisant l'IA pour analyser le contenu des messages et suggérer des destinations appropriées, il permettrait de réduire considérablement le temps consacré à la gestion manuelle des messages et d'améliorer la précision du routage.
Ce module répondrait particulièrement bien au cas d'utilisation où des communications externes (comme un client qui envoie un courriel à un employé qui nous répond en incluant notre soumission) doivent être associées aux bons objets dans le système Odoo.

View file

@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
from . import mail_message
from . import mail_message_ai_suggestion
from . import mail_message_ai_analyzer
from . import res_config_settings

View file

@ -0,0 +1,103 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class MailMessage(models.Model):
_inherit = "mail.message"
ai_suggestion_ids = fields.One2many('mail.message.ai.suggestion', 'message_id', string="Suggestions IA")
ai_suggestion_count = fields.Integer(compute='_compute_ai_suggestion_count', string="Nombre de suggestions")
ai_analyzed = fields.Boolean(string="Analysé par IA", default=False)
ai_top_suggestion_id = fields.Many2one('mail.message.ai.suggestion', compute='_compute_top_suggestion')
ai_analysis_result = fields.Text(string="Résultat d'analyse IA", readonly=True)
ai_activity_id = fields.Many2one('mail.activity', string="Activité associée")
def action_analyze_with_ai(self):
"""Déclenche l'analyse IA du message"""
for message in self.filtered(lambda m: m.is_unattached):
# Utilisation d'OpenWebUI pour l'analyse
use_openwebui = self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.use_openwebui', False)
if use_openwebui and use_openwebui == 'True':
self.env['mail.message.ai.openwebui'].analyze_message_with_openwebui(message)
else:
# Fallback sur l'analyseur standard
self.env['mail.message.ai.analyzer'].analyze_message(message)
return True
def action_analyze_with_openwebui(self):
"""Déclenche l'analyse OpenWebUI du message et crée une activité"""
self.ensure_one()
if not self.is_unattached:
return False
result = self.env['mail.message.ai.openwebui'].analyze_message_with_openwebui(self)
if result:
# Stockage du résultat d'analyse au format JSON
self.ai_analysis_result = str(result)
return True
return False
@api.depends('ai_suggestion_ids')
def _compute_ai_suggestion_count(self):
"""Calcule le nombre de suggestions IA pour chaque message"""
for message in self:
message.ai_suggestion_count = len(message.ai_suggestion_ids)
@api.depends('ai_suggestion_ids.confidence_score')
def _compute_top_suggestion(self):
"""Identifie la suggestion avec le score de confiance le plus élevé"""
for message in self:
suggestions = message.ai_suggestion_ids.sorted(key=lambda s: s.confidence_score, reverse=True)
message.ai_top_suggestion_id = suggestions[0] if suggestions else False
def action_apply_ai_suggestion(self):
"""Applique la suggestion IA avec le score de confiance le plus élevé"""
self.ensure_one()
if self.ai_top_suggestion_id:
return self.ai_top_suggestion_id.action_apply_suggestion()
return False
def action_view_ai_suggestions(self):
"""Ouvre la vue des suggestions IA pour le message"""
self.ensure_one()
return {
'name': _('Suggestions IA'),
'type': 'ir.actions.act_window',
'res_model': 'mail.message.ai.suggestion',
'view_mode': 'tree,form',
'domain': [('message_id', '=', self.id)],
'context': {'default_message_id': self.id},
}
@api.model
def _message_route_process_unrouted(self, message_dict):
"""Hook pour traiter automatiquement les messages non routés avec IA"""
# Cette méthode sera appelée par mail_manual_routing après la création d'un message non routé
message_id = message_dict.get('id')
if not message_id:
return True
message = self.browse(message_id)
if not message.exists() or not message.is_unattached:
return True
# Vérification si l'analyse automatique est activée
if self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_analyze_enabled', False) and self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_analyze_enabled') == 'True':
# Utilisation d'OpenWebUI si configuré
if self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.use_openwebui', False) and self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.use_openwebui') == 'True':
self.env['mail.message.ai.openwebui'].analyze_message_with_openwebui(message)
else:
# Fallback sur l'analyseur standard
self.env['mail.message.ai.analyzer'].analyze_message(message)
# Routage automatique si configuré et si le score de confiance est suffisant
auto_routing = self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_routing_enabled', False)
threshold_str = self.env['ir.config_parameter'].sudo().get_param('mail_manual_routing_ai.auto_routing_threshold', '0.95')
threshold = float(threshold_str) if threshold_str else 0.95
if (auto_routing == 'True' and message.ai_top_suggestion_id and
message.ai_top_suggestion_id.confidence_score >= threshold):
message.ai_top_suggestion_id.action_apply_suggestion()
return True

View file

@ -0,0 +1,236 @@
# -*- coding: utf-8 -*-
import logging
import re
from html2text import html2text
from odoo import api, models, _
from odoo.tools.misc import clean_context
_logger = logging.getLogger(__name__)
class MailMessageAIAnalyzer(models.AbstractModel):
_name = "mail.message.ai.analyzer"
_description = "Analyseur IA pour messages"
def analyze_message(self, message):
"""
Analyse un message pour générer des suggestions de routage
:param message: mail.message à analyser
:return: True si l'analyse a été effectuée
"""
self.ensure_one()
if not message or not message.is_unattached or message.ai_analyzed:
return False
try:
# Extraction du texte du message
body_text = html2text(message.body) if message.body else ""
subject_text = message.subject or ""
# Analyse des pièces jointes
attachment_texts = []
for attachment in message.attachment_ids:
attachment_text = self._extract_text_from_attachment(attachment)
if attachment_text:
attachment_texts.append(attachment_text)
# Extraction d'entités et de références
entities = self._extract_entities(body_text, subject_text, attachment_texts)
# Recherche de correspondances dans la base de données
suggestions = self._find_matching_records(entities, message)
# Création des suggestions
for suggestion in suggestions:
self.env['mail.message.ai.suggestion'].create({
'message_id': message.id,
'suggested_model': suggestion['model'],
'suggested_record_id': suggestion['record_id'],
'confidence_score': suggestion['score'],
'suggestion_reason': suggestion['reason'],
})
# Marquer le message comme analysé
message.ai_analyzed = True
return True
except Exception as e:
_logger.error("Erreur lors de l'analyse IA du message %s: %s", message.id, str(e))
return False
def _extract_text_from_attachment(self, attachment):
"""
Extrait le texte d'une pièce jointe
:param attachment: ir.attachment à analyser
:return: texte extrait ou chaîne vide
"""
# Dans une implémentation réelle, utiliserait des bibliothèques comme PyPDF2, pytesseract, etc.
# Pour cette version de démonstration, on ne fait qu'extraire le nom du fichier
return attachment.name or ""
def _extract_entities(self, body_text, subject_text, attachment_texts):
"""
Extrait des entités et références du texte
:param body_text: texte du corps du message
:param subject_text: texte du sujet du message
:param attachment_texts: liste des textes extraits des pièces jointes
:return: dictionnaire d'entités extraites
"""
entities = {
'references': [],
'emails': [],
'numbers': [],
'keywords': [],
}
# Extraction de références potentielles (ex: SO123, P00456, etc.)
all_text = body_text + " " + subject_text + " " + " ".join(attachment_texts)
# Recherche de références de type SO12345, PO12345, etc.
ref_patterns = [
(r'SO[0-9]{5,}', 'sale.order'),
(r'PO[0-9]{5,}', 'purchase.order'),
(r'INV[0-9]{5,}', 'account.move'),
(r'P[0-9]{5,}', 'project.project'),
(r'T[0-9]{5,}', 'project.task'),
]
for pattern, model in ref_patterns:
matches = re.findall(pattern, all_text)
for match in matches:
entities['references'].append({
'text': match,
'type': 'reference',
'model': model,
'confidence': 0.8,
})
# Extraction d'emails
email_pattern = r'[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}'
emails = re.findall(email_pattern, all_text)
for email in emails:
entities['emails'].append({
'text': email,
'type': 'email',
'confidence': 0.9,
})
# Extraction de numéros
number_pattern = r'\b\d{5,}\b'
numbers = re.findall(number_pattern, all_text)
for number in numbers:
entities['numbers'].append({
'text': number,
'type': 'number',
'confidence': 0.6,
})
# Extraction de mots-clés (simplifiée pour la démonstration)
keywords = ['projet', 'facture', 'commande', 'devis', 'client', 'fournisseur', 'livraison', 'paiement']
for keyword in keywords:
if keyword.lower() in all_text.lower():
entities['keywords'].append({
'text': keyword,
'type': 'keyword',
'confidence': 0.5,
})
return entities
def _find_matching_records(self, entities, message):
"""
Recherche des enregistrements correspondant aux entités extraites
:param entities: dictionnaire d'entités extraites
:param message: message original
:return: liste de suggestions
"""
suggestions = []
# Recherche par références
for ref in entities.get('references', []):
model = ref.get('model')
text = ref.get('text')
if model and text:
# Recherche par nom ou référence
records = self.env[model].sudo().search([
'|',
('name', '=', text),
('name', 'ilike', text),
], limit=3)
for record in records:
suggestions.append({
'model': model,
'record_id': record.id,
'score': ref.get('confidence', 0.5) * 0.9, # Ajustement du score
'reason': _("Référence détectée: %s correspond à %s") % (text, record.display_name),
})
# Recherche par emails
for email_entity in entities.get('emails', []):
email = email_entity.get('text')
if email:
# Recherche de partenaires avec cet email
partners = self.env['res.partner'].sudo().search([
'|',
('email', '=ilike', email),
('email', '=ilike', email.split('@')[0] + '%'), # Correspondance partielle
], limit=3)
for partner in partners:
# Recherche des documents récents liés à ce partenaire
for model, field in [('sale.order', 'partner_id'), ('project.project', 'partner_id')]:
if model in self.env:
records = self.env[model].sudo().search([
(field, '=', partner.id),
], limit=2, order='create_date desc')
for record in records:
suggestions.append({
'model': model,
'record_id': record.id,
'score': email_entity.get('confidence', 0.5) * 0.7,
'reason': _("Email %s associé à %s, qui est lié à %s") % (
email, partner.display_name, record.display_name),
})
# Recherche par mots-clés
models_by_keyword = {
'projet': 'project.project',
'facture': 'account.move',
'commande': 'sale.order',
'devis': 'sale.order',
'client': 'res.partner',
'fournisseur': 'res.partner',
'livraison': 'stock.picking',
'paiement': 'account.payment',
}
for keyword_entity in entities.get('keywords', []):
keyword = keyword_entity.get('text')
if keyword and keyword in models_by_keyword:
model = models_by_keyword[keyword]
# Recherche des enregistrements récents de ce type
if model in self.env:
records = self.env[model].sudo().search([], limit=2, order='create_date desc')
for record in records:
suggestions.append({
'model': model,
'record_id': record.id,
'score': keyword_entity.get('confidence', 0.5) * 0.4, # Score plus faible pour les mots-clés génériques
'reason': _("Mot-clé '%s' détecté, suggérant %s récent") % (
keyword, record.display_name),
})
# Tri et limitation des suggestions
suggestions.sort(key=lambda s: s.get('score', 0), reverse=True)
return suggestions[:5] # Limite à 5 suggestions

View file

@ -0,0 +1,321 @@
# -*- coding: utf-8 -*-
import base64
import json
import logging
import requests
from datetime import timedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class MailMessageAIOpenWebUI(models.AbstractModel):
_name = "mail.message.ai.openwebui"
_description = "Intégration OpenWebUI pour l'analyse des messages"
@api.model
def _get_openwebui_endpoint(self):
"""Récupère l'URL de l'API OpenWebUI depuis les paramètres système"""
return self.env['ir.config_parameter'].sudo().get_param(
'mail_manual_routing_ai.openwebui_endpoint'
) or 'http://localhost:3000/api/chat'
@api.model
def _get_openwebui_api_key(self):
"""Récupère la clé API OpenWebUI depuis les paramètres système"""
return self.env['ir.config_parameter'].sudo().get_param(
'mail_manual_routing_ai.openwebui_api_key'
) or ''
@api.model
def _get_responsible_user_id(self):
"""Récupère l'utilisateur responsable des messages perdus"""
user_id = self.env['ir.config_parameter'].sudo().get_param(
'mail_manual_routing_ai.responsible_user_id'
)
return int(user_id) if user_id else 1
def analyze_message_with_openwebui(self, message):
"""
Envoie le message et ses pièces jointes à OpenWebUI pour analyse
et crée une activité pour l'utilisateur responsable
:param message: mail.message à analyser
:return: dict avec les résultats de l'analyse ou False en cas d'erreur
"""
if not message or not message.is_unattached:
return False
try:
# Préparation du contenu à envoyer
content = self._prepare_message_content(message)
# Appel à l'API OpenWebUI
response = self._call_openwebui_api(content)
if not response:
return False
# Création des suggestions basées sur l'analyse OpenWebUI
suggestions = self._create_suggestions_from_analysis(message, response)
# Création d'une activité pour l'utilisateur responsable
self._create_activity_for_message(message, response)
# Marquer le message comme analysé
message.ai_analyzed = True
return response
except Exception as e:
_logger.error("Erreur lors de l'analyse OpenWebUI du message %s: %s", message.id, str(e))
return False
def _prepare_message_content(self, message):
"""
Prépare le contenu du message pour l'envoi à OpenWebUI
:param message: mail.message à analyser
:return: dict avec le contenu formaté
"""
# Extraction du texte du message
try:
from html2text import html2text
except ImportError:
# Fonction de secours si html2text n'est pas installé
def html2text(html):
return html.replace('<br>', '\n').replace('<p>', '\n').replace('</p>', '\n')
body_text = html2text(message.body) if message.body else ""
# Préparation des pièces jointes
attachments = []
for attachment in message.attachment_ids:
try:
# Récupération du contenu binaire de la pièce jointe
if attachment.datas:
attachment_data = {
'name': attachment.name,
'type': attachment.mimetype or 'application/octet-stream',
'content': attachment.datas.decode('utf-8') if attachment.datas else '',
}
attachments.append(attachment_data)
except Exception as e:
_logger.warning("Impossible de traiter la pièce jointe %s: %s", attachment.name, str(e))
# Construction du prompt pour OpenWebUI
prompt = f"""
Analyse ce courriel et ses pièces jointes pour déterminer:
1. Le type de demande (demande de service, confirmation de commande, question, autre)
2. L'urgence (faible, moyenne, élevée)
3. Les actions recommandées
4. Le département concerné (ventes, support, comptabilité, autre)
5. Les mots-clés importants
Réponds au format JSON avec les champs suivants:
{{
"type_demande": "...",
"urgence": "...",
"actions_recommandees": ["...", "..."],
"departement": "...",
"mots_cles": ["...", "..."],
"resume": "...",
"modele_suggere": "..." (sale.order, project.project, helpdesk.ticket, etc.),
"confiance": ... (valeur entre 0 et 1)
}}
Courriel:
De: {message.email_from}
À: {message.email_to or 'Non spécifié'}
Sujet: {message.subject or 'Sans objet'}
Date: {message.date}
Corps du message:
{body_text}
Nombre de pièces jointes: {len(attachments)}
"""
return {
'prompt': prompt,
'attachments': attachments,
'message_id': message.id,
}
def _call_openwebui_api(self, content):
"""
Appelle l'API OpenWebUI avec le contenu préparé
:param content: dict avec le contenu à envoyer
:return: dict avec la réponse de l'API ou False en cas d'erreur
"""
endpoint = self._get_openwebui_endpoint()
api_key = self._get_openwebui_api_key()
if not endpoint:
_logger.error("Endpoint OpenWebUI non configuré")
return False
headers = {
'Content-Type': 'application/json',
}
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
try:
# Dans une implémentation réelle, nous enverrions réellement la requête
# Pour cette démonstration, nous simulons une réponse
# requests.post(endpoint, json=content, headers=headers)
# Simulation d'une réponse OpenWebUI
response = {
'type_demande': 'demande de service',
'urgence': 'moyenne',
'actions_recommandees': [
'Créer un ticket de support',
'Contacter le client pour plus d\'informations'
],
'departement': 'support',
'mots_cles': ['problème', 'logiciel', 'erreur'],
'resume': 'Le client signale un problème avec le module de facturation.',
'modele_suggere': 'helpdesk.ticket',
'confiance': 0.85
}
return response
except Exception as e:
_logger.error("Erreur lors de l'appel à l'API OpenWebUI: %s", str(e))
return False
def _create_suggestions_from_analysis(self, message, analysis):
"""
Crée des suggestions basées sur l'analyse OpenWebUI
:param message: mail.message analysé
:param analysis: résultat de l'analyse OpenWebUI
:return: liste des suggestions créées
"""
suggestions = []
if not analysis or not isinstance(analysis, dict):
return suggestions
# Récupération du modèle suggéré et du score de confiance
model = analysis.get('modele_suggere')
confidence = analysis.get('confiance', 0.5)
if model and model in self.env:
# Recherche des enregistrements récents de ce type
records = self.env[model].sudo().search([], limit=3, order='create_date desc')
for record in records:
suggestion = self.env['mail.message.ai.suggestion'].create({
'message_id': message.id,
'suggested_model': model,
'suggested_record_id': record.id,
'confidence_score': confidence * 0.9, # Légère réduction du score pour les suggestions génériques
'suggestion_reason': _(
"OpenWebUI a identifié ce message comme une %s (%s%% de confiance). "
"Résumé: %s"
) % (
analysis.get('type_demande', 'demande'),
int(confidence * 100),
analysis.get('resume', 'Non disponible')
),
})
suggestions.append(suggestion)
return suggestions
def _create_activity_for_message(self, message, analysis):
"""
Crée une activité (todo) pour l'utilisateur responsable
:param message: mail.message analysé
:param analysis: résultat de l'analyse OpenWebUI
:return: l'activité créée ou False
"""
if not analysis or not isinstance(analysis, dict):
return False
# Détermination de la priorité basée sur l'urgence
urgence = analysis.get('urgence', 'moyenne').lower()
priority_map = {
'faible': 0,
'moyenne': 1,
'élevée': 2,
'elevee': 2,
'haute': 2,
}
priority = priority_map.get(urgence, 1)
# Détermination de la date d'échéance basée sur l'urgence
due_days_map = {
'faible': 5,
'moyenne': 2,
'élevée': 1,
'elevee': 1,
'haute': 1,
}
due_days = due_days_map.get(urgence, 2)
date_deadline = fields.Date.today() + timedelta(days=due_days)
# Récupération du modèle parent pour les messages perdus
parent = self.env['lost.message.parent'].sudo().search([], limit=1)
if not parent:
return False
# Récupération de l'utilisateur responsable
responsible_user_id = self._get_responsible_user_id()
# Création du résumé pour l'activité
summary = _("Message perdu: %s") % (message.subject or 'Sans objet')
# Création de la note avec les détails de l'analyse
note = _("""
<p><strong>Analyse IA du message perdu</strong></p>
<ul>
<li><strong>Type de demande:</strong> %(type)s</li>
<li><strong>Urgence:</strong> %(urgence)s</li>
<li><strong>Département concerné:</strong> %(departement)s</li>
<li><strong>Résumé:</strong> %(resume)s</li>
</ul>
<p><strong>Actions recommandées:</strong></p>
<ul>
%(actions)s
</ul>
<p><strong>Mots-clés:</strong> %(mots_cles)s</p>
<p><a href="/mail/view?model=lost.message.parent&res_id=%(parent_id)s">Voir le message</a></p>
""") % {
'type': analysis.get('type_demande', 'Non identifié'),
'urgence': analysis.get('urgence', 'Non identifié'),
'departement': analysis.get('departement', 'Non identifié'),
'resume': analysis.get('resume', 'Non disponible'),
'actions': ''.join([f"<li>{action}</li>" for action in analysis.get('actions_recommandees', ['Aucune action suggérée'])]),
'mots_cles': ', '.join(analysis.get('mots_cles', ['Aucun'])),
'parent_id': parent.id,
}
# Création de l'activité
activity_values = {
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'summary': summary,
'note': note,
'user_id': responsible_user_id,
'res_id': parent.id,
'res_model_id': self.env['ir.model'].sudo().search([('model', '=', 'lost.message.parent')], limit=1).id,
'date_deadline': date_deadline,
}
# Vérifier si le champ priority existe dans le modèle mail.activity
if 'priority' in self.env['mail.activity']._fields:
activity_values['priority'] = priority
activity = self.env['mail.activity'].create(activity_values)
return activity

View file

@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
class MailMessageAISuggestion(models.Model):
_name = "mail.message.ai.suggestion"
_description = "Suggestions IA pour messages perdus"
_order = "confidence_score desc, id desc"
message_id = fields.Many2one('mail.message', string="Message", required=True, ondelete='cascade')
suggested_model = fields.Char(string="Modèle suggéré")
suggested_record_id = fields.Integer(string="ID d'enregistrement suggéré")
suggested_record_name = fields.Char(string="Nom de l'enregistrement", compute="_compute_suggested_record_name", store=True)
confidence_score = fields.Float(string="Score de confiance", help="Score de confiance de l'IA (0-1)")
suggestion_reason = fields.Text(string="Explication", help="Explication de la suggestion par l'IA")
is_applied = fields.Boolean(string="Appliqué", default=False)
user_feedback = fields.Selection([
('positive', 'Correcte'),
('negative', 'Incorrecte'),
], string="Retour utilisateur")
date_created = fields.Datetime(string="Date de création", default=fields.Datetime.now)
@api.depends('suggested_model', 'suggested_record_id')
def _compute_suggested_record_name(self):
"""Récupère le nom de l'enregistrement suggéré pour l'affichage"""
for suggestion in self:
if suggestion.suggested_model and suggestion.suggested_record_id:
try:
record = self.env[suggestion.suggested_model].sudo().browse(suggestion.suggested_record_id)
if record.exists():
suggestion.suggested_record_name = record.display_name
else:
suggestion.suggested_record_name = _("Enregistrement non trouvé")
except Exception:
suggestion.suggested_record_name = _("Modèle non valide")
else:
suggestion.suggested_record_name = False
def name_get(self):
"""Personnalise l'affichage du nom des suggestions"""
result = []
for suggestion in self:
name = f"{suggestion.suggested_model or '?'} - {suggestion.suggested_record_name or '?'} ({int(suggestion.confidence_score * 100)}%)"
result.append((suggestion.id, name))
return result
def action_apply_suggestion(self):
"""Applique la suggestion en attachant le message à l'enregistrement suggéré"""
self.ensure_one()
if not self.suggested_model or not self.suggested_record_id:
return False
# Utilise le wizard de mail_manual_routing pour attacher le message
wizard = self.env['mail.message.attach.wizard'].create({
'message_ids': [(6, 0, [self.message_id.id])],
'model': self.suggested_model,
'res_id': self.suggested_record_id,
})
result = wizard.action_attach_mail_message()
# Marque la suggestion comme appliquée et enregistre un feedback positif
self.write({
'is_applied': True,
'user_feedback': 'positive',
})
return result
def action_mark_feedback(self, feedback_type):
"""Enregistre le feedback de l'utilisateur sur la suggestion"""
self.ensure_one()
self.write({
'user_feedback': feedback_type,
})
return True

View file

@ -0,0 +1,70 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
ai_auto_analyze_enabled = fields.Boolean(
string="Analyse automatique des messages perdus",
config_parameter="mail_manual_routing_ai.auto_analyze_enabled",
default=True,
help="Analyser automatiquement les messages perdus avec l'IA"
)
ai_auto_routing_enabled = fields.Boolean(
string="Routage automatique",
config_parameter="mail_manual_routing_ai.auto_routing_enabled",
default=False,
help="Router automatiquement les messages selon les suggestions de l'IA si le score de confiance est suffisant"
)
ai_auto_routing_threshold = fields.Float(
string="Seuil de confiance pour routage automatique",
config_parameter="mail_manual_routing_ai.auto_routing_threshold",
default=0.95,
help="Score de confiance minimum pour le routage automatique (0-1)"
)
ai_models_allowed = fields.Char(
string="Modèles autorisés pour le routage IA",
config_parameter="mail_manual_routing_ai.models_allowed",
default="sale.order,project.project,project.task,res.partner",
help="Liste de modèles séparés par des virgules autorisés pour le routage IA"
)
ai_learning_enabled = fields.Boolean(
string="Apprentissage continu",
config_parameter="mail_manual_routing_ai.learning_enabled",
default=True,
help="Utiliser les retours utilisateurs pour améliorer les futures suggestions"
)
# Paramètres OpenWebUI
ai_use_openwebui = fields.Boolean(
string="Utiliser OpenWebUI",
config_parameter='mail_manual_routing_ai.use_openwebui'
)
ai_openwebui_endpoint = fields.Char(
string="URL de l'API OpenWebUI",
config_parameter="mail_manual_routing_ai.openwebui_endpoint",
default="http://localhost:3000/api/chat",
help="URL de l'API OpenWebUI pour l'analyse des messages"
)
ai_openwebui_api_key = fields.Char(
string="Clé API OpenWebUI",
config_parameter="mail_manual_routing_ai.openwebui_api_key",
default="",
help="Clé API pour l'authentification avec OpenWebUI"
)
ai_responsible_user_id = fields.Many2one(
'res.users',
string="Utilisateur responsable des messages perdus",
config_parameter="mail_manual_routing_ai.responsible_user_id",
default=lambda self: self.env.ref('base.user_admin').id,
help="Utilisateur qui recevra les activités pour les messages perdus analysés"
)

View file

@ -0,0 +1,2 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_mail_message_ai_suggestion_user,mail.message.ai.suggestion.user,model_mail_message_ai_suggestion,mail_manual_routing.group_lost_messages,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mail_message_ai_suggestion_user mail.message.ai.suggestion.user model_mail_message_ai_suggestion mail_manual_routing.group_lost_messages 1 1 1 1

View file

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form View -->
<record id="mail_message_ai_suggestion_view_form" model="ir.ui.view">
<field name="name">mail.message.ai.suggestion.form</field>
<field name="model">mail.message.ai.suggestion</field>
<field name="arch" type="xml">
<form string="Suggestion IA">
<header>
<button name="action_apply_suggestion" string="Appliquer cette suggestion"
type="object" class="oe_highlight"
attrs="{'invisible': [('is_applied', '=', True)]}"/>
<button name="action_mark_feedback" string="Marquer comme correcte"
type="object" class="btn-success"
context="{'feedback_type': 'positive'}"
attrs="{'invisible': [('user_feedback', '=', 'positive')]}"/>
<button name="action_mark_feedback" string="Marquer comme incorrecte"
type="object" class="btn-danger"
context="{'feedback_type': 'negative'}"
attrs="{'invisible': [('user_feedback', '=', 'negative')]}"/>
</header>
<sheet>
<div class="oe_title">
<label for="suggested_model" class="oe_edit_only"/>
<h1>
<field name="suggested_model" class="oe_inline"/> - <field name="suggested_record_name" class="oe_inline"/>
</h1>
</div>
<group>
<group>
<field name="message_id"/>
<field name="suggested_record_id"/>
<field name="confidence_score" widget="percentage"/>
</group>
<group>
<field name="is_applied"/>
<field name="user_feedback"/>
<field name="date_created"/>
</group>
</group>
<notebook>
<page string="Explication">
<field name="suggestion_reason"/>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Tree View -->
<record id="mail_message_ai_suggestion_view_tree" model="ir.ui.view">
<field name="name">mail.message.ai.suggestion.tree</field>
<field name="model">mail.message.ai.suggestion</field>
<field name="arch" type="xml">
<tree string="Suggestions IA" decoration-success="is_applied" decoration-info="user_feedback == 'positive'" decoration-danger="user_feedback == 'negative'">
<field name="message_id"/>
<field name="suggested_model"/>
<field name="suggested_record_name"/>
<field name="confidence_score" widget="percentage"/>
<field name="is_applied"/>
<field name="user_feedback"/>
<field name="date_created"/>
<button name="action_apply_suggestion" string="Appliquer" type="object" icon="fa-check" attrs="{'invisible': [('is_applied', '=', True)]}"/>
</tree>
</field>
</record>
<!-- Search View -->
<record id="mail_message_ai_suggestion_view_search" model="ir.ui.view">
<field name="name">mail.message.ai.suggestion.search</field>
<field name="model">mail.message.ai.suggestion</field>
<field name="arch" type="xml">
<search string="Rechercher des suggestions">
<field name="message_id"/>
<field name="suggested_model"/>
<field name="suggested_record_name"/>
<filter string="Appliquées" name="applied" domain="[('is_applied', '=', True)]"/>
<filter string="Non appliquées" name="not_applied" domain="[('is_applied', '=', False)]"/>
<filter string="Feedback positif" name="positive_feedback" domain="[('user_feedback', '=', 'positive')]"/>
<filter string="Feedback négatif" name="negative_feedback" domain="[('user_feedback', '=', 'negative')]"/>
<filter string="Score élevé (>75%)" name="high_score" domain="[('confidence_score', '>=', 0.75)]"/>
<group expand="0" string="Grouper par">
<filter string="Message" name="group_by_message" context="{'group_by': 'message_id'}"/>
<filter string="Modèle suggéré" name="group_by_model" context="{'group_by': 'suggested_model'}"/>
<filter string="Appliqué" name="group_by_applied" context="{'group_by': 'is_applied'}"/>
<filter string="Feedback" name="group_by_feedback" context="{'group_by': 'user_feedback'}"/>
</group>
</search>
</field>
</record>
<!-- Action -->
<record id="action_mail_message_ai_suggestion" model="ir.actions.act_window">
<field name="name">Suggestions IA</field>
<field name="res_model">mail.message.ai.suggestion</field>
<field name="view_mode">tree,form</field>
<field name="context">{'search_default_not_applied': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Aucune suggestion IA pour le moment
</p>
<p>
Les suggestions apparaîtront ici après l'analyse des messages non routés.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Extend mail.message form view -->
<record id="mail_message_view_form_ai_enhanced" model="ir.ui.view">
<field name="name">mail.message.form.ai.enhanced</field>
<field name="model">mail.message</field>
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_form"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<button string="Analyser avec IA"
name="action_analyze_with_ai"
type="object"
class="btn-primary"
attrs="{'invisible': ['|', ('is_unattached', '=', False), ('ai_analyzed', '=', True)]}"/>
</xpath>
<xpath expr="//field[@name='lost_comments']" position="after">
<field name="ai_analyzed" invisible="1"/>
<field name="ai_suggestion_count" invisible="1"/>
<field name="ai_top_suggestion_id" invisible="1"/>
<div class="alert alert-info" attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}">
<div class="d-flex align-items-center">
<i class="fa fa-robot mr-2"/>
<span>L'IA suggère de router ce message vers : </span>
<field name="ai_top_suggestion_id" readonly="1" options="{'no_open': True}"/>
<button string="Appliquer"
name="action_apply_ai_suggestion"
type="object"
class="btn btn-sm btn-primary ml-2"
attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}"/>
<button string="Voir toutes les suggestions"
name="action_view_ai_suggestions"
type="object"
class="btn btn-sm btn-secondary ml-2"
attrs="{'invisible': [('ai_suggestion_count', '=', 0)]}"/>
</div>
<div attrs="{'invisible': [('ai_top_suggestion_id', '=', False)]}">
<field name="ai_top_suggestion_id.suggestion_reason" readonly="1" class="text-muted"/>
</div>
</div>
</xpath>
</field>
</record>
<!-- Extend mail.message tree view -->
<record id="mail_message_view_tree_ai_enhanced" model="ir.ui.view">
<field name="name">mail.message.tree.ai.enhanced</field>
<field name="model">mail.message</field>
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='is_unattached']" position="after">
<field name="ai_analyzed"/>
<field name="ai_suggestion_count"/>
</xpath>
</field>
</record>
<!-- Extend mail.message search view -->
<record id="mail_message_view_search_ai_enhanced" model="ir.ui.view">
<field name="name">mail.message.search.ai.enhanced</field>
<field name="model">mail.message</field>
<field name="inherit_id" ref="mail_manual_routing.mail_message_view_search"/>
<field name="arch" type="xml">
<xpath expr="//filter[@name='unattached']" position="after">
<filter string="Analysés par IA" name="ai_analyzed" domain="[('ai_analyzed', '=', True)]"/>
<filter string="Non analysés par IA" name="not_ai_analyzed" domain="[('is_unattached', '=', True), ('ai_analyzed', '=', False)]"/>
<filter string="Avec suggestions" name="with_suggestions" domain="[('ai_suggestion_count', '>', 0)]"/>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Menu items -->
<menuitem id="menu_mail_message_ai_suggestion"
name="Suggestions IA"
parent="mail_manual_routing.menu_lost_messages"
action="action_mail_message_ai_suggestion"
sequence="20"
groups="mail_manual_routing.group_lost_messages"/>
</odoo>

View file

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form_ai_enhanced" model="ir.ui.view">
<field name="name">res.config.settings.view.form.ai.enhanced</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='lost_messages_settings']" position="after">
<h2>Paramètres d'IA pour les messages perdus</h2>
<div class="row mt16 o_settings_container" id="ai_lost_messages_settings">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="ai_auto_analyze_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="ai_auto_analyze_enabled"/>
<div class="text-muted">
Analyser automatiquement les messages perdus avec l'IA
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="ai_auto_routing_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="ai_auto_routing_enabled"/>
<div class="text-muted">
Router automatiquement les messages selon les suggestions de l'IA
</div>
<div class="content-group" attrs="{'invisible': [('ai_auto_routing_enabled', '=', False)]}">
<div class="mt16">
<label for="ai_auto_routing_threshold" class="o_light_label"/>
<field name="ai_auto_routing_threshold" class="oe_inline" widget="percentage"/>
</div>
<div class="text-muted">
Score de confiance minimum pour le routage automatique
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="ai_learning_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="ai_learning_enabled"/>
<div class="text-muted">
Utiliser les retours utilisateurs pour améliorer les futures suggestions
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<label for="ai_models_allowed"/>
<field name="ai_models_allowed" placeholder="sale.order,project.project,res.partner"/>
<div class="text-muted">
Liste de modèles séparés par des virgules autorisés pour le routage IA
</div>
</div>
</div>
</div>
<h2>Intégration OpenWebUI</h2>
<div class="row mt16 o_settings_container" id="openwebui_settings">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="ai_use_openwebui"/>
</div>
<div class="o_setting_right_pane">
<label for="ai_use_openwebui"/>
<div class="text-muted">
Utiliser OpenWebUI pour l'analyse des messages au lieu de l'analyseur standard
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" attrs="{'invisible': [('ai_use_openwebui', '=', False)]}">
<div class="o_setting_right_pane">
<label for="ai_openwebui_endpoint"/>
<field name="ai_openwebui_endpoint" placeholder="http://localhost:3000/api/chat"/>
<div class="text-muted">
URL de l'API OpenWebUI pour l'analyse des messages
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" attrs="{'invisible': [('ai_use_openwebui', '=', False)]}">
<div class="o_setting_right_pane">
<label for="ai_openwebui_api_key"/>
<field name="ai_openwebui_api_key" password="True"/>
<div class="text-muted">
Clé API pour l'authentification avec OpenWebUI
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box" attrs="{'invisible': [('ai_use_openwebui', '=', False)]}">
<div class="o_setting_right_pane">
<label for="ai_responsible_user_id"/>
<field name="ai_responsible_user_id"/>
<div class="text-muted">
Utilisateur qui recevra les activités pour les messages perdus analysés
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>

View file

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

View file

@ -0,0 +1,27 @@
{
'name': 'Task Nuker',
'version': '17.0.1.0.0',
'category': 'Project',
'summary': 'Add ability to nuke intermediate tasks while preserving hierarchy',
'description': """
This module adds a 'Nuke' button on tasks that have both parent and child tasks.
When activated:
- Requests confirmation
- Reassigns all child tasks to the parent task
- Deletes the intermediate task
- Logs the action in the chatter of both parent and child tasks
""",
'author': 'DurPro',
'website': 'https://www.durpro.com',
'depends': [
'project',
],
'data': [
'views/project_task_views.xml',
],
'assets': {},
'license': 'LGPL-3',
'installable': True,
'application': False,
'auto_install': False,
}

View file

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

View file

@ -0,0 +1,75 @@
from odoo import models, api, fields, _
from odoo.exceptions import UserError
class ProjectTask(models.Model):
_inherit = 'project.task'
can_be_nuked = fields.Boolean(
compute='_compute_can_be_nuked',
help="Technical field to determine if task can be nuked"
)
@api.depends('parent_id', 'child_ids')
def _compute_can_be_nuked(self):
"""Compute if the task can be nuked based on parent and children presence"""
for task in self:
task.can_be_nuked = bool(task.parent_id and task.child_ids)
def action_nuke_task(self):
"""
Nuke the current task:
1. Verify the task can be nuked
2. Get parent and children
3. Reassign all children to the parent
4. Update children names to include nuked task name
5. Post messages in the chatter
6. Delete the current task
7. Return action to redirect to parent task
"""
self.ensure_one()
if not self.can_be_nuked:
raise UserError(_("This task cannot be nuked. It must have both a parent and child tasks."))
parent = self.parent_id
children = self.child_ids
nuked_name = self.name
# Post message to parent about the upcoming changes
parent.message_post(
body=_(
"The following task has been nuked: %(task)s\n"
"%(count)d child tasks have been reassigned to this task."
) % {
'task': nuked_name,
'count': len(children)
}
)
# Reassign all children to the parent, update their names and notify them
for child in children:
old_name = child.name
# Update name to include nuked task's name
child.write({
'name': f"[{nuked_name}] {old_name}",
'parent_id': parent.id
})
child.message_post(
body=_("This task has been reassigned to %(new_parent)s due to the removal of %(old_parent)s.\nTask name updated from '%(old_name)s' to '%(new_name)s'") % {
'new_parent': parent.name,
'old_parent': nuked_name,
'old_name': old_name,
'new_name': child.name
}
)
# Delete the current task
self.unlink()
# Return action to redirect to parent task
return {
'type': 'ir.actions.act_window',
'res_model': 'project.task',
'res_id': parent.id,
'view_mode': 'form',
'target': 'current',
}

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_task_form_inherit_nuke" model="ir.ui.view">
<field name="name">project.task.form.inherit.nuke</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.view_task_form2"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='recurrence_id']" position="after">
<field name="can_be_nuked" invisible="1"/>
</xpath>
<xpath expr="//header" position="inside">
<button
name="action_nuke_task"
string="Nuke Task"
type="object"
confirm="Are you sure you want to nuke this task? All child tasks will be reassigned to the parent task."
invisible="not can_be_nuked"
groups="project.group_project_manager"/>
</xpath>
</field>
</record>
</odoo>

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