From eb33b832f8dee453f48790fdc014cd29fb4a75cc Mon Sep 17 00:00:00 2001 From: Olivier Date: Wed, 13 May 2026 17:21:07 +0200 Subject: [PATCH] Add configurable report language --- .env.example | 2 + README.fr.md | 2 + README.md | 2 + docs/configuration.md | 2 + docs/rapport-pdf.md | 3 + src/pve_backup_report/cli.py | 1 + src/pve_backup_report/config.py | 9 + src/pve_backup_report/i18n.py | 196 ++++++++++++++ src/pve_backup_report/report_weasy_pdf.py | 242 ++++++++++-------- .../templates/report.html.j2 | 28 +- tests/test_config.py | 47 ++++ tests/test_report_weasy_pdf.py | 21 ++ 12 files changed, 442 insertions(+), 113 deletions(-) create mode 100644 src/pve_backup_report/i18n.py diff --git a/.env.example b/.env.example index db340dc..87a4b7e 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,8 @@ PVE_API_TOKEN_ID= PVE_API_TOKEN_SECRET= REPORT_OUTPUT_DIR=/reports REPORT_TIMEZONE=Europe/Paris +# Langue du rapport PDF: fr ou en. +REPORT_LANGUAGE=fr PVE_VERIFY_TLS=true # Optionnel: chemin vers une CA interne montee dans le conteneur. # Non utilise si PVE_VERIFY_TLS=false. diff --git a/README.fr.md b/README.fr.md index 6af9d6f..da6d9c0 100644 --- a/README.fr.md +++ b/README.fr.md @@ -62,6 +62,7 @@ PVE_API_TOKEN_ID=backup-report@pve!report PVE_API_TOKEN_SECRET=change-me REPORT_OUTPUT_DIR=/reports REPORT_TIMEZONE=Europe/Paris +REPORT_LANGUAGE=fr PVE_VERIFY_TLS=true ``` @@ -198,6 +199,7 @@ L'utilisateur qui exécute la crontab doit avoir le droit de lire `.env` et d'é - 🌐 `PVE_API_URL` : URL d'un noeud PVE joignable, par exemple `https://pve.example.invalid:8006`. - 🔑 `PVE_API_TOKEN_ID` : identifiant complet du token, par exemple `backup-report@pve!report`. - 🔒 `PVE_API_TOKEN_SECRET` : secret du token. +- 🌍 `REPORT_LANGUAGE` : langue du rapport PDF, `fr` ou `en`. Défaut : `fr`. - 🛡️ `PVE_VERIFY_TLS` : laisser `true` en production ; utiliser `PVE_CA_BUNDLE` pour une CA interne. - 🕒 `PVE_TASK_HISTORY_LIMIT` : nombre de tâches PVE récentes inspectées pour retrouver la dernière sauvegarde. - 📜 `PVE_TASK_LOG_LIMIT` : nombre de lignes récupérées par log `vzdump` pour extraire le détail par VM/CT. diff --git a/README.md b/README.md index 4416840..973cefd 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,7 @@ PVE_API_TOKEN_ID=backup-report@pve!report PVE_API_TOKEN_SECRET=change-me REPORT_OUTPUT_DIR=/reports REPORT_TIMEZONE=Europe/Paris +REPORT_LANGUAGE=fr PVE_VERIFY_TLS=true ``` @@ -195,6 +196,7 @@ The user running the crontab must have read access to `.env` and write access to - 🌐 `PVE_API_URL`: URL of a reachable PVE node, e.g. `https://pve.example.invalid:8006`. - 🔑 `PVE_API_TOKEN_ID`: full token identifier, e.g. `backup-report@pve!report`. - 🔒 `PVE_API_TOKEN_SECRET`: token secret. +- 🌍 `REPORT_LANGUAGE`: PDF report language, `fr` or `en`. Default: `fr`. - 🛡️ `PVE_VERIFY_TLS`: keep `true` in production; use `PVE_CA_BUNDLE` for an internal CA. - 🕒 `PVE_TASK_HISTORY_LIMIT`: number of recent PVE tasks inspected to find the latest backup. - 📜 `PVE_TASK_LOG_LIMIT`: number of lines retrieved per `vzdump` log to extract per-VM/CT detail. diff --git a/docs/configuration.md b/docs/configuration.md index 77a174c..711ec3d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -10,6 +10,7 @@ PVE_API_TOKEN_ID= PVE_API_TOKEN_SECRET= REPORT_OUTPUT_DIR=/reports REPORT_TIMEZONE=Europe/Paris +REPORT_LANGUAGE=fr ``` Un fichier `.env.example` est fourni comme modele. Le fichier `.env` reel ne doit pas etre commite. @@ -36,6 +37,7 @@ REPORT_FILENAME_PREFIX=rapport-sauvegardes-pve | `PVE_API_TOKEN_SECRET` | Oui | Secret du token API PVE. | | `REPORT_OUTPUT_DIR` | Non | Repertoire de sortie des rapports PDF. Defaut applicatif : `reports/`. En Docker Compose, utiliser `/reports`. | | `REPORT_TIMEZONE` | Non | Fuseau horaire utilise pour les dates du rapport. Defaut : `Europe/Paris`. | +| `REPORT_LANGUAGE` | Non | Langue du rapport PDF. Valeurs supportees : `fr` ou `en`. Defaut : `fr`. | | `PVE_VERIFY_TLS` | Non | Active la verification TLS. Defaut : `true`. | | `PVE_CA_BUNDLE` | Non | Chemin vers une CA interne montee dans le conteneur. | | `PVE_TIMEOUT_SECONDS` | Non | Timeout HTTP. Defaut : `30`. | diff --git a/docs/rapport-pdf.md b/docs/rapport-pdf.md index 6bae88d..612df4a 100644 --- a/docs/rapport-pdf.md +++ b/docs/rapport-pdf.md @@ -9,6 +9,9 @@ Il doit privilegier la clarte, la date de generation, la couverture de sauvegard Etat actuel : la generation PDF est implementee avec `WeasyPrint` a partir d'un template HTML/CSS. L'ancien rendu `reportlab` reste present dans le depot comme reference technique, mais la commande `--generate-pdf` utilise le rendu WeasyPrint. +La langue du rapport WeasyPrint est configurable avec `REPORT_LANGUAGE=fr` ou `REPORT_LANGUAGE=en`. +La valeur par defaut reste `fr`. + Etat du rendu WeasyPrint : - Page de garde complete (break-after page) : barre de logo sur fond blanc en haut, titre principal en bleu `#1f4e79` 32pt centre, barre de metadonnees (Generation, Version) ancree en bas de page via un spacer flex. diff --git a/src/pve_backup_report/cli.py b/src/pve_backup_report/cli.py index 6616a7f..4d59ff3 100644 --- a/src/pve_backup_report/cli.py +++ b/src/pve_backup_report/cli.py @@ -205,6 +205,7 @@ def run(argv: Sequence[str] | None = None) -> int: report_output_dir, config.report_filename_prefix, config.pbs_hostnames, + config.report_language, ) except (OSError, RuntimeError) as exc: logger.error("Generation PDF echouee: %s", exc) diff --git a/src/pve_backup_report/config.py b/src/pve_backup_report/config.py index 9a30715..5b56990 100644 --- a/src/pve_backup_report/config.py +++ b/src/pve_backup_report/config.py @@ -6,6 +6,8 @@ from collections.abc import Mapping from dataclasses import dataclass from pathlib import Path +from pve_backup_report.i18n import TranslationError, normalize_language + class ConfigError(ValueError): """Erreur de configuration runtime.""" @@ -48,6 +50,7 @@ class AppConfig: pbs_servers: tuple[PbsServerConfig, ...] log_level: str report_filename_prefix: str + report_language: str = "fr" @property def configured_pbs_servers(self) -> tuple[PbsServerConfig, ...]: @@ -62,6 +65,11 @@ def load_config(env_file: str | Path | None = ".env") -> AppConfig: "PVE_TIMEOUT_SECONDS", ) + try: + report_language = normalize_language(os.getenv("REPORT_LANGUAGE", "fr")) + except TranslationError as exc: + raise ConfigError(str(exc)) from exc + config = AppConfig( pve_api_url=require_env("PVE_API_URL"), pve_api_token_id=require_env("PVE_API_TOKEN_ID"), @@ -83,6 +91,7 @@ def load_config(env_file: str | Path | None = ".env") -> AppConfig: os.getenv("PVE_TASK_LOG_LIMIT", "5000"), "PVE_TASK_LOG_LIMIT", ), + report_language=report_language, pbs_hostnames=parse_mapping(os.getenv("PBS_HOSTNAMES", ""), "PBS_HOSTNAMES"), pbs_servers=parse_pbs_servers( os.environ, diff --git a/src/pve_backup_report/i18n.py b/src/pve_backup_report/i18n.py new file mode 100644 index 0000000..72e0f44 --- /dev/null +++ b/src/pve_backup_report/i18n.py @@ -0,0 +1,196 @@ +from __future__ import annotations + +from dataclasses import dataclass + + +class TranslationError(ValueError): + """Invalid report language.""" + + +def normalize_language(value: str | None) -> str: + language = (value or "fr").strip().lower() + aliases = { + "fr": "fr", + "fra": "fr", + "fr_fr": "fr", + "fr-fr": "fr", + "french": "fr", + "francais": "fr", + "français": "fr", + "en": "en", + "eng": "en", + "en_us": "en", + "en-us": "en", + "en_gb": "en", + "en-gb": "en", + "english": "en", + "anglais": "en", + } + normalized = aliases.get(language) + if normalized is None: + raise TranslationError("REPORT_LANGUAGE doit valoir fr ou en") + return normalized + + +@dataclass(frozen=True) +class Translator: + language: str = "fr" + + @property + def html_lang(self) -> str: + return normalize_language(self.language) + + def text(self, key: str) -> str: + language = normalize_language(self.language) + return TRANSLATIONS.get(language, {}).get(key, TRANSLATIONS["fr"].get(key, key)) + + def cell(self, value: object | None) -> object: + if normalize_language(self.language) == "fr": + return value + if not isinstance(value, str): + return value + text = value + + if text in CELL_TRANSLATIONS_EN: + return CELL_TRANSLATIONS_EN[text] + if text.startswith("Succes"): + return text.replace("Succes", "Success", 1).replace("duree ", "duration ") + if text.startswith("Echec"): + return text.replace("Echec", "Failure", 1).replace("duree ", "duration ") + if text.startswith("Indetermine"): + return text.replace("Indetermine", "Undetermined", 1).replace("duree ", "duration ") + return text + + def row(self, values: list[object]) -> list[object]: + return [self.cell(value) for value in values] + + +TRANSLATIONS = { + "fr": { + "title": "Rapport des sauvegardes Proxmox VE", + "subtitle": "Synthese operationnelle et element de preuve pour audit.", + "footer_document": "Document automatique de suivi et d'audit", + "generation": "Génération", + "version": "Version", + "toc": "Table des matieres", + "summary": "Resume", + "pbs_storages": "Stockages PBS déclarés sur PVE", + "pbs_access_users": "Utilisateurs PBS - Audit des accès", + "pbs_datastore_usages": "Espaces de stockage PBS", + "retention_policies": "Politique de retention", + "backup_jobs": "Jobs de sauvegarde", + "missing_guests": "VM/CT non sauvegardees", + "coverage_group": "Sauvegarde des VM/CT", + "coverage_title": "Sauvegarde des VM/CT - {namespace}", + "retention_group": "Retention des sauvegardes VM/CT", + "retention_title": "Retention des sauvegardes VM/CT {server_name} - {namespace}", + "issues": "Anomalies", + "no_pbs_storage": "Aucun stockage PBS collecte.", + "no_pbs_user": "Aucun utilisateur PBS collecte.", + "no_pbs_usage": "Aucun espace de stockage PBS collecte.", + "no_retention_policy": "Aucune politique de retention PBS collectee.", + "no_backup_job": "Aucun job de sauvegarde collecte.", + "no_missing_guest": "Aucune VM/CT non sauvegardee detectee.", + "no_guest": "Aucune VM/CT collectee.", + "no_backup_retention": "Aucune retention de sauvegarde VM/CT {server_name} collectee.", + "no_issue": "Aucune anomalie detectee.", + "gc_warning": ( + "Le nombre de versions des sauvegardes des VM/CT peut apparaitre superieur " + "au nombre de versions declarees car le garbage collector du PBS concerne " + "est en cours d'execution au moment de la generation du rapport." + ), + }, + "en": { + "title": "Proxmox VE Backup Report", + "subtitle": "Operational summary and audit evidence.", + "footer_document": "Automated monitoring and audit document", + "generation": "Generated", + "version": "Version", + "toc": "Table of contents", + "summary": "Summary", + "pbs_storages": "PBS storages declared in PVE", + "pbs_access_users": "PBS users - Access audit", + "pbs_datastore_usages": "PBS storage usage", + "retention_policies": "Retention policy", + "backup_jobs": "Backup jobs", + "missing_guests": "VM/CT without backup", + "coverage_group": "VM/CT backups", + "coverage_title": "VM/CT backups - {namespace}", + "retention_group": "VM/CT backup retention", + "retention_title": "VM/CT backup retention {server_name} - {namespace}", + "issues": "Anomalies", + "no_pbs_storage": "No PBS storage collected.", + "no_pbs_user": "No PBS user collected.", + "no_pbs_usage": "No PBS storage usage collected.", + "no_retention_policy": "No PBS retention policy collected.", + "no_backup_job": "No backup job collected.", + "no_missing_guest": "No VM/CT without backup detected.", + "no_guest": "No VM/CT collected.", + "no_backup_retention": "No VM/CT backup retention collected for {server_name}.", + "no_issue": "No anomaly detected.", + "gc_warning": ( + "The VM/CT backup version count may appear higher than the configured " + "retention count because the relevant PBS garbage collector is running " + "while the report is generated." + ), + }, +} + + +CELL_TRANSLATIONS_EN = { + "Indicateur": "Indicator", + "Valeur": "Value", + "Conteneurs LXC": "LXC containers", + "Stockages PBS": "PBS storages", + "Jobs de sauvegarde": "Backup jobs", + "Jobs actifs": "Active jobs", + "Jobs inactifs": "Inactive jobs", + "Sauvegardes PBS planifiees": "Planned PBS backups", + "Sauvegardes non PBS planifiees": "Planned non-PBS backups", + "Sauvegardes vers PBS desactive": "Backups to disabled PBS storage", + "Non sauvegardees": "Not backed up", + "Indeterminees": "Undetermined", + "Serveur PBS": "PBS server", + "Actif": "Enabled", + "Storage PVE": "PVE storage", + "Commentaire": "Comment", + "Espace total": "Total space", + "Espace consomme": "Used space", + "Espace libre": "Free space", + "Planification": "Schedule", + "Derniere": "Last", + "Horaire": "Hourly", + "Jour": "Daily", + "Semaine": "Weekly", + "Mois": "Monthly", + "Annee": "Yearly", + "Profondeur": "Depth", + "Noeud": "Node", + "Etat": "State", + "Etat de la VM": "VM state", + "Sauvegarde": "Backup", + "Frequence de sauvegarde": "Backup schedule", + "Derniere sauvegarde": "Last backup", + "Nom": "Name", + "Nom VM/CT": "VM/CT name", + "Etat PVE": "PVE state", + "Nombre de versions": "Version count", + "Nombre attendu de versions": "Expected version count", + "Plus ancienne": "Oldest", + "Plus recente": "Newest", + "Taille": "Size", + "Severite": "Severity", + "Composant": "Component", + "Details": "Details", + "oui": "yes", + "non": "no", + "aucune": "none", + "non renseigne": "not specified", + "non renseigné": "not specified", + "Non-active": "Inactive", + "Active sur PVE": "Active on PVE", + "Non-active sur PVE": "Not active on PVE", + "Aucune interruption attendue": "No interruption expected", + "Suspension temporaire": "Temporary suspension", + "Arret pendant sauvegarde": "Stopped during backup", +} diff --git a/src/pve_backup_report/report_weasy_pdf.py b/src/pve_backup_report/report_weasy_pdf.py index 521b21f..05e2d42 100644 --- a/src/pve_backup_report/report_weasy_pdf.py +++ b/src/pve_backup_report/report_weasy_pdf.py @@ -6,6 +6,7 @@ from pathlib import Path from typing import Any from pve_backup_report import __version__ +from pve_backup_report.i18n import Translator, normalize_language from pve_backup_report.models import ReportData from pve_backup_report.report_pdf import ( backup_retention_server_names, @@ -28,11 +29,6 @@ TEMPLATE_PACKAGE = "pve_backup_report" TEMPLATE_DIR = "templates" HTML_TEMPLATE = "report.html.j2" CSS_TEMPLATE = "report.css" -RETENTION_GC_RUNNING_WARNING = ( - "Le nombre de versions des sauvegardes des VM/CT peut apparaitre superieur " - "au nombre de versions declarees car le garbage collector du PBS concerne " - "est en cours d'execution au moment de la generation du rapport." -) @dataclass(frozen=True) @@ -52,10 +48,11 @@ def render_pdf( output_dir: Path, filename_prefix: str = "rapport-sauvegardes-pve", pbs_hostnames: dict[str, str] | None = None, + language: str = "fr", ) -> Path: output_dir.mkdir(parents=True, exist_ok=True) pdf_path = unique_report_path(output_dir, filename_prefix, report_data.summary.generated_at) - html = render_html(report_data, pbs_hostnames or {}) + html = render_html(report_data, pbs_hostnames or {}, language) try: from weasyprint import HTML @@ -77,7 +74,11 @@ def render_pdf( return pdf_path -def render_html(report_data: ReportData, pbs_hostnames: dict[str, str] | None = None) -> str: +def render_html( + report_data: ReportData, + pbs_hostnames: dict[str, str] | None = None, + language: str = "fr", +) -> str: try: from jinja2 import Environment, PackageLoader, select_autoescape except ImportError as exc: @@ -90,79 +91,94 @@ def render_html(report_data: ReportData, pbs_hostnames: dict[str, str] | None = autoescape=select_autoescape(["html", "j2"]), ) template = environment.get_template(HTML_TEMPLATE) - context = build_template_context(report_data, pbs_hostnames or {}) - context["css"] = load_template_css() + context = build_template_context(report_data, pbs_hostnames or {}, language) + context["css"] = load_template_css(context["title"]) return template.render(**context) def build_template_context( report_data: ReportData, pbs_hostnames: dict[str, str] | None = None, + language: str = "fr", ) -> dict[str, Any]: + translator = Translator(normalize_language(language)) generated_at = report_data.summary.generated_at - sections = build_sections(report_data, pbs_hostnames or {}) + sections = build_sections(report_data, pbs_hostnames or {}, translator) return { - "title": "Rapport des sauvegardes Proxmox VE", - "subtitle": "Synthese operationnelle et element de preuve pour audit.", + "html_lang": translator.html_lang, + "title": translator.text("title"), + "subtitle": translator.text("subtitle"), "version": __version__, - "generated_at": format_datetime(generated_at) or "non renseigne", + "generated_at": translator.cell(format_datetime(generated_at) or "non renseigne"), + "footer_document": translator.text("footer_document"), + "generation_label": translator.text("generation"), + "version_label": translator.text("version"), + "toc_label": translator.text("toc"), + "alert_labels": translator.row(["Non sauvegardees", "Anomalies"]), "sections": sections, } -def build_sections(report_data: ReportData, pbs_hostnames: dict[str, str]) -> list[ReportSection]: - retention_sections = build_all_backup_retention_sections(report_data) +def build_sections( + report_data: ReportData, + pbs_hostnames: dict[str, str], + translator: Translator, +) -> list[ReportSection]: + retention_sections = build_all_backup_retention_sections(report_data, translator) sections = [ - build_summary_section(report_data), - build_pbs_storages_section(report_data), - build_pbs_access_users_section(report_data), - build_pbs_datastore_usages_section(report_data), - build_retention_policies_section(report_data), - build_backup_jobs_section(report_data), - build_missing_guests_section(report_data), - build_coverage_group_section(), - *build_coverage_sections(report_data, pbs_hostnames), + build_summary_section(report_data, translator), + build_pbs_storages_section(report_data, translator), + build_pbs_access_users_section(report_data, translator), + build_pbs_datastore_usages_section(report_data, translator), + build_retention_policies_section(report_data, translator), + build_backup_jobs_section(report_data, translator), + build_missing_guests_section(report_data, translator), + build_coverage_group_section(translator), + *build_coverage_sections(report_data, pbs_hostnames, translator), ] if retention_sections: - sections.extend([build_retention_group_section(), *retention_sections]) - sections.append(build_issues_section(report_data)) + sections.extend([build_retention_group_section(translator), *retention_sections]) + sections.append(build_issues_section(report_data, translator)) return sections -def build_all_backup_retention_sections(report_data: ReportData) -> list[ReportSection]: +def build_all_backup_retention_sections( + report_data: ReportData, + translator: Translator, +) -> list[ReportSection]: sections = [] for server_name in backup_retention_server_names(report_data): - sections.extend(build_backup_retention_sections(report_data, server_name)) + sections.extend(build_backup_retention_sections(report_data, server_name, translator)) return sections -def build_coverage_group_section() -> ReportSection: +def build_coverage_group_section(translator: Translator) -> ReportSection: return ReportSection( section_id="sauvegarde-vmct", - title="Sauvegarde des VM/CT", + title=translator.text("coverage_group"), headers=[], rows=[], level=1, ) -def build_retention_group_section() -> ReportSection: +def build_retention_group_section(translator: Translator) -> ReportSection: return ReportSection( section_id="retention-sauvegardes-vmct", - title="Retention des sauvegardes VM/CT", + title=translator.text("retention_group"), headers=[], rows=[], level=1, ) -def build_summary_section(report_data: ReportData) -> ReportSection: +def build_summary_section(report_data: ReportData, translator: Translator) -> ReportSection: summary = report_data.summary return ReportSection( section_id="resume", - title="Resume", - headers=["Indicateur", "Valeur"], - rows=[ + title=translator.text("summary"), + headers=translator.row(["Indicateur", "Valeur"]), + rows=[translator.row(row) for row in [ ["VM", summary.total_vm], ["Conteneurs LXC", summary.total_ct], ["Total VM/CT", summary.total_guests], @@ -176,16 +192,16 @@ def build_summary_section(report_data: ReportData) -> ReportSection: ["Non sauvegardees", summary.missing_count], ["Indeterminees", summary.indeterminate_count], ["Anomalies", summary.issue_count], - ], + ]], ) -def build_pbs_storages_section(report_data: ReportData) -> ReportSection: +def build_pbs_storages_section(report_data: ReportData, translator: Translator) -> ReportSection: return ReportSection( section_id="stockages-pbs", - title="Stockages PBS déclarés sur PVE", - headers=["ID", "Username", "Serveur PBS", "Datastore", "Namespace", "Actif"], - rows=[ + title=translator.text("pbs_storages"), + headers=translator.row(["ID", "Username", "Serveur PBS", "Datastore", "Namespace", "Actif"]), + rows=[translator.row(row) for row in [ [ storage.storage_id, display(storage.username), @@ -195,16 +211,16 @@ def build_pbs_storages_section(report_data: ReportData) -> ReportSection: display_bool(storage.enabled), ] for storage in report_data.pbs_storages - ], - empty_message="Aucun stockage PBS collecte.", + ]], + empty_message=translator.text("no_pbs_storage"), ) -def build_pbs_access_users_section(report_data: ReportData) -> ReportSection: +def build_pbs_access_users_section(report_data: ReportData, translator: Translator) -> ReportSection: return ReportSection( section_id="utilisateurs-pbs-audit-acces", - title="Utilisateurs PBS - Audit des accès", - headers=[ + title=translator.text("pbs_access_users"), + headers=translator.row([ "Serveur PBS", "Auth-id", "Storage PVE", @@ -215,8 +231,8 @@ def build_pbs_access_users_section(report_data: ReportData) -> ReportSection: "Email", "Permissions", "Commentaire", - ], - rows=[ + ]), + rows=[translator.row(row) for row in [ [ user.server_name, display(user.auth_id), @@ -230,29 +246,29 @@ def build_pbs_access_users_section(report_data: ReportData) -> ReportSection: display(user.comment), ] for user in report_data.pbs_access_users - ], - empty_message="Aucun utilisateur PBS collecte.", + ]], + empty_message=translator.text("no_pbs_user"), ) -def build_pbs_datastore_usages_section(report_data: ReportData) -> ReportSection: +def build_pbs_datastore_usages_section(report_data: ReportData, translator: Translator) -> ReportSection: return ReportSection( section_id="espaces-stockage-pbs", - title="Espaces de stockage PBS", - headers=["Serveur PBS", "Datastore", "Espace total", "Espace consomme", "Espace libre"], + title=translator.text("pbs_datastore_usages"), + headers=translator.row(["Serveur PBS", "Datastore", "Espace total", "Espace consomme", "Espace libre"]), rows=[ - pbs_datastore_usage_row(usage) + translator.row(pbs_datastore_usage_row(usage)) for usage in report_data.pbs_datastore_usages ], - empty_message="Aucun espace de stockage PBS collecte.", + empty_message=translator.text("no_pbs_usage"), ) -def build_retention_policies_section(report_data: ReportData) -> ReportSection: +def build_retention_policies_section(report_data: ReportData, translator: Translator) -> ReportSection: return ReportSection( section_id="politique-retention", - title="Politique de retention", - headers=[ + title=translator.text("retention_policies"), + headers=translator.row([ "Serveur PBS", "Datastore", "Namespace", @@ -265,21 +281,21 @@ def build_retention_policies_section(report_data: ReportData) -> ReportSection: "Mois", "Annee", "Profondeur", - ], + ]), rows=[ - retention_policy_row(policy) + translator.row(retention_policy_row(policy)) for policy in report_data.pbs_retention_policies ], - empty_message="Aucune politique de retention PBS collectee.", + empty_message=translator.text("no_retention_policy"), ) -def build_backup_jobs_section(report_data: ReportData) -> ReportSection: +def build_backup_jobs_section(report_data: ReportData, translator: Translator) -> ReportSection: return ReportSection( section_id="jobs-sauvegarde", - title="Jobs de sauvegarde", - headers=["ID", "Storage", "Horaire", "Actif", "Mode", "Selection", "Exclusion"], - rows=[ + title=translator.text("backup_jobs"), + headers=translator.row(["ID", "Storage", "Horaire", "Actif", "Mode", "Selection", "Exclusion"]), + rows=[translator.row(row) for row in [ [ job.job_id, display(job.storage), @@ -290,24 +306,28 @@ def build_backup_jobs_section(report_data: ReportData) -> ReportSection: display(job.excluded), ] for job in report_data.backup_jobs - ], - empty_message="Aucun job de sauvegarde collecte.", + ]], + empty_message=translator.text("no_backup_job"), page_break_after=True, ) -def build_missing_guests_section(report_data: ReportData) -> ReportSection: +def build_missing_guests_section(report_data: ReportData, translator: Translator) -> ReportSection: missing = [item for item in report_data.coverage if item.status == "non_sauvegardee"] return ReportSection( section_id="vmct-non-sauvegardees", - title="VM/CT non sauvegardees", - headers=["VMID", "Nom", "Notes", "Type", "Noeud", "Etat", "Detail"], - rows=[coverage_row(item, include_storage=False) for item in missing], - empty_message="Aucune VM/CT non sauvegardee detectee.", + title=translator.text("missing_guests"), + headers=translator.row(["VMID", "Nom", "Notes", "Type", "Noeud", "Etat", "Detail"]), + rows=[translator.row(coverage_row(item, include_storage=False)) for item in missing], + empty_message=translator.text("no_missing_guest"), ) -def build_coverage_sections(report_data: ReportData, pbs_hostnames: dict[str, str]) -> list[ReportSection]: +def build_coverage_sections( + report_data: ReportData, + pbs_hostnames: dict[str, str], + translator: Translator, +) -> list[ReportSection]: namespace_by_storage = { storage.storage_id: storage.namespace for storage in report_data.pbs_storages } @@ -325,10 +345,12 @@ def build_coverage_sections(report_data: ReportData, pbs_hostnames: dict[str, st return [ ReportSection( section_id="sauvegarde-vmct", - title="Sauvegarde des VM/CT - non renseigne", - headers=coverage_headers_without_namespace(), + title=translator.text("coverage_title").format( + namespace=translator.cell("non renseigne") + ), + headers=coverage_headers_without_namespace(translator), rows=[], - empty_message="Aucune VM/CT collectee.", + empty_message=translator.text("no_guest"), page_break_after=True, ) ] @@ -342,6 +364,7 @@ def build_coverage_sections(report_data: ReportData, pbs_hostnames: dict[str, st namespace_by_storage, server_by_storage, report_data, + translator, ) for item in sorted( coverage_by_namespace[namespace], @@ -351,18 +374,18 @@ def build_coverage_sections(report_data: ReportData, pbs_hostnames: dict[str, st sections.append( ReportSection( section_id=f"sauvegarde-vmct-{section_id_fragment(namespace)}", - title=f"Sauvegarde des VM/CT - {namespace}", - headers=coverage_headers_without_namespace(), + title=translator.text("coverage_title").format(namespace=translator.cell(namespace)), + headers=coverage_headers_without_namespace(translator), rows=rows, - empty_message="Aucune VM/CT collectee.", + empty_message=translator.text("no_guest"), page_break_after=namespace == sorted_namespaces[-1], ) ) return sections -def coverage_headers_without_namespace() -> list[str]: - return [ +def coverage_headers_without_namespace(translator: Translator) -> list[str]: + return translator.row([ "VMID", "Nom", "Notes", @@ -375,7 +398,7 @@ def coverage_headers_without_namespace() -> list[str]: "Mode", "Frequence de sauvegarde", "Derniere sauvegarde", - ] + ]) def coverage_row_without_namespace( @@ -383,6 +406,7 @@ def coverage_row_without_namespace( namespace_by_storage, server_by_storage, report_data: ReportData, + translator: Translator, ) -> list[Any]: row = coverage_row( item, @@ -391,7 +415,7 @@ def coverage_row_without_namespace( server_by_storage=server_by_storage, last_backup_by_vmid=report_data.last_backup_results, ) - return row[:9] + row[10:] + return translator.row(row[:9] + row[10:]) def coverage_sort_key_without_namespace(item) -> tuple[str, int]: @@ -417,9 +441,13 @@ def section_id_fragment(value: str) -> str: return fragment or "non-renseigne" -def build_backup_retention_sections(report_data: ReportData, server_name: str) -> list[ReportSection]: +def build_backup_retention_sections( + report_data: ReportData, + server_name: str, + translator: Translator, +) -> list[ReportSection]: rows = build_backup_retention_rows(report_data, server_name) - headers = [str(value) for value in rows[0]] + headers = translator.row([str(value) for value in rows[0]]) data_rows = rows[1:] headers_without_namespace = headers[:2] + headers[3:] @@ -427,10 +455,13 @@ def build_backup_retention_sections(report_data: ReportData, server_name: str) - return [ ReportSection( section_id=f"retention-{server_name.lower()}", - title=f"Retention des sauvegardes VM/CT {server_name} - non renseigne", + title=translator.text("retention_title").format( + server_name=server_name, + namespace=translator.cell("non renseigne"), + ), headers=headers_without_namespace, rows=[], - empty_message=f"Aucune retention de sauvegarde VM/CT {server_name} collectee.", + empty_message=translator.text("no_backup_retention").format(server_name=server_name), page_break_after=True, ) ] @@ -444,16 +475,22 @@ def build_backup_retention_sections(report_data: ReportData, server_name: str) - sorted_namespaces = sorted(rows_by_namespace, key=sort_text) for namespace in sorted_namespaces: namespace_rows = rows_by_namespace[namespace] - section_rows = [retention_row_without_namespace(row) for row in namespace_rows] + section_rows = [ + translator.row(retention_row_without_namespace(row)) + for row in namespace_rows + ] sections.append( ReportSection( section_id=f"retention-{server_name.lower()}-{section_id_fragment(namespace)}", - title=f"Retention des sauvegardes VM/CT {server_name} - {namespace}", + title=translator.text("retention_title").format( + server_name=server_name, + namespace=translator.cell(namespace), + ), headers=headers_without_namespace, rows=section_rows, - empty_message=f"Aucune retention de sauvegarde VM/CT {server_name} collectee.", + empty_message=translator.text("no_backup_retention").format(server_name=server_name), page_break_after=namespace == sorted_namespaces[-1], - warning=retention_gc_warning(report_data, server_name, namespace_rows), + warning=retention_gc_warning(report_data, server_name, namespace_rows, translator), ) ) return sections @@ -467,6 +504,7 @@ def retention_gc_warning( report_data: ReportData, server_name: str, rows: list[list[Any]], + translator: Translator, ) -> str | None: gc_by_datastore = { status.datastore: status @@ -481,7 +519,7 @@ def retention_gc_warning( continue gc_status = gc_by_datastore.get(datastore) if gc_status is not None and gc_status.status == "en_cours": - return RETENTION_GC_RUNNING_WARNING + return translator.text("gc_warning") return None @@ -510,12 +548,12 @@ def retention_datastore_for_row( return None -def build_issues_section(report_data: ReportData) -> ReportSection: +def build_issues_section(report_data: ReportData, translator: Translator) -> ReportSection: return ReportSection( section_id="anomalies", - title="Anomalies", - headers=["Severite", "Composant", "Message", "Details"], - rows=[ + title=translator.text("issues"), + headers=translator.row(["Severite", "Composant", "Message", "Details"]), + rows=[translator.row(row) for row in [ [ issue.severity, issue.component, @@ -523,14 +561,18 @@ def build_issues_section(report_data: ReportData) -> ReportSection: display(issue.details), ] for issue in report_data.issues - ], - empty_message="Aucune anomalie detectee.", + ]], + empty_message=translator.text("no_issue"), ) -def load_template_css() -> str: - return ( +def load_template_css(title: str = "Rapport des sauvegardes Proxmox VE") -> str: + css = ( resources.files(TEMPLATE_PACKAGE) .joinpath(TEMPLATE_DIR, CSS_TEMPLATE) .read_text(encoding="utf-8") ) + return css.replace( + 'content: "Rapport des sauvegardes Proxmox VE";', + f'content: "{title}";', + ) diff --git a/src/pve_backup_report/templates/report.html.j2 b/src/pve_backup_report/templates/report.html.j2 index df0b8e6..9a93bf5 100644 --- a/src/pve_backup_report/templates/report.html.j2 +++ b/src/pve_backup_report/templates/report.html.j2 @@ -1,15 +1,18 @@ - + {{ title }}