""" 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