Compare commits
2 commits
34e4da64a8
...
2d221c543c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2d221c543c | |||
| 5ce7179c19 |
12 changed files with 730 additions and 29 deletions
|
|
@ -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 !"))
|
||||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -94,6 +94,9 @@
|
|||
addIntersectionAssetsLayer(container);
|
||||
addConnectionLinesLayer();
|
||||
}
|
||||
|
||||
// Ensure asset layers are stacked above basemaps
|
||||
bringAssetLayersToTop();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -224,6 +227,8 @@
|
|||
addLineLayer();
|
||||
addPointLayer();
|
||||
}
|
||||
|
||||
bringAssetLayersToTop();
|
||||
}
|
||||
|
||||
function addPointLayer() {
|
||||
|
|
@ -651,9 +656,29 @@
|
|||
'current-asset-icon',
|
||||
];
|
||||
|
||||
function getBasemapBeforeLayer(m) {
|
||||
if (!m || !m.getStyle) return undefined;
|
||||
const styleLayers = (m.getStyle() && m.getStyle().layers) || [];
|
||||
const drawLayer = styleLayers.find(l => l.id && (l.id.startsWith('gl-draw-') || l.id.startsWith('custom-draw-')));
|
||||
if (drawLayer) return drawLayer.id;
|
||||
|
||||
// Return first existing asset layer so basemap is placed below it
|
||||
for (const id of ASSET_LAYER_ORDER) {
|
||||
if (m.getLayer(id)) return id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function bringAssetLayersToTop() {
|
||||
if (!map || typeof map.isStyleLoaded !== 'function' || !map.isStyleLoaded()) return;
|
||||
ASSET_LAYER_ORDER.forEach(id => {
|
||||
if (map.getLayer(id)) map.moveLayer(id);
|
||||
if (map.getLayer(id)) {
|
||||
try {
|
||||
map.moveLayer(id);
|
||||
} catch (e) {
|
||||
// ignore if layer cannot be moved
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -661,16 +686,25 @@
|
|||
if (typeof LayerSwitcherControl === 'undefined') return;
|
||||
|
||||
const layerSwitcher = new LayerSwitcherControl({
|
||||
onBasemapChange: function() {
|
||||
bringAssetLayersToTop();
|
||||
},
|
||||
layers: [
|
||||
{
|
||||
id: 'ortho',
|
||||
name: 'Orthophotos',
|
||||
icon: 'satellite',
|
||||
group: 'ortho',
|
||||
beforeLayer: getBasemapBeforeLayer,
|
||||
onLayerAdded: () => bringAssetLayersToTop(),
|
||||
onVisibilityChange: (layerId, isVisible) => {
|
||||
if (isVisible) bringAssetLayersToTop();
|
||||
},
|
||||
layer: {
|
||||
type: 'raster',
|
||||
paint: { 'raster-opacity': 1 }
|
||||
},
|
||||
basemaps: (window.MAP_BACKGROUNDS && window.MAP_BACKGROUNDS.length > 0) ? window.MAP_BACKGROUNDS : undefined,
|
||||
sublayers: [
|
||||
{
|
||||
id: 'urbis',
|
||||
|
|
@ -700,6 +734,12 @@
|
|||
|
||||
map.addControl(layerSwitcher, 'top-left');
|
||||
|
||||
// Listen for layer switcher events on map to keep asset layers on top
|
||||
map.on('layerswitcher:layerAdded', bringAssetLayersToTop);
|
||||
map.on('layerswitcher:visibilityChange', (e) => {
|
||||
if (e && e.visible) bringAssetLayersToTop();
|
||||
});
|
||||
|
||||
// The LayerSwitcherControl appends sublayer selectors to document.body with
|
||||
// position:fixed and hardcoded coordinates designed for fullscreen maps.
|
||||
// We move each selector into #asset-map (position:relative) and switch to
|
||||
|
|
@ -723,8 +763,9 @@
|
|||
|
||||
// After any ortho interaction, put asset layers back on top
|
||||
map.getContainer().addEventListener('click', (e) => {
|
||||
if (e.target.closest('.layer-switcher-btn') || e.target.closest('.layer-switcher-sublayer-btn')) {
|
||||
setTimeout(bringAssetLayersToTop, 150);
|
||||
if (e.target.closest('.layer-switcher-btn') || e.target.closest('.layer-switcher-sublayer-btn') || e.target.closest('.layer-switcher-item')) {
|
||||
setTimeout(bringAssetLayersToTop, 50);
|
||||
setTimeout(bringAssetLayersToTop, 200);
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
|
@ -761,6 +802,10 @@
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
bringAssetLayersToTop();
|
||||
};
|
||||
|
||||
window.bringAssetLayersToTop = bringAssetLayersToTop;
|
||||
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -278,6 +278,10 @@
|
|||
}
|
||||
});
|
||||
|
||||
if (typeof window.bringAssetLayersToTop === 'function') {
|
||||
window.bringAssetLayersToTop();
|
||||
}
|
||||
|
||||
// Remove draw control and listeners
|
||||
if (drawInstance && drawControlAdded) {
|
||||
mapInstance.off('draw.create', handleDrawCreate);
|
||||
|
|
|
|||
|
|
@ -1852,11 +1852,11 @@
|
|||
{% block script %}
|
||||
<!-- MapLibre GL 5.7.0 -->
|
||||
<script src="{% static 'common/maplibre-gl.js' %}"></script>
|
||||
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
||||
<script src="{% static 'common/layer-switcher-control.js' %}?v=20261010_order"></script>
|
||||
<script src="{% static 'assets/asset_cms_edit_mode.js' %}"></script>
|
||||
<script src="{% static 'assets/asset_location_edit.js' %}"></script>
|
||||
<!-- MapLibre map initialization for asset detail -->
|
||||
<script src="{% static 'assets/asset_generic_map.js' %}"></script>
|
||||
<script src="{% static 'assets/asset_generic_map.js' %}?v=20261010_order"></script>
|
||||
<!-- MapboxDraw for geometry editing (Point, LineString, Polygon) -->
|
||||
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
|
||||
<script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script>
|
||||
|
|
@ -1865,11 +1865,11 @@
|
|||
{% block extra_scripts %}
|
||||
<!-- MapLibre GL 5.7.0 -->
|
||||
<script src="{% static 'common/maplibre-gl.js' %}"></script>
|
||||
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
||||
<script src="{% static 'common/layer-switcher-control.js' %}?v=20261010_order"></script>
|
||||
<script src="{% static 'assets/asset_cms_edit_mode.js' %}"></script>
|
||||
<script src="{% static 'assets/asset_location_edit.js' %}"></script>
|
||||
<!-- MapLibre map initialization for asset detail -->
|
||||
<script src="{% static 'assets/asset_generic_map.js' %}"></script>
|
||||
<script src="{% static 'assets/asset_generic_map.js' %}?v=20261010_order"></script>
|
||||
<!-- MapboxDraw for geometry editing (Point, LineString, Polygon) -->
|
||||
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
|
||||
<script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ class NatureLocationsGeoJSONTest(TestCase):
|
|||
|
||||
# Create a user
|
||||
self.user = User.objects.create_user(username="testuser", password="password123")
|
||||
self.config = UserConfig.objects.create(user=self.user)
|
||||
self.config = UserConfig.objects.create(user=self.user, is_intern=True, limit_assets_to_contracts=False)
|
||||
|
||||
# Give thematic access to user
|
||||
UserThematics.objects.create(
|
||||
|
|
@ -5720,6 +5720,8 @@ class NatureLocationDetailViewTest(TestCase):
|
|||
self.assertTemplateNotUsed(response, 'assets/naturegreensurface_detail.html')
|
||||
self.assertContains(response, "GS-001")
|
||||
self.assertContains(response, 'id="asset-map"')
|
||||
self.assertContains(response, 'asset_generic_map.js?v=20261010_order')
|
||||
self.assertContains(response, 'layer-switcher-control.js?v=20261010_order')
|
||||
|
||||
|
||||
|
||||
|
|
@ -5771,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")
|
||||
|
||||
|
|
|
|||
|
|
@ -748,6 +748,9 @@
|
|||
if (mainLayer.onVisibilityChange && typeof mainLayer.onVisibilityChange === 'function') {
|
||||
mainLayer.onVisibilityChange(layerId, false, this.map);
|
||||
}
|
||||
if (typeof this.options.onVisibilityChange === 'function') {
|
||||
this.options.onVisibilityChange(layerId, false, this.map);
|
||||
}
|
||||
if (typeof this.map.fire === 'function') {
|
||||
this.map.fire('layerswitcher:visibilityChange', { layerId, visible: false });
|
||||
}
|
||||
|
|
@ -776,7 +779,7 @@
|
|||
const mainLayer = this.layers[0] || {};
|
||||
let beforeLayer = typeof mainLayer.beforeLayer === 'function'
|
||||
? mainLayer.beforeLayer(this.map)
|
||||
: mainLayer.beforeLayer;
|
||||
: (mainLayer.beforeLayer || (typeof this.options.beforeLayer === 'function' ? this.options.beforeLayer(this.map) : this.options.beforeLayer));
|
||||
|
||||
if (this.map.getLayer('territory-mask')) {
|
||||
beforeLayer = 'territory-mask';
|
||||
|
|
@ -788,7 +791,15 @@
|
|||
beforeLayer = undefined;
|
||||
const layers = (this.map.getStyle() && this.map.getStyle().layers) || [];
|
||||
for (const l of layers) {
|
||||
if (l.id.startsWith('gl-draw-') || l.id.startsWith('custom-draw-') || l.id.startsWith('polygon-')) {
|
||||
if (
|
||||
l.id.startsWith('gl-draw-') ||
|
||||
l.id.startsWith('custom-draw-') ||
|
||||
l.id.startsWith('polygon-') ||
|
||||
l.id.startsWith('asset-') ||
|
||||
l.id.startsWith('nature-') ||
|
||||
l.id.startsWith('layer-') ||
|
||||
l.id.startsWith('intersection-')
|
||||
) {
|
||||
beforeLayer = l.id;
|
||||
break;
|
||||
}
|
||||
|
|
@ -827,9 +838,15 @@
|
|||
if (mainLayer.onLayerAdded && typeof mainLayer.onLayerAdded === 'function') {
|
||||
mainLayer.onLayerAdded(layerId, this.map);
|
||||
}
|
||||
if (typeof this.options.onLayerAdded === 'function') {
|
||||
this.options.onLayerAdded(layerId, this.map);
|
||||
}
|
||||
if (mainLayer.onVisibilityChange && typeof mainLayer.onVisibilityChange === 'function') {
|
||||
mainLayer.onVisibilityChange(layerId, true, this.map);
|
||||
}
|
||||
if (typeof this.options.onVisibilityChange === 'function') {
|
||||
this.options.onVisibilityChange(layerId, true, this.map);
|
||||
}
|
||||
if (typeof this.map.fire === 'function') {
|
||||
this.map.fire('layerswitcher:layerAdded', { layerId, config: bg });
|
||||
this.map.fire('layerswitcher:visibilityChange', { layerId, visible: true });
|
||||
|
|
|
|||
1
loko/controls/management/__init__.py
Normal file
1
loko/controls/management/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Management package for controls app."""
|
||||
1
loko/controls/management/commands/__init__.py
Normal file
1
loko/controls/management/commands/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Management commands for controls app."""
|
||||
180
loko/controls/management/commands/renumber_control_codes.py
Normal file
180
loko/controls/management/commands/renumber_control_codes.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue