fix: detect odoo chatter updates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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==
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user