feat: enrich ticket update notifications

This commit is contained in:
2026-07-24 10:06:18 +02:00
parent 39908314d9
commit 93b6947feb
7 changed files with 166 additions and 13 deletions
+1
View File
@@ -3,3 +3,4 @@
/WAPT/certificate.crt /WAPT/certificate.crt
/WAPT/manifest.sha256 /WAPT/manifest.sha256
/WAPT/signature.sha256 /WAPT/signature.sha256
__pycache__/
+26 -1
View File
@@ -50,9 +50,13 @@ database = CHANGE_ME_ODOO_DATABASE
username = CHANGE_ME_ODOO_USERNAME username = CHANGE_ME_ODOO_USERNAME
api_key = CHANGE_ME_ODOO_API_KEY api_key = CHANGE_ME_ODOO_API_KEY
model = helpdesk.ticket model = helpdesk.ticket
fields = id,name,stage_id,partner_id,user_id,write_date
[http] [http]
timeout = 30 timeout = 30
[notification]
max_tickets = 50
``` ```
Au deploiement, `install()` copie ce modele vers `WAPT.private_dir/ticketing.ini` uniquement si le fichier n'existe pas deja. Il faut ensuite renseigner le fichier prive sur la machine cible. Les mises a jour du paquet n'ecrasent donc pas une configuration deja remplie. Au deploiement, `install()` copie ce modele vers `WAPT.private_dir/ticketing.ini` uniquement si le fichier n'existe pas deja. Il faut ensuite renseigner le fichier prive sur la machine cible. Les mises a jour du paquet n'ecrasent donc pas une configuration deja remplie.
@@ -86,6 +90,10 @@ Le runtime lit et ecrit ce fichier dans `WAPT.private_dir`. Il n'est pas version
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. 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.
`odoo.fields` controle les champs recuperes depuis Odoo pour enrichir la notification. Les champs `id` et `write_date` sont toujours ajoutes s'ils sont absents, car ils sont necessaires a la comparaison d'etat.
`notification.max_tickets` limite le nombre de lignes detaillees envoyees dans Rocket.Chat. Le message indique toujours le nombre total de tickets modifies.
## Protocole Odoo ## Protocole Odoo
Le script utilise l'endpoint configure sous la forme `<odoo.url>/jsonrpc` avec le protocole JSON-RPC Odoo standard. Le script utilise l'endpoint configure sous la forme `<odoo.url>/jsonrpc` avec le protocole JSON-RPC Odoo standard.
@@ -114,12 +122,29 @@ Lecture des tickets :
"params": { "params": {
"service": "object", "service": "object",
"method": "execute_kw", "method": "execute_kw",
"args": ["database", "uid", "api_key", "helpdesk.ticket", "search_read", [[]], {"fields": ["id", "write_date"]}] "args": ["database", "uid", "api_key", "helpdesk.ticket", "search_read", [[]], {"fields": ["id", "name", "stage_id", "partner_id", "user_id", "write_date"]}]
}, },
"id": 1 "id": 1
} }
``` ```
## Notification
Quand des tickets changent, Rocket.Chat recoit un message avec :
- le nombre total de tickets modifies ;
- une ligne par ticket affiche, limitee par `notification.max_tickets` ;
- l'ID, le titre, le type de changement, le statut, le client, l'assigne, la nouvelle date de modification et l'ancienne date quand elle existe.
Exemple :
```text
Tickets Odoo mis a jour depuis la derniere verification : 2
- #187 | Probleme imprimante | type: modifie | statut: En cours | client: Comitari | assigne: Support | date: 2026-07-24 10:00:00 | ancienne date: 2026-07-24 09:00:00
- #251 | Nouveau ticket | type: nouveau | statut: - | client: - | assigne: - | date: 2026-07-24 11:00:00
```
## Tests ## Tests
Les tests unitaires peuvent etre executes hors WAPT : Les tests unitaires peuvent etre executes hors WAPT :
+8
View File
@@ -112,3 +112,11 @@ Ce fichier consigne les interventions Codex sur le projet afin de garder une tra
- Correction de version demandee : passage de `0.1.0-3` a `0.1-10` pour rattraper les increments precedents. - Correction de version demandee : passage de `0.1.0-3` a `0.1-10` pour rattraper les increments precedents.
- Attention : `target_os` fait partie des attributs signes, le paquet doit etre regenere/resigne avant publication. - Attention : `target_os` fait partie des attributs signes, le paquet doit etre regenere/resigne avant publication.
- Regle projet retenue : incrementer `WAPT/control:version` a chaque nouvelle version du paquet. - Regle projet retenue : incrementer `WAPT/control:version` a chaque nouvelle version du paquet.
### Notification Rocket.Chat plus detaillee
- Recuperation de champs Odoo supplementaires par defaut : `name`, `stage_id`, `partner_id`, `user_id`.
- Ajout de l'option `odoo.fields` pour ajuster les champs lus par `search_read`.
- Ajout de l'option `notification.max_tickets` pour limiter le nombre de lignes detaillees dans Rocket.Chat.
- Remplacement du message brut contenant seulement une liste d'IDs par un message avec nombre total, titre, statut, client, assigne, date courante et ancienne date quand disponible.
- Incrementation de la version WAPT locale de `0.1-11` a `0.1-12`.
+4 -4
View File
@@ -1,5 +1,5 @@
package : comi-odoo-ticketing package : comi-odoo-ticketing
version : 0.1-10 version : 0.1-12
architecture : all architecture : all
section : base section : base
priority : optional priority : optional
@@ -29,7 +29,7 @@ editor :
keywords : keywords :
licence : licence :
homepage : homepage :
package_uuid : 0afd3de2-c939-918b-67b0-d79a8ccba84b package_uuid : 94cab12e-61fb-4a49-fcd2-c1d160d18b22
valid_from : valid_from :
valid_until : valid_until :
forced_install_on : forced_install_on :
@@ -39,6 +39,6 @@ max_os_version :
icon_sha256sum : icon_sha256sum :
signer : pcosson_key signer : pcosson_key
signer_fingerprint: a25582410cf03bad179a60c189f459a0b03821c92c0cedf209e82448a66a9b4e signer_fingerprint: a25582410cf03bad179a60c189f459a0b03821c92c0cedf209e82448a66a9b4e
signature_date : 2026-07-24T07:19:02.000000 signature_date : 2026-07-24T07:48:26.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 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 : dBwE3rudjuJOvYhduNbzizfvql8Fqo13fs6anaHSoRoBCnRteqzJy4JRmaA6OV+YJOtfC89E7OnNBKAOvO1YErsaanKzFG+wkZFBZEgGkBcJZsb0xec5iLHoslEt6ZJw9ldsSs0dzRH1tzIb7nthMi5AOkPPqICKf5mXkYiOtAcBG9HjFa8YeYtBwyFM5ME2XeKkqpsESh03cOseNLpQ9DB2vMe0qZHd14nPZe5TpOFen+F4xklHSfemiMU8AFPclewTuhkYC8mqps2+WkHmTWl35ilJGg8BC2nLxiydotmOwc+QqpAYw90N6r9lVnPUFa1Bh2QqnmJnk5bfePYKTQ== signature : FGra+/RipOk5jmR4CkBmu4s9DQkzJ3u8bGhssL3zpRgzynMdnfNDSgVkBIM5XoQsqb+GluNnvqzGT19jvhOr1OVxF4Fk6ErMK/oXezLe/lX2vezTfRGoSUzVG+KPZrzqcuM0Q0g6gu2sctASMw0RuMMdMVgXWrZt/zVu+wS6P6O5MpCoOVRLMoV22jXAuXSz888rwBmSOyVU860Un1wPm4zin+gaJgvsy3GZQSSMhJBR8CGAj8QmeL6cjJrspJ0LDiE8m4UJXHJvlAsRIGxs9Y928L6x2Tg0pl6j02wNnZTKeh3mAM5KZRy5UJR8fbqXbBDuJJR7R1gux2WTw7mEeQ==
+87 -8
View File
@@ -12,6 +12,8 @@ CONFIG_FILE = "ticketing.ini"
CONFIG_TEMPLATE_FILE = "ticketing.ini.example" CONFIG_TEMPLATE_FILE = "ticketing.ini.example"
STATE_FILE = "tickets_state.json" STATE_FILE = "tickets_state.json"
DEFAULT_TIMEOUT = 30 DEFAULT_TIMEOUT = 30
DEFAULT_TICKET_FIELDS = ["id", "name", "stage_id", "partner_id", "user_id", "write_date"]
DEFAULT_MAX_NOTIFIED_TICKETS = 50
def get_config_path(): def get_config_path():
@@ -33,12 +35,12 @@ def audit():
conf_wapt = load_config() conf_wapt = load_config()
tickets = fetch_odoo_tickets(conf_wapt) tickets = fetch_odoo_tickets(conf_wapt)
previous_tickets_state = load_tickets_state(get_state_path()) previous_tickets_state = load_tickets_state(get_state_path())
tickets_updated = get_updated_ticket_ids(tickets, previous_tickets_state) tickets_updated = get_updated_tickets(tickets, previous_tickets_state)
message = build_notification_message(
if tickets_updated: tickets_updated,
message = "Les tickets suivants ont ete mis a jour depuis la derniere verification : %s" % tickets_updated previous_tickets_state,
else: get_max_notified_tickets(conf_wapt),
message = "Aucun ticket n'a ete mis a jour depuis la derniere verification." )
send_to_rocket(message, conf_wapt=conf_wapt) send_to_rocket(message, conf_wapt=conf_wapt)
save_tickets_state(get_state_path(), build_tickets_state(tickets)) save_tickets_state(get_state_path(), build_tickets_state(tickets))
@@ -92,6 +94,22 @@ def get_http_timeout(conf_wapt):
return conf_wapt.getint("http", "timeout", fallback=DEFAULT_TIMEOUT) return conf_wapt.getint("http", "timeout", fallback=DEFAULT_TIMEOUT)
def get_odoo_fields(conf_wapt):
fields = [
field.strip()
for field in conf_wapt.get("odoo", "fields", fallback=",".join(DEFAULT_TICKET_FIELDS)).split(",")
if field.strip()
]
for required_field in ["id", "write_date"]:
if required_field not in fields:
fields.append(required_field)
return fields
def get_max_notified_tickets(conf_wapt):
return conf_wapt.getint("notification", "max_tickets", fallback=DEFAULT_MAX_NOTIFIED_TICKETS)
def build_odoo_payload(service, method, args): def build_odoo_payload(service, method, args):
return { return {
"jsonrpc": "2.0", "jsonrpc": "2.0",
@@ -160,7 +178,7 @@ def fetch_odoo_tickets(conf_wapt):
conf_wapt.get("odoo", "model", fallback="helpdesk.ticket"), conf_wapt.get("odoo", "model", fallback="helpdesk.ticket"),
"search_read", "search_read",
[[]], [[]],
{"fields": ["id", "write_date"]}, {"fields": get_odoo_fields(conf_wapt)},
], ],
) )
@@ -186,6 +204,13 @@ def build_tickets_state(tickets):
def get_updated_ticket_ids(tickets, previous_tickets_state): def get_updated_ticket_ids(tickets, previous_tickets_state):
return [
ticket["id"]
for ticket in get_updated_tickets(tickets, previous_tickets_state)
]
def get_updated_tickets(tickets, previous_tickets_state):
tickets_updated = [] tickets_updated = []
for ticket in tickets: for ticket in tickets:
@@ -193,11 +218,65 @@ def get_updated_ticket_ids(tickets, previous_tickets_state):
ticket_write_date = ticket["write_date"] ticket_write_date = ticket["write_date"]
if previous_tickets_state.get(ticket_id) != ticket_write_date: if previous_tickets_state.get(ticket_id) != ticket_write_date:
tickets_updated.append(ticket["id"]) tickets_updated.append(ticket)
return tickets_updated return tickets_updated
def display_odoo_value(value):
if value in (False, None, ""):
return "-"
if isinstance(value, (list, tuple)):
if len(value) >= 2:
return str(value[1])
if len(value) == 1:
return str(value[0])
return "-"
return str(value)
def format_ticket_line(ticket, previous_tickets_state):
ticket_id = str(ticket["id"])
previous_write_date = previous_tickets_state.get(ticket_id)
change_type = "nouveau" if previous_write_date is None else "modifie"
parts = [
"#%s" % ticket["id"],
display_odoo_value(ticket.get("name", "Sans titre")),
"type: %s" % change_type,
"statut: %s" % display_odoo_value(ticket.get("stage_id")),
"client: %s" % display_odoo_value(ticket.get("partner_id")),
"assigne: %s" % display_odoo_value(ticket.get("user_id")),
"date: %s" % display_odoo_value(ticket.get("write_date")),
]
if previous_write_date is not None:
parts.append("ancienne date: %s" % previous_write_date)
return "- " + " | ".join(parts)
def build_notification_message(tickets_updated, previous_tickets_state, max_tickets):
if not tickets_updated:
return "Aucun ticket n'a ete mis a jour depuis la derniere verification."
displayed_tickets = tickets_updated[:max_tickets]
hidden_count = len(tickets_updated) - len(displayed_tickets)
lines = [
"Tickets Odoo mis a jour depuis la derniere verification : %s" % len(tickets_updated),
"",
]
lines.extend([
format_ticket_line(ticket, previous_tickets_state)
for ticket in displayed_tickets
])
if hidden_count > 0:
lines.extend([
"",
"... %s ticket(s) supplementaire(s) non affiche(s)." % hidden_count,
])
return "\n".join(lines)
def send_to_rocket(message_text, attachments=None, conf_wapt=None): def send_to_rocket(message_text, attachments=None, conf_wapt=None):
""" """
Envoie un message a Rocket.Chat via un webhook. Envoie un message a Rocket.Chat via un webhook.
+36
View File
@@ -69,6 +69,38 @@ class TicketingStateTest(unittest.TestCase):
self.assertEqual(ticketing.get_updated_ticket_ids(tickets, previous_state), [2, 3]) self.assertEqual(ticketing.get_updated_ticket_ids(tickets, previous_state), [2, 3])
def test_build_notification_message_is_verbose_and_limited(self):
previous_state = {
"1": "2026-07-24 09:00:00",
}
tickets = [
{
"id": 1,
"name": "Probleme imprimante",
"stage_id": [4, "En cours"],
"partner_id": [8, "Comitari"],
"user_id": [9, "Support"],
"write_date": "2026-07-24 10:00:00",
},
{
"id": 2,
"name": "Nouveau ticket",
"stage_id": False,
"partner_id": False,
"user_id": False,
"write_date": "2026-07-24 11:00:00",
},
]
message = ticketing.build_notification_message(tickets, previous_state, 1)
self.assertIn("Tickets Odoo mis a jour depuis la derniere verification : 2", message)
self.assertIn("#1 | Probleme imprimante | type: modifie", message)
self.assertIn("statut: En cours", message)
self.assertIn("ancienne date: 2026-07-24 09:00:00", message)
self.assertIn("1 ticket(s) supplementaire(s) non affiche(s)", message)
self.assertNotIn("Nouveau ticket", message)
def test_load_tickets_state_returns_empty_dict_when_file_is_missing(self): def test_load_tickets_state_returns_empty_dict_when_file_is_missing(self):
missing_path = os.path.join(tempfile.gettempdir(), "missing-ticket-state.json") missing_path = os.path.join(tempfile.gettempdir(), "missing-ticket-state.json")
@@ -102,6 +134,10 @@ class TicketingStateTest(unittest.TestCase):
self.assertEqual(post.call_args.kwargs["timeout"], 5) 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"]["service"], "object")
self.assertEqual(post.call_args.kwargs["json"]["params"]["method"], "execute_kw") self.assertEqual(post.call_args.kwargs["json"]["params"]["method"], "execute_kw")
self.assertEqual(
post.call_args.kwargs["json"]["params"]["args"][6]["fields"],
["id", "name", "stage_id", "partner_id", "user_id", "write_date"],
)
@patch("ticketing_setup.requests.post") @patch("ticketing_setup.requests.post")
def test_fetch_odoo_tickets_rejects_jsonrpc_error(self, post): def test_fetch_odoo_tickets_rejects_jsonrpc_error(self, post):
+4
View File
@@ -7,6 +7,10 @@ database = CHANGE_ME_ODOO_DATABASE
username = CHANGE_ME_ODOO_USERNAME username = CHANGE_ME_ODOO_USERNAME
api_key = CHANGE_ME_ODOO_API_KEY api_key = CHANGE_ME_ODOO_API_KEY
model = helpdesk.ticket model = helpdesk.ticket
fields = id,name,stage_id,partner_id,user_id,write_date
[http] [http]
timeout = 30 timeout = 30
[notification]
max_tickets = 50