Spaces:
Sleeping
Sleeping
| """ | |
| 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 |