From e666571023fd7755c6855bfec92d40674cdb75fe Mon Sep 17 00:00:00 2001 From: pcosson Date: Fri, 24 Jul 2026 10:43:02 +0200 Subject: [PATCH] fix: detect odoo chatter updates --- README.md | 11 +++++- TRAVAIL.md | 8 ++++ WAPT/control | 8 ++-- setup.py | 87 +++++++++++++++++++++++++++++++++++++++-- tests/test_ticketing.py | 86 +++++++++++++++++++++++++++++++++------- ticketing.ini.example | 2 + 6 files changed, 177 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index bcd89f6..0a55e7a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,8 @@ model = helpdesk.ticket fields = id,name,stage_id,partner_id,user_id,write_date limit = 0 order = id asc +track_chatter = true +chatter_model = mail.message [http] timeout = 30 @@ -86,11 +88,14 @@ Hors WAPT, le script ne peut pas etre lance tel quel sans fournir les objets et ```json { - "ticket_id": "write_date" + "ticket_id": { + "write_date": "date de modification du ticket", + "last_chatter_update": "date et id du dernier message chatter" + } } ``` -Le runtime lit et ecrit ce fichier dans `WAPT.private_dir`. Il n'est pas versionne dans le depot. +Le runtime lit et ecrit ce fichier dans `WAPT.private_dir`. Il n'est pas versionne dans le depot. Les anciens etats au format simple `ticket_id -> write_date` restent lisibles. La cle API est utilisee comme mot de passe d'API Odoo pour authentifier `username`. Il est aussi possible de renseigner `uid` dans la section `[odoo]` pour eviter l'appel d'authentification. @@ -98,6 +103,8 @@ La cle API est utilisee comme mot de passe d'API Odoo pour authentifier `usernam `odoo.limit = 0` force la recuperation de tous les tickets. `odoo.order = id asc` stabilise la comparaison avec l'etat local. +`odoo.track_chatter = true` active le suivi des notes/commentaires Odoo en lisant le dernier `mail.message` rattache a chaque ticket. Cela permet de detecter une note ajoutee dans un ticket meme si `helpdesk.ticket.write_date` ne change pas. + `notification.max_tickets` limite le nombre de tickets affiches dans Rocket.Chat. `notification.max_message_chars` limite la taille totale du message pour eviter les rejets Rocket.Chat sur les gros audits. `notification.ticket_url_template` controle le lien d'ouverture du ticket ; les variables disponibles sont `{odoo_url}`, `{id}` et `{model}`. ## Protocole Odoo diff --git a/TRAVAIL.md b/TRAVAIL.md index cd2b3d4..ca3c87b 100644 --- a/TRAVAIL.md +++ b/TRAVAIL.md @@ -143,3 +143,11 @@ Ce fichier consigne les interventions Codex sur le projet afin de garder une tra - Conservation du resume indiquant combien de tickets supplementaires ne sont pas affiches. - Ajout du corps de reponse Rocket.Chat dans les logs en cas d'erreur HTTP. - Incrementation de la version WAPT de `0.1-15` a `0.1-16`. + +### Detection des notes Odoo + +- Analyse du cas ou l'ajout d'une note dans le chatter d'un ticket ne declenche pas de notification. +- Ajout du suivi optionnel de `mail.message` via `odoo.track_chatter = true`. +- L'etat local stocke maintenant `write_date` du ticket et le dernier marqueur de message chatter connu. +- Ajout de `odoo.chatter_model = mail.message` dans le modele de configuration. +- Incrementation de la version WAPT de `0.1-17` a `0.1-18`. diff --git a/WAPT/control b/WAPT/control index c764c84..43e2778 100644 --- a/WAPT/control +++ b/WAPT/control @@ -1,5 +1,5 @@ package : comi-odoo-ticketing -version : 0.1-16 +version : 0.1-18 architecture : all section : base priority : optional @@ -29,7 +29,7 @@ editor : keywords : licence : homepage : -package_uuid : 26501dce-4f1c-c379-c701-370a6908dd53 +package_uuid : f974e9d0-c356-77d6-4e13-273510424e1f valid_from : valid_until : forced_install_on : @@ -39,6 +39,6 @@ max_os_version : icon_sha256sum : signer : pcosson_key signer_fingerprint: a25582410cf03bad179a60c189f459a0b03821c92c0cedf209e82448a66a9b4e -signature_date : 2026-07-24T08:27:24.000000 +signature_date : 2026-07-24T08:37:11.000000 signed_attributes : package,version,architecture,section,priority,name,categories,maintainer,description,depends,conflicts,maturity,locale,target_os,min_wapt_version,sources,installed_size,impacted_process,description_fr,description_pl,description_de,description_es,description_pt,description_it,description_nl,description_ru,audit_schedule,editor,keywords,licence,homepage,package_uuid,valid_from,valid_until,forced_install_on,changelog,min_os_version,max_os_version,icon_sha256sum,signer,signer_fingerprint,signature_date,signed_attributes -signature : HUbxaUjEjN7gWHV0cfLtO0ofdC4NYqFZp4+0+Lq2bv6f7wD+U3cNXIOj1tvnrXZEs9RKWKcsNVgmq7bwWz0wpWCOTT2TDZ/PI0srd6NEq7jkBg4l6AE6ssjVjr/SRDFKB2bPEcQI7e0Mgj7uG6ho2eCvZFV2txH5pW4vV78GTZffTxvmZddi6PVKXMk4SBmWB8FFNCAPPvS50NVvmPQio7JHgRy4EBMWSSjcTLMcDAcHvcWp8BYGXy3csbpIxKfe8RIIkMLPT7cWQiIJRMzWurXanGXwXD8YF1lSwrXNmz5sOCmJ5MII9ljA/irbp3OsCvO9T5zgJkRWpw0wD/KGRw== +signature : o1xwbr7V5kTvRJ7GrHHWd0xzEjrPovHQjVcEOj4N7j56/m0MaiwScoBAYJ+CaNEfiP2MuMPGqJz+WuYLZrKZvXNZhTfP0fdP6ligApowHvjUJEdwJ/cpzwKcXd/yD8Q0bLLChNrFFQz8wRoaBNsvmcJCPfPp353aNOemKe8+JubgAZTdF4VdyxMbglMDBRy4ECnK5YNpZWYFzHEamVWyoQ3LTo92UHL/PdZrvefQ88MOfGH055npv1YdR/AD235BrjDm1AD0CqIMMe1UnFD41XcBwB9mxD7Wh0jLmzDqRCnCbzfuq98VgNp0L1PZQJXgeu1tVrG9ycGrexdQrDWPzA== diff --git a/setup.py b/setup.py index 4de7b30..b11c148 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,8 @@ DEFAULT_MAX_NOTIFIED_TICKETS = 50 DEFAULT_MAX_MESSAGE_CHARS = 3500 DEFAULT_TICKET_URL_TEMPLATE = "{odoo_url}/web#id={id}&model={model}&view_type=form" DEFAULT_ODOO_ORDER = "id asc" +DEFAULT_CHATTER_MODEL = "mail.message" +DEFAULT_CHATTER_ORDER = "res_id asc, id desc" def get_config_path(): @@ -135,6 +137,14 @@ def get_odoo_order(conf_wapt): return conf_wapt.get("odoo", "order", fallback=DEFAULT_ODOO_ORDER) +def should_track_chatter(conf_wapt): + return conf_wapt.getboolean("odoo", "track_chatter", fallback=True) + + +def get_chatter_model(conf_wapt): + return conf_wapt.get("odoo", "chatter_model", fallback=DEFAULT_CHATTER_MODEL) + + def build_odoo_payload(service, method, args): return { "jsonrpc": "2.0", @@ -192,7 +202,7 @@ def get_odoo_uid(conf_wapt): def fetch_odoo_tickets(conf_wapt): uid = get_odoo_uid(conf_wapt) - return call_odoo_jsonrpc( + tickets = call_odoo_jsonrpc( conf_wapt, "object", "execute_kw", @@ -210,6 +220,55 @@ def fetch_odoo_tickets(conf_wapt): }, ], ) + if should_track_chatter(conf_wapt) and tickets: + enrich_tickets_with_chatter_state(conf_wapt, uid, tickets) + return tickets + + +def fetch_ticket_chatter_messages(conf_wapt, uid, ticket_ids): + return call_odoo_jsonrpc( + conf_wapt, + "object", + "execute_kw", + [ + conf_wapt.get("odoo", "database"), + uid, + conf_wapt.get("odoo", "api_key"), + get_chatter_model(conf_wapt), + "search_read", + [[ + ["model", "=", conf_wapt.get("odoo", "model", fallback="helpdesk.ticket")], + ["res_id", "in", ticket_ids], + ]], + { + "fields": ["id", "res_id", "write_date", "date"], + "limit": 0, + "order": DEFAULT_CHATTER_ORDER, + }, + ], + ) + + +def build_chatter_marker(message): + message_date = message.get("write_date") or message.get("date") or "" + return "%s|%s" % (message_date, message.get("id")) + + +def enrich_tickets_with_chatter_state(conf_wapt, uid, tickets): + ticket_ids = [ticket["id"] for ticket in tickets] + messages = fetch_ticket_chatter_messages(conf_wapt, uid, ticket_ids) + latest_messages_by_ticket = {} + + for message in messages: + ticket_id = str(message.get("res_id")) + marker = build_chatter_marker(message) + if marker > latest_messages_by_ticket.get(ticket_id, ""): + latest_messages_by_ticket[ticket_id] = marker + + for ticket in tickets: + ticket_id = str(ticket["id"]) + if ticket_id in latest_messages_by_ticket: + ticket["_last_chatter_update"] = latest_messages_by_ticket[ticket_id] def load_tickets_state(state_path): @@ -227,11 +286,30 @@ def save_tickets_state(state_path, tickets_state): def build_tickets_state(tickets): return { - str(ticket["id"]): ticket["write_date"] + str(ticket["id"]): build_ticket_state_value(ticket) for ticket in tickets } +def build_ticket_state_value(ticket): + return { + "write_date": ticket["write_date"], + "last_chatter_update": ticket.get("_last_chatter_update"), + } + + +def normalize_ticket_state_value(value): + if isinstance(value, dict): + return { + "write_date": value.get("write_date"), + "last_chatter_update": value.get("last_chatter_update"), + } + return { + "write_date": value, + "last_chatter_update": None, + } + + def get_updated_ticket_ids(tickets, previous_tickets_state): return [ ticket["id"] @@ -244,9 +322,10 @@ def get_updated_tickets(tickets, previous_tickets_state): for ticket in tickets: ticket_id = str(ticket["id"]) - ticket_write_date = ticket["write_date"] + previous_state = normalize_ticket_state_value(previous_tickets_state.get(ticket_id)) + current_state = build_ticket_state_value(ticket) - if previous_tickets_state.get(ticket_id) != ticket_write_date: + if previous_state != current_state: tickets_updated.append(ticket) return tickets_updated diff --git a/tests/test_ticketing.py b/tests/test_ticketing.py index d470b0a..dea7dae 100644 --- a/tests/test_ticketing.py +++ b/tests/test_ticketing.py @@ -39,6 +39,8 @@ def make_config(): config.set("odoo", "model", "helpdesk.ticket") config.set("odoo", "limit", "0") config.set("odoo", "order", "id asc") + config.set("odoo", "track_chatter", "true") + config.set("odoo", "chatter_model", "mail.message") config.add_section("rocket") config.set("rocket", "webhook_url", "https://rocket.example.test/webhook-test") config.add_section("http") @@ -51,19 +53,35 @@ def make_config(): class TicketingStateTest(unittest.TestCase): def test_build_tickets_state_uses_string_ids_for_json_keys(self): state = ticketing.build_tickets_state([ - {"id": 12, "write_date": "2026-07-24 10:00:00"}, + { + "id": 12, + "write_date": "2026-07-24 10:00:00", + "_last_chatter_update": "2026-07-24 10:05:00|99", + }, {"id": 34, "write_date": "2026-07-24 11:00:00"}, ]) self.assertEqual(state, { - "12": "2026-07-24 10:00:00", - "34": "2026-07-24 11:00:00", + "12": { + "write_date": "2026-07-24 10:00:00", + "last_chatter_update": "2026-07-24 10:05:00|99", + }, + "34": { + "write_date": "2026-07-24 11:00:00", + "last_chatter_update": None, + }, }) def test_get_updated_ticket_ids_detects_new_and_changed_tickets(self): previous_state = { - "1": "2026-07-24 09:00:00", - "2": "2026-07-24 09:30:00", + "1": { + "write_date": "2026-07-24 09:00:00", + "last_chatter_update": None, + }, + "2": { + "write_date": "2026-07-24 09:30:00", + "last_chatter_update": None, + }, } tickets = [ {"id": 1, "write_date": "2026-07-24 09:00:00"}, @@ -73,6 +91,21 @@ class TicketingStateTest(unittest.TestCase): self.assertEqual(ticketing.get_updated_ticket_ids(tickets, previous_state), [2, 3]) + def test_get_updated_ticket_ids_detects_chatter_change(self): + previous_state = { + "260": { + "write_date": "2026-07-24 09:00:00", + "last_chatter_update": "2026-07-24 09:15:00|1200", + }, + } + tickets = [{ + "id": 260, + "write_date": "2026-07-24 09:00:00", + "_last_chatter_update": "2026-07-24 10:00:00|1201", + }] + + self.assertEqual(ticketing.get_updated_ticket_ids(tickets, previous_state), [260]) + def test_build_notification_message_is_verbose_and_limited(self): previous_state = { "1": "2026-07-24 09:00:00", @@ -159,7 +192,12 @@ class TicketingStateTest(unittest.TestCase): def test_save_and_load_tickets_state_roundtrip(self): with tempfile.TemporaryDirectory() as tmpdir: state_path = os.path.join(tmpdir, "tickets_state.json") - expected_state = {"42": "2026-07-24 12:00:00"} + expected_state = { + "42": { + "write_date": "2026-07-24 12:00:00", + "last_chatter_update": None, + }, + } ticketing.save_tickets_state(state_path, expected_state) @@ -173,23 +211,41 @@ class TicketingStateTest(unittest.TestCase): tickets_response.json.return_value = { "result": [{"id": 42, "write_date": "2026-07-24 12:00:00"}], } - post.side_effect = [auth_response, tickets_response] + chatter_response = Mock() + chatter_response.json.return_value = { + "result": [{ + "id": 100, + "res_id": 42, + "write_date": "2026-07-24 12:05:00", + "date": "2026-07-24 12:04:00", + }], + } + post.side_effect = [auth_response, tickets_response, chatter_response] tickets = ticketing.fetch_odoo_tickets(make_config()) - self.assertEqual(tickets, [{"id": 42, "write_date": "2026-07-24 12:00:00"}]) + self.assertEqual(tickets, [{ + "id": 42, + "write_date": "2026-07-24 12:00:00", + "_last_chatter_update": "2026-07-24 12:05:00|100", + }]) auth_response.raise_for_status.assert_called_once_with() tickets_response.raise_for_status.assert_called_once_with() - self.assertEqual(post.call_count, 2) - self.assertEqual(post.call_args.kwargs["timeout"], 5) - self.assertEqual(post.call_args.kwargs["json"]["params"]["service"], "object") - self.assertEqual(post.call_args.kwargs["json"]["params"]["method"], "execute_kw") + chatter_response.raise_for_status.assert_called_once_with() + self.assertEqual(post.call_count, 3) + ticket_call = post.call_args_list[1] + self.assertEqual(ticket_call.kwargs["timeout"], 5) + self.assertEqual(ticket_call.kwargs["json"]["params"]["service"], "object") + self.assertEqual(ticket_call.kwargs["json"]["params"]["method"], "execute_kw") self.assertEqual( - post.call_args.kwargs["json"]["params"]["args"][6]["fields"], + ticket_call.kwargs["json"]["params"]["args"][6]["fields"], ["id", "name", "stage_id", "partner_id", "user_id", "write_date"], ) - self.assertEqual(post.call_args.kwargs["json"]["params"]["args"][6]["limit"], 0) - self.assertEqual(post.call_args.kwargs["json"]["params"]["args"][6]["order"], "id asc") + self.assertEqual(ticket_call.kwargs["json"]["params"]["args"][6]["limit"], 0) + self.assertEqual(ticket_call.kwargs["json"]["params"]["args"][6]["order"], "id asc") + chatter_call = post.call_args_list[2] + self.assertEqual(chatter_call.kwargs["json"]["params"]["args"][3], "mail.message") + self.assertEqual(chatter_call.kwargs["json"]["params"]["args"][6]["limit"], 0) @patch("ticketing_setup.requests.post") def test_fetch_odoo_tickets_rejects_jsonrpc_error(self, post): diff --git a/ticketing.ini.example b/ticketing.ini.example index 1bf1b47..1da1d83 100644 --- a/ticketing.ini.example +++ b/ticketing.ini.example @@ -10,6 +10,8 @@ model = helpdesk.ticket fields = id,name,stage_id,partner_id,user_id,write_date limit = 0 order = id asc +track_chatter = true +chatter_model = mail.message [http] timeout = 30