* correction de la fonction grist_priority_bals ()

* ajout des colonnes en .env
This commit is contained in:
2026-06-15 19:24:37 +02:00
parent 2c845e2043
commit 4df38342f9
3 changed files with 79 additions and 198 deletions
+13 -4
View File
@@ -12,10 +12,19 @@ GRIST_BASE_URL=https://grist.votre-instance.fr
TABLE_BALS=BALs
TABLE_HISTORIQUE=Historique
# Colonnes Grist
COL_EMAIL=Courriel
COL_SYNC=Synchronisation
COL_NB_SYNCS=Nb_syncs
# Colonnes Grist TABLE_BALS
COL_EMAIL=Courriel #Addresse a synchoniser
COL_SYNC=Synchronisation #Bool si vrai, synchoniser
COL_PRIORITY=priority
COL_NB_SYNCS=Nb_syncs #contient le nombre de synchonisation de la BAL
# Colonnes Grist TABLE_HISTORIQUE
COL_BAL=Bal
COL_STATUS=Status
COL_NB_EMAILS=Nb_emails
COL_DUREE_SEC=Duree_sec
COL_LOG=Log
COL_DATE=Date
# ── Serveurs IMAP ─────────────────────────────────────────────
HOST1=imap.source.fr
+19
View File
@@ -13,3 +13,22 @@ cp imapsync-daemon.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now imapsync-daemon
journalctl -fu imapsync-daemon
# Etapes de migration de BAL
## 1 Création
* creer la bal sur Dimail
* desactiver le smtp
* remplir la fiche
* mettre le secondary pour importation
* rattacher l'identity ProConnect (si BAL non générique)
* récupérer les catégories (si catégories trouvées sur O365)
## 2 Procedure de synchro
Procédure facultative si nouvelle boite
* faire des syncho en boucle (jamais plus d'une toutes les 15 minutes)
## 3 finaliser la migration
* desactiver la bal coté 365 (groupe "migration dinum")
* activer le smtp coté Dimail
* récupérer les catégories (si trouvées sur O365) BIS
* recupérer le calendrier (si trouvé sur O365)
* continuer les synchros pendant 6H, toutes les 5 minutes
+47 -194
View File
@@ -37,6 +37,9 @@ Variables d'environnement requises (fichier .env ou export shell) :
"""
import argparse
import http
import http.client
import json
import logging
import math
import os
@@ -54,7 +57,6 @@ import requests
# ─────────────────────────────────────────────
# Arguments CLI
# ─────────────────────────────────────────────
parser = argparse.ArgumentParser(
description="Daemon imapsync piloté par Grist",
formatter_class=argparse.RawDescriptionHelpFormatter,
@@ -71,11 +73,10 @@ parser.add_argument(
)
ARGS = parser.parse_args()
# ─────────────────────────────────────────────
# Configuration depuis l'environnement
# ─────────────────────────────────────────────
def require_env(name: str) -> str:
val = os.environ.get(name)
if not val:
@@ -104,7 +105,7 @@ load_dotenv()
GRIST_API_KEY = require_env("GRIST_API_KEY")
GRIST_DOC_ID = require_env("GRIST_DOC_ID")
GRIST_BASE_URL = opt_env("GRIST_BASE_URL", "http://localhost:8484")
GRIST_BASE_URL = require_env("GRIST_BASE_URL")
HOST1 = require_env("HOST1")
HOST2 = require_env("HOST2")
USER1_PREFIX = require_env("USER1_PREFIX")
@@ -120,18 +121,23 @@ N_WORKERS = int(opt_env("N_WORKERS", "4"))
POLL_INTERVAL = int(opt_env("POLL_INTERVAL", "30"))
NEW_RATIO = float(opt_env("NEW_RATIO", "0.20"))
# Interval minimum entre 2 vérification d'une meme BAL
# Interval en minute minimum entre 2 vérification d'une meme BAL
MIN_INTERVAL_MINUTES = 15
# Noms des tables Grist (casse exacte telle qu'affichée dans Grist)
TABLE_BALS = "BALs"
TABLE_HISTORIQUE = "Historique"
TABLE_BALS = require_env("TABLE_BALS")
# Colonnes Grist
COL_EMAIL = "Courriel"
COL_SYNC = "Synchronisation" # booléen déclencheur
COL_NB_SYNCS = "Nb_syncs" # compteur calculé par formule Grist
COL_EMAIL = require_env("COL_EMAIL")
COL_SYNC = require_env("COL_SYNC")
COL_NB_SYNCS = require_env("COL_NB_SYNCS")
COL_PRIORITY = require_env("COL_PRIORITY")
TABLE_HISTORIQUE = require_env("TABLE_HISTORIQUE")
COL_BAL = require_env("COL_BAL")
COL_STATUS = require_env("COL_STATUS")
COL_NB_EMAILS = require_env("COL_NB_EMAILS")
COL_DUREE_SEC = require_env("COL_DUREE_SEC")
COL_LOG = require_env("COL_LOG")
COL_DATE = require_env("COL_DATE")
TOKEN_VALIDITY = 30 # Minutes
@@ -140,7 +146,6 @@ _LAST_RENEWAL_TIME = 0.0
# ─────────────────────────────────────────────
# Logging
# ─────────────────────────────────────────────
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
@@ -152,11 +157,10 @@ logging.basicConfig(
)
log = logging.getLogger(__name__)
# ─────────────────────────────────────────────
# Renouvellement du token OAuth2
# ─────────────────────────────────────────────
def renew_oauth2_token() -> bool:
"""
Exécute : OAUTH2IMAP --token_file OAUTH2_TOKEN_FILE USER1_PREFIX
@@ -201,181 +205,22 @@ def renew_oauth2_token() -> bool:
# ─────────────────────────────────────────────
# Mode --test
# ─────────────────────────────────────────────
def run_tests() -> bool:
"""
Vérifie tous les prérequis de la configuration.
Retourne True si tout est OK, False sinon.
"""
ok = True
sep = "" * 56
def check(label: str, passed: bool, detail: str = ""):
nonlocal ok
icon = "" if passed else ""
msg = f" {icon} {label}"
if detail:
msg += f"{detail}"
print(msg)
if not passed:
ok = False
print()
print("" * 56)
print(" imapsync_daemon — test de configuration")
print("" * 56)
# ── Variables d'environnement ────────────────
print(f"\n{sep}")
print(" Variables d'environnement")
print(sep)
env_checks = [
("GRIST_API_KEY", GRIST_API_KEY, None),
("GRIST_DOC_ID", GRIST_DOC_ID, None),
("GRIST_BASE_URL", GRIST_BASE_URL, GRIST_BASE_URL),
("HOST1", HOST1, HOST1),
("HOST2", HOST2, HOST2),
("USER1_PREFIX", USER1_PREFIX, USER1_PREFIX),
("USER2_PREFIX", USER2_PREFIX, USER2_PREFIX),
("OAUTH2_TOKEN_FILE", OAUTH2_TOKEN_FILE, OAUTH2_TOKEN_FILE),
("PASS2", PASS2, "défini"),
("IMAPSYNC", IMAPSYNC, IMAPSYNC),
("OAUTH2IMAP", OAUTH2IMAP, OAUTH2IMAP),
("TMPDIR_CACHE", TMPDIR_CACHE, TMPDIR_CACHE),
("N_WORKERS", str(N_WORKERS), str(N_WORKERS)),
("POLL_INTERVAL", str(POLL_INTERVAL), f"{POLL_INTERVAL}s"),
("NEW_RATIO", str(NEW_RATIO), f"{NEW_RATIO:.0%}"),
]
for name, val, shown in env_checks:
set_ok = bool(val)
check(name, set_ok, shown if set_ok else "MANQUANTE")
# ── Fichiers et répertoires ──────────────────
print(f"\n{sep}")
print(" Fichiers et répertoires")
print(sep)
for exe_name, exe_path in [("imapsync", IMAPSYNC), ("oauth2imap", OAUTH2IMAP)]:
p = Path(exe_path)
found = p.exists() and os.access(p, os.X_OK)
check(
f"{exe_name} exécutable",
found,
str(p) + (" (trouvé)" if found else " (introuvable)"),
)
token_p = Path(OAUTH2_TOKEN_FILE)
check(
"OAUTH2_TOKEN_FILE",
token_p.exists() and token_p.is_file(),
str(token_p) + (" (trouvé)" if token_p.exists() else " (introuvable)"),
)
tmpdir_p = Path(TMPDIR_CACHE)
if tmpdir_p.exists():
check("TMPDIR_CACHE", True, f"{tmpdir_p} (existe)")
else:
try:
tmpdir_p.mkdir(parents=True, exist_ok=True)
check("TMPDIR_CACHE", True, f"{tmpdir_p} (créé)")
except OSError as e:
check("TMPDIR_CACHE", False, str(e))
# ── Renouvellement token ─────────────────────
print(f"\n{sep}")
print(" Renouvellement token OAuth2")
print(sep)
token_ok = renew_oauth2_token()
check(
"oauth2imap --token_file … renew",
token_ok,
"succès" if token_ok else "échec — vérifier oauth2imap et le token",
)
# ── Connectivité Grist ───────────────────────
print(f"\n{sep}")
print(" Connectivité Grist")
print(sep)
grist_headers = {
"Authorization": f"Bearer {GRIST_API_KEY}",
"Content-Type": "application/json",
}
try:
resp = requests.get(
f"{GRIST_BASE_URL}/api/docs/{GRIST_DOC_ID}",
headers=grist_headers,
timeout=10,
)
resp.raise_for_status()
doc_name = resp.json().get("name", "?")
check("Accès document Grist", True, f'"{doc_name}"')
except requests.RequestException as e:
check("Accès document Grist", False, str(e))
for table in (TABLE_BALS, TABLE_HISTORIQUE):
try:
resp = requests.get(
f"{GRIST_BASE_URL}/api/docs/{GRIST_DOC_ID}/tables/{table}/records",
headers=grist_headers,
timeout=10,
)
resp.raise_for_status()
nb = len(resp.json().get("records", []))
if table == TABLE_BALS and nb > 0:
sample = resp.json()["records"][0]["fields"]
has_nb_syncs = COL_NB_SYNCS in sample
detail = (
f"{nb} enregistrements"
if has_nb_syncs
else f"{nb} enregistrements ⚠ colonne '{COL_NB_SYNCS}' absente — à créer dans Grist"
)
check(f"Lecture table {table}", True, detail)
else:
check(f"Lecture table {table}", True, f"{nb} enregistrements")
except requests.RequestException as e:
check(f"Lecture table {table}", False, str(e))
grist_add_historique("mathieu.maura@sdis66.fr", "Test", 0, 10, "Test")
# ── Paramètres calculés ──────────────────────
print(f"\n{sep}")
print(" Paramètres calculés")
print(sep)
slots_new = math.ceil(N_WORKERS * NEW_RATIO)
slots_rep = N_WORKERS - slots_new
print(f" • N_WORKERS={N_WORKERS} NEW_RATIO={NEW_RATIO:.0%}")
print(f"{slots_new} slot(s) nouvelles BAL")
print(f"{slots_rep} slot(s) re-passes")
if ARGS.dry:
print(" • Mode --dry actif : imapsync sera lancé avec --dry")
# ── Résumé ───────────────────────────────────
print()
print("" * 56)
if ok:
print(" Résultat : OK — configuration valide")
else:
print(" Résultat : ERREUR — corriger les points marqués ✗")
print("" * 56)
print()
return ok
http.client.HTTPConnection.debuglevel = 1
print(f" → payload: {grist_priority_bals()}")
return True
# ─────────────────────────────────────────────
# Grist API
# ─────────────────────────────────────────────
GRIST_HEADERS = {
"Authorization": f"Bearer {GRIST_API_KEY}",
"Content-Type": "application/json",
}
def grist_url(table: str) -> str:
def grist_records_url(table: str) -> str:
return f"{GRIST_BASE_URL}/api/docs/{GRIST_DOC_ID}/tables/{table}/records"
@@ -389,7 +234,10 @@ def grist_fetch_bals() -> list[dict]:
# 1. Récupération triée via l'API Grist
params = {"sort": "Date_derniere_passe"}
resp = requests.get(
grist_url(TABLE_BALS), headers=GRIST_HEADERS, params=params, timeout=15
grist_records_url(TABLE_BALS),
headers=GRIST_HEADERS,
params=params,
timeout=15,
)
resp.raise_for_status()
records = resp.json().get("records", [])
@@ -445,25 +293,29 @@ def grist_priority_bals() -> dict:
log.info("grist_priority_bals")
try:
# 1. Récupération triée via l'API Grist
params = {"limit": 1, "filter": {"priority": ["true"], COL_SYNC: ["true"]}}
params = {
"limit": 1,
"filter": json.dumps({COL_PRIORITY: [True], COL_SYNC: [True]}),
}
resp = requests.get(
grist_url(TABLE_BALS), headers=GRIST_HEADERS, json=params, timeout=15
grist_records_url(TABLE_BALS),
headers=GRIST_HEADERS,
params=params,
timeout=15,
)
resp.raise_for_status()
records = resp.json().get("records", [])
# for r in records:
r = records[0]
log.info(f"grist_priority_bals: {r.id}")
fields = r["fields"]
# Vérification de la condition de synchronisation globale
if fields.get(COL_SYNC) is not True:
return {}
# Si le timestamp existe, on valide l'écart de temps
if fields.get("Date_derniere_passe") is not None:
# Au moins une passe déjà faite
if fields.get("Date_derniere_passe") is None:
return {}
return r
@@ -487,12 +339,12 @@ def grist_add_historique(
"records": [
{
"fields": {
"Bal": email,
"Status": final_status,
"Nb_emails": nb_emails,
"Duree_sec": duree_sec,
"Log": log_text[:4000],
"Date": datetime.now(timezone.utc).isoformat(),
COL_BAL: email,
COL_STATUS: final_status,
COL_NB_EMAILS: nb_emails,
COL_DUREE_SEC: duree_sec,
COL_LOG: log_text[:4000],
COL_DATE: datetime.now(timezone.utc).isoformat(),
}
}
]
@@ -500,7 +352,10 @@ def grist_add_historique(
# print(f" → payload: {payload}")
try:
resp = requests.post(
grist_url(TABLE_HISTORIQUE), headers=GRIST_HEADERS, json=payload, timeout=15
grist_records_url(TABLE_HISTORIQUE),
headers=GRIST_HEADERS,
json=payload,
timeout=15,
)
resp.raise_for_status()
log.info(f"[{email}] Résultat écrit dans HISTORIQUE ({final_status})")
@@ -511,8 +366,6 @@ def grist_add_historique(
# ─────────────────────────────────────────────
# Partition nouvelles BAL / re-passes
# ─────────────────────────────────────────────
def partition_bals(rows: list[dict], available: int) -> list[dict]:
"""
Répartit les slots disponibles (`available`) entre nouvelles BAL et re-passes.