# Copyright 2023 Louis Paternault # # Publié sous l'une des licences suivantes : # - GNU GPLv3 ou supérieure ; # - WTFPL 2.0 ; # - Licence Publique Rien À Branler version 1.0 ; # - 2-Clause BSD License ; # - libérée dans le domaine public, si votre loi locale le permet. """matos2pdf : Convertit un fichier CSV produit par MATOS en un PDF. Usage : Déplacez le fichier CSV produit par MATOS sur ce fichier Python pour l'exécuter. Un PDF devrait être produit au même endroit. Ou alors, en ligne de commande : python matos2pdf.py MON-FICHIER-CSV.csv Version du 28/04/2023 Des questions ? Des remarques ? Louis.Paternault@ac-grenoble.fr """ # Journal des modifications # # - 14/11/2023 # - Correction d'un bug : Ajout de {} à la fin de \textbackslash. # - Ajout de la possibilité de répondre "*" pour choisir quels tickets conserver. # # - 05/10/2023 # - Affichage du nom du poste dans la liste des problèmes résolus. # # - 05/06/2023 # - Gestion typographiquement correcte du point médian. # # - 28/04/2023 # - Avant génération du fichier tex, propose à l'utilisateur d'ignorer certains tickets # (ceux contenant une date). # # - 24/04/2023 # - Traitement des tirets bas et accents circonflexes dans les descriptions de ticket, # qui font que le fichier tex produit ne pouvait pas être compilé. # # - 27/03/2023 # - Version initiale (convertit le fichier CSV de MATOS en fichier tex, et compile ce dernier). # pylint: disable=non-ascii-name import csv import datetime import functools import locale import operator import pathlib import re import shutil import subprocess import sys import tempfile TEX_DÉBUT = r""" \documentclass[12pt]{article} \renewcommand{\familydefault}{\sfdefault} \usepackage[ a4paper, margin=5mm, %landscape, ]{geometry} \setlength{\parindent}{0pt} \newenvironment{rapport}[3]{ \hrule\strut % Un carré qui ne charge aucun paquet \raisebox{1ex}{\fbox{\phantom{,}}} \textbf{\large\sffamily #1} #2 \texttt{#3} \hfill RàS / Confirmé / Corrigé / Ignoré \\ }{ \par~ } % Point médian \newcommand\pointmedian{\kern-0.25em\textperiodcentered\kern-0.25em} \catcode`·=\active \def·{\pointmedian} \begin{document} """ TEX_PROBLÈME = r""" \begin{{rapport}}{{{poste}}}{{{problème.datetime}}}{{{problème.utilisateur}}} {problème.message} \end{{rapport}} """ TEX_FIN = r""" \end{document} """ class Problème: """Problème rapporté par un·e utilisateur·ice.""" def __init__(self, ligne): self.poste = ligne["Client"] self.timestamp = int(ligne["TimeStamp Unix"]) self.utilisateur_brut = ligne["Utilisateur"] self.message_brut = ligne["Anomalie"] @property def datetime(self): """Renvoit la date et l'heure sous la forme de texte.""" return datetime.datetime.fromtimestamp(self.timestamp).strftime("%a %x %X") @property def utilisateur(self): """Renvoit le nom de l'utilisateur·ice.""" return self.utilisateur_brut.split(" ")[0] @property def message(self): """Renvoit le message, nettoyé des caractères spéciaux LaTeX.""" return ( self.message_brut.replace("\\", "\\textbackslash{}") .replace("{", r"\{") .replace("}", r"\}") .replace("^", r"\textasciicircum{}") .replace("_", r"\_") .replace("%", r"\%") ) @functools.cached_property def salle(self): """Renvoit la salle de l'anomalie. Les postes sont supposés être nommés SALLE-POSTE (par exemple E123-P08 pour le poste 8 de la salle E123), donc cette méthode renvoit la partie précédent le trait d'union. Si aucun trait d'union n'est trouvé, renvoit une chaîne vide. """ découpage = self.poste.split("-", maxsplit=1) if len(découpage) == 0: return "" return découpage[0] def _lit_nombres(texte, minimum, maximum): """Demande à l'utilisateur·ice de choisir un ensemble de nombres Les nombres proposés vont de `minimum` à `maximum-1` (qui ont donc la même valeur que les arguments de range()). """ while True: réponse = input(texte) if réponse.strip() == "*": yield from range(minimum, maximum) return try: nombres = [int(item) for item in réponse.split()] for nombre in nombres: if not minimum <= nombre <= maximum: raise ValueError(f"{nombre} est trop petit ou trop grand.") except ValueError as error: print(f"ERREUR : {error}.") continue yield from nombres return RE_DATE = re.compile(r"[0-9]{1,2}/[0-9]{1,2}") def filtre(problèmes): """Supprime certains problèmes.""" candidats = [] for problème in problèmes: if RE_DATE.search(problème.message_brut): candidats.append(problème) else: yield problème if not candidats: return print("#" * 80) print("Ces problèmes sont peut-être résolus. Lesquels conserver ?") for i, problème in enumerate(sorted(candidats, key=operator.attrgetter("poste"))): print( f"[{i:>{len(str(len(candidats)))}}] {problème.poste}: {problème.message_brut.strip()}" ) for i in _lit_nombres( """Répondre par une liste de nombres séparés par des espaces ("*" pour tout inclure) : """, 0, len(candidats), ): yield candidats[i] def analyse(fichier): """Renvoit une liste de problèmes""" for ligne in csv.DictReader(fichier, delimiter=";", quotechar='"'): yield Problème(ligne) def matos2pdf(nom): """Convertit le fichier CSV en fichier PDF.""" # Définition de la langue print("Choix de la langue…") try: locale.setlocale(locale.LC_ALL, ("fr_FR", "UTF-8")) except locale.Error as error: print( f"Erreur lors du choix de la langue ({error}). Utilisation de la langue par défaut." ) # Lecture et filtre du CSV print("Lecture et filtre du fichier CSV…") with open(nom, mode="r", encoding="iso-8859-1") as fichier: problèmes = sorted( filtre(analyse(fichier)), key=operator.attrgetter("poste", "timestamp") ) with tempfile.TemporaryDirectory() as tmpdirname: nomtex = pathlib.Path(tmpdirname) / "matos.tex" # Écriture du fichier tex print(f"Création du fichier tex dans le dossier {tmpdirname}…") with open(nomtex, mode="w", encoding="utf8") as fichiertex: fichiertex.write(TEX_DÉBUT) précédente_salle = None for problème in problèmes: if problème.salle == précédente_salle: poste = rf"\phantom{{{problème.salle}}}{problème.poste[len(problème.salle):]}" else: poste = problème.poste fichiertex.write(TEX_PROBLÈME.format(problème=problème, poste=poste)) précédente_salle = problème.salle fichiertex.write(TEX_FIN) # Compilation print("Compilation du fichier tex…") subprocess.check_call( ["lualatex", "matos.tex"], cwd=tmpdirname, stdin=subprocess.DEVNULL, ) # Copie vers le dossier de destination print("Copie du fichier pdf…") shutil.copy(nomtex.with_suffix(".pdf"), pathlib.Path(nom).with_suffix(".pdf")) # Ouverture du fichier print("Ouverture du fichier pdf…") subprocess.run( ["explorer", pathlib.Path(nom).with_suffix(".pdf")], check=False, stdin=subprocess.DEVNULL, ) def main(): """Fonction principale""" try: if len(sys.argv) == 2 and sys.argv[1].lower() not in ("-h", "--help"): matos2pdf(sys.argv[1]) sys.exit(0) else: print(__doc__) input("Appuyez sur la touche [Entrée] pour quitter…") sys.exit(1) except Exception: # pylint: disable=broad-exception-caught # pylint: disable=line-too-long, import-outside-toplevel import traceback traceback.print_exc() print() print("Oups… Quelque chose s'est mal passé.") print( "Des questions ? Des remarques ? Ce programme a été écrit par Louis Paternault : Louis.Paternault@ac-grenoble.fr" ) input("Appuyez sur la touche [Entrée] pour quitter…") sys.exit(1) if __name__ == "__main__": main()