From 2d221c543c145f7c373f151254879bf3fb094586 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Sat, 10 Oct 2026 16:06:14 +0200 Subject: [PATCH] fix(interventions): cancel obsolete and >12m expired tree prescriptions --- .../cancel_obsolete_tree_interventions.py | 279 ++++++++++++++++++ .../commands/import_fulcrum_trees.py | 92 ++++-- loko/assets/tests.py | 74 +++++ loko/controls/management/__init__.py | 1 + loko/controls/management/commands/__init__.py | 1 + .../commands/renumber_control_codes.py | 180 +++++++++++ .../static/controls/controls_index.css | 5 + loko/controls/tests.py | 39 +++ 8 files changed, 652 insertions(+), 19 deletions(-) create mode 100644 loko/assets/management/commands/cancel_obsolete_tree_interventions.py create mode 100644 loko/controls/management/__init__.py create mode 100644 loko/controls/management/commands/__init__.py create mode 100644 loko/controls/management/commands/renumber_control_codes.py diff --git a/loko/assets/management/commands/cancel_obsolete_tree_interventions.py b/loko/assets/management/commands/cancel_obsolete_tree_interventions.py new file mode 100644 index 0000000..09f5c0a --- /dev/null +++ b/loko/assets/management/commands/cancel_obsolete_tree_interventions.py @@ -0,0 +1,279 @@ +from datetime import timedelta +from django.core.management.base import BaseCommand +from django.db import connection, transaction +from django.utils import timezone +from django.contrib.contenttypes.models import ContentType + +from assets.models import NatureTree +from interventions.models import Intervention, SourceCategory + + +class Command(BaseCommand): + help = ( + "Annule les interventions arboricoles caduques ou périmées :\n" + "1. Interventions prescrites avant un diagnostic plus récent sur l'arbre (remplacées par ré-inspection).\n" + "2. Prescriptions actives dont la date limite est échue depuis plus de X mois (défaut : 12 mois).\n" + "Met à jour les événements de timeline et recalcule automatiquement les dates de prochaine inspection." + ) + + def add_arguments(self, parser): + parser.add_argument( + '--months', + type=int, + default=12, + help="Délai en mois après échéance au-delà duquel une prescription non exécutée est annulée (défaut : 12).", + ) + parser.add_argument( + '--dry-run', + action='store_true', + help="Simuler l'opération et afficher les comptages sans modifier la base de données.", + ) + parser.add_argument( + '--no-recalc-trees', + action='store_true', + help="Ne pas recalculer next_inspection_date sur les arbres après l'annulation.", + ) + + def handle(self, *args, **options): + months = options['months'] + dry_run = options['dry_run'] + recalc_trees = not options['no_recalc_trees'] + + now = timezone.now() + # Calcul de la date seuil d'échéance (ex: il y a 12 mois) + cutoff_date = (now - timedelta(days=int(months * 30.4375))).date() + + self.stdout.write(self.style.MIGRATE_HEADING( + f"=== Nettoyage des interventions d'arbres caduques et échues ===" + )) + self.stdout.write(f"Date de référence : {now.strftime('%d/%m/%Y %H:%M')}") + self.stdout.write(f"Seuil d'échéance ({months} mois) : interventions échues avant le {cutoff_date.strftime('%d/%m/%Y')}\n") + + if dry_run: + self.stdout.write(self.style.WARNING("[MODE SIMULATION] Aucune modification ne sera enregistrée.\n")) + + content_type_tree = ContentType.objects.get_for_model(NatureTree) + source_fulcrum = SourceCategory.objects.filter(name_fr="Fulcrum").first() + source_fulcrum_id = source_fulcrum.id if source_fulcrum else None + + with connection.cursor() as cur: + # ------------------------------------------------------------- + # COMPTAGE RÈGLE 1 : Diagnostic plus récent sur l'arbre + # ------------------------------------------------------------- + cur.execute(""" + SELECT COUNT(DISTINCT i.id) + FROM interventions_intervention i + JOIN interventions_interventionasset ia ON ia.intervention_id = i.id + JOIN assets_naturetree t ON t.id = ia.object_id + WHERE ia.content_type_id = %s + AND i.status NOT IN ('finished', 'canceled') + AND t.last_phytosanitary_date IS NOT NULL + AND ( + (i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date) + OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date) + ); + """, [content_type_tree.id]) + count_rule_diag = cur.fetchone()[0] + + # ------------------------------------------------------------- + # COMPTAGE RÈGLE 2 : Échéance dépassée > X mois (sans être dans règle 1) + # ------------------------------------------------------------- + cur.execute(""" + SELECT COUNT(DISTINCT i.id) + FROM interventions_intervention i + LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id + LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s + WHERE i.status NOT IN ('finished', 'canceled') + AND (ia.content_type_id = %s OR i.source_category_id = %s) + AND i.expected_end_time IS NOT NULL + AND i.expected_end_time::date < %s + AND NOT ( + t.last_phytosanitary_date IS NOT NULL + AND ( + (i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date) + OR (i.expected_end_time::date < t.last_phytosanitary_date) + ) + ); + """, [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date]) + count_rule_expired = cur.fetchone()[0] + + # Total combiné + cur.execute(""" + SELECT COUNT(DISTINCT i.id) + FROM interventions_intervention i + LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id + LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s + WHERE i.status NOT IN ('finished', 'canceled') + AND (ia.content_type_id = %s OR i.source_category_id = %s) + AND ( + (t.last_phytosanitary_date IS NOT NULL AND ( + (i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date) + OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date) + )) + OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < %s) + ); + """, [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date]) + total_to_cancel = cur.fetchone()[0] + + self.stdout.write(f"1. Prescriptions caduques (arbre ré-inspecté ultérieurement) : {count_rule_diag}") + self.stdout.write(f"2. Prescriptions échues depuis plus de {months} mois sans nouveau diag : {count_rule_expired}") + self.stdout.write(self.style.MIGRATE_LABEL(f"Total des interventions à annuler : {total_to_cancel}\n")) + + if total_to_cancel == 0: + self.stdout.write(self.style.SUCCESS("Aucune intervention à annuler. La base est déjà assainie.")) + return + + if dry_run: + self.stdout.write(self.style.WARNING( + f"[MODE SIMULATION] {total_to_cancel} interventions auraient été passées au statut 'canceled'.\n" + "Exécutez sans --dry-run pour appliquer." + )) + return + + # ------------------------------------------------------------- + # EXÉCUTION RÉELLE EN TRANSACTION + # ------------------------------------------------------------- + self.stdout.write("Application des annulations...") + with transaction.atomic(): + with connection.cursor() as cur: + # 1a. Timeline events pour la règle 1 (diagnostic plus récent) + cur.execute(""" + INSERT INTO interventions_interventiontimeline ( + intervention_id, event_time, event_type, from_status, to_status, + event_description, field_changes + ) + SELECT + i.id, + %s, + 'status_change', + i.status, + 'canceled', + 'Intervention annulée automatiquement : prescription caduque suite au nouveau diagnostic de l''arbre du ' || + TO_CHAR(t.last_phytosanitary_date, 'DD/MM/YYYY') || '.', + ('{"status": ["' || i.status || '", "canceled"]}')::json + FROM interventions_intervention i + JOIN interventions_interventionasset ia ON ia.intervention_id = i.id + JOIN assets_naturetree t ON t.id = ia.object_id + WHERE ia.content_type_id = %s + AND i.status NOT IN ('finished', 'canceled') + AND t.last_phytosanitary_date IS NOT NULL + AND ( + (i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date) + OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date) + ) + AND NOT EXISTS ( + SELECT 1 FROM interventions_interventiontimeline tl + WHERE tl.intervention_id = i.id + AND tl.to_status = 'canceled' + ); + """, [now, content_type_tree.id]) + tl_diag_count = cur.rowcount + + # 1b. Timeline events pour la règle 2 (échues > X mois) + cur.execute(""" + INSERT INTO interventions_interventiontimeline ( + intervention_id, event_time, event_type, from_status, to_status, + event_description, field_changes + ) + SELECT + i.id, + %s, + 'status_change', + i.status, + 'canceled', + 'Intervention annulée automatiquement : prescription échue depuis plus de ' || %s || ' mois (' || + TO_CHAR(i.expected_end_time, 'DD/MM/YYYY') || ') sans nouveau diagnostic.', + ('{"status": ["' || i.status || '", "canceled"]}')::json + FROM interventions_intervention i + LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id + WHERE i.status NOT IN ('finished', 'canceled') + AND (ia.content_type_id = %s OR i.source_category_id = %s) + AND i.expected_end_time IS NOT NULL + AND i.expected_end_time::date < %s + AND NOT EXISTS ( + SELECT 1 FROM interventions_interventiontimeline tl + WHERE tl.intervention_id = i.id + AND tl.to_status = 'canceled' + ); + """, [now, months, content_type_tree.id, source_fulcrum_id, cutoff_date]) + tl_expired_count = cur.rowcount + + # 2. Mise à jour effective du statut vers 'canceled' + cur.execute(""" + UPDATE interventions_intervention i + SET status = 'canceled', + status_order = 200, + cancellation_type = 'non_relevant' + FROM interventions_interventionasset ia + LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s + WHERE ia.intervention_id = i.id + AND (ia.content_type_id = %s OR i.source_category_id = %s) + AND i.status NOT IN ('finished', 'canceled') + AND ( + (t.last_phytosanitary_date IS NOT NULL AND ( + (i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date) + OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date) + )) + OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < %s) + ); + """, [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date]) + updated_count = cur.rowcount + + # Traitement des interventions orphelines sans lien ia (ex: source Fulcrum sans arbre matché) + cur.execute(""" + UPDATE interventions_intervention i + SET status = 'canceled', + status_order = 200, + cancellation_type = 'non_relevant' + WHERE i.source_category_id = %s + AND i.status NOT IN ('finished', 'canceled') + AND i.expected_end_time IS NOT NULL + AND i.expected_end_time::date < %s; + """, [source_fulcrum_id, cutoff_date]) + updated_count += cur.rowcount + + self.stdout.write(self.style.SUCCESS( + f" {updated_count} interventions passées au statut 'canceled'." + )) + self.stdout.write(f" {tl_diag_count + tl_expired_count} événements d'historique créés dans la timeline.") + + # 3. Recalcul des prochaines dates d'inspection sur les arbres + if recalc_trees: + self.stdout.write("Recalcul des prochaines inspections sur les arbres...") + # 3a. Arbres avec intervention active résiduelle + cur.execute(""" + UPDATE assets_naturetree t + SET next_inspection_date = sub.min_date + FROM ( + SELECT ia.object_id AS tree_id, MIN(i.expected_end_time)::date AS min_date + FROM interventions_interventionasset ia + JOIN interventions_intervention i ON i.id = ia.intervention_id + WHERE ia.content_type_id = %s + AND i.status NOT IN ('finished', 'canceled') + AND i.expected_end_time IS NOT NULL + GROUP BY ia.object_id + ) sub + WHERE t.id = sub.tree_id; + """, [content_type_tree.id]) + trees_active = cur.rowcount + + # 3b. Arbres sans intervention active : formule théorique = dernier diagnostic + périodicité + cur.execute(""" + UPDATE assets_naturetree t + SET next_inspection_date = (t.last_phytosanitary_date + (COALESCE(t.pruning_frequency_years, 3) || ' years')::interval)::date + WHERE t.last_phytosanitary_date IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM interventions_interventionasset ia + JOIN interventions_intervention i ON i.id = ia.intervention_id + WHERE ia.content_type_id = %s + AND ia.object_id = t.id + AND i.status NOT IN ('finished', 'canceled') + AND i.expected_end_time IS NOT NULL + ); + """, [content_type_tree.id]) + trees_theo = cur.rowcount + + self.stdout.write(f" {trees_active} arbres mis à jour avec prochaine intervention active.") + self.stdout.write(f" {trees_theo} arbres recalculés selon cycle théorique (diag + périodicité).") + + self.stdout.write(self.style.SUCCESS("\nOpération terminée avec succès !")) diff --git a/loko/assets/management/commands/import_fulcrum_trees.py b/loko/assets/management/commands/import_fulcrum_trees.py index 163e0f9..7da8f19 100644 --- a/loko/assets/management/commands/import_fulcrum_trees.py +++ b/loko/assets/management/commands/import_fulcrum_trees.py @@ -1039,21 +1039,29 @@ class Command(BaseCommand): updated_begin = cur.rowcount self.log(f" Diagnostic initial (expected_begin) : {created_begin} créés, {updated_begin} mis à jour") - def cancel_obsolete_tree_interventions(self): + def cancel_obsolete_tree_interventions(self, months_overdue=12): """ - Annule les interventions en attente dont l'échéance (expected_end_time) - est strictement antérieure au dernier diagnostic phytosanitaire de l'arbre (last_phytosanitary_date). - Enregistre un événement dans la timeline expliquant la raison de l'annulation. + Annule les interventions arboricoles caduques ou périmées : + 1. Règle du nouveau diagnostic : prescriptions antérieures au dernier diagnostic de l'arbre + (i.expected_begin_time < t.last_phytosanitary_date OU i.expected_end_time < t.last_phytosanitary_date) + 2. Règle de l'échéance dépassée : prescriptions actives échues depuis plus de 12 mois sans exécution + (i.expected_end_time < cutoff_date) + Enregistre les événements correspondants dans InterventionTimeLine. """ - self.log("--- Détection et annulation des interventions caduques antérieures au dernier diagnostic ---") + self.log(f"--- Détection et annulation des interventions caduques et échues (> {months_overdue} mois) ---") from django.db import connection from django.utils import timezone + from datetime import timedelta content_type_tree = ContentType.objects.get_for_model(NatureTree) now = timezone.now() + cutoff_date = (now - timedelta(days=int(months_overdue * 30.4375))).date() + + source_fulcrum = SourceCategory.objects.filter(name_fr="Fulcrum").first() + source_fulcrum_id = source_fulcrum.id if source_fulcrum else None with connection.cursor() as cur: - # 1. Insertion des événements d'annulation dans InterventionTimeLine + # 1a. Timeline events pour les interventions caduques suite à nouveau diagnostic cur.execute(""" INSERT INTO interventions_interventiontimeline ( intervention_id, event_time, event_type, from_status, to_status, @@ -1065,9 +1073,7 @@ class Command(BaseCommand): 'status_change', i.status, 'canceled', - 'Intervention annulée automatiquement : prescription échue (' || - TO_CHAR(i.expected_end_time, 'DD/MM/YYYY') || - ') caduque suite au nouveau diagnostic de l''arbre du ' || + 'Intervention annulée automatiquement : prescription caduque suite au nouveau diagnostic de l''arbre du ' || TO_CHAR(t.last_phytosanitary_date, 'DD/MM/YYYY') || '.', ('{"status": ["' || i.status || '", "canceled"]}')::json FROM interventions_intervention i @@ -1075,16 +1081,47 @@ class Command(BaseCommand): JOIN assets_naturetree t ON t.id = ia.object_id WHERE ia.content_type_id = %s AND i.status NOT IN ('finished', 'canceled') - AND i.expected_end_time IS NOT NULL AND t.last_phytosanitary_date IS NOT NULL - AND i.expected_end_time::date < t.last_phytosanitary_date + AND ( + (i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date) + OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date) + ) AND NOT EXISTS ( SELECT 1 FROM interventions_interventiontimeline tl WHERE tl.intervention_id = i.id AND tl.to_status = 'canceled' ); """, [now, content_type_tree.id]) - events_count = cur.rowcount + events_diag_count = cur.rowcount + + # 1b. Timeline events pour les prescriptions échues depuis plus de 12 mois + cur.execute(""" + INSERT INTO interventions_interventiontimeline ( + intervention_id, event_time, event_type, from_status, to_status, + event_description, field_changes + ) + SELECT + i.id, + %s, + 'status_change', + i.status, + 'canceled', + 'Intervention annulée automatiquement : prescription échue depuis plus de ' || %s || ' mois (' || + TO_CHAR(i.expected_end_time, 'DD/MM/YYYY') || ') sans nouveau diagnostic.', + ('{"status": ["' || i.status || '", "canceled"]}')::json + FROM interventions_intervention i + LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id + WHERE i.status NOT IN ('finished', 'canceled') + AND (ia.content_type_id = %s OR i.source_category_id = %s) + AND i.expected_end_time IS NOT NULL + AND i.expected_end_time::date < %s + AND NOT EXISTS ( + SELECT 1 FROM interventions_interventiontimeline tl + WHERE tl.intervention_id = i.id + AND tl.to_status = 'canceled' + ); + """, [now, months_overdue, content_type_tree.id, source_fulcrum_id, cutoff_date]) + events_expired_count = cur.rowcount # 2. Mise à jour du statut des interventions vers 'canceled' cur.execute(""" @@ -1093,17 +1130,34 @@ class Command(BaseCommand): status_order = 200, cancellation_type = 'non_relevant' FROM interventions_interventionasset ia - JOIN assets_naturetree t ON t.id = ia.object_id + LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s WHERE ia.intervention_id = i.id - AND ia.content_type_id = %s + AND (ia.content_type_id = %s OR i.source_category_id = %s) AND i.status NOT IN ('finished', 'canceled') - AND i.expected_end_time IS NOT NULL - AND t.last_phytosanitary_date IS NOT NULL - AND i.expected_end_time::date < t.last_phytosanitary_date; - """, [content_type_tree.id]) + AND ( + (t.last_phytosanitary_date IS NOT NULL AND ( + (i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date) + OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date) + )) + OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < %s) + ); + """, [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date]) canceled_count = cur.rowcount - self.log(f" Interventions caduques annulées : {canceled_count} (événements timeline créés : {events_count})") + # 2b. Annuler également d'éventuelles interventions orphelines Fulcrum sans lien InterventionAsset + cur.execute(""" + UPDATE interventions_intervention i + SET status = 'canceled', + status_order = 200, + cancellation_type = 'non_relevant' + WHERE i.source_category_id = %s + AND i.status NOT IN ('finished', 'canceled') + AND i.expected_end_time IS NOT NULL + AND i.expected_end_time::date < %s; + """, [source_fulcrum_id, cutoff_date]) + canceled_count += cur.rowcount + + self.log(f" Interventions caduques / échues annulées : {canceled_count} (événements timeline créés : {events_diag_count + events_expired_count})") def sync_tree_maintenance_dates(self, gestion_file=None): """ diff --git a/loko/assets/tests.py b/loko/assets/tests.py index 53588b2..d163a5c 100644 --- a/loko/assets/tests.py +++ b/loko/assets/tests.py @@ -5773,3 +5773,77 @@ class NatureTreeEcoServicesTest(TestCase): self.assertEqual(tree.amenity_value, 0.0) self.assertEqual(tree.cooling_energy_indicator, 0.0) self.assertEqual(tree.cooling_indicator, 0.0) + + +class CancelObsoleteTreeInterventionsCommandTest(TestCase): + def test_cancel_obsolete_tree_interventions(self): + import io + from datetime import date, timedelta + from django.utils import timezone + from django.core.management import call_command + from assets.models import NatureTree + from interventions.models import Intervention, InterventionAsset, SourceCategory + from django.contrib.contenttypes.models import ContentType + + content_type_tree = ContentType.objects.get_for_model(NatureTree) + source_fulcrum, _ = SourceCategory.objects.get_or_create(name_fr="Fulcrum") + + now = timezone.now() + tree1 = NatureTree.objects.create( + code="TR-OBS-1", + last_phytosanitary_date=date(2024, 6, 1), + pruning_frequency_years=3 + ) + tree2 = NatureTree.objects.create( + code="TR-OBS-2", + last_phytosanitary_date=date(2021, 1, 1), + pruning_frequency_years=2 + ) + + # Itv 1: prescribed in 2021 (before tree1's last diag in 2024) -> should be canceled (Rule 1) + itv1 = Intervention.objects.create( + code="I999001", + status="to_be_planned", + expected_begin_time=now.replace(year=2021, month=5, day=1), + expected_end_time=now.replace(year=2022, month=5, day=1), + source_category=source_fulcrum + ) + InterventionAsset.objects.create(intervention=itv1, content_type=content_type_tree, object_id=tree1.id) + + # Itv 2: on tree 2, prescribed in 2021 with deadline 2022 (>12 months expired) -> should be canceled (Rule 2) + itv2 = Intervention.objects.create( + code="I999002", + status="to_be_planned", + expected_begin_time=now.replace(year=2021, month=1, day=1), + expected_end_time=now.replace(year=2022, month=1, day=1), + source_category=source_fulcrum + ) + InterventionAsset.objects.create(intervention=itv2, content_type=content_type_tree, object_id=tree2.id) + + # Itv 3: current/future intervention (deadline in future) on tree1 -> should NOT be canceled + itv3 = Intervention.objects.create( + code="I999003", + status="to_be_planned", + expected_begin_time=now.replace(year=2024, month=7, day=1), + expected_end_time=now + timedelta(days=60), + source_category=source_fulcrum + ) + InterventionAsset.objects.create(intervention=itv3, content_type=content_type_tree, object_id=tree1.id) + + # Dry run test + out = io.StringIO() + call_command('cancel_obsolete_tree_interventions', '--months=12', '--dry-run', stdout=out) + itv1.refresh_from_db() + self.assertEqual(itv1.status, "to_be_planned") + + # Actual run test + out = io.StringIO() + call_command('cancel_obsolete_tree_interventions', '--months=12', stdout=out) + itv1.refresh_from_db() + itv2.refresh_from_db() + itv3.refresh_from_db() + + self.assertEqual(itv1.status, "canceled") + self.assertEqual(itv2.status, "canceled") + self.assertEqual(itv3.status, "to_be_planned") + diff --git a/loko/controls/management/__init__.py b/loko/controls/management/__init__.py new file mode 100644 index 0000000..9547ba6 --- /dev/null +++ b/loko/controls/management/__init__.py @@ -0,0 +1 @@ +"""Management package for controls app.""" diff --git a/loko/controls/management/commands/__init__.py b/loko/controls/management/commands/__init__.py new file mode 100644 index 0000000..8a1eb04 --- /dev/null +++ b/loko/controls/management/commands/__init__.py @@ -0,0 +1 @@ +"""Management commands for controls app.""" diff --git a/loko/controls/management/commands/renumber_control_codes.py b/loko/controls/management/commands/renumber_control_codes.py new file mode 100644 index 0000000..2f4349d --- /dev/null +++ b/loko/controls/management/commands/renumber_control_codes.py @@ -0,0 +1,180 @@ +import re +from django.core.management.base import BaseCommand +from django.db import transaction +from controls.models import Control + + +class Command(BaseCommand): + help = ( + "Renumérote les codes de contrôle pour assurer un format propre et uniforme 'CTRL-XXXX'. " + "Par défaut, renumérote uniquement les contrôles ayant un code non standard (textes libres, " + "trop longs, etc.) et conserve l'ancien libellé dans la description si nécessaire." + ) + + def add_arguments(self, parser): + parser.add_argument( + '--all', + action='store_true', + help="Renuméroter TOUS les contrôles chronologiquement depuis CTRL-0001 (au lieu de seulement les non-standard).", + ) + parser.add_argument( + '--dry-run', + action='store_true', + help="Afficher les modifications prévues sans les enregistrer en base de données.", + ) + parser.add_argument( + '--no-backup-desc', + action='store_true', + help="Ne pas ajouter l'ancien code texte dans la description du contrôle.", + ) + parser.add_argument( + '--prefix', + type=str, + default='CTRL', + help="Préfixe utilisé pour les codes (défaut : 'CTRL').", + ) + parser.add_argument( + '--digits', + type=int, + default=4, + help="Nombre minimal de chiffres pour le numéro (défaut : 4 pour CTRL-0001).", + ) + + def handle(self, *args, **options): + dry_run = options['dry_run'] + renumber_all = options['all'] + backup_desc = not options['no_backup_desc'] + prefix = options['prefix'].strip().upper() + digits = options['digits'] + + pattern = re.compile(rf'^{re.escape(prefix)}-\d+$') + + # Récupération de tous les contrôles ordonnés chronologiquement + controls_qs = Control.objects.all().order_by('created_at', 'id') + total_count = controls_qs.count() + + if total_count == 0: + self.stdout.write(self.style.WARNING("Aucun contrôle trouvé en base de données.")) + return + + self.stdout.write(self.style.MIGRATE_HEADING( + f"=== Renumérotation des contrôles (Total : {total_count}) ===" + )) + + if dry_run: + self.stdout.write(self.style.WARNING("[MODE SIMULATION] Aucune modification ne sera appliquée.\n")) + + changes = [] + + if renumber_all: + # Mode ALL : renuméroter l'intégralité des contrôles chronologiquement + for idx, ctrl in enumerate(controls_qs, start=1): + new_code = f"{prefix}-{idx:0{digits}d}" + old_code = ctrl.control_code or '' + needs_update = (old_code != new_code) + new_desc = self._compute_description(ctrl, old_code, pattern, backup_desc) + + if needs_update or (new_desc != ctrl.description): + changes.append({ + 'control': ctrl, + 'old_code': old_code, + 'new_code': new_code, + 'old_desc': ctrl.description or '', + 'new_desc': new_desc, + }) + else: + # Mode ciblé : renuméroter uniquement les codes non-standard + # Calcul du numéro maximal existant parmi les codes standard + existing_codes = Control.objects.filter( + control_code__regex=rf'^{re.escape(prefix)}-\d+$' + ).values_list('control_code', flat=True) + + max_num = 0 + for code in existing_codes: + try: + num = int(code.split('-')[1]) + if num > max_num: + max_num = num + except (IndexError, ValueError): + pass + + self.stdout.write(f"Numéro standard maximal actuellement en base : {prefix}-{max_num:0{digits}d}\n") + + next_num = max_num + 1 + for ctrl in controls_qs: + old_code = ctrl.control_code or '' + is_standard = bool(pattern.match(old_code)) + + if not is_standard: + new_code = f"{prefix}-{next_num:0{digits}d}" + next_num += 1 + new_desc = self._compute_description(ctrl, old_code, pattern, backup_desc) + + changes.append({ + 'control': ctrl, + 'old_code': old_code, + 'new_code': new_code, + 'old_desc': ctrl.description or '', + 'new_desc': new_desc, + }) + + if not changes: + self.stdout.write(self.style.SUCCESS("Tous les codes de contrôle sont déjà au format attendu. Rien à faire.")) + return + + self.stdout.write(f"Nombre de contrôles à modifier : {len(changes)}\n") + self.stdout.write(f"{'ID':<6} | {'Ancien Code':<35} -> {'Nouveau Code':<12} | {'Description sauvegardée ?'}") + self.stdout.write("-" * 80) + + for item in changes: + old_c_trunc = (item['old_code'][:32] + '...') if len(item['old_code']) > 35 else item['old_code'] + desc_note = "Oui" if item['new_desc'] != item['old_desc'] else "Non" + self.stdout.write( + f"{item['control'].id:<6} | {old_c_trunc:<35} -> {item['new_code']:<12} | {desc_note}" + ) + + if dry_run: + self.stdout.write(self.style.WARNING( + f"\n[MODE SIMULATION] {len(changes)} contrôles auraient été modifiés. " + "Exécutez sans --dry-run pour appliquer." + )) + return + + # Application en deux passes avec transaction atomique pour éviter tout conflit de contrainte unique + self.stdout.write("\nApplication des modifications...") + with transaction.atomic(): + # Passe 1 : Codes temporaires + for item in changes: + Control.objects.filter(id=item['control'].id).update( + control_code=f"__TMP_REN_{item['control'].id}__" + ) + + # Passe 2 : Codes finaux et descriptions + for item in changes: + Control.objects.filter(id=item['control'].id).update( + control_code=item['new_code'], + description=item['new_desc'] + ) + + self.stdout.write(self.style.SUCCESS( + f"\nSuccès : {len(changes)} contrôles ont été renumérotés avec succès." + )) + + def _compute_description(self, ctrl, old_code, pattern, backup_desc): + """ + Conserve l'ancien code dans la description si c'était un texte personnalisé + pour éviter toute perte d'information. + """ + current_desc = ctrl.description or '' + if not backup_desc or not old_code or pattern.match(old_code): + return current_desc + + # Si l'ancien code n'est pas déjà présent dans la description + if old_code not in current_desc: + prefix_text = f"[Ancien code : {old_code}]" + if current_desc.strip(): + return f"{prefix_text} {current_desc.strip()}" + else: + return prefix_text + + return current_desc diff --git a/loko/controls/static/controls/controls_index.css b/loko/controls/static/controls/controls_index.css index 018f645..8929523 100644 --- a/loko/controls/static/controls/controls_index.css +++ b/loko/controls/static/controls/controls_index.css @@ -663,6 +663,10 @@ text-decoration: none !important; white-space: nowrap !important; display: inline-block; + max-width: 125px; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; padding: 2px 7px; border-radius: 5px; background-color: #f8fafc; @@ -703,6 +707,7 @@ .controls-table td.col-control_code { max-width: 140px; width: 140px; + min-width: 105px; } .controls-table th.col-thematic_names, diff --git a/loko/controls/tests.py b/loko/controls/tests.py index 14ef393..e260b0d 100644 --- a/loko/controls/tests.py +++ b/loko/controls/tests.py @@ -1,6 +1,7 @@ import io import json from django.test import TestCase +from django.core.management import call_command from django.contrib.auth import get_user_model from django.urls import reverse from openpyxl import load_workbook @@ -383,3 +384,41 @@ class ControlsIndexTests(TestCase): self.ctrl1.status = "canceled" self.assertTrue(self.ctrl1.is_cancelled) + +class RenumberControlCodesCommandTest(TestCase): + def test_renumber_non_standard_codes(self): + c1 = Control.objects.create(control_code="CTRL-0010") + c2 = Control.objects.create(control_code="Booste test bureau", description="Test desc") + c3 = Control.objects.create(control_code="Tunnel Delta: Sortie EP") + + # Dry run: no changes should be saved + out = io.StringIO() + call_command('renumber_control_codes', '--dry-run', stdout=out) + c2.refresh_from_db() + self.assertEqual(c2.control_code, "Booste test bureau") + + # Actual run: renumber only non-standard codes + out = io.StringIO() + call_command('renumber_control_codes', stdout=out) + c1.refresh_from_db() + c2.refresh_from_db() + c3.refresh_from_db() + + self.assertEqual(c1.control_code, "CTRL-0010") + self.assertEqual(c2.control_code, "CTRL-0011") + self.assertEqual(c3.control_code, "CTRL-0012") + self.assertIn("[Ancien code : Booste test bureau]", c2.description) + self.assertEqual(c3.description, "[Ancien code : Tunnel Delta: Sortie EP]") + + def test_renumber_all_codes(self): + c1 = Control.objects.create(control_code="CTRL-0050") + c2 = Control.objects.create(control_code="Ancien code test") + + out = io.StringIO() + call_command('renumber_control_codes', '--all', stdout=out) + c1.refresh_from_db() + c2.refresh_from_db() + + self.assertEqual(c1.control_code, "CTRL-0001") + self.assertEqual(c2.control_code, "CTRL-0002") +