Compare commits

..

2 commits

12 changed files with 730 additions and 29 deletions

View file

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

View file

@ -1039,21 +1039,29 @@ class Command(BaseCommand):
updated_begin = cur.rowcount updated_begin = cur.rowcount
self.log(f" Diagnostic initial (expected_begin) : {created_begin} créés, {updated_begin} mis à jour") 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) Annule les interventions arboricoles caduques ou périmées :
est strictement antérieure au dernier diagnostic phytosanitaire de l'arbre (last_phytosanitary_date). 1. Règle du nouveau diagnostic : prescriptions antérieures au dernier diagnostic de l'arbre
Enregistre un événement dans la timeline expliquant la raison de l'annulation. (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.db import connection
from django.utils import timezone from django.utils import timezone
from datetime import timedelta
content_type_tree = ContentType.objects.get_for_model(NatureTree) content_type_tree = ContentType.objects.get_for_model(NatureTree)
now = timezone.now() 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: 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(""" cur.execute("""
INSERT INTO interventions_interventiontimeline ( INSERT INTO interventions_interventiontimeline (
intervention_id, event_time, event_type, from_status, to_status, intervention_id, event_time, event_type, from_status, to_status,
@ -1065,9 +1073,7 @@ class Command(BaseCommand):
'status_change', 'status_change',
i.status, i.status,
'canceled', 'canceled',
'Intervention annulée automatiquement : prescription échue (' || 'Intervention annulée automatiquement : prescription caduque suite au nouveau diagnostic de l''arbre du ' ||
TO_CHAR(i.expected_end_time, 'DD/MM/YYYY') ||
') caduque suite au nouveau diagnostic de l''arbre du ' ||
TO_CHAR(t.last_phytosanitary_date, 'DD/MM/YYYY') || '.', TO_CHAR(t.last_phytosanitary_date, 'DD/MM/YYYY') || '.',
('{"status": ["' || i.status || '", "canceled"]}')::json ('{"status": ["' || i.status || '", "canceled"]}')::json
FROM interventions_intervention i FROM interventions_intervention i
@ -1075,16 +1081,47 @@ class Command(BaseCommand):
JOIN assets_naturetree t ON t.id = ia.object_id JOIN assets_naturetree t ON t.id = ia.object_id
WHERE ia.content_type_id = %s WHERE ia.content_type_id = %s
AND i.status NOT IN ('finished', 'canceled') AND i.status NOT IN ('finished', 'canceled')
AND i.expected_end_time IS NOT NULL
AND t.last_phytosanitary_date 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 ( AND NOT EXISTS (
SELECT 1 FROM interventions_interventiontimeline tl SELECT 1 FROM interventions_interventiontimeline tl
WHERE tl.intervention_id = i.id WHERE tl.intervention_id = i.id
AND tl.to_status = 'canceled' AND tl.to_status = 'canceled'
); );
""", [now, content_type_tree.id]) """, [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' # 2. Mise à jour du statut des interventions vers 'canceled'
cur.execute(""" cur.execute("""
@ -1093,17 +1130,34 @@ class Command(BaseCommand):
status_order = 200, status_order = 200,
cancellation_type = 'non_relevant' cancellation_type = 'non_relevant'
FROM interventions_interventionasset ia 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 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.status NOT IN ('finished', 'canceled')
AND i.expected_end_time IS NOT NULL AND (
AND t.last_phytosanitary_date IS NOT NULL (t.last_phytosanitary_date IS NOT NULL AND (
AND i.expected_end_time::date < t.last_phytosanitary_date; (i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
""", [content_type_tree.id]) 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 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): def sync_tree_maintenance_dates(self, gestion_file=None):
""" """

View file

@ -94,6 +94,9 @@
addIntersectionAssetsLayer(container); addIntersectionAssetsLayer(container);
addConnectionLinesLayer(); addConnectionLinesLayer();
} }
// Ensure asset layers are stacked above basemaps
bringAssetLayersToTop();
}); });
} }
@ -224,6 +227,8 @@
addLineLayer(); addLineLayer();
addPointLayer(); addPointLayer();
} }
bringAssetLayersToTop();
} }
function addPointLayer() { function addPointLayer() {
@ -651,9 +656,29 @@
'current-asset-icon', '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() { function bringAssetLayersToTop() {
if (!map || typeof map.isStyleLoaded !== 'function' || !map.isStyleLoaded()) return;
ASSET_LAYER_ORDER.forEach(id => { 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; if (typeof LayerSwitcherControl === 'undefined') return;
const layerSwitcher = new LayerSwitcherControl({ const layerSwitcher = new LayerSwitcherControl({
onBasemapChange: function() {
bringAssetLayersToTop();
},
layers: [ layers: [
{ {
id: 'ortho', id: 'ortho',
name: 'Orthophotos', name: 'Orthophotos',
icon: 'satellite', icon: 'satellite',
group: 'ortho', group: 'ortho',
beforeLayer: getBasemapBeforeLayer,
onLayerAdded: () => bringAssetLayersToTop(),
onVisibilityChange: (layerId, isVisible) => {
if (isVisible) bringAssetLayersToTop();
},
layer: { layer: {
type: 'raster', type: 'raster',
paint: { 'raster-opacity': 1 } paint: { 'raster-opacity': 1 }
}, },
basemaps: (window.MAP_BACKGROUNDS && window.MAP_BACKGROUNDS.length > 0) ? window.MAP_BACKGROUNDS : undefined,
sublayers: [ sublayers: [
{ {
id: 'urbis', id: 'urbis',
@ -700,6 +734,12 @@
map.addControl(layerSwitcher, 'top-left'); 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 // The LayerSwitcherControl appends sublayer selectors to document.body with
// position:fixed and hardcoded coordinates designed for fullscreen maps. // position:fixed and hardcoded coordinates designed for fullscreen maps.
// We move each selector into #asset-map (position:relative) and switch to // 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 // After any ortho interaction, put asset layers back on top
map.getContainer().addEventListener('click', (e) => { map.getContainer().addEventListener('click', (e) => {
if (e.target.closest('.layer-switcher-btn') || e.target.closest('.layer-switcher-sublayer-btn')) { if (e.target.closest('.layer-switcher-btn') || e.target.closest('.layer-switcher-sublayer-btn') || e.target.closest('.layer-switcher-item')) {
setTimeout(bringAssetLayersToTop, 150); setTimeout(bringAssetLayersToTop, 50);
setTimeout(bringAssetLayersToTop, 200);
} }
}, true); }, true);
} }
@ -761,6 +802,10 @@
}); });
} }
} }
bringAssetLayersToTop();
}; };
window.bringAssetLayersToTop = bringAssetLayersToTop;
})(); })();

View file

@ -278,6 +278,10 @@
} }
}); });
if (typeof window.bringAssetLayersToTop === 'function') {
window.bringAssetLayersToTop();
}
// Remove draw control and listeners // Remove draw control and listeners
if (drawInstance && drawControlAdded) { if (drawInstance && drawControlAdded) {
mapInstance.off('draw.create', handleDrawCreate); mapInstance.off('draw.create', handleDrawCreate);

View file

@ -1852,11 +1852,11 @@
{% block script %} {% block script %}
<!-- MapLibre GL 5.7.0 --> <!-- MapLibre GL 5.7.0 -->
<script src="{% static 'common/maplibre-gl.js' %}"></script> <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_cms_edit_mode.js' %}"></script>
<script src="{% static 'assets/asset_location_edit.js' %}"></script> <script src="{% static 'assets/asset_location_edit.js' %}"></script>
<!-- MapLibre map initialization for asset detail --> <!-- 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) --> <!-- MapboxDraw for geometry editing (Point, LineString, Polygon) -->
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script> <script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script> <script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script>
@ -1865,11 +1865,11 @@
{% block extra_scripts %} {% block extra_scripts %}
<!-- MapLibre GL 5.7.0 --> <!-- MapLibre GL 5.7.0 -->
<script src="{% static 'common/maplibre-gl.js' %}"></script> <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_cms_edit_mode.js' %}"></script>
<script src="{% static 'assets/asset_location_edit.js' %}"></script> <script src="{% static 'assets/asset_location_edit.js' %}"></script>
<!-- MapLibre map initialization for asset detail --> <!-- 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) --> <!-- MapboxDraw for geometry editing (Point, LineString, Polygon) -->
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script> <script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script> <script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script>

View file

@ -26,7 +26,7 @@ class NatureLocationsGeoJSONTest(TestCase):
# Create a user # Create a user
self.user = User.objects.create_user(username="testuser", password="password123") 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 # Give thematic access to user
UserThematics.objects.create( UserThematics.objects.create(
@ -5720,6 +5720,8 @@ class NatureLocationDetailViewTest(TestCase):
self.assertTemplateNotUsed(response, 'assets/naturegreensurface_detail.html') self.assertTemplateNotUsed(response, 'assets/naturegreensurface_detail.html')
self.assertContains(response, "GS-001") self.assertContains(response, "GS-001")
self.assertContains(response, 'id="asset-map"') 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.amenity_value, 0.0)
self.assertEqual(tree.cooling_energy_indicator, 0.0) self.assertEqual(tree.cooling_energy_indicator, 0.0)
self.assertEqual(tree.cooling_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")

View file

@ -748,6 +748,9 @@
if (mainLayer.onVisibilityChange && typeof mainLayer.onVisibilityChange === 'function') { if (mainLayer.onVisibilityChange && typeof mainLayer.onVisibilityChange === 'function') {
mainLayer.onVisibilityChange(layerId, false, this.map); 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') { if (typeof this.map.fire === 'function') {
this.map.fire('layerswitcher:visibilityChange', { layerId, visible: false }); this.map.fire('layerswitcher:visibilityChange', { layerId, visible: false });
} }
@ -776,7 +779,7 @@
const mainLayer = this.layers[0] || {}; const mainLayer = this.layers[0] || {};
let beforeLayer = typeof mainLayer.beforeLayer === 'function' let beforeLayer = typeof mainLayer.beforeLayer === 'function'
? mainLayer.beforeLayer(this.map) ? 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')) { if (this.map.getLayer('territory-mask')) {
beforeLayer = 'territory-mask'; beforeLayer = 'territory-mask';
@ -788,7 +791,15 @@
beforeLayer = undefined; beforeLayer = undefined;
const layers = (this.map.getStyle() && this.map.getStyle().layers) || []; const layers = (this.map.getStyle() && this.map.getStyle().layers) || [];
for (const l of 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; beforeLayer = l.id;
break; break;
} }
@ -827,9 +838,15 @@
if (mainLayer.onLayerAdded && typeof mainLayer.onLayerAdded === 'function') { if (mainLayer.onLayerAdded && typeof mainLayer.onLayerAdded === 'function') {
mainLayer.onLayerAdded(layerId, this.map); mainLayer.onLayerAdded(layerId, this.map);
} }
if (typeof this.options.onLayerAdded === 'function') {
this.options.onLayerAdded(layerId, this.map);
}
if (mainLayer.onVisibilityChange && typeof mainLayer.onVisibilityChange === 'function') { if (mainLayer.onVisibilityChange && typeof mainLayer.onVisibilityChange === 'function') {
mainLayer.onVisibilityChange(layerId, true, this.map); 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') { if (typeof this.map.fire === 'function') {
this.map.fire('layerswitcher:layerAdded', { layerId, config: bg }); this.map.fire('layerswitcher:layerAdded', { layerId, config: bg });
this.map.fire('layerswitcher:visibilityChange', { layerId, visible: true }); this.map.fire('layerswitcher:visibilityChange', { layerId, visible: true });

View file

@ -0,0 +1 @@
"""Management package for controls app."""

View file

@ -0,0 +1 @@
"""Management commands for controls app."""

View 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

View file

@ -663,6 +663,10 @@
text-decoration: none !important; text-decoration: none !important;
white-space: nowrap !important; white-space: nowrap !important;
display: inline-block; display: inline-block;
max-width: 125px;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
padding: 2px 7px; padding: 2px 7px;
border-radius: 5px; border-radius: 5px;
background-color: #f8fafc; background-color: #f8fafc;
@ -703,6 +707,7 @@
.controls-table td.col-control_code { .controls-table td.col-control_code {
max-width: 140px; max-width: 140px;
width: 140px; width: 140px;
min-width: 105px;
} }
.controls-table th.col-thematic_names, .controls-table th.col-thematic_names,

View file

@ -1,6 +1,7 @@
import io import io
import json import json
from django.test import TestCase from django.test import TestCase
from django.core.management import call_command
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.urls import reverse from django.urls import reverse
from openpyxl import load_workbook from openpyxl import load_workbook
@ -383,3 +384,41 @@ class ControlsIndexTests(TestCase):
self.ctrl1.status = "canceled" self.ctrl1.status = "canceled"
self.assertTrue(self.ctrl1.is_cancelled) 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")