diff --git a/mail.py b/mail.py index dcb60c3..19aa5cb 100644 --- a/mail.py +++ b/mail.py @@ -1,34 +1,49 @@ +import logging import smtplib -from email.mime.text import MIMEText from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +logger = logging.getLogger("ovh_factures.fetcher") + + +def _as_header(value) -> str: + # Convertit en chaîne pour les en-têtes MIME; join pour listes/tuples. + if isinstance(value, (list, tuple, set)): + return ", ".join(map(str, value)) + return str(value) + + +def _as_rcpt_list(value): + # Liste de destinataires pour SMTP + if value is None: + return [] + if isinstance(value, (list, tuple, set)): + return [str(v) for v in value] + return [str(value)] def construct_html(bills: list[tuple[str, str]]) -> str: - rows = [] - for bill_id, date in bills: - rows.append( - f"
  • Facture n°{bill_id} — " - f"émise le {date}
  • " - ) - - template = f""" - - - - Nouvelle(s) facture(s) reçue(s) - - -
    -

    - Vous avez reçu {len(bills)} nouvelle(s) facture(s) -

    - -
    - - """ - return template + try: + rows = [ + f"
  • Facture n°{b} — " + f"émise le {d}
  • " + for b, d in bills + ] + return f""" + + Nouvelle(s) facture(s) + +
    +

    + Vous avez reçu {len(bills)} nouvelle(s) facture(s) +

    + +
    + + """ + except Exception as e: + logger.exception("Erreur dans construct_html", e) + return "" def send_email( @@ -39,14 +54,22 @@ def send_email( smtp_mail_address, smpt_port, email_to, + on_error=None, ): - msg = MIMEMultipart() - msg["From"] = email_from - msg["To"] = email_to - msg["Subject"] = subject - msg.attach(MIMEText(content, "html")) + try: + msg = MIMEMultipart() + msg["From"] = _as_header(email_from) + msg["To"] = _as_header(email_to) + msg["Subject"] = _as_header(subject) + msg.attach(MIMEText(str(content), "html")) - with smtplib.SMTP(smtp_mail_address, smpt_port) as server: - server.starttls() - server.login(email_from, email_password) - server.sendmail(email_from, email_to, msg.as_string()) + rcpts = _as_rcpt_list(email_to) + + with smtplib.SMTP(smtp_mail_address, smpt_port) as server: + server.starttls() + server.login(str(email_from), str(email_password)) + server.sendmail(str(email_from), rcpts, msg.as_string()) + except Exception as e: + logger.exception("Erreur dans send_email") + if on_error: + on_error(e) diff --git a/main.py b/main.py index 446d311..4b24fd8 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,6 @@ import os +import argparse +import concurrent.futures import mail as ml from datetime import date, datetime import dotenv @@ -6,47 +8,42 @@ import ovh import fetcher as ft from urllib.request import urlretrieve import logging -from logging.handlers import RotatingFileHandler +from logging.handlers import TimedRotatingFileHandler import traceback import sqlite3 +import time as tm -# --- Configuration du logging --- -logging.addLevelName(logging.DEBUG, "DÉBOGAGE") -logging.addLevelName(logging.INFO, "INFO") -logging.addLevelName(logging.WARNING, "AVERTISSEMENT") -logging.addLevelName(logging.ERROR, "ERREUR") -logging.addLevelName(logging.CRITICAL, "CRITIQUE") -logger = logging.getLogger("ovh_factures") -logger.setLevel(logging.INFO) -formatter = logging.Formatter( - fmt="%(asctime)s | %(levelname)s | %(message)s", - datefmt="%Y-%m-%d %H:%M:%S", -) -# Console -ch = logging.StreamHandler() -ch.setFormatter(formatter) -logger.addHandler(ch) -# Fichier -fh = RotatingFileHandler( - "ovh_factures.log", maxBytes=5_000_000, backupCount=3, encoding="utf-8" -) -fh.setFormatter(formatter) -logger.addHandler(fh) +def init(): + global logger + # --- Configuration du logging --- + logging.addLevelName(logging.DEBUG, "DÉBOGAGE") + logging.addLevelName(logging.INFO, "INFO") + logging.addLevelName(logging.WARNING, "AVERTISSEMENT") + logging.addLevelName(logging.ERROR, "ERREUR") -# Chargement des variables d'environnement (.env) -dotenv.load_dotenv() -APP_KEY = os.environ["APP_KEY"] -APP_SECRET = os.environ["APP_SECRET"] -CONSUMER_KEY = os.environ["CONSUMER_KEY"] -PATH_OVH = os.environ["OVH_PATH"] -DB_PATH = os.environ["DB_PATH"] -EMAIL = os.environ["EMAIL"] -EMAIL_PASSWORD = os.environ["EMAIL_PASSWORD"] -SMTP_MAIL_ADDRESS = os.environ["SMTP_MAIL_ADDRESS"] -SMTP_PORT = os.environ["SMTP_PORT"] -EMAIL_TO = os.environ["EMAIL_TO"] -YEAR = datetime.now().year # Année courante (int) + os.makedirs(PATH_LOG, exist_ok=True) + logger = logging.getLogger(os.path.join(PATH_LOG, "ovh")) + logger.setLevel(logging.INFO) + formatter = logging.Formatter( + fmt="%(asctime)s | %(levelname)s | %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + # Console + ch = logging.StreamHandler() + ch.setFormatter(formatter) + logger.addHandler(ch) + # Fichier + + fh = TimedRotatingFileHandler( + os.path.join(PATH_LOG, "ovh.log"), + when="M", + interval=1, + backupCount=12, + encoding="utf-8", + ) + fh.setFormatter(formatter) + logger.addHandler(fh) def get_conn(): @@ -62,6 +59,12 @@ def get_conn(): bill_id TEXT PRIMARY KEY, bill_year INT )""") + + conn.execute(""" + CREATE TABLE IF NOT EXISTS dj_bill ( + bill_id TEXT PRIMARY KEY, + bill_year INT + )""") conn.commit() logger.info("Base SQLite initialisée et table 'bills' disponible") return conn @@ -85,76 +88,83 @@ def send_error_mail(error_msg): pass -def add_entries_to_db(entries: list[tuple[str, int]], conn): +def add_entries_to_db(entries: list[tuple[str, int]], conn, table: str): """ - Insère en lot des paires (bill_id, bill_year) dans la table 'bills' avec gestion de conflit sur bill_id. + Insère en lot des paires (bill_id, bill_year) dans la table spécifiée avec gestion de conflit sur bill_id. """ try: - logger.debug("Insertion batch dans 'bills': %d entrées", len(entries)) - conn.executemany( - """ - INSERT INTO bills (bill_id, bill_year) + logger.debug("Insertion batch dans '%s': %d entrées", table, len(entries)) + query = f""" + INSERT INTO {table} (bill_id, bill_year) VALUES (?, ?) ON CONFLICT(bill_id) DO NOTHING - """, - entries, - ) + """ + conn.executemany(query, entries) conn.commit() - logger.info("Insertion batch dans 'bills' validée") + logger.info("Insertion batch dans '%s' validée", table) except Exception as e: - logger.exception("Échec d'insertion batch dans 'bills': %s", e) + logger.exception("Échec d'insertion batch dans '%s': %s", table, e) send_error_mail(traceback.format_exc()) raise -def get_entries_from_db(conn) -> set[str]: +def get_entries_from_db(conn, table: str) -> set[str]: """ - Récupère l'ensemble des bill_id présents dans la table 'bills' et les retourne sous forme de set[str]. + Récupère l'ensemble des bill_id présents dans la table demandée et les retourne sous forme de set[str]. """ + if table not in _ALLOWED_TABLES: + raise ValueError(f"Table inconnue: {table}") try: - logger.debug("Sélection des bill_id depuis 'bills'") - cursor = conn.execute("SELECT bill_id FROM bills") + logger.debug("Sélection des bill_id depuis '%s'", table) + cursor = conn.execute(f"SELECT bill_id FROM {table}") rows = cursor.fetchall() logger.info("Sélection terminée: %d bill_id récupérés", len(rows)) return {row[0] for row in rows} except Exception as e: - logger.exception("Échec de lecture des bill_id depuis 'bills': %s", e) + logger.exception("Échec de lecture des bill_id depuis '%s': %s", table, e) send_error_mail(traceback.format_exc()) raise def compare_db_to_data(db_data: set[str], data: list[str]) -> list[str]: - """ - Compare une collection d'identifiants 'data' à l'ensemble 'db_data' et retourne la liste des éléments absents de 'db_data'. - """ - missings_current_year = list() - for bill_id in data: - if bill_id not in db_data: - missings_current_year.append(bill_id) - return missings_current_year + return [x for x in data if x not in db_data] def indexer(ids: list[str]) -> list[str]: """ - Parcourt le répertoire de l'année courante, filtre les factures déjà présentes localement, conserve les factures absentes datées de l'année courante, et enregistre en base celles qui appartiennent à une autre année. + Parcourt le répertoire de l'année courante, filtre les factures déjà présentes localement, + conserve les factures absentes datées de l'année courante, et enregistre en base celles + qui appartiennent à une autre année. Gère explicitement les cas 31/12 (YEAR-1) et 01/01 (YEAR). """ conn = get_conn() logger.info("Indexation des factures pour l'année %s", YEAR) - target_dir = os.path.join(PATH_OVH, str(YEAR)) + target_dir = os.path.join(PATH_OVH, str(YEAR)) try: - ids_already_in = os.listdir(target_dir) + ids_already_in = {fn for fn in os.listdir(target_dir) if fn.endswith(".pdf")} except FileNotFoundError: logger.warning("Dossier %s inexistant, aucune facture locale", target_dir) - ids_already_in = [] + ids_already_in = set() - missing = compare_db_to_data( - get_entries_from_db(conn), [x for x in ids if f"{x}.pdf" not in ids_already_in] - ) + expected_missing = [x for x in ids if f"{x}.pdf" not in ids_already_in] + missing = compare_db_to_data(get_entries_from_db(conn, "bills"), expected_missing) logger.info("%d factures absentes détectées", len(missing)) result: list[str] = [] - not_valid_year: list[tuple[str, int]] = list() + not_valid_year: list[tuple[str, int]] = [] + + now = datetime.now() + boundary_run = (now.month, now.day) in {(12, 31), (1, 1)} + + bills_downloaded_dj = set() + if boundary_run: + try: + bills_downloaded_dj = set(get_entries_from_db(conn, "dj_bill")) + except Exception: + bills_downloaded_dj = set() + + dj_bills: list[tuple[str, date]] = [] + for bill_id in missing: try: meta = ft.fetch_invoice_content( @@ -165,17 +175,42 @@ def indexer(ids: list[str]) -> list[str]: ) except Exception as e: logger.error("Impossible de récupérer le json pour %s : %s", bill_id, e) - send_error_mail(traceback.format_exc()) continue - bill_year = datetime.fromisoformat(meta["date"]).year - if bill_year == YEAR: # todo 1 januray case + + try: + bill_dt = datetime.fromisoformat(meta["date"]).date() + except Exception: + logger.error("Date invalide pour %s: %r", bill_id, meta.get("date")) + continue + + if bill_dt.year == YEAR: result.append(bill_id) else: - not_valid_year.append((bill_id, bill_year)) + not_valid_year.append((bill_id, bill_dt.year)) + + if boundary_run: + is_dec31_prev = bill_dt == date(YEAR - 1, 12, 31) + is_jan1_curr = bill_dt == date(YEAR, 1, 1) + if (is_dec31_prev or is_jan1_curr) and bill_id not in bills_downloaded_dj: + dj_bills.append((bill_id, bill_dt)) + + if not_valid_year: + add_entries_to_db(not_valid_year, conn, "bills") + logger.info( + "Ajout de %d entrées hors année %s dans 'bills'", len(not_valid_year), YEAR + ) + + if dj_bills: + try: + add_entries_to_db(dj_bills, conn, "dj_bill") + logger.info( + "Ajout de %d factures de bascule (31/12, 01/01) dans 'dj_bill'", + len(dj_bills), + ) + except Exception as e: + logger.error("Échec insertion 'dj_bill': %s", e) - add_entries_to_db(not_valid_year, conn) - logger.info(f"Ajouter {len(not_valid_year)} entrées a la base de donnée") logger.info("%d factures retenues pour téléchargement", len(result)) return result @@ -224,8 +259,7 @@ def save_pdf(bill: dict) -> None: Télécharge le PDF d’une facture dans un sous-dossier par année. Noms de fichiers : .pdf """ - - year_dir = os.path.join(PATH_OVH, str(date.year)) + year_dir = os.path.join(PATH_OVH, str(datetime.now().year)) os.makedirs(year_dir, exist_ok=True) dest = os.path.join(year_dir, f"{bill['billId']}.pdf") @@ -240,6 +274,27 @@ def save_pdf(bill: dict) -> None: if __name__ == "__main__": + # Chargement des variables d'environnement (.env) + parser = argparse.ArgumentParser() + parser.add_argument("-e", "--env", required=True, help="Path of .env file") + args = parser.parse_args() + + dotenv.load_dotenv(args.env) + APP_KEY = os.environ["APP_KEY"] + APP_SECRET = os.environ["APP_SECRET"] + CONSUMER_KEY = os.environ["CONSUMER_KEY"] + PATH_OVH = os.environ["OVH_PATH"] + PATH_LOG = os.environ["LOG_PATH"] + DB_PATH = os.environ["DB_PATH"] + EMAIL = os.environ["EMAIL"] + EMAIL_PASSWORD = os.environ["EMAIL_PASSWORD"] + SMTP_MAIL_ADDRESS = os.environ["SMTP_MAIL_ADDRESS"] + SMTP_PORT = os.environ["SMTP_PORT"] + EMAIL_TO = os.environ["EMAIL_TO"].strip().split(",") + YEAR = datetime.now().year # Année courante (int) + _ALLOWED_TABLES = {"bills", "dj_bill"} + init() + start = tm.time() logger.info("Démarrage du traitement des factures OVH pour %s", YEAR) os.makedirs(os.path.join(PATH_OVH, str(YEAR)), exist_ok=True) @@ -248,18 +303,21 @@ if __name__ == "__main__": bills_str = [] for bill_id in ids_candidats: bills_json.append((bill_id, get_bill(bill_id))) - # pdf enregistrement. - if len(bills_json) > 0: - for bill_json in bills_json: - save_pdf(bill_json[1]) - date = datetime.fromisoformat(bill_json[1]["date"]).date() - bills_str.append( - ( - bill_json[0], - f"{date}", - ) - ) + # pdf enregistrement. + + if bills_json: + with concurrent.futures.ThreadPoolExecutor() as ex: + futures = [] + for b in bills_json: + futures.append(ex.submit(save_pdf, b[1])) + # tm.sleep(0.1) + for f in futures: + f.result(timeout=10) + + for bill_id, bill_payload in bills_json: + d = datetime.fromisoformat(bill_payload["date"]).date() + bills_str.append((bill_id, f"{d}")) content = ml.construct_html(bills_str) ml.send_email( "Reçu de facture(s)", @@ -271,3 +329,5 @@ if __name__ == "__main__": email_to=EMAIL_TO, ) logger.info("Traitement terminé : %d factures téléchargées", len(ids_candidats)) + end = tm.time() + logger.info(f"Runned for {round(end - start, 2)}secs")