Add configurable report language
This commit is contained in:
@@ -3,6 +3,8 @@ PVE_API_TOKEN_ID=
|
|||||||
PVE_API_TOKEN_SECRET=
|
PVE_API_TOKEN_SECRET=
|
||||||
REPORT_OUTPUT_DIR=/reports
|
REPORT_OUTPUT_DIR=/reports
|
||||||
REPORT_TIMEZONE=Europe/Paris
|
REPORT_TIMEZONE=Europe/Paris
|
||||||
|
# Langue du rapport PDF: fr ou en.
|
||||||
|
REPORT_LANGUAGE=fr
|
||||||
PVE_VERIFY_TLS=true
|
PVE_VERIFY_TLS=true
|
||||||
# Optionnel: chemin vers une CA interne montee dans le conteneur.
|
# Optionnel: chemin vers une CA interne montee dans le conteneur.
|
||||||
# Non utilise si PVE_VERIFY_TLS=false.
|
# Non utilise si PVE_VERIFY_TLS=false.
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ PVE_API_TOKEN_ID=backup-report@pve!report
|
|||||||
PVE_API_TOKEN_SECRET=change-me
|
PVE_API_TOKEN_SECRET=change-me
|
||||||
REPORT_OUTPUT_DIR=/reports
|
REPORT_OUTPUT_DIR=/reports
|
||||||
REPORT_TIMEZONE=Europe/Paris
|
REPORT_TIMEZONE=Europe/Paris
|
||||||
|
REPORT_LANGUAGE=fr
|
||||||
PVE_VERIFY_TLS=true
|
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_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_ID` : identifiant complet du token, par exemple `backup-report@pve!report`.
|
||||||
- 🔒 `PVE_API_TOKEN_SECRET` : secret du token.
|
- 🔒 `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_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_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.
|
- 📜 `PVE_TASK_LOG_LIMIT` : nombre de lignes récupérées par log `vzdump` pour extraire le détail par VM/CT.
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ PVE_API_TOKEN_ID=backup-report@pve!report
|
|||||||
PVE_API_TOKEN_SECRET=change-me
|
PVE_API_TOKEN_SECRET=change-me
|
||||||
REPORT_OUTPUT_DIR=/reports
|
REPORT_OUTPUT_DIR=/reports
|
||||||
REPORT_TIMEZONE=Europe/Paris
|
REPORT_TIMEZONE=Europe/Paris
|
||||||
|
REPORT_LANGUAGE=fr
|
||||||
PVE_VERIFY_TLS=true
|
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_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_ID`: full token identifier, e.g. `backup-report@pve!report`.
|
||||||
- 🔒 `PVE_API_TOKEN_SECRET`: token secret.
|
- 🔒 `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_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_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.
|
- 📜 `PVE_TASK_LOG_LIMIT`: number of lines retrieved per `vzdump` log to extract per-VM/CT detail.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ PVE_API_TOKEN_ID=
|
|||||||
PVE_API_TOKEN_SECRET=
|
PVE_API_TOKEN_SECRET=
|
||||||
REPORT_OUTPUT_DIR=/reports
|
REPORT_OUTPUT_DIR=/reports
|
||||||
REPORT_TIMEZONE=Europe/Paris
|
REPORT_TIMEZONE=Europe/Paris
|
||||||
|
REPORT_LANGUAGE=fr
|
||||||
```
|
```
|
||||||
|
|
||||||
Un fichier `.env.example` est fourni comme modele. Le fichier `.env` reel ne doit pas etre commite.
|
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. |
|
| `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_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_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_VERIFY_TLS` | Non | Active la verification TLS. Defaut : `true`. |
|
||||||
| `PVE_CA_BUNDLE` | Non | Chemin vers une CA interne montee dans le conteneur. |
|
| `PVE_CA_BUNDLE` | Non | Chemin vers une CA interne montee dans le conteneur. |
|
||||||
| `PVE_TIMEOUT_SECONDS` | Non | Timeout HTTP. Defaut : `30`. |
|
| `PVE_TIMEOUT_SECONDS` | Non | Timeout HTTP. Defaut : `30`. |
|
||||||
|
|||||||
@@ -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.
|
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.
|
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 :
|
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.
|
- 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.
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ def run(argv: Sequence[str] | None = None) -> int:
|
|||||||
report_output_dir,
|
report_output_dir,
|
||||||
config.report_filename_prefix,
|
config.report_filename_prefix,
|
||||||
config.pbs_hostnames,
|
config.pbs_hostnames,
|
||||||
|
config.report_language,
|
||||||
)
|
)
|
||||||
except (OSError, RuntimeError) as exc:
|
except (OSError, RuntimeError) as exc:
|
||||||
logger.error("Generation PDF echouee: %s", exc)
|
logger.error("Generation PDF echouee: %s", exc)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ from collections.abc import Mapping
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pve_backup_report.i18n import TranslationError, normalize_language
|
||||||
|
|
||||||
|
|
||||||
class ConfigError(ValueError):
|
class ConfigError(ValueError):
|
||||||
"""Erreur de configuration runtime."""
|
"""Erreur de configuration runtime."""
|
||||||
@@ -48,6 +50,7 @@ class AppConfig:
|
|||||||
pbs_servers: tuple[PbsServerConfig, ...]
|
pbs_servers: tuple[PbsServerConfig, ...]
|
||||||
log_level: str
|
log_level: str
|
||||||
report_filename_prefix: str
|
report_filename_prefix: str
|
||||||
|
report_language: str = "fr"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def configured_pbs_servers(self) -> tuple[PbsServerConfig, ...]:
|
def configured_pbs_servers(self) -> tuple[PbsServerConfig, ...]:
|
||||||
@@ -62,6 +65,11 @@ def load_config(env_file: str | Path | None = ".env") -> AppConfig:
|
|||||||
"PVE_TIMEOUT_SECONDS",
|
"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(
|
config = AppConfig(
|
||||||
pve_api_url=require_env("PVE_API_URL"),
|
pve_api_url=require_env("PVE_API_URL"),
|
||||||
pve_api_token_id=require_env("PVE_API_TOKEN_ID"),
|
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"),
|
os.getenv("PVE_TASK_LOG_LIMIT", "5000"),
|
||||||
"PVE_TASK_LOG_LIMIT",
|
"PVE_TASK_LOG_LIMIT",
|
||||||
),
|
),
|
||||||
|
report_language=report_language,
|
||||||
pbs_hostnames=parse_mapping(os.getenv("PBS_HOSTNAMES", ""), "PBS_HOSTNAMES"),
|
pbs_hostnames=parse_mapping(os.getenv("PBS_HOSTNAMES", ""), "PBS_HOSTNAMES"),
|
||||||
pbs_servers=parse_pbs_servers(
|
pbs_servers=parse_pbs_servers(
|
||||||
os.environ,
|
os.environ,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pve_backup_report import __version__
|
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.models import ReportData
|
||||||
from pve_backup_report.report_pdf import (
|
from pve_backup_report.report_pdf import (
|
||||||
backup_retention_server_names,
|
backup_retention_server_names,
|
||||||
@@ -28,11 +29,6 @@ TEMPLATE_PACKAGE = "pve_backup_report"
|
|||||||
TEMPLATE_DIR = "templates"
|
TEMPLATE_DIR = "templates"
|
||||||
HTML_TEMPLATE = "report.html.j2"
|
HTML_TEMPLATE = "report.html.j2"
|
||||||
CSS_TEMPLATE = "report.css"
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -52,10 +48,11 @@ def render_pdf(
|
|||||||
output_dir: Path,
|
output_dir: Path,
|
||||||
filename_prefix: str = "rapport-sauvegardes-pve",
|
filename_prefix: str = "rapport-sauvegardes-pve",
|
||||||
pbs_hostnames: dict[str, str] | None = None,
|
pbs_hostnames: dict[str, str] | None = None,
|
||||||
|
language: str = "fr",
|
||||||
) -> Path:
|
) -> Path:
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
pdf_path = unique_report_path(output_dir, filename_prefix, report_data.summary.generated_at)
|
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:
|
try:
|
||||||
from weasyprint import HTML
|
from weasyprint import HTML
|
||||||
@@ -77,7 +74,11 @@ def render_pdf(
|
|||||||
return pdf_path
|
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:
|
try:
|
||||||
from jinja2 import Environment, PackageLoader, select_autoescape
|
from jinja2 import Environment, PackageLoader, select_autoescape
|
||||||
except ImportError as exc:
|
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"]),
|
autoescape=select_autoescape(["html", "j2"]),
|
||||||
)
|
)
|
||||||
template = environment.get_template(HTML_TEMPLATE)
|
template = environment.get_template(HTML_TEMPLATE)
|
||||||
context = build_template_context(report_data, pbs_hostnames or {})
|
context = build_template_context(report_data, pbs_hostnames or {}, language)
|
||||||
context["css"] = load_template_css()
|
context["css"] = load_template_css(context["title"])
|
||||||
return template.render(**context)
|
return template.render(**context)
|
||||||
|
|
||||||
|
|
||||||
def build_template_context(
|
def build_template_context(
|
||||||
report_data: ReportData,
|
report_data: ReportData,
|
||||||
pbs_hostnames: dict[str, str] | None = None,
|
pbs_hostnames: dict[str, str] | None = None,
|
||||||
|
language: str = "fr",
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
translator = Translator(normalize_language(language))
|
||||||
generated_at = report_data.summary.generated_at
|
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 {
|
return {
|
||||||
"title": "Rapport des sauvegardes Proxmox VE",
|
"html_lang": translator.html_lang,
|
||||||
"subtitle": "Synthese operationnelle et element de preuve pour audit.",
|
"title": translator.text("title"),
|
||||||
|
"subtitle": translator.text("subtitle"),
|
||||||
"version": __version__,
|
"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,
|
"sections": sections,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def build_sections(report_data: ReportData, pbs_hostnames: dict[str, str]) -> list[ReportSection]:
|
def build_sections(
|
||||||
retention_sections = build_all_backup_retention_sections(report_data)
|
report_data: ReportData,
|
||||||
|
pbs_hostnames: dict[str, str],
|
||||||
|
translator: Translator,
|
||||||
|
) -> list[ReportSection]:
|
||||||
|
retention_sections = build_all_backup_retention_sections(report_data, translator)
|
||||||
sections = [
|
sections = [
|
||||||
build_summary_section(report_data),
|
build_summary_section(report_data, translator),
|
||||||
build_pbs_storages_section(report_data),
|
build_pbs_storages_section(report_data, translator),
|
||||||
build_pbs_access_users_section(report_data),
|
build_pbs_access_users_section(report_data, translator),
|
||||||
build_pbs_datastore_usages_section(report_data),
|
build_pbs_datastore_usages_section(report_data, translator),
|
||||||
build_retention_policies_section(report_data),
|
build_retention_policies_section(report_data, translator),
|
||||||
build_backup_jobs_section(report_data),
|
build_backup_jobs_section(report_data, translator),
|
||||||
build_missing_guests_section(report_data),
|
build_missing_guests_section(report_data, translator),
|
||||||
build_coverage_group_section(),
|
build_coverage_group_section(translator),
|
||||||
*build_coverage_sections(report_data, pbs_hostnames),
|
*build_coverage_sections(report_data, pbs_hostnames, translator),
|
||||||
]
|
]
|
||||||
if retention_sections:
|
if retention_sections:
|
||||||
sections.extend([build_retention_group_section(), *retention_sections])
|
sections.extend([build_retention_group_section(translator), *retention_sections])
|
||||||
sections.append(build_issues_section(report_data))
|
sections.append(build_issues_section(report_data, translator))
|
||||||
return sections
|
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 = []
|
sections = []
|
||||||
for server_name in backup_retention_server_names(report_data):
|
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
|
return sections
|
||||||
|
|
||||||
|
|
||||||
def build_coverage_group_section() -> ReportSection:
|
def build_coverage_group_section(translator: Translator) -> ReportSection:
|
||||||
return ReportSection(
|
return ReportSection(
|
||||||
section_id="sauvegarde-vmct",
|
section_id="sauvegarde-vmct",
|
||||||
title="Sauvegarde des VM/CT",
|
title=translator.text("coverage_group"),
|
||||||
headers=[],
|
headers=[],
|
||||||
rows=[],
|
rows=[],
|
||||||
level=1,
|
level=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_retention_group_section() -> ReportSection:
|
def build_retention_group_section(translator: Translator) -> ReportSection:
|
||||||
return ReportSection(
|
return ReportSection(
|
||||||
section_id="retention-sauvegardes-vmct",
|
section_id="retention-sauvegardes-vmct",
|
||||||
title="Retention des sauvegardes VM/CT",
|
title=translator.text("retention_group"),
|
||||||
headers=[],
|
headers=[],
|
||||||
rows=[],
|
rows=[],
|
||||||
level=1,
|
level=1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_summary_section(report_data: ReportData) -> ReportSection:
|
def build_summary_section(report_data: ReportData, translator: Translator) -> ReportSection:
|
||||||
summary = report_data.summary
|
summary = report_data.summary
|
||||||
return ReportSection(
|
return ReportSection(
|
||||||
section_id="resume",
|
section_id="resume",
|
||||||
title="Resume",
|
title=translator.text("summary"),
|
||||||
headers=["Indicateur", "Valeur"],
|
headers=translator.row(["Indicateur", "Valeur"]),
|
||||||
rows=[
|
rows=[translator.row(row) for row in [
|
||||||
["VM", summary.total_vm],
|
["VM", summary.total_vm],
|
||||||
["Conteneurs LXC", summary.total_ct],
|
["Conteneurs LXC", summary.total_ct],
|
||||||
["Total VM/CT", summary.total_guests],
|
["Total VM/CT", summary.total_guests],
|
||||||
@@ -176,16 +192,16 @@ def build_summary_section(report_data: ReportData) -> ReportSection:
|
|||||||
["Non sauvegardees", summary.missing_count],
|
["Non sauvegardees", summary.missing_count],
|
||||||
["Indeterminees", summary.indeterminate_count],
|
["Indeterminees", summary.indeterminate_count],
|
||||||
["Anomalies", summary.issue_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(
|
return ReportSection(
|
||||||
section_id="stockages-pbs",
|
section_id="stockages-pbs",
|
||||||
title="Stockages PBS déclarés sur PVE",
|
title=translator.text("pbs_storages"),
|
||||||
headers=["ID", "Username", "Serveur PBS", "Datastore", "Namespace", "Actif"],
|
headers=translator.row(["ID", "Username", "Serveur PBS", "Datastore", "Namespace", "Actif"]),
|
||||||
rows=[
|
rows=[translator.row(row) for row in [
|
||||||
[
|
[
|
||||||
storage.storage_id,
|
storage.storage_id,
|
||||||
display(storage.username),
|
display(storage.username),
|
||||||
@@ -195,16 +211,16 @@ def build_pbs_storages_section(report_data: ReportData) -> ReportSection:
|
|||||||
display_bool(storage.enabled),
|
display_bool(storage.enabled),
|
||||||
]
|
]
|
||||||
for storage in report_data.pbs_storages
|
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(
|
return ReportSection(
|
||||||
section_id="utilisateurs-pbs-audit-acces",
|
section_id="utilisateurs-pbs-audit-acces",
|
||||||
title="Utilisateurs PBS - Audit des accès",
|
title=translator.text("pbs_access_users"),
|
||||||
headers=[
|
headers=translator.row([
|
||||||
"Serveur PBS",
|
"Serveur PBS",
|
||||||
"Auth-id",
|
"Auth-id",
|
||||||
"Storage PVE",
|
"Storage PVE",
|
||||||
@@ -215,8 +231,8 @@ def build_pbs_access_users_section(report_data: ReportData) -> ReportSection:
|
|||||||
"Email",
|
"Email",
|
||||||
"Permissions",
|
"Permissions",
|
||||||
"Commentaire",
|
"Commentaire",
|
||||||
],
|
]),
|
||||||
rows=[
|
rows=[translator.row(row) for row in [
|
||||||
[
|
[
|
||||||
user.server_name,
|
user.server_name,
|
||||||
display(user.auth_id),
|
display(user.auth_id),
|
||||||
@@ -230,29 +246,29 @@ def build_pbs_access_users_section(report_data: ReportData) -> ReportSection:
|
|||||||
display(user.comment),
|
display(user.comment),
|
||||||
]
|
]
|
||||||
for user in report_data.pbs_access_users
|
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(
|
return ReportSection(
|
||||||
section_id="espaces-stockage-pbs",
|
section_id="espaces-stockage-pbs",
|
||||||
title="Espaces de stockage PBS",
|
title=translator.text("pbs_datastore_usages"),
|
||||||
headers=["Serveur PBS", "Datastore", "Espace total", "Espace consomme", "Espace libre"],
|
headers=translator.row(["Serveur PBS", "Datastore", "Espace total", "Espace consomme", "Espace libre"]),
|
||||||
rows=[
|
rows=[
|
||||||
pbs_datastore_usage_row(usage)
|
translator.row(pbs_datastore_usage_row(usage))
|
||||||
for usage in report_data.pbs_datastore_usages
|
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(
|
return ReportSection(
|
||||||
section_id="politique-retention",
|
section_id="politique-retention",
|
||||||
title="Politique de retention",
|
title=translator.text("retention_policies"),
|
||||||
headers=[
|
headers=translator.row([
|
||||||
"Serveur PBS",
|
"Serveur PBS",
|
||||||
"Datastore",
|
"Datastore",
|
||||||
"Namespace",
|
"Namespace",
|
||||||
@@ -265,21 +281,21 @@ def build_retention_policies_section(report_data: ReportData) -> ReportSection:
|
|||||||
"Mois",
|
"Mois",
|
||||||
"Annee",
|
"Annee",
|
||||||
"Profondeur",
|
"Profondeur",
|
||||||
],
|
]),
|
||||||
rows=[
|
rows=[
|
||||||
retention_policy_row(policy)
|
translator.row(retention_policy_row(policy))
|
||||||
for policy in report_data.pbs_retention_policies
|
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(
|
return ReportSection(
|
||||||
section_id="jobs-sauvegarde",
|
section_id="jobs-sauvegarde",
|
||||||
title="Jobs de sauvegarde",
|
title=translator.text("backup_jobs"),
|
||||||
headers=["ID", "Storage", "Horaire", "Actif", "Mode", "Selection", "Exclusion"],
|
headers=translator.row(["ID", "Storage", "Horaire", "Actif", "Mode", "Selection", "Exclusion"]),
|
||||||
rows=[
|
rows=[translator.row(row) for row in [
|
||||||
[
|
[
|
||||||
job.job_id,
|
job.job_id,
|
||||||
display(job.storage),
|
display(job.storage),
|
||||||
@@ -290,24 +306,28 @@ def build_backup_jobs_section(report_data: ReportData) -> ReportSection:
|
|||||||
display(job.excluded),
|
display(job.excluded),
|
||||||
]
|
]
|
||||||
for job in report_data.backup_jobs
|
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,
|
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"]
|
missing = [item for item in report_data.coverage if item.status == "non_sauvegardee"]
|
||||||
return ReportSection(
|
return ReportSection(
|
||||||
section_id="vmct-non-sauvegardees",
|
section_id="vmct-non-sauvegardees",
|
||||||
title="VM/CT non sauvegardees",
|
title=translator.text("missing_guests"),
|
||||||
headers=["VMID", "Nom", "Notes", "Type", "Noeud", "Etat", "Detail"],
|
headers=translator.row(["VMID", "Nom", "Notes", "Type", "Noeud", "Etat", "Detail"]),
|
||||||
rows=[coverage_row(item, include_storage=False) for item in missing],
|
rows=[translator.row(coverage_row(item, include_storage=False)) for item in missing],
|
||||||
empty_message="Aucune VM/CT non sauvegardee detectee.",
|
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 = {
|
namespace_by_storage = {
|
||||||
storage.storage_id: storage.namespace for storage in report_data.pbs_storages
|
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 [
|
return [
|
||||||
ReportSection(
|
ReportSection(
|
||||||
section_id="sauvegarde-vmct",
|
section_id="sauvegarde-vmct",
|
||||||
title="Sauvegarde des VM/CT - non renseigne",
|
title=translator.text("coverage_title").format(
|
||||||
headers=coverage_headers_without_namespace(),
|
namespace=translator.cell("non renseigne")
|
||||||
|
),
|
||||||
|
headers=coverage_headers_without_namespace(translator),
|
||||||
rows=[],
|
rows=[],
|
||||||
empty_message="Aucune VM/CT collectee.",
|
empty_message=translator.text("no_guest"),
|
||||||
page_break_after=True,
|
page_break_after=True,
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@@ -342,6 +364,7 @@ def build_coverage_sections(report_data: ReportData, pbs_hostnames: dict[str, st
|
|||||||
namespace_by_storage,
|
namespace_by_storage,
|
||||||
server_by_storage,
|
server_by_storage,
|
||||||
report_data,
|
report_data,
|
||||||
|
translator,
|
||||||
)
|
)
|
||||||
for item in sorted(
|
for item in sorted(
|
||||||
coverage_by_namespace[namespace],
|
coverage_by_namespace[namespace],
|
||||||
@@ -351,18 +374,18 @@ def build_coverage_sections(report_data: ReportData, pbs_hostnames: dict[str, st
|
|||||||
sections.append(
|
sections.append(
|
||||||
ReportSection(
|
ReportSection(
|
||||||
section_id=f"sauvegarde-vmct-{section_id_fragment(namespace)}",
|
section_id=f"sauvegarde-vmct-{section_id_fragment(namespace)}",
|
||||||
title=f"Sauvegarde des VM/CT - {namespace}",
|
title=translator.text("coverage_title").format(namespace=translator.cell(namespace)),
|
||||||
headers=coverage_headers_without_namespace(),
|
headers=coverage_headers_without_namespace(translator),
|
||||||
rows=rows,
|
rows=rows,
|
||||||
empty_message="Aucune VM/CT collectee.",
|
empty_message=translator.text("no_guest"),
|
||||||
page_break_after=namespace == sorted_namespaces[-1],
|
page_break_after=namespace == sorted_namespaces[-1],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return sections
|
return sections
|
||||||
|
|
||||||
|
|
||||||
def coverage_headers_without_namespace() -> list[str]:
|
def coverage_headers_without_namespace(translator: Translator) -> list[str]:
|
||||||
return [
|
return translator.row([
|
||||||
"VMID",
|
"VMID",
|
||||||
"Nom",
|
"Nom",
|
||||||
"Notes",
|
"Notes",
|
||||||
@@ -375,7 +398,7 @@ def coverage_headers_without_namespace() -> list[str]:
|
|||||||
"Mode",
|
"Mode",
|
||||||
"Frequence de sauvegarde",
|
"Frequence de sauvegarde",
|
||||||
"Derniere sauvegarde",
|
"Derniere sauvegarde",
|
||||||
]
|
])
|
||||||
|
|
||||||
|
|
||||||
def coverage_row_without_namespace(
|
def coverage_row_without_namespace(
|
||||||
@@ -383,6 +406,7 @@ def coverage_row_without_namespace(
|
|||||||
namespace_by_storage,
|
namespace_by_storage,
|
||||||
server_by_storage,
|
server_by_storage,
|
||||||
report_data: ReportData,
|
report_data: ReportData,
|
||||||
|
translator: Translator,
|
||||||
) -> list[Any]:
|
) -> list[Any]:
|
||||||
row = coverage_row(
|
row = coverage_row(
|
||||||
item,
|
item,
|
||||||
@@ -391,7 +415,7 @@ def coverage_row_without_namespace(
|
|||||||
server_by_storage=server_by_storage,
|
server_by_storage=server_by_storage,
|
||||||
last_backup_by_vmid=report_data.last_backup_results,
|
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]:
|
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"
|
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)
|
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:]
|
data_rows = rows[1:]
|
||||||
headers_without_namespace = headers[:2] + headers[3:]
|
headers_without_namespace = headers[:2] + headers[3:]
|
||||||
|
|
||||||
@@ -427,10 +455,13 @@ def build_backup_retention_sections(report_data: ReportData, server_name: str) -
|
|||||||
return [
|
return [
|
||||||
ReportSection(
|
ReportSection(
|
||||||
section_id=f"retention-{server_name.lower()}",
|
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,
|
headers=headers_without_namespace,
|
||||||
rows=[],
|
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,
|
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)
|
sorted_namespaces = sorted(rows_by_namespace, key=sort_text)
|
||||||
for namespace in sorted_namespaces:
|
for namespace in sorted_namespaces:
|
||||||
namespace_rows = rows_by_namespace[namespace]
|
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(
|
sections.append(
|
||||||
ReportSection(
|
ReportSection(
|
||||||
section_id=f"retention-{server_name.lower()}-{section_id_fragment(namespace)}",
|
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,
|
headers=headers_without_namespace,
|
||||||
rows=section_rows,
|
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],
|
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
|
return sections
|
||||||
@@ -467,6 +504,7 @@ def retention_gc_warning(
|
|||||||
report_data: ReportData,
|
report_data: ReportData,
|
||||||
server_name: str,
|
server_name: str,
|
||||||
rows: list[list[Any]],
|
rows: list[list[Any]],
|
||||||
|
translator: Translator,
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
gc_by_datastore = {
|
gc_by_datastore = {
|
||||||
status.datastore: status
|
status.datastore: status
|
||||||
@@ -481,7 +519,7 @@ def retention_gc_warning(
|
|||||||
continue
|
continue
|
||||||
gc_status = gc_by_datastore.get(datastore)
|
gc_status = gc_by_datastore.get(datastore)
|
||||||
if gc_status is not None and gc_status.status == "en_cours":
|
if gc_status is not None and gc_status.status == "en_cours":
|
||||||
return RETENTION_GC_RUNNING_WARNING
|
return translator.text("gc_warning")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -510,12 +548,12 @@ def retention_datastore_for_row(
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def build_issues_section(report_data: ReportData) -> ReportSection:
|
def build_issues_section(report_data: ReportData, translator: Translator) -> ReportSection:
|
||||||
return ReportSection(
|
return ReportSection(
|
||||||
section_id="anomalies",
|
section_id="anomalies",
|
||||||
title="Anomalies",
|
title=translator.text("issues"),
|
||||||
headers=["Severite", "Composant", "Message", "Details"],
|
headers=translator.row(["Severite", "Composant", "Message", "Details"]),
|
||||||
rows=[
|
rows=[translator.row(row) for row in [
|
||||||
[
|
[
|
||||||
issue.severity,
|
issue.severity,
|
||||||
issue.component,
|
issue.component,
|
||||||
@@ -523,14 +561,18 @@ def build_issues_section(report_data: ReportData) -> ReportSection:
|
|||||||
display(issue.details),
|
display(issue.details),
|
||||||
]
|
]
|
||||||
for issue in report_data.issues
|
for issue in report_data.issues
|
||||||
],
|
]],
|
||||||
empty_message="Aucune anomalie detectee.",
|
empty_message=translator.text("no_issue"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_template_css() -> str:
|
def load_template_css(title: str = "Rapport des sauvegardes Proxmox VE") -> str:
|
||||||
return (
|
css = (
|
||||||
resources.files(TEMPLATE_PACKAGE)
|
resources.files(TEMPLATE_PACKAGE)
|
||||||
.joinpath(TEMPLATE_DIR, CSS_TEMPLATE)
|
.joinpath(TEMPLATE_DIR, CSS_TEMPLATE)
|
||||||
.read_text(encoding="utf-8")
|
.read_text(encoding="utf-8")
|
||||||
)
|
)
|
||||||
|
return css.replace(
|
||||||
|
'content: "Rapport des sauvegardes Proxmox VE";',
|
||||||
|
f'content: "{title}";',
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="fr">
|
<html lang="{{ html_lang }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>{{ title }}</title>
|
<title>{{ title }}</title>
|
||||||
<style>{{ css|safe }}</style>
|
<style>{{ css|safe }}</style>
|
||||||
<style>
|
<style>
|
||||||
@page {
|
@page {
|
||||||
|
@top-left {
|
||||||
|
content: "{{ title }}";
|
||||||
|
}
|
||||||
@bottom-left {
|
@bottom-left {
|
||||||
border-top: 0.5pt solid #dce6f1;
|
border-top: 0.5pt solid #dce6f1;
|
||||||
color: #5b6770;
|
color: #5b6770;
|
||||||
content: "Document automatique de suivi et d'audit";
|
content: "{{ footer_document }}";
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
font-size: 7.5pt;
|
font-size: 7.5pt;
|
||||||
padding-top: 1.5mm;
|
padding-top: 1.5mm;
|
||||||
@@ -26,7 +29,7 @@
|
|||||||
@bottom-right {
|
@bottom-right {
|
||||||
border-top: 0.5pt solid #dce6f1;
|
border-top: 0.5pt solid #dce6f1;
|
||||||
color: #5b6770;
|
color: #5b6770;
|
||||||
content: "Version : {{ version }}";
|
content: "{{ version_label }} : {{ version }}";
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
font-size: 7.5pt;
|
font-size: 7.5pt;
|
||||||
padding-top: 1.5mm;
|
padding-top: 1.5mm;
|
||||||
@@ -52,12 +55,12 @@
|
|||||||
{# ── Cell status class macro ─────────────────────────────────────── #}
|
{# ── Cell status class macro ─────────────────────────────────────── #}
|
||||||
{%- macro cell_class(cell) -%}
|
{%- macro cell_class(cell) -%}
|
||||||
{%- set s = cell|string -%}
|
{%- set s = cell|string -%}
|
||||||
{%- if s == "Active" or s == "Active sur PVE" -%}status-active
|
{%- if s == "Active" or s == "Active sur PVE" or s == "Active on PVE" -%}status-active
|
||||||
{%- elif s == "Non-active" or s == "Non-active sur PVE" -%}status-inactive
|
{%- elif s == "Non-active" or s == "Inactive" or s == "Non-active sur PVE" or s == "Not active on PVE" -%}status-inactive
|
||||||
{%- elif s.startswith("Succes") -%}status-success
|
{%- elif s.startswith("Succes") or s.startswith("Success") -%}status-success
|
||||||
{%- elif s.startswith("Echec") -%}status-error
|
{%- elif s.startswith("Echec") or s.startswith("Failure") -%}status-error
|
||||||
{%- elif s.startswith("Indetermine") -%}status-indeterminate
|
{%- elif s.startswith("Indetermine") or s.startswith("Undetermined") -%}status-indeterminate
|
||||||
{%- elif s == "non renseigne" -%}muted
|
{%- elif s == "non renseigne" or s == "not specified" -%}muted
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{%- endmacro -%}
|
{%- endmacro -%}
|
||||||
|
|
||||||
@@ -87,18 +90,18 @@
|
|||||||
<div class="cover-spacer"></div>
|
<div class="cover-spacer"></div>
|
||||||
<div class="cover-meta-bar">
|
<div class="cover-meta-bar">
|
||||||
<div class="cover-meta-item">
|
<div class="cover-meta-item">
|
||||||
<span class="cover-meta-label">Génération</span>
|
<span class="cover-meta-label">{{ generation_label }}</span>
|
||||||
<span class="cover-meta-value">{{ generated_at }}</span>
|
<span class="cover-meta-value">{{ generated_at }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cover-meta-item">
|
<div class="cover-meta-item">
|
||||||
<span class="cover-meta-label">Version</span>
|
<span class="cover-meta-label">{{ version_label }}</span>
|
||||||
<span class="cover-meta-value">{{ version }}</span>
|
<span class="cover-meta-value">{{ version }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="toc">
|
<nav class="toc">
|
||||||
<h2>Table des matieres</h2>
|
<h2>{{ toc_label }}</h2>
|
||||||
<ol>
|
<ol>
|
||||||
{% for section in sections %}
|
{% for section in sections %}
|
||||||
<li class="toc-item toc-l{{ section.level }}">
|
<li class="toc-item toc-l{{ section.level }}">
|
||||||
@@ -126,7 +129,6 @@
|
|||||||
<p class="warning">{{ section.warning }}</p>
|
<p class="warning">{{ section.warning }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if section.section_id == "resume" and section.rows %}
|
{% if section.section_id == "resume" and section.rows %}
|
||||||
{%- set alert_labels = ["Non sauvegardees", "Anomalies"] -%}
|
|
||||||
<div class="kpi-grid">
|
<div class="kpi-grid">
|
||||||
{% for row in section.rows %}
|
{% for row in section.rows %}
|
||||||
{%- set is_alert = row[0] in alert_labels and row[1]|int > 0 -%}
|
{%- set is_alert = row[0] in alert_labels and row[1]|int > 0 -%}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def test_load_config_from_env_file(tmp_path, monkeypatch) -> None:
|
|||||||
monkeypatch.delenv("PVE_API_URL", raising=False)
|
monkeypatch.delenv("PVE_API_URL", raising=False)
|
||||||
monkeypatch.delenv("PVE_API_TOKEN_ID", raising=False)
|
monkeypatch.delenv("PVE_API_TOKEN_ID", raising=False)
|
||||||
monkeypatch.delenv("PVE_API_TOKEN_SECRET", raising=False)
|
monkeypatch.delenv("PVE_API_TOKEN_SECRET", raising=False)
|
||||||
|
monkeypatch.delenv("REPORT_LANGUAGE", raising=False)
|
||||||
for key in list(os.environ):
|
for key in list(os.environ):
|
||||||
if key.startswith("PBS") and key != "PBS_HOSTNAMES":
|
if key.startswith("PBS") and key != "PBS_HOSTNAMES":
|
||||||
monkeypatch.delenv(key, raising=False)
|
monkeypatch.delenv(key, raising=False)
|
||||||
@@ -35,6 +36,7 @@ def test_load_config_from_env_file(tmp_path, monkeypatch) -> None:
|
|||||||
assert config.pve_backup_jobs_endpoint == "/cluster/backup"
|
assert config.pve_backup_jobs_endpoint == "/cluster/backup"
|
||||||
assert config.pve_task_history_limit == 500
|
assert config.pve_task_history_limit == 500
|
||||||
assert config.pve_task_log_limit == 5000
|
assert config.pve_task_log_limit == 5000
|
||||||
|
assert config.report_language == "fr"
|
||||||
assert config.configured_pbs_servers == ()
|
assert config.configured_pbs_servers == ()
|
||||||
assert config.pbs_hostnames == {
|
assert config.pbs_hostnames == {
|
||||||
"192.0.2.10": "backup-a",
|
"192.0.2.10": "backup-a",
|
||||||
@@ -76,3 +78,48 @@ def test_parse_pbs_servers_rejects_incomplete_api_block() -> None:
|
|||||||
pve_verify_tls=True,
|
pve_verify_tls=True,
|
||||||
pve_timeout_seconds=30,
|
pve_timeout_seconds=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_accepts_report_language(tmp_path, monkeypatch) -> None:
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
env_file.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"PVE_API_URL=https://pve.example.invalid:8006",
|
||||||
|
"PVE_API_TOKEN_ID=backup-report@pve!report",
|
||||||
|
"PVE_API_TOKEN_SECRET=secret",
|
||||||
|
"REPORT_LANGUAGE=en",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.delenv("PVE_API_URL", raising=False)
|
||||||
|
monkeypatch.delenv("PVE_API_TOKEN_ID", raising=False)
|
||||||
|
monkeypatch.delenv("PVE_API_TOKEN_SECRET", raising=False)
|
||||||
|
monkeypatch.delenv("REPORT_LANGUAGE", raising=False)
|
||||||
|
|
||||||
|
config = load_config(env_file)
|
||||||
|
|
||||||
|
assert config.report_language == "en"
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_config_rejects_invalid_report_language(tmp_path, monkeypatch) -> None:
|
||||||
|
env_file = tmp_path / ".env"
|
||||||
|
env_file.write_text(
|
||||||
|
"\n".join(
|
||||||
|
[
|
||||||
|
"PVE_API_URL=https://pve.example.invalid:8006",
|
||||||
|
"PVE_API_TOKEN_ID=backup-report@pve!report",
|
||||||
|
"PVE_API_TOKEN_SECRET=secret",
|
||||||
|
"REPORT_LANGUAGE=de",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
monkeypatch.delenv("PVE_API_URL", raising=False)
|
||||||
|
monkeypatch.delenv("PVE_API_TOKEN_ID", raising=False)
|
||||||
|
monkeypatch.delenv("PVE_API_TOKEN_SECRET", raising=False)
|
||||||
|
monkeypatch.delenv("REPORT_LANGUAGE", raising=False)
|
||||||
|
|
||||||
|
with pytest.raises(ConfigError, match="REPORT_LANGUAGE doit valoir fr ou en"):
|
||||||
|
load_config(env_file)
|
||||||
|
|||||||
@@ -235,6 +235,27 @@ def test_render_html_keeps_css_unescaped() -> None:
|
|||||||
assert '<h1 class="section-group-title">Retention des sauvegardes VM/CT</h1>' not in html
|
assert '<h1 class="section-group-title">Retention des sauvegardes VM/CT</h1>' not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_html_supports_english_labels() -> None:
|
||||||
|
report_data = ReportData(
|
||||||
|
coverage=[
|
||||||
|
BackupCoverage(
|
||||||
|
guest=Guest(vmid=100, name="srv", guest_type="qemu"),
|
||||||
|
status="non_sauvegardee",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
summary=ReportSummary(generated_at=datetime(2026, 5, 9, 2, 0)),
|
||||||
|
)
|
||||||
|
|
||||||
|
html = render_html(report_data, language="en")
|
||||||
|
|
||||||
|
assert '<html lang="en">' in html
|
||||||
|
assert "Proxmox VE Backup Report" in html
|
||||||
|
assert "Table of contents" in html
|
||||||
|
assert "VM/CT without backup" in html
|
||||||
|
assert "not specified" in html
|
||||||
|
assert "Rapport des sauvegardes Proxmox VE" not in html
|
||||||
|
|
||||||
|
|
||||||
def test_pdf_pbs_access_users_table_keeps_expected_fields_without_raw_secrets() -> None:
|
def test_pdf_pbs_access_users_table_keeps_expected_fields_without_raw_secrets() -> None:
|
||||||
report_data = ReportData(
|
report_data = ReportData(
|
||||||
pbs_access_users=[
|
pbs_access_users=[
|
||||||
|
|||||||
Reference in New Issue
Block a user