* correction de la fonction grist_priority_bals ()
* ajout des colonnes en .env
This commit is contained in:
+13
-4
@@ -12,10 +12,19 @@ GRIST_BASE_URL=https://grist.votre-instance.fr
|
|||||||
TABLE_BALS=BALs
|
TABLE_BALS=BALs
|
||||||
TABLE_HISTORIQUE=Historique
|
TABLE_HISTORIQUE=Historique
|
||||||
|
|
||||||
# Colonnes Grist
|
# Colonnes Grist TABLE_BALS
|
||||||
COL_EMAIL=Courriel
|
COL_EMAIL=Courriel #Addresse a synchoniser
|
||||||
COL_SYNC=Synchronisation
|
COL_SYNC=Synchronisation #Bool si vrai, synchoniser
|
||||||
COL_NB_SYNCS=Nb_syncs
|
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 ─────────────────────────────────────────────
|
# ── Serveurs IMAP ─────────────────────────────────────────────
|
||||||
HOST1=imap.source.fr
|
HOST1=imap.source.fr
|
||||||
|
|||||||
@@ -13,3 +13,22 @@ cp imapsync-daemon.service /etc/systemd/system/
|
|||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable --now imapsync-daemon
|
systemctl enable --now imapsync-daemon
|
||||||
journalctl -fu 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
@@ -37,6 +37,9 @@ Variables d'environnement requises (fichier .env ou export shell) :
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import http
|
||||||
|
import http.client
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
@@ -54,7 +57,6 @@ import requests
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Arguments CLI
|
# Arguments CLI
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Daemon imapsync piloté par Grist",
|
description="Daemon imapsync piloté par Grist",
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
@@ -71,11 +73,10 @@ parser.add_argument(
|
|||||||
)
|
)
|
||||||
ARGS = parser.parse_args()
|
ARGS = parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Configuration depuis l'environnement
|
# Configuration depuis l'environnement
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def require_env(name: str) -> str:
|
def require_env(name: str) -> str:
|
||||||
val = os.environ.get(name)
|
val = os.environ.get(name)
|
||||||
if not val:
|
if not val:
|
||||||
@@ -104,7 +105,7 @@ load_dotenv()
|
|||||||
|
|
||||||
GRIST_API_KEY = require_env("GRIST_API_KEY")
|
GRIST_API_KEY = require_env("GRIST_API_KEY")
|
||||||
GRIST_DOC_ID = require_env("GRIST_DOC_ID")
|
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")
|
HOST1 = require_env("HOST1")
|
||||||
HOST2 = require_env("HOST2")
|
HOST2 = require_env("HOST2")
|
||||||
USER1_PREFIX = require_env("USER1_PREFIX")
|
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"))
|
POLL_INTERVAL = int(opt_env("POLL_INTERVAL", "30"))
|
||||||
NEW_RATIO = float(opt_env("NEW_RATIO", "0.20"))
|
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
|
MIN_INTERVAL_MINUTES = 15
|
||||||
|
|
||||||
|
TABLE_BALS = require_env("TABLE_BALS")
|
||||||
# Noms des tables Grist (casse exacte telle qu'affichée dans Grist)
|
|
||||||
TABLE_BALS = "BALs"
|
|
||||||
TABLE_HISTORIQUE = "Historique"
|
|
||||||
|
|
||||||
# Colonnes Grist
|
# Colonnes Grist
|
||||||
COL_EMAIL = "Courriel"
|
COL_EMAIL = require_env("COL_EMAIL")
|
||||||
COL_SYNC = "Synchronisation" # booléen déclencheur
|
COL_SYNC = require_env("COL_SYNC")
|
||||||
COL_NB_SYNCS = "Nb_syncs" # compteur calculé par formule Grist
|
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
|
TOKEN_VALIDITY = 30 # Minutes
|
||||||
|
|
||||||
@@ -140,7 +146,6 @@ _LAST_RENEWAL_TIME = 0.0
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Logging
|
# Logging
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||||
@@ -152,11 +157,10 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Renouvellement du token OAuth2
|
# Renouvellement du token OAuth2
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def renew_oauth2_token() -> bool:
|
def renew_oauth2_token() -> bool:
|
||||||
"""
|
"""
|
||||||
Exécute : OAUTH2IMAP --token_file OAUTH2_TOKEN_FILE USER1_PREFIX
|
Exécute : OAUTH2IMAP --token_file OAUTH2_TOKEN_FILE USER1_PREFIX
|
||||||
@@ -201,181 +205,22 @@ def renew_oauth2_token() -> bool:
|
|||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Mode --test
|
# Mode --test
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def run_tests() -> bool:
|
def run_tests() -> bool:
|
||||||
"""
|
http.client.HTTPConnection.debuglevel = 1
|
||||||
Vérifie tous les prérequis de la configuration.
|
print(f" → payload: {grist_priority_bals()}")
|
||||||
Retourne True si tout est OK, False sinon.
|
return True
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
# Grist API
|
# Grist API
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
GRIST_HEADERS = {
|
GRIST_HEADERS = {
|
||||||
"Authorization": f"Bearer {GRIST_API_KEY}",
|
"Authorization": f"Bearer {GRIST_API_KEY}",
|
||||||
"Content-Type": "application/json",
|
"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"
|
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
|
# 1. Récupération triée via l'API Grist
|
||||||
params = {"sort": "Date_derniere_passe"}
|
params = {"sort": "Date_derniere_passe"}
|
||||||
resp = requests.get(
|
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()
|
resp.raise_for_status()
|
||||||
records = resp.json().get("records", [])
|
records = resp.json().get("records", [])
|
||||||
@@ -445,25 +293,29 @@ def grist_priority_bals() -> dict:
|
|||||||
log.info("grist_priority_bals")
|
log.info("grist_priority_bals")
|
||||||
try:
|
try:
|
||||||
# 1. Récupération triée via l'API Grist
|
# 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(
|
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()
|
resp.raise_for_status()
|
||||||
records = resp.json().get("records", [])
|
records = resp.json().get("records", [])
|
||||||
|
|
||||||
# for r in records:
|
|
||||||
r = records[0]
|
r = records[0]
|
||||||
log.info(f"grist_priority_bals: {r.id}")
|
|
||||||
fields = r["fields"]
|
fields = r["fields"]
|
||||||
|
|
||||||
# Vérification de la condition de synchronisation globale
|
# Vérification de la condition de synchronisation globale
|
||||||
if fields.get(COL_SYNC) is not True:
|
if fields.get(COL_SYNC) is not True:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# Si le timestamp existe, on valide l'écart de temps
|
# Au moins une passe déjà faite
|
||||||
if fields.get("Date_derniere_passe") is not None:
|
if fields.get("Date_derniere_passe") is None:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
return r
|
return r
|
||||||
@@ -487,12 +339,12 @@ def grist_add_historique(
|
|||||||
"records": [
|
"records": [
|
||||||
{
|
{
|
||||||
"fields": {
|
"fields": {
|
||||||
"Bal": email,
|
COL_BAL: email,
|
||||||
"Status": final_status,
|
COL_STATUS: final_status,
|
||||||
"Nb_emails": nb_emails,
|
COL_NB_EMAILS: nb_emails,
|
||||||
"Duree_sec": duree_sec,
|
COL_DUREE_SEC: duree_sec,
|
||||||
"Log": log_text[:4000],
|
COL_LOG: log_text[:4000],
|
||||||
"Date": datetime.now(timezone.utc).isoformat(),
|
COL_DATE: datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -500,7 +352,10 @@ def grist_add_historique(
|
|||||||
# print(f" → payload: {payload}")
|
# print(f" → payload: {payload}")
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
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()
|
resp.raise_for_status()
|
||||||
log.info(f"[{email}] Résultat écrit dans HISTORIQUE ({final_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
|
# Partition nouvelles BAL / re-passes
|
||||||
# ─────────────────────────────────────────────
|
# ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def partition_bals(rows: list[dict], available: int) -> list[dict]:
|
def partition_bals(rows: list[dict], available: int) -> list[dict]:
|
||||||
"""
|
"""
|
||||||
Répartit les slots disponibles (`available`) entre nouvelles BAL et re-passes.
|
Répartit les slots disponibles (`available`) entre nouvelles BAL et re-passes.
|
||||||
|
|||||||
Reference in New Issue
Block a user