chore: secure ticketing configuration
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
/ticketing.ini
|
||||||
|
/tickets_state.json
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# comi-odoo-ticketing
|
||||||
|
|
||||||
|
Script WAPT de surveillance des tickets Odoo Helpdesk Comitari.
|
||||||
|
|
||||||
|
Le projet interroge Odoo via JSON-RPC, compare la date de modification des tickets avec un etat local JSON, puis envoie un message Rocket.Chat via webhook quand des tickets ont change depuis la derniere verification.
|
||||||
|
|
||||||
|
## Contenu du depot
|
||||||
|
|
||||||
|
| Fichier | Role |
|
||||||
|
| --- | --- |
|
||||||
|
| `setup.py` | Script WAPT principal. Contient les fonctions `install()`, `audit()` et `send_to_rocket()`. |
|
||||||
|
| `WAPT/control` | Metadata minimale du paquet WAPT. |
|
||||||
|
| `ticketing.ini.example` | Modele de configuration avec placeholders, copie dans le repertoire prive WAPT sous le nom `ticketing.ini`. |
|
||||||
|
| `tests/test_ticketing.py` | Tests unitaires de la logique de comparaison et de persistance d'etat. |
|
||||||
|
| `.env` | Variables d'environnement locales pour pointer vers l'installation Python/WAPT Windows. |
|
||||||
|
| `TRAVAIL.md` | Journal de travail maintenu par Codex. |
|
||||||
|
|
||||||
|
## Fonctionnement
|
||||||
|
|
||||||
|
1. `install()` copie `ticketing.ini.example` vers `WAPT.private_dir/ticketing.ini` si le fichier cible n'existe pas encore.
|
||||||
|
2. Le fichier copie doit etre renseigne sur la machine cible avec l'URL Odoo, la cle API et le webhook Rocket.Chat.
|
||||||
|
3. `audit()` appelle l'endpoint JSON-RPC Odoo configure dans `ticketing.ini`.
|
||||||
|
4. La methode Odoo appelee est `helpdesk.ticket.search_read`, avec les champs `id` et `write_date`.
|
||||||
|
5. Le script charge l'ancien etat depuis `tickets_state.json`.
|
||||||
|
6. Chaque ticket nouveau ou dont `write_date` a change est ajoute a la liste des tickets mis a jour.
|
||||||
|
7. Un message est envoye dans Rocket.Chat via le webhook configure dans le fichier INI prive.
|
||||||
|
8. Le nouvel etat est sauvegarde dans `WAPT.private_dir/tickets_state.json`.
|
||||||
|
|
||||||
|
## Prerequis
|
||||||
|
|
||||||
|
- Environnement WAPT avec `setuphelpers`.
|
||||||
|
- Python compatible avec l'environnement WAPT cible.
|
||||||
|
- Acces reseau vers Odoo Comitari.
|
||||||
|
- Acces reseau vers Rocket.Chat Comitari.
|
||||||
|
- Dependances Python :
|
||||||
|
- `requests`
|
||||||
|
- modules standard : `json`, `os`, `shutil`, `configparser`
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
La configuration sensible est centralisee dans la copie privee `WAPT.private_dir/ticketing.ini`. Le depot ne versionne que `ticketing.ini.example`, avec des placeholders :
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[rocket]
|
||||||
|
webhook_url = CHANGE_ME_ROCKET_CHAT_WEBHOOK_URL
|
||||||
|
|
||||||
|
[odoo]
|
||||||
|
url = https://CHANGE_ME_ODOO_HOST
|
||||||
|
api_key = CHANGE_ME_ODOO_API_KEY
|
||||||
|
model = helpdesk.ticket
|
||||||
|
|
||||||
|
[http]
|
||||||
|
timeout = 30
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Si une valeur obligatoire est absente ou conserve un placeholder `CHANGE_ME...`, l'audit echoue explicitement avec un message indiquant la cle a renseigner.
|
||||||
|
|
||||||
|
Important : ne pas renseigner de vrais secrets dans `ticketing.ini.example`. Le fichier local `ticketing.ini` est ignore par Git.
|
||||||
|
|
||||||
|
## Execution
|
||||||
|
|
||||||
|
Dans un paquet WAPT, l'execution attendue passe par les hooks WAPT :
|
||||||
|
|
||||||
|
```python
|
||||||
|
install()
|
||||||
|
audit()
|
||||||
|
```
|
||||||
|
|
||||||
|
Hors WAPT, le script ne peut pas etre lance tel quel sans fournir les objets et chemins attendus par `setuphelpers`, notamment `WAPT.private_dir`.
|
||||||
|
|
||||||
|
## Etat local
|
||||||
|
|
||||||
|
`tickets_state.json` est stocke dans `WAPT.private_dir` et contient un dictionnaire JSON au format :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ticket_id": "write_date"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le runtime lit et ecrit ce fichier dans `WAPT.private_dir`. Il n'est pas versionne dans le depot.
|
||||||
|
|
||||||
|
## Protocole Odoo
|
||||||
|
|
||||||
|
Le script utilise l'endpoint configure sous la forme `<odoo.url>/jsonrpc` avec un payload JSON-RPC direct :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "call",
|
||||||
|
"params": {
|
||||||
|
"model": "helpdesk.ticket",
|
||||||
|
"method": "search_read",
|
||||||
|
"args": [[], ["id", "write_date"]]
|
||||||
|
},
|
||||||
|
"id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Il faut donc que l'instance Odoo expose bien un endpoint compatible avec ce format. Si l'instance attend le protocole externe Odoo standard `execute_kw`, il faudra adapter `fetch_odoo_tickets()`.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Les tests unitaires peuvent etre executes hors WAPT :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -m unittest discover -s tests
|
||||||
|
```
|
||||||
|
|
||||||
|
## Points d'attention connus
|
||||||
|
|
||||||
|
- Les secrets doivent etre renseignes uniquement dans la copie privee `WAPT.private_dir/ticketing.ini`.
|
||||||
|
- Les secrets deja presents dans l'historique Git doivent etre regeneres cote Odoo et Rocket.Chat.
|
||||||
|
|
||||||
|
## Prochaines ameliorations recommandees
|
||||||
|
|
||||||
|
1. Confirmer en environnement cible que l'endpoint Odoo accepte bien le payload JSON-RPC direct documente ci-dessus.
|
||||||
|
2. Completer les metadonnees `WAPT/control` selon les conventions internes Comitari.
|
||||||
|
3. Ajouter un test d'integration manuel ou automatise sur une instance Odoo de recette.
|
||||||
+95
@@ -0,0 +1,95 @@
|
|||||||
|
# Journal de travail
|
||||||
|
|
||||||
|
Ce fichier consigne les interventions Codex sur le projet afin de garder une trace exploitable des analyses, decisions et suites a traiter.
|
||||||
|
|
||||||
|
## 2026-06-24
|
||||||
|
|
||||||
|
### Demande
|
||||||
|
|
||||||
|
- Analyser le projet.
|
||||||
|
- Creer un README et le maintenir a jour.
|
||||||
|
- Alimenter un fichier Markdown dedie au travail effectue.
|
||||||
|
|
||||||
|
### Analyse realisee
|
||||||
|
|
||||||
|
- Le depot contient un script WAPT principal `setup.py`.
|
||||||
|
- Le script surveille les tickets Odoo Helpdesk via JSON-RPC.
|
||||||
|
- Il compare les champs `id` et `write_date` avec un etat local `tickets_state.pkl`.
|
||||||
|
- Il notifie Rocket.Chat via un webhook configure dans `rocket.ini`.
|
||||||
|
- Le fichier `tickets_state.pkl` initial a ete inspecte avec `pickletools` sans charger le pickle comme code executable ; il contient actuellement un dictionnaire vide.
|
||||||
|
- `.env` contient des chemins Windows vers l'environnement WAPT.
|
||||||
|
|
||||||
|
### Fichiers ajoutes
|
||||||
|
|
||||||
|
- `README.md` : documentation du projet, fonctionnement, prerequis, configuration, risques et ameliorations recommandees.
|
||||||
|
- `TRAVAIL.md` : journal de travail maintenu par Codex.
|
||||||
|
|
||||||
|
### Points techniques identifies
|
||||||
|
|
||||||
|
- Bug bloquant probable dans `setup.py` : `tickets_updated.auppend(ticket_id)` doit etre corrige en `tickets_updated.append(ticket_id)`.
|
||||||
|
- Secrets presents en clair :
|
||||||
|
- cle API Odoo dans `setup.py` ;
|
||||||
|
- webhook Rocket.Chat dans `rocket.ini`.
|
||||||
|
- `pickle.load()` est sensible si le fichier d'etat peut etre modifie par une source non fiable.
|
||||||
|
- `db` et `xmlrpc.client` semblent inutilises.
|
||||||
|
- Le chemin de `tickets_state.pkl` est relatif au repertoire courant.
|
||||||
|
|
||||||
|
### Suites recommandees
|
||||||
|
|
||||||
|
1. Corriger la faute `auppend`.
|
||||||
|
2. Sortir les secrets du depot.
|
||||||
|
3. Remplacer l'etat pickle par JSON.
|
||||||
|
4. Ajouter un test minimal sur la detection des tickets modifies.
|
||||||
|
5. Verifier la conformite du payload JSON-RPC avec l'API Odoo reellement exposee.
|
||||||
|
|
||||||
|
### Modification configuration secrete
|
||||||
|
|
||||||
|
- Retrait de la cle API Odoo codee en dur dans `setup.py`.
|
||||||
|
- Remplacement du webhook reel par des placeholders.
|
||||||
|
- Centralisation de la configuration sensible dans `ticketing.ini.example`, copiee en prive sous le nom `ticketing.ini` :
|
||||||
|
- `rocket.webhook_url`
|
||||||
|
- `odoo.url`
|
||||||
|
- `odoo.api_key`
|
||||||
|
- `odoo.model`
|
||||||
|
- `install()` copie le fichier INI modele vers `WAPT.private_dir/ticketing.ini` uniquement s'il n'existe pas deja, afin de ne pas ecraser une configuration renseignee sur la machine cible.
|
||||||
|
- `audit()` charge maintenant l'URL Odoo et la cle API depuis le fichier INI prive.
|
||||||
|
- `send_to_rocket()` charge maintenant le webhook depuis le fichier INI prive.
|
||||||
|
- Correction du bug `tickets_updated.auppend(ticket_id)` en `tickets_updated.append(ticket_id)`.
|
||||||
|
- Deplacement du fichier d'etat vers `WAPT.private_dir/tickets_state.pkl` pour eviter de dependre du repertoire courant.
|
||||||
|
- Ajout d'une validation explicite des placeholders `CHANGE_ME...` pour eviter une execution avec une configuration non renseignee.
|
||||||
|
- Ajout de `.gitignore` pour eviter de versionner un `ticketing.ini` rempli localement et un futur fichier d'etat runtime.
|
||||||
|
|
||||||
|
## 2026-07-24
|
||||||
|
|
||||||
|
### Analyse des ameliorations possibles
|
||||||
|
|
||||||
|
- Remplacer `pickle` par JSON pour le fichier d'etat runtime.
|
||||||
|
- Extraire la recuperation Odoo, la comparaison d'etat et l'envoi Rocket.Chat dans des fonctions testables.
|
||||||
|
- Ajouter des timeouts explicites aux appels HTTP Odoo et Rocket.Chat.
|
||||||
|
- Faire echouer l'audit si l'appel Odoo echoue, afin d'eviter d'ecraser l'etat avec une liste vide en cas d'indisponibilite.
|
||||||
|
- Utiliser `response.raise_for_status()` aussi pour l'appel Rocket.Chat.
|
||||||
|
- Ajouter des tests unitaires sur la detection des tickets nouveaux ou modifies.
|
||||||
|
- Retirer du depot le reliquat `tickets_state.pkl`, aujourd'hui remplace par l'etat stocke dans `WAPT.private_dir`.
|
||||||
|
- Ajouter un fichier de controle WAPT si le depot doit etre un paquet complet.
|
||||||
|
- Mettre en place une rotation des secrets deja presents dans l'historique Git.
|
||||||
|
|
||||||
|
### Mise en oeuvre des ameliorations
|
||||||
|
|
||||||
|
- Remplacement de l'etat runtime `tickets_state.pkl` par `tickets_state.json`.
|
||||||
|
- Suppression de l'utilisation de `pickle` dans le code.
|
||||||
|
- Ajout des fonctions testables :
|
||||||
|
- `build_odoo_payload()`
|
||||||
|
- `fetch_odoo_tickets()`
|
||||||
|
- `load_tickets_state()`
|
||||||
|
- `save_tickets_state()`
|
||||||
|
- `build_tickets_state()`
|
||||||
|
- `get_updated_ticket_ids()`
|
||||||
|
- Ajout d'un timeout HTTP configurable via `http.timeout`, avec valeur par defaut a 30 secondes.
|
||||||
|
- Durcissement de l'appel Odoo : les erreurs HTTP, les erreurs JSON-RPC et les reponses sans `result` font echouer l'audit au lieu de sauvegarder un etat vide.
|
||||||
|
- Durcissement de l'appel Rocket.Chat avec `raise_for_status()`.
|
||||||
|
- Ajout de `tests/test_ticketing.py` pour valider la comparaison des tickets et la lecture/ecriture JSON.
|
||||||
|
- Suppression du reliquat `tickets_state.pkl` du repertoire projet.
|
||||||
|
- Mise a jour de `README.md`, `ticketing.ini.example` et `.gitignore`.
|
||||||
|
- Ajout de tests avec mocks `requests` sur `fetch_odoo_tickets()` et `send_to_rocket()`.
|
||||||
|
- Ajout d'un fichier `WAPT/control` minimal.
|
||||||
|
- Documentation du payload JSON-RPC Odoo attendu dans `README.md`.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package : comi-odoo-ticketing
|
||||||
|
version : 0.1.0-2
|
||||||
|
architecture : all
|
||||||
|
section : base
|
||||||
|
priority : optional
|
||||||
|
name : Comitari Odoo ticketing audit
|
||||||
|
categories : Utilities
|
||||||
|
maintainer : Comitari
|
||||||
|
description : Surveille les tickets Odoo Helpdesk et notifie Rocket.Chat via webhook.
|
||||||
|
depends :
|
||||||
|
conflicts :
|
||||||
|
maturity : PROD
|
||||||
|
locale : all
|
||||||
|
target_os : windows
|
||||||
|
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 : 0afd3de2-c939-918b-67b0-d79a8ccba84b
|
||||||
|
valid_from :
|
||||||
|
valid_until :
|
||||||
|
forced_install_on :
|
||||||
|
changelog :
|
||||||
|
min_os_version :
|
||||||
|
max_os_version :
|
||||||
|
icon_sha256sum :
|
||||||
|
signer : pcosson_key
|
||||||
|
signer_fingerprint: a25582410cf03bad179a60c189f459a0b03821c92c0cedf209e82448a66a9b4e
|
||||||
|
signature_date : 2026-07-24T07:19:02.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 : dBwE3rudjuJOvYhduNbzizfvql8Fqo13fs6anaHSoRoBCnRteqzJy4JRmaA6OV+YJOtfC89E7OnNBKAOvO1YErsaanKzFG+wkZFBZEgGkBcJZsb0xec5iLHoslEt6ZJw9ldsSs0dzRH1tzIb7nthMi5AOkPPqICKf5mXkYiOtAcBG9HjFa8YeYtBwyFM5ME2XeKkqpsESh03cOseNLpQ9DB2vMe0qZHd14nPZe5TpOFen+F4xklHSfemiMU8AFPclewTuhkYC8mqps2+WkHmTWl35ilJGg8BC2nLxiydotmOwc+QqpAYw90N6r9lVnPUFa1Bh2QqnmJnk5bfePYKTQ==
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
[rocket]
|
|
||||||
url= https://chat.comitari.fr/hooks/64d4d02760b38508f62a5bcb/ncKSYRiLM9oNXagK5c7G3KWX2qEzET3kbFFXKnNAhtfZQEQ9
|
|
||||||
@@ -1,125 +1,183 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from setuphelpers import *
|
from setuphelpers import *
|
||||||
import xmlrpc.client
|
|
||||||
import pickle
|
|
||||||
import os
|
|
||||||
from configparser import ConfigParser
|
|
||||||
import requests
|
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from configparser import ConfigParser
|
||||||
|
|
||||||
# Configuration
|
import requests
|
||||||
url = 'https://odoo.comitari.fr'
|
|
||||||
db = 'comitari'
|
|
||||||
state_file = 'tickets_state.pkl'
|
|
||||||
api_key = "182534f9e754ce1016e86b86b4b5b47199659a6f"
|
|
||||||
headers = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': f'Bearer {api_key}',
|
|
||||||
}
|
|
||||||
|
|
||||||
# Endpoint URL
|
|
||||||
endpoint = f'{url}/jsonrpc'
|
|
||||||
|
|
||||||
# Payload pour récupérer tous les tickets
|
CONFIG_FILE = "ticketing.ini"
|
||||||
payload = {
|
CONFIG_TEMPLATE_FILE = "ticketing.ini.example"
|
||||||
'jsonrpc': '2.0',
|
STATE_FILE = "tickets_state.json"
|
||||||
'method': 'call',
|
DEFAULT_TIMEOUT = 30
|
||||||
'params': {
|
|
||||||
'model': 'helpdesk.ticket',
|
|
||||||
'method': 'search_read',
|
def get_config_path():
|
||||||
'args': [[], ['id', 'write_date']],
|
return makepath(WAPT.private_dir, CONFIG_FILE)
|
||||||
},
|
|
||||||
'id': 1,
|
|
||||||
}
|
def get_state_path():
|
||||||
|
return makepath(WAPT.private_dir, STATE_FILE)
|
||||||
|
|
||||||
|
|
||||||
def install():
|
def install():
|
||||||
|
config_path = get_config_path()
|
||||||
|
|
||||||
plugin_inifile = makepath(WAPT.private_dir, "rocket.ini")
|
if not isfile(config_path):
|
||||||
|
shutil.copyfile(CONFIG_TEMPLATE_FILE, config_path)
|
||||||
|
|
||||||
if not isfile(plugin_inifile):
|
|
||||||
filecopyto("rocket.ini", WAPT.private_dir)
|
|
||||||
|
|
||||||
def audit():
|
def audit():
|
||||||
|
conf_wapt = load_config()
|
||||||
try:
|
tickets = fetch_odoo_tickets(conf_wapt)
|
||||||
# Récupération de l'état actuel de tous les tickets
|
previous_tickets_state = load_tickets_state(get_state_path())
|
||||||
response = requests.post(endpoint, json=payload, headers=headers)
|
tickets_updated = get_updated_ticket_ids(tickets, previous_tickets_state)
|
||||||
response.raise_for_status() # Vérifie si la requête a réussi
|
|
||||||
response_data = response.json()
|
|
||||||
|
|
||||||
# Vérifie si 'result' est présent dans la réponse
|
|
||||||
if 'result' in response_data:
|
|
||||||
tickets = response_data['result']
|
|
||||||
else:
|
|
||||||
print("Erreur: La clé 'result' n'est pas présente dans la réponse.")
|
|
||||||
print("Réponse complète:", response_data)
|
|
||||||
tickets = []
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
print(f"Erreur lors de la requête: {e}")
|
|
||||||
tickets = []
|
|
||||||
|
|
||||||
# Vérification si un état précédent existe
|
|
||||||
if os.path.exists(state_file):
|
|
||||||
with open(state_file, 'rb') as f:
|
|
||||||
previous_tickets_state = pickle.load(f)
|
|
||||||
else:
|
|
||||||
previous_tickets_state = {}
|
|
||||||
|
|
||||||
|
|
||||||
# Comparaison et affichage des résultats
|
|
||||||
tickets_updated = []
|
|
||||||
for ticket in tickets:
|
|
||||||
ticket_id = ticket['id']
|
|
||||||
ticket_write_date = ticket['write_date']
|
|
||||||
|
|
||||||
if ticket_id in previous_tickets_state:
|
|
||||||
if ticket_write_date != previous_tickets_state[ticket_id]:
|
|
||||||
tickets_updated.auppend(ticket_id)
|
|
||||||
else:
|
|
||||||
tickets_updated.append(ticket_id)
|
|
||||||
|
|
||||||
if tickets_updated:
|
if tickets_updated:
|
||||||
message = f"Les tickets suivants ont été mis à jour depuis la dernière vérification : {tickets_updated}"
|
message = "Les tickets suivants ont ete mis a jour depuis la derniere verification : %s" % tickets_updated
|
||||||
send_to_rocket(message)
|
|
||||||
|
|
||||||
else:
|
else:
|
||||||
message = "Aucun ticket n'a été mis à jour depuis la dernière vérification."
|
message = "Aucun ticket n'a ete mis a jour depuis la derniere verification."
|
||||||
send_to_rocket(message)
|
|
||||||
# Sauvegarde de l'état actuel pour la prochaine exécution
|
send_to_rocket(message, conf_wapt=conf_wapt)
|
||||||
current_tickets_state = {ticket['id']: ticket['write_date'] for ticket in tickets}
|
save_tickets_state(get_state_path(), build_tickets_state(tickets))
|
||||||
with open(state_file, 'wb') as f:
|
|
||||||
pickle.dump(current_tickets_state, f)
|
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def send_to_rocket(message_text, attachments=None):
|
def load_config():
|
||||||
"""
|
config_path = get_config_path()
|
||||||
Envoie un message à Rocket.Chat via un webhook.
|
|
||||||
|
|
||||||
:param message_text: Texte du message à envoyer
|
|
||||||
:param attachments: Liste de pièces jointes (facultatif)
|
|
||||||
"""
|
|
||||||
smtp_inifile = makepath(WAPT.private_dir, "rocket.ini")
|
|
||||||
conf_wapt = ConfigParser()
|
conf_wapt = ConfigParser()
|
||||||
conf_wapt.read(smtp_inifile)
|
conf_wapt.read(config_path)
|
||||||
|
|
||||||
webhook_url = conf_wapt.get("rocket", "url")
|
required_options = [
|
||||||
|
("odoo", "url"),
|
||||||
|
("odoo", "api_key"),
|
||||||
|
("rocket", "webhook_url"),
|
||||||
|
]
|
||||||
|
missing_options = [
|
||||||
|
"%s.%s" % (section, option)
|
||||||
|
for section, option in required_options
|
||||||
|
if not conf_wapt.has_option(section, option)
|
||||||
|
]
|
||||||
|
|
||||||
# Construire le message
|
if missing_options:
|
||||||
|
raise Exception(
|
||||||
|
"Configuration incomplete dans %s : %s"
|
||||||
|
% (config_path, ", ".join(missing_options))
|
||||||
|
)
|
||||||
|
|
||||||
|
placeholder_options = [
|
||||||
|
"%s.%s" % (section, option)
|
||||||
|
for section, option in required_options
|
||||||
|
if "CHANGE_ME" in conf_wapt.get(section, option).strip()
|
||||||
|
]
|
||||||
|
if placeholder_options:
|
||||||
|
raise Exception(
|
||||||
|
"Configuration a renseigner dans %s : %s"
|
||||||
|
% (config_path, ", ".join(placeholder_options))
|
||||||
|
)
|
||||||
|
|
||||||
|
return conf_wapt
|
||||||
|
|
||||||
|
|
||||||
|
def get_http_timeout(conf_wapt):
|
||||||
|
return conf_wapt.getint("http", "timeout", fallback=DEFAULT_TIMEOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def build_odoo_payload(conf_wapt):
|
||||||
|
return {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"method": "call",
|
||||||
|
"params": {
|
||||||
|
"model": conf_wapt.get("odoo", "model", fallback="helpdesk.ticket"),
|
||||||
|
"method": "search_read",
|
||||||
|
"args": [[], ["id", "write_date"]],
|
||||||
|
},
|
||||||
|
"id": 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_odoo_tickets(conf_wapt):
|
||||||
|
odoo_url = conf_wapt.get("odoo", "url").rstrip("/")
|
||||||
|
endpoint = "%s/jsonrpc" % odoo_url
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer %s" % conf_wapt.get("odoo", "api_key"),
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
endpoint,
|
||||||
|
json=build_odoo_payload(conf_wapt),
|
||||||
|
headers=headers,
|
||||||
|
timeout=get_http_timeout(conf_wapt),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
response_data = response.json()
|
||||||
|
|
||||||
|
if "error" in response_data:
|
||||||
|
raise Exception("Erreur Odoo JSON-RPC : %s" % response_data["error"])
|
||||||
|
if "result" not in response_data:
|
||||||
|
raise Exception("Reponse Odoo invalide : cle 'result' absente")
|
||||||
|
|
||||||
|
return response_data["result"]
|
||||||
|
|
||||||
|
|
||||||
|
def load_tickets_state(state_path):
|
||||||
|
if not os.path.exists(state_path):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with open(state_path, "r") as state_file:
|
||||||
|
return json.load(state_file)
|
||||||
|
|
||||||
|
|
||||||
|
def save_tickets_state(state_path, tickets_state):
|
||||||
|
with open(state_path, "w") as state_file:
|
||||||
|
json.dump(tickets_state, state_file, indent=2, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def build_tickets_state(tickets):
|
||||||
|
return {
|
||||||
|
str(ticket["id"]): ticket["write_date"]
|
||||||
|
for ticket in tickets
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_updated_ticket_ids(tickets, previous_tickets_state):
|
||||||
|
tickets_updated = []
|
||||||
|
|
||||||
|
for ticket in tickets:
|
||||||
|
ticket_id = str(ticket["id"])
|
||||||
|
ticket_write_date = ticket["write_date"]
|
||||||
|
|
||||||
|
if previous_tickets_state.get(ticket_id) != ticket_write_date:
|
||||||
|
tickets_updated.append(ticket["id"])
|
||||||
|
|
||||||
|
return tickets_updated
|
||||||
|
|
||||||
|
|
||||||
|
def send_to_rocket(message_text, attachments=None, conf_wapt=None):
|
||||||
|
"""
|
||||||
|
Envoie un message a Rocket.Chat via un webhook.
|
||||||
|
|
||||||
|
:param message_text: Texte du message a envoyer
|
||||||
|
:param attachments: Liste de pieces jointes (facultatif)
|
||||||
|
:param conf_wapt: Configuration deja chargee (facultatif)
|
||||||
|
"""
|
||||||
|
if conf_wapt is None:
|
||||||
|
conf_wapt = load_config()
|
||||||
|
|
||||||
message = {
|
message = {
|
||||||
'text': message_text
|
"text": message_text,
|
||||||
}
|
}
|
||||||
if attachments:
|
if attachments:
|
||||||
message['attachments'] = attachments
|
message["attachments"] = attachments
|
||||||
|
|
||||||
# Envoyer la requête POST
|
|
||||||
response = requests.post(webhook_url, data=json.dumps(message), headers={'Content-Type': 'application/json'})
|
|
||||||
|
|
||||||
# Vérifier la réponse
|
|
||||||
if response.status_code == 200:
|
|
||||||
print('Message envoyé avec succès.')
|
|
||||||
else:
|
|
||||||
print(f'Échec de l\'envoi du message. Statut de la réponse : {response.status_code}')
|
|
||||||
print(f'Erreur : {response.text}')
|
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
conf_wapt.get("rocket", "webhook_url"),
|
||||||
|
json=message,
|
||||||
|
headers={"Content-Type": "application/json"},
|
||||||
|
timeout=get_http_timeout(conf_wapt),
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
print("Message envoye avec succes.")
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import types
|
||||||
|
import unittest
|
||||||
|
from configparser import ConfigParser
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
SETUP_PATH = os.path.join(PROJECT_ROOT, "setup.py")
|
||||||
|
|
||||||
|
|
||||||
|
def load_setup_module():
|
||||||
|
setuphelpers = types.ModuleType("setuphelpers")
|
||||||
|
setuphelpers.makepath = lambda *parts: os.path.join(*parts)
|
||||||
|
setuphelpers.isfile = os.path.isfile
|
||||||
|
setuphelpers.WAPT = types.SimpleNamespace(private_dir=tempfile.gettempdir())
|
||||||
|
sys.modules["setuphelpers"] = setuphelpers
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location("ticketing_setup", SETUP_PATH)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules["ticketing_setup"] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
ticketing = load_setup_module()
|
||||||
|
|
||||||
|
|
||||||
|
def make_config():
|
||||||
|
config = ConfigParser()
|
||||||
|
config.add_section("odoo")
|
||||||
|
config.set("odoo", "url", "https://odoo.example.test")
|
||||||
|
config.set("odoo", "api_key", "test-api-key")
|
||||||
|
config.set("odoo", "model", "helpdesk.ticket")
|
||||||
|
config.add_section("rocket")
|
||||||
|
config.set("rocket", "webhook_url", "https://rocket.example.test/webhook-test")
|
||||||
|
config.add_section("http")
|
||||||
|
config.set("http", "timeout", "5")
|
||||||
|
return 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": 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",
|
||||||
|
})
|
||||||
|
|
||||||
|
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",
|
||||||
|
}
|
||||||
|
tickets = [
|
||||||
|
{"id": 1, "write_date": "2026-07-24 09:00:00"},
|
||||||
|
{"id": 2, "write_date": "2026-07-24 10:00:00"},
|
||||||
|
{"id": 3, "write_date": "2026-07-24 10:30:00"},
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertEqual(ticketing.get_updated_ticket_ids(tickets, previous_state), [2, 3])
|
||||||
|
|
||||||
|
def test_load_tickets_state_returns_empty_dict_when_file_is_missing(self):
|
||||||
|
missing_path = os.path.join(tempfile.gettempdir(), "missing-ticket-state.json")
|
||||||
|
|
||||||
|
self.assertEqual(ticketing.load_tickets_state(missing_path), {})
|
||||||
|
|
||||||
|
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"}
|
||||||
|
|
||||||
|
ticketing.save_tickets_state(state_path, expected_state)
|
||||||
|
|
||||||
|
self.assertEqual(ticketing.load_tickets_state(state_path), expected_state)
|
||||||
|
|
||||||
|
@patch("ticketing_setup.requests.post")
|
||||||
|
def test_fetch_odoo_tickets_uses_timeout_and_returns_result(self, post):
|
||||||
|
response = Mock()
|
||||||
|
response.json.return_value = {
|
||||||
|
"result": [{"id": 42, "write_date": "2026-07-24 12:00:00"}],
|
||||||
|
}
|
||||||
|
post.return_value = response
|
||||||
|
|
||||||
|
tickets = ticketing.fetch_odoo_tickets(make_config())
|
||||||
|
|
||||||
|
self.assertEqual(tickets, [{"id": 42, "write_date": "2026-07-24 12:00:00"}])
|
||||||
|
response.raise_for_status.assert_called_once_with()
|
||||||
|
post.assert_called_once()
|
||||||
|
self.assertEqual(post.call_args.kwargs["timeout"], 5)
|
||||||
|
|
||||||
|
@patch("ticketing_setup.requests.post")
|
||||||
|
def test_fetch_odoo_tickets_rejects_jsonrpc_error(self, post):
|
||||||
|
response = Mock()
|
||||||
|
response.json.return_value = {"error": {"message": "Access denied"}}
|
||||||
|
post.return_value = response
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(Exception, "Erreur Odoo JSON-RPC"):
|
||||||
|
ticketing.fetch_odoo_tickets(make_config())
|
||||||
|
|
||||||
|
@patch("ticketing_setup.requests.post")
|
||||||
|
def test_send_to_rocket_raises_for_http_errors(self, post):
|
||||||
|
response = Mock()
|
||||||
|
post.return_value = response
|
||||||
|
|
||||||
|
ticketing.send_to_rocket("message de test", conf_wapt=make_config())
|
||||||
|
|
||||||
|
response.raise_for_status.assert_called_once_with()
|
||||||
|
self.assertEqual(post.call_args.kwargs["timeout"], 5)
|
||||||
|
self.assertEqual(post.call_args.kwargs["json"], {"text": "message de test"})
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
[rocket]
|
||||||
|
webhook_url = CHANGE_ME_ROCKET_CHAT_WEBHOOK_URL
|
||||||
|
|
||||||
|
[odoo]
|
||||||
|
url = https://CHANGE_ME_ODOO_HOST
|
||||||
|
api_key = CHANGE_ME_ODOO_API_KEY
|
||||||
|
model = helpdesk.ticket
|
||||||
|
|
||||||
|
[http]
|
||||||
|
timeout = 30
|
||||||
@@ -1 +0,0 @@
|
|||||||
�}�.
|
|
||||||
Reference in New Issue
Block a user