""" 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" ) )