fix: detect odoo chatter updates

This commit is contained in:
2026-07-24 10:43:02 +02:00
parent b10b82a141
commit e666571023
6 changed files with 177 additions and 25 deletions
+9 -2
View File
@@ -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
+8
View File
@@ -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`.
+4 -4
View File
@@ -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==
+83 -4
View File
@@ -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
+71 -15
View File
@@ -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):
+2
View File
@@ -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