Le problÚme résolu
Vous avez besoin de savoir qui a modifié quoi et quand sur vos fiches Grist ? Cette solution crée un journal de bord automatique qui enregistre chaque modification avec :
La date et lâheure
Lâutilisateur qui a fait le changement
Lâancienne et la nouvelle valeur
Exemple de rendu :
đ Modification du 23 Nov 2025 14:30 (Marie DUPONT)
- Statut : En attente â ValidĂ©
- PrioritĂ© : Normale â Haute
đ
Créé le 20 Nov 2025 09:15 (par Jean MARTIN)
- Nom Du Projet : Mon super projet
- Direction : DSI
- Statut : En attente
Architecture de la solution
La solution repose sur 2 champs qui travaillent ensemble :
| Champ | Type | RĂŽle |
|---|---|---|
HISTORIQUE_BRUT |
Texte (formule dâinitialisation) | Stocke lâhistorique + un snapshot encodĂ© en base64 |
HISTORIQUE |
Texte (formule) | Affiche lâhistorique nettoyĂ© (sans le snapshot technique) |
Pourquoi 2 champs et pas 1 seul ?
Pour dĂ©tecter les modifications, on doit comparer lâĂ©tat actuel avec lâĂ©tat prĂ©cĂ©dent. Mais Grist ne garde pas lâhistorique des valeurs !
Notre solution : à chaque sauvegarde, on stocke une « photo » (snapshot) de toutes les valeurs, encodée en base64. Ce snapshot ressemble à ça :
[SNAP:eyJOT01fRFVfUFJPSkVUIjoiTW9uIHByb2pldCIsIlNUQVRVVCI6IkVuIGNvdXJzIn0=]
Câest illisible pour un humain, mais indispensable pour la comparaison !
Le problĂšme : si on nâavait quâun seul champ, lâutilisateur verrait ce charabia base64 dans lâhistorique.
La solution :
HISTORIQUE_BRUTstocke tout (historique lisible + snapshot technique)HISTORIQUEfiltre lâaffichage pour masquer le snapshot et ne montrer que la partie lisible
â Lâutilisateur ne voit que HISTORIQUE, propre et lisible ! ![]()
Le principe technique
- à chaque sauvegarde, on crée un snapshot (photo) de toutes les valeurs du record
- On encode ce snapshot en base64 et on le stocke dans le champ
- Ă la prochaine modification, on compare le nouveau snapshot avec lâancien
- On génÚre les lignes de différences détectées
- Lâhistorique sâempile : le plus rĂ©cent en haut
Mise en place étape par étape
Ătape 1 : CrĂ©er le champ HISTORIQUE_BRUT
- Ajoutez une nouvelle colonne nommée
HISTORIQUE_BRUT - Type : Texte
Important : Utilisez une formule dâinitialisation (pas une formule normale)
- Cliquez sur « DĂ©finir la formule dâinitialisation » dans les options de colonne
- Cochez « Appliquer sur les nouvelles lignes »
- Cochez « RĂ©appliquer en cas de modification » â « Nâimporte quel champ (except formulas) »
- Collez la formule complĂšte ci-dessous
Ătape 2 : CrĂ©er le champ HISTORIQUE
- Ajoutez une nouvelle colonne nommée
HISTORIQUE - Type : Texte avec formule (formule normale cette fois)
- Collez la formule de filtrage ci-dessous
Ătape 3 : Configurer lâaffichage
- Affichez le champ
HISTORIQUEdans vos vues (pas HISTORIQUE_BRUT) - Masquez
HISTORIQUE_BRUTsi vous le souhaitez (il doit exister mais peut ĂȘtre cachĂ©)
Formule HISTORIQUE_BRUT (formule dâinitialisation)
# =============================================================================
# đ SYSTĂME DE SUIVI D'HISTORIQUE DES MODIFICATIONS
# =============================================================================
# Cette formule crée un journal automatique de toutes les modifications
# Elle utilise un systÚme de "snapshots" encodés en base64 pour comparer
# les valeurs entre chaque sauvegarde
#
# đĄ Fonctionne comme une "formule d'initialisation" (DĂ©finir la formule d'initialisation)
# avec "Appliquer sur les nouvelles lignes" ET "Réappliquer en cas de modification" cochés
# =============================================================================
# =============================================================================
# âïž CONFIGURATION - ZONE MODIFIABLE
# =============================================================================
# Personnalisez ici les colonnes Ă exclure du suivi
# Liste des colonnes Ă ignorer (noms exacts)
COLONNES_EXCLUES = {
'id', # Identifiant technique Grist
'manualSort', # Ordre de tri manuel
'HISTORIQUE_BRUT', # Ce champ lui-mĂȘme (Ă©vite la boucle infinie)
'HISTORIQUE', # Le champ d'affichage filtré
'Formula' # Colonnes de type formule
}
# Patterns à exclure (tout champ contenant ces textes sera ignoré)
PATTERNS_EXCLUS = [
'gristHelper_', # Colonnes techniques Grist
'gristhelper_', # Variante minuscule
'#Summary#', # Colonnes de résumé
'#Lookup#', # Colonnes de lookup
'_summary_', # Autre pattern de résumé
'#lookup#' # Autre pattern de lookup
]
# Configuration des colonnes d'affichage pour les références
# Format: 'NomTable': 'NomColonneAAfficher'
# Exemple: 'Clients': 'NOM_CLIENT' affichera le nom au lieu de l'ID
SHOW_COLUMNS_PAR_TABLE = {
# Décommentez et adaptez selon vos tables :
# 'MaTable': 'MA_COLONNE_DISPLAY',
}
# =============================================================================
# đ§ FONCTIONS UTILITAIRES - NE PAS MODIFIER
# =============================================================================
def exclure(nom_col):
"""
VĂ©rifie si une colonne doit ĂȘtre exclue du suivi
Retourne True si la colonne doit ĂȘtre ignorĂ©e
"""
# Exclure si dans la liste noire ou commence par #
if nom_col in COLONNES_EXCLUES or nom_col.startswith('#'):
return True
# Exclure si contient un des patterns exclus
return any(p.lower() in nom_col.lower() for p in PATTERNS_EXCLUS)
def formater_date(date_obj):
"""
Formate une date en français lisible
Exemple: 23 Nov 2024
"""
if not date_obj:
return ''
try:
# Dictionnaire des mois en français abrégé
mois = {
1:'Jan', 2:'Fev', 3:'Mar', 4:'Avr', 5:'Mai', 6:'Juin',
7:'Juil', 8:'Aout', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'
}
jour = str(date_obj.day).zfill(2)
mois_str = mois.get(date_obj.month, str(date_obj.month))
annee = str(date_obj.year)
return f"{jour} {mois_str} {annee}"
except:
return str(date_obj)
def extraire_display_reference(ref_obj):
"""
Extrait la valeur d'affichage d'un objet Reference
GÚre les colonnes de référence vers d'autres tables
"""
if not ref_obj or not hasattr(ref_obj, '_table'):
return None
try:
# Récupérer le nom de la table référencée
table_name = ref_obj._table.table_id if hasattr(ref_obj._table, 'table_id') else None
# Si configuration personnalisée existe pour cette table
if table_name and table_name in SHOW_COLUMNS_PAR_TABLE:
col_name = SHOW_COLUMNS_PAR_TABLE[table_name]
if hasattr(ref_obj, col_name):
val = getattr(ref_obj, col_name, None)
if val is not None and not hasattr(val, '_table'):
s = str(val).strip()
if s and s != 'None':
return s
# Sinon utiliser gristHelper_Display (colonne d'affichage par défaut)
if hasattr(ref_obj, 'gristHelper_Display'):
display = getattr(ref_obj, 'gristHelper_Display', None)
if display is not None and not hasattr(display, '_table'):
s = str(display).strip()
if s and s != 'None':
return s
# Fallback : chercher la premiĂšre colonne texte non vide
attrs = sorted([a for a in dir(ref_obj) if not a.startswith('_') and not a.startswith('#')])
colonnes_metier = [a for a in attrs if a not in ['id', 'manualSort', 'isCompleted', 'isDuplicated'] and not a.startswith('gristHelper')]
for attr in colonnes_metier:
try:
val = getattr(ref_obj, attr, None)
if val is None or hasattr(val, '_table') or isinstance(val, (list, tuple)):
continue
if isinstance(val, str):
s = val.strip()
if s and s != 'None':
return s
except:
continue
return str(ref_obj).strip()
except:
try:
return str(ref_obj).strip()
except:
return ''
def formater_valeur(nom_col, val):
"""
Formate une valeur selon son type pour un affichage lisible
GÚre : dates, booléens, listes, références, texte simple
"""
if val is None:
return ''
# Traitement des dates
try:
if hasattr(val, 'day') and hasattr(val, 'month'):
return formater_date(val)
except:
pass
# Traitement des booléens
if isinstance(val, bool):
return 'Oui' if val else 'Non'
# Traitement des listes (Reference Lists)
if isinstance(val, (list, tuple)):
if not val:
return ''
elements = []
for item in val:
display = extraire_display_reference(item)
if display:
elements.append(display)
else:
s = str(item).strip()
if s and s != 'None' and not s.startswith('Record('):
elements.append(s)
return ', '.join(elements) if elements else ''
# Traitement des références simples
if hasattr(val, '_table'):
display = extraire_display_reference(val)
if display:
return display
return ''
# Traitement des valeurs simples (texte, nombres)
try:
s = str(val).strip()
if not s or s == 'None':
return ''
if s.startswith('Record(') or s.startswith('[Record('):
return ''
if s == '[]':
return ''
return s
except:
return ''
def obtenir_colonnes():
"""
RécupÚre la liste de toutes les colonnes de la table actuelle
Exclut automatiquement les colonnes techniques
"""
cols = set()
try:
for attr in dir(type(rec)):
if not attr.startswith('_') and not exclure(attr):
attr_obj = getattr(type(rec), attr, None)
if isinstance(attr_obj, property) or hasattr(attr_obj, '__get__'):
cols.add(attr)
except:
pass
return sorted(list(cols))
def libelle(nom_col):
"""
Convertit un nom de colonne technique en libellé lisible
Exemple: 'NOM_DU_PROJET' â 'Nom Du Projet'
"""
mots = nom_col.replace('_', ' ').split()
return ' '.join(m if (m.isupper() and len(m) <= 5) else m.capitalize() for m in mots)
def timestamp():
"""
GénÚre un timestamp formaté avec date et heure
Exemple: 23 Nov 2024 14:30
"""
t = NOW()
mois = {
1:'Jan', 2:'Fev', 3:'Mar', 4:'Avr', 5:'Mai', 6:'Juin',
7:'Juil', 8:'Aout', 9:'Sep', 10:'Oct', 11:'Nov', 12:'Dec'
}
jour = str(t.day).zfill(2)
mois_str = mois[t.month]
annee = str(t.year)
heure = str(t.hour).zfill(2)
minute = str(t.minute).zfill(2)
return f"{jour} {mois_str} {annee} {heure}:{minute}"
def nom_user():
"""
RécupÚre le nom de l'utilisateur connecté
"""
try:
return user.Name
except:
return "Inconnu"
# =============================================================================
# đž GESTION DES SNAPSHOTS - NE PAS MODIFIER
# =============================================================================
def creer_snap():
"""
Crée un snapshot (photo) de toutes les valeurs actuelles du record
Retourne un dictionnaire {colonne: valeur_formatée}
"""
snap = {}
for col in obtenir_colonnes():
try:
val = getattr(rec, col, None)
snap[col] = formater_valeur(col, val)
except:
snap[col] = ''
return snap
def encoder_snap(snap):
"""
Encode un snapshot en base64 pour stockage
Format: [SNAP:données_encodées]
"""
import base64, json
data = json.dumps(snap, ensure_ascii=False)
encoded = base64.b64encode(data.encode('utf-8')).decode('ascii')
return f"[SNAP:{encoded}]"
def decoder_snap(texte):
"""
Décode le premier snapshot trouvé dans le texte
Retourne le dictionnaire des valeurs précédentes
"""
if not texte or '[SNAP:' not in texte:
return {}
try:
import base64, json
debut = texte.find('[SNAP:')
if debut == -1:
return {}
fin = texte.find(']', debut)
if fin == -1:
return {}
encoded = texte[debut+6:fin]
data = base64.b64decode(encoded.encode('ascii')).decode('utf-8')
return json.loads(data)
except:
return {}
# =============================================================================
# đŻ LOGIQUE PRINCIPALE - NE PAS MODIFIER
# =============================================================================
if not value:
# =========================================================================
# CAS 1 : CRĂATION D'UN NOUVEAU RECORD
# =========================================================================
# Le champ est vide â c'est une nouvelle fiche
# On enregistre l'état initial avec toutes les valeurs renseignées
snap = creer_snap()
ts = timestamp()
usr = nom_user()
# Construire le message de création
lignes = [f"đ
Créé le {ts} (par {usr})"]
# Lister toutes les valeurs non vides
for col in sorted(snap.keys()):
val = snap[col]
if val:
lignes.append(f"- {libelle(col)} : {val}")
# Ajouter le snapshot encodé (sera filtré à l'affichage)
lignes.append("")
lignes.append(encoder_snap(snap))
return '\n'.join(lignes)
else:
# =========================================================================
# CAS 2 : MODIFICATION D'UN RECORD EXISTANT
# =========================================================================
# Le champ contient dĂ©jĂ de l'historique â on compare avec le snapshot prĂ©cĂ©dent
snap_actuel = creer_snap()
snap_prec = decoder_snap(value)
# Détecter les colonnes ajoutées ou supprimées (évolution du schéma)
colonnes_actuelles = set(snap_actuel.keys())
colonnes_precedentes = set(snap_prec.keys())
colonnes_ajoutees = colonnes_actuelles - colonnes_precedentes
colonnes_supprimees = colonnes_precedentes - colonnes_actuelles
# Liste pour stocker tous les changements détectés
changements = []
# Traiter les colonnes supprimées
for col in sorted(colonnes_supprimees):
lib = libelle(col)
val_old = snap_prec.get(col, '')
if val_old:
changements.append(f" - đïž {lib} : colonne supprimĂ©e (ancienne valeur : {val_old})")
else:
changements.append(f" - đïž {lib} : colonne supprimĂ©e")
# Traiter les colonnes ajoutées
for col in sorted(colonnes_ajoutees):
val_new = snap_actuel[col]
if val_new:
lib = libelle(col)
changements.append(f" - â {lib} : nouvelle colonne ajoutĂ©e ({val_new})")
# Comparer les valeurs des colonnes existantes
for col in sorted(colonnes_actuelles & colonnes_precedentes):
val_new = snap_actuel[col]
val_old = snap_prec.get(col, '')
if val_new != val_old:
lib = libelle(col)
if val_old and val_new:
# Modification : ancienne â nouvelle
changements.append(f" - {lib} : {val_old} â {val_new}")
elif val_new:
# Ajout de valeur (était vide)
changements.append(f" - {lib} : {val_new}")
elif val_old:
# Suppression de valeur
changements.append(f" - {lib} : (supprimé)")
# Si aucun changement détecté, ne rien faire
if not changements:
return value
# Construire le nouveau bloc d'historique
ts = timestamp()
usr = nom_user()
nouvelle = [
f"đ Modification du {ts} ({usr})",
'\n'.join(changements),
"",
encoder_snap(snap_actuel),
""
]
# Ajouter en tĂȘte de l'historique existant
return '\n'.join(nouvelle) + '\n' + value
Formule HISTORIQUE (formule normale)
Cette formule filtre lâaffichage pour masquer les snapshots techniques :
# =============================================================================
# đ FILTRE D'AFFICHAGE DE L'HISTORIQUE
# =============================================================================
# Cette formule nettoie l'historique brut pour l'affichage :
# - Supprime les lignes de snapshot encodées en base64
# - Nettoie les lignes vides multiples
# =============================================================================
# Si pas d'historique, retourner vide
if not $HISTORIQUE_BRUT:
return ''
texte = $HISTORIQUE_BRUT
resultat = []
lignes = texte.split('\n')
for ligne in lignes:
# Ignorer les lignes de snapshot (données techniques encodées)
if ligne.strip().startswith('[SNAP:'):
continue
resultat.append(ligne)
# Nettoyer les lignes vides multiples consécutives
output = []
ligne_vide_precedente = False
for ligne in resultat:
if ligne.strip() == '':
# N'ajouter qu'une seule ligne vide Ă la suite
if not ligne_vide_precedente:
output.append(ligne)
ligne_vide_precedente = True
else:
output.append(ligne)
ligne_vide_precedente = False
return '\n'.join(output)
Personnalisation
Exclure des colonnes du suivi
Si vous ne voulez pas suivre les modifications de certains champs, modifiez COLONNES_EXCLUES dans la formule HISTORIQUE_BRUT et ajoutez le nom des champs que vous ne voulez pas suivre :
COLONNES_EXCLUES = {
'id', 'manualSort', 'HISTORIQUE_BRUT', 'HISTORIQUE',
'MA_COLONNE_A_IGNORER', # â Ajoutez ici
'AUTRE_COLONNE'
}
Configurer lâaffichage des rĂ©fĂ©rences
Si vous avez des colonnes de type Reference, configurez lâaffichage :
SHOW_COLUMNS_PAR_TABLE = {
'Clients': 'NOM_CLIENT', # Affiche le nom au lieu de l'ID
'Utilisateurs': 'EMAIL', # Affiche l'email
}
Changer les emojis
Vous pouvez personnaliser les emojis dans les lignes :
đ: CrĂ©ationđ: Modificationđïž: Colonne supprimĂ©eâ: Colonne ajoutĂ©e
Points dâattention
-
Formule dâinitialisation : HISTORIQUE_BRUT doit ĂȘtre une formule dâinitialisation, pas une formule normale. Sinon elle se recalculera en boucle !
-
Performance : Sur des tables avec beaucoup de colonnes, le snapshot peut devenir volumineux. Excluez les colonnes non pertinentes.
-
Records existants : Lâhistorique ne dĂ©marre quâĂ partir du moment oĂč vous ajoutez ces champs. Les modifications antĂ©rieures ne sont pas trackĂ©es.
-
Formules exclues : Les colonnes de type formule sont automatiquement exclues (elles se recalculent, pas besoin de les tracker).