WorkflowClaim / appsheet_client.py
Rzx69's picture
Modification pour l'Url
1607748
Raw
History Blame Contribute Delete
9.14 kB
"""
Client partagé pour écrire dans les tables AppSheet via l'API REST.
Centralise l'appel HTTP vers AppSheet afin que /extract-claim et
/enrich-claim (donc extract_claim_from_email et drive_enrichment) passent
tous les deux par le même code pour modifier les tables.
Variables d'environnement requises (secrets du Space HF) :
APPSHEET_APP_ID ID de l'application AppSheet
APPSHEET_API_KEY Application Access Key AppSheet
"""
import json
import logging
import os
import requests
logger = logging.getLogger("appsheet_client")
APPSHEET_APP_ID = (os.environ.get("APPSHEET_APP_ID") or "").strip()
APPSHEET_API_KEY = (os.environ.get("APPSHEET_API_KEY") or "").strip()
TABLE_RECLAMATIONS = "Reclamations"
TABLE_RETARD = "Retard"
TABLE_MEDIAS = "Medias Cameras"
def appsheet_action(table: str, action: str, rows: list) -> dict:
"""Appelle l'API AppSheet (Add / Edit / Delete) sur une table donnée."""
if not (APPSHEET_APP_ID and APPSHEET_API_KEY):
raise EnvironmentError(
"APPSHEET_APP_ID / APPSHEET_API_KEY non définis. Configurez-les "
"comme secrets du Space HF."
)
url = f"https://api.appsheet.com/api/v2/apps/{APPSHEET_APP_ID}/tables/{table}/Action"
headers = {
"ApplicationAccessKey": APPSHEET_API_KEY,
"Content-Type": "application/json",
}
payload = {
"Action": action,
"Properties": {"Locale": "fr-FR"},
"Rows": rows,
}
logger.info("Appel AppSheet %s/%s — Rows envoyées : %s", table, action, rows)
response = requests.post(url, headers=headers, json=payload, timeout=60)
if response.status_code >= 300:
raise RuntimeError(
f"Erreur AppSheet API ({table}/{action}) : "
f"{response.status_code} {response.text}"
)
# Un statut 200 OK ne garantit PAS qu'une ligne a réellement été
# modifiée : si le Row ID envoyé ne correspond à aucune ligne existante,
# AppSheet peut répondre 200 sans avoir rien changé. On logue donc
# systématiquement le corps de la réponse pour pouvoir vérifier.
logger.info("Réponse AppSheet %s/%s : %s", table, action, response.text)
return response.json() if response.text else {}
def obtenir_row_id_reclamation(id_reclamation: str) -> str:
"""Retrouve le Row ID (vraie clé AppSheet) d'une ligne Reclamations à
partir de notre identifiant métier ID Reclamation. Nécessaire pour tout
Edit sur Reclamations, et pour toute valeur de Ref pointant vers cette
table (ex: colonne 'ID Reclamation' de Medias Cameras), car AppSheet
valide les références contre la vraie clé, pas contre un champ texte
unique quelconque."""
matches = find_rows(
TABLE_RECLAMATIONS,
f'Filter(Reclamations, [ID Reclamation] = "{id_reclamation}")',
)
logger.info(
"Recherche Row ID pour ID Reclamation=%r : %d ligne(s) trouvée(s) — %s",
id_reclamation, len(matches), matches,
)
if not matches:
raise ValueError(
f"Aucune ligne Reclamations trouvée pour ID Reclamation={id_reclamation!r} "
"(impossible de retrouver le Row ID)"
)
return matches[0].get("Row ID")
def edit_reclamation(id_reclamation: str, champs: dict) -> str:
"""Met à jour une ligne existante de Reclamations.
`champs` est un dict {nom_colonne_appsheet: valeur}.
Retourne le Row ID de la ligne modifiée (réutilisable pour écrire des
Ref vers cette ligne, ex: Medias Cameras).
"""
row_id = obtenir_row_id_reclamation(id_reclamation)
row = {"Row ID": row_id, "ID Reclamation": id_reclamation, **champs}
try:
appsheet_action(TABLE_RECLAMATIONS, "Edit", [row])
except Exception:
logger.exception("Échec de la mise à jour AppSheet pour %s", id_reclamation)
raise
# L'action Edit peut répondre 200 avec un corps vide même quand rien
# n'a été réellement modifié (ex: valeur rejetée silencieusement). On
# relit donc la ligne pour confirmer, plutôt que de faire confiance à
# l'absence d'erreur HTTP.
verif = find_rows(
TABLE_RECLAMATIONS,
f'Filter(Reclamations, [Row ID] = "{row_id}")',
)
if verif:
ecarts = {
cle: (valeur, verif[0].get(cle))
for cle, valeur in champs.items()
if str(verif[0].get(cle)) != str(valeur)
}
if ecarts:
logger.warning(
"Edit sur Reclamations %s : certains champs ne correspondent pas "
"après écriture (attendu, obtenu) : %s", id_reclamation, ecarts,
)
else:
logger.info("Edit sur Reclamations %s confirmé après relecture.", id_reclamation)
else:
logger.warning("Impossible de relire la ligne %s après l'Edit pour vérification.", id_reclamation)
return row_id
def obtenir_row_id_retard(id_retard: str) -> str:
"""Retrouve le Row ID (vraie clé AppSheet) d'une ligne Retard à partir de
notre identifiant métier ID Retard."""
matches = find_rows(
TABLE_RETARD,
f'Filter(Retard, [ID Retard] = "{id_retard}")',
)
logger.info(
"Recherche Row ID pour ID Retard=%r : %d ligne(s) trouvée(s) — %s",
id_retard, len(matches), matches,
)
if not matches:
raise ValueError(
f"Aucune ligne Retard trouvée pour ID Retard={id_retard!r} "
"(impossible de retrouver le Row ID)"
)
return matches[0].get("Row ID")
def edit_retard(id_retard: str, champs: dict) -> str:
"""Met à jour une ligne existante de Retard.
`champs` est un dict {nom_colonne_appsheet: valeur}.
Retourne le Row ID de la ligne modifiée.
"""
row_id = obtenir_row_id_retard(id_retard)
row = {"Row ID": row_id, "ID Retard": id_retard, **champs}
try:
appsheet_action(TABLE_RETARD, "Edit", [row])
except Exception:
logger.exception("Échec de la mise à jour AppSheet pour %s", id_retard)
raise
# Cf. edit_reclamation : un 200 OK ne garantit pas qu'une ligne a été
# réellement modifiée, on relit donc la ligne pour confirmer.
verif = find_rows(
TABLE_RETARD,
f'Filter(Retard, [Row ID] = "{row_id}")',
)
if verif:
ecarts = {
cle: (valeur, verif[0].get(cle))
for cle, valeur in champs.items()
if str(verif[0].get(cle)) != str(valeur)
}
if ecarts:
logger.warning(
"Edit sur Retard %s : certains champs ne correspondent pas "
"après écriture (attendu, obtenu) : %s", id_retard, ecarts,
)
else:
logger.info("Edit sur Retard %s confirmé après relecture.", id_retard)
else:
logger.warning("Impossible de relire la ligne %s après l'Edit pour vérification.", id_retard)
return row_id
def ajouter_medias(rows: list):
"""Ajoute plusieurs lignes à Medias Cameras en un seul appel."""
if not rows:
return
appsheet_action(TABLE_MEDIAS, "Add", rows)
def edit_media(row_id: str, id_media: str, champs: dict):
"""Met à jour une ligne existante de Medias Cameras (ex: colonne
"Role Media"). `champs` est un dict {nom_colonne_appsheet: valeur}."""
row = {"Row ID": row_id, "ID Media": id_media, **champs}
appsheet_action(TABLE_MEDIAS, "Edit", [row])
def find_rows(table: str, selector: str = None) -> list:
"""Lit des lignes d'une table AppSheet (action Find), avec un filtre
optionnel au format expression AppSheet, ex:
'Filter(Reclamations, [ID Reclamation] = "abc-123")'
Retourne une liste de dicts {nom_colonne: valeur}.
"""
if not (APPSHEET_APP_ID and APPSHEET_API_KEY):
raise EnvironmentError(
"APPSHEET_APP_ID / APPSHEET_API_KEY non définis."
)
url = f"https://api.appsheet.com/api/v2/apps/{APPSHEET_APP_ID}/tables/{table}/Action"
headers = {
"ApplicationAccessKey": APPSHEET_API_KEY,
"Content-Type": "application/json",
}
properties = {"Locale": "fr-FR"}
if selector:
properties["Selector"] = selector
payload = {"Action": "Find", "Properties": properties, "Rows": []}
response = requests.post(url, headers=headers, json=payload, timeout=60)
if response.status_code >= 300:
raise RuntimeError(
f"Erreur AppSheet API ({table}/Find) : {response.status_code} {response.text}"
)
return response.json() if response.text else []
def lire_url(valeur) -> str:
"""Extrait le lien réel d'une colonne AppSheet de type Url.
L'API Find renvoie ces colonnes sous forme de chaîne JSON, ex:
'{"Url":"https://...","LinkText":"..."}' — et ce MÊME quand la colonne
est vide ('{"Url":"","LinkText":""}'). Un simple `if valeur` est donc
toujours vrai : il faut décoder le JSON et lire le sous-champ "Url"."""
if not valeur:
return ""
try:
data = json.loads(valeur)
except (TypeError, ValueError):
return valeur
return data.get("Url", "") if isinstance(data, dict) else valeur