loko/streetup/common/management/commands/refresh_reporting_views.py
2026-07-22 14:48:40 +02:00

151 lines
5.7 KiB
Python

"""
Management command : refresh_reporting_views
Crée (première exécution) ou rafraîchit (exécutions suivantes) les vues
matérialisées du schéma "reporting".
Utilise REFRESH MATERIALIZED VIEW CONCURRENTLY pour ne pas bloquer les
lectures — exige qu'un index unique existe sur chaque vue (créé lors du
premier CREATE via le fichier SQL correspondant).
Usage :
python manage.py refresh_reporting_views
python manage.py refresh_reporting_views --force-recreate
"""
import time
from pathlib import Path
from django.core.management.base import BaseCommand
from django.db import connection
# Ordre de création/refresh : les dépendances d'abord.
# Clés : "schema.view_name" (doit correspondre au nom du fichier SQL sans extension)
VIEWS_IN_ORDER = [
"reporting.mv_location_hierarchy",
"reporting.mv_interventions",
]
SQL_DIR = (
Path(__file__).resolve()
.parent # commands/
.parent # management/
.parent # common/
.parent # streetup/ (racine Django)
/ "reporting"
/ "sql"
)
def _view_exists(cursor, view_name: str) -> bool:
"""Vérifie si la vue matérialisée existe dans pg_matviews."""
schema, name = view_name.split(".")
cursor.execute(
"SELECT 1 FROM pg_matviews WHERE schemaname = %s AND matviewname = %s",
[schema, name],
)
return cursor.fetchone() is not None
class Command(BaseCommand):
help = (
"Crée ou rafraîchit les vues matérialisées du schéma reporting. "
"Destiné à être exécuté nuitamment via ScheduledCommand (3h00)."
)
def add_arguments(self, parser):
parser.add_argument(
"--force-recreate",
action="store_true",
default=False,
help=(
"Force la recréation complète des vues (DROP + CREATE + INDEX). "
"Bloquant pendant la recréation. Nécessaire après un changement "
"de structure dans les fichiers SQL."
),
)
def handle(self, *args, **options):
force = options["force_recreate"]
total_start = time.time()
self.stdout.write(self.style.MIGRATE_HEADING("Reporting views refresh"))
with connection.cursor() as cursor:
# 1. Créer le schéma si nécessaire
cursor.execute("CREATE SCHEMA IF NOT EXISTS reporting;")
self.stdout.write(" Schéma reporting : OK")
for full_name in VIEWS_IN_ORDER:
_, name = full_name.split(".")
start = time.time()
sql_file = SQL_DIR / f"{name}.sql"
if not sql_file.exists():
self.stderr.write(
self.style.ERROR(f" Fichier SQL introuvable : {sql_file}")
)
continue
if not _view_exists(cursor, full_name) or force:
# ── Première création ou recréation forcée ──────────────
try:
sql = sql_file.read_text(encoding="utf-8")
# Adapter le schéma django. pour l'environnement de test ou de développement
from django.conf import settings
import os
db_schema = os.getenv("DB_SCHEMA")
if not db_schema:
options = settings.DATABASES.get('default', {}).get('OPTIONS', {}).get('options', '')
if 'search_path=' in options:
db_schema = options.split('search_path=')[1].split(',')[0].strip()
else:
db_schema = 'public'
if settings.DATABASES['default']['NAME'].startswith('test_'):
db_schema = 'public'
sql = sql.replace("django.", f"{db_schema}.")
cursor.execute(sql)
elapsed = time.time() - start
verb = "recréée" if force else "créée"
self.stdout.write(
self.style.SUCCESS(
f" {full_name} : {verb} ({elapsed:.1f}s)"
)
)
except Exception as exc:
self.stderr.write(
self.style.ERROR(
f" {full_name} : ERREUR lors de la création — {exc}"
)
)
raise
else:
# ── Refresh non-bloquant ────────────────────────────────
try:
cursor.execute(
f"REFRESH MATERIALIZED VIEW CONCURRENTLY {full_name};"
)
elapsed = time.time() - start
self.stdout.write(
self.style.SUCCESS(
f" {full_name} : rafraîchie ({elapsed:.1f}s)"
)
)
except Exception as exc:
self.stderr.write(
self.style.ERROR(
f" {full_name} : ERREUR lors du refresh — {exc}"
)
)
raise
total = time.time() - total_start
self.stdout.write(
self.style.SUCCESS(
f"\nReporting views refresh terminé en {total:.1f}s"
)
)