Compare commits
3 commits
958d8dbc65
...
74ef7d3a34
| Author | SHA1 | Date | |
|---|---|---|---|
| 74ef7d3a34 | |||
| 9af4f99f17 | |||
| e201a928b8 |
7 changed files with 433 additions and 121 deletions
|
|
@ -0,0 +1,220 @@
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import connection, transaction
|
||||||
|
from assets.models import NatureLocation, NatureTree
|
||||||
|
from common.models import Municipality
|
||||||
|
|
||||||
|
|
||||||
|
MUNICIPALITY_PREFIXES = {
|
||||||
|
'AND': 'Anderlecht',
|
||||||
|
'AUD': 'Auderghem',
|
||||||
|
'BRK': 'Berchem-Sainte-Agathe',
|
||||||
|
'BSA': 'Berchem-Sainte-Agathe',
|
||||||
|
'BXL': 'Bruxelles',
|
||||||
|
'BRU': 'Bruxelles',
|
||||||
|
'ETT': 'Etterbeek',
|
||||||
|
'EVE': 'Evere',
|
||||||
|
'FOR': 'Forest',
|
||||||
|
'GAN': 'Ganshoren',
|
||||||
|
'IXL': 'Ixelles',
|
||||||
|
'IXE': 'Ixelles',
|
||||||
|
'JET': 'Jette',
|
||||||
|
'KOE': 'Koekelberg',
|
||||||
|
'MOL': 'Molenbeek-Saint-Jean',
|
||||||
|
'STG': 'Saint-Gilles',
|
||||||
|
'STJ': 'Saint-Josse-ten-Noode',
|
||||||
|
'SCH': 'Schaerbeek',
|
||||||
|
'UCC': 'Uccle',
|
||||||
|
'WAT': 'Watermael-Boitsfort',
|
||||||
|
'WLU': 'Woluwe-Saint-Lambert',
|
||||||
|
'WSL': 'Woluwe-Saint-Lambert',
|
||||||
|
'WSP': 'Woluwe-Saint-Pierre',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Rattache spatialement les arbres (NatureTree) aux tronçons des sections vertes (NatureLocation, ex: WAT_R22_008)."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--max-distance',
|
||||||
|
type=float,
|
||||||
|
default=25.0,
|
||||||
|
help="Distance maximale en mètres pour le rattachement (défaut: 25.0m, 0 pour intersection stricte)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--section-code',
|
||||||
|
type=str,
|
||||||
|
default=None,
|
||||||
|
help="Code d'un tronçon spécifique à cibler (ex: WAT_R22_008)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help="Simuler le rattachement sans modifier la base de données"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--skip-sync-munis',
|
||||||
|
action='store_true',
|
||||||
|
help="Ne pas synchroniser la commune sur les sections vertes"
|
||||||
|
)
|
||||||
|
|
||||||
|
def log(self, msg):
|
||||||
|
self.stdout.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
max_dist = options['max_distance']
|
||||||
|
target_section = options['section_code']
|
||||||
|
dry_run = options['dry_run']
|
||||||
|
sync_munis = not options['skip_sync_munis']
|
||||||
|
|
||||||
|
self.log("=== RATTACHEMENT SPATIAL DES ARBRES AUX TRONÇONS DE SECTIONS VERTES ===")
|
||||||
|
self.log(f"Distance max : {max_dist} m {'(Intersection stricte)' if max_dist == 0 else ''}")
|
||||||
|
if target_section:
|
||||||
|
self.log(f"Section ciblée : {target_section}")
|
||||||
|
if dry_run:
|
||||||
|
self.log("[SIMULATION / DRY-RUN] Aucune modification ne sera enregistrée.")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# 1. Synchronisation des communes sur les sections vertes
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
if sync_munis and not dry_run:
|
||||||
|
self.log("1. Synchronisation des communes sur les sections vertes...")
|
||||||
|
munis_by_name = {m.name_fr.lower(): m for m in Municipality.objects.all()}
|
||||||
|
updated_munis = 0
|
||||||
|
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
# Intersections géométriques
|
||||||
|
cursor.execute("""
|
||||||
|
UPDATE assets_naturelocation l
|
||||||
|
SET municipality_id = m.id
|
||||||
|
FROM common_municipality m
|
||||||
|
WHERE l.code ~ '^[A-Z]{3}_'
|
||||||
|
AND l.municipality_id IS NULL
|
||||||
|
AND l.geom IS NOT NULL
|
||||||
|
AND ST_Intersects(ST_PointOnSurface(l.geom), m.geom);
|
||||||
|
""")
|
||||||
|
updated_munis += cursor.rowcount
|
||||||
|
|
||||||
|
# Complément par préfixe de code pour les cas frontaliers
|
||||||
|
locs_without_muni = NatureLocation.objects.filter(code__regex=r'^[A-Z]{3}_', municipality__isnull=True)
|
||||||
|
for loc in locs_without_muni:
|
||||||
|
pfx = loc.code[:3].upper()
|
||||||
|
muni_name = MUNICIPALITY_PREFIXES.get(pfx)
|
||||||
|
if muni_name and muni_name.lower() in munis_by_name:
|
||||||
|
loc.municipality = munis_by_name[muni_name.lower()]
|
||||||
|
loc.save(update_fields=['municipality'])
|
||||||
|
updated_munis += 1
|
||||||
|
|
||||||
|
self.log(f" Communes renseignées sur {updated_munis} sections vertes.")
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# 2. Analyse et Rattachement des Arbres
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
t0 = time.time()
|
||||||
|
self.log("2. Analyse géospatiale du rattachement arbres -> sections vertes...")
|
||||||
|
|
||||||
|
section_filter_sql = ""
|
||||||
|
params = [max_dist]
|
||||||
|
if target_section:
|
||||||
|
section_filter_sql = "AND nearest.code = %s"
|
||||||
|
params.append(target_section)
|
||||||
|
|
||||||
|
count_query = f"""
|
||||||
|
WITH nearest AS (
|
||||||
|
SELECT
|
||||||
|
t.id AS tree_id,
|
||||||
|
nearest_loc.id AS loc_id,
|
||||||
|
nearest_loc.code AS loc_code,
|
||||||
|
nearest_loc.name_fr AS loc_name,
|
||||||
|
nearest_loc.dist
|
||||||
|
FROM assets_naturetree t
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT l.id, l.code, l.name_fr, ST_Distance(t.geom, l.geom) AS dist
|
||||||
|
FROM assets_naturelocation l
|
||||||
|
WHERE l.code ~ '^[A-Z]{{3}}_' AND l.geom IS NOT NULL
|
||||||
|
ORDER BY t.geom <-> l.geom
|
||||||
|
LIMIT 1
|
||||||
|
) nearest_loc
|
||||||
|
WHERE t.geom IS NOT NULL AND t.lat != 0 AND t.lon != 0
|
||||||
|
AND nearest_loc.dist <= %s
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_matched,
|
||||||
|
COUNT(*) FILTER (WHERE dist = 0) AS inside,
|
||||||
|
COUNT(*) FILTER (WHERE dist > 0 AND dist <= 5) AS within_5m,
|
||||||
|
COUNT(*) FILTER (WHERE dist > 5 AND dist <= 15) AS within_15m,
|
||||||
|
COUNT(*) FILTER (WHERE dist > 15 AND dist <= 25) AS within_25m,
|
||||||
|
COUNT(*) FILTER (WHERE dist > 25) AS beyond_25m,
|
||||||
|
COUNT(DISTINCT loc_id) AS distinct_sections
|
||||||
|
FROM nearest
|
||||||
|
WHERE 1=1 {section_filter_sql};
|
||||||
|
"""
|
||||||
|
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(count_query, params)
|
||||||
|
stats = cursor.fetchone()
|
||||||
|
|
||||||
|
total_matched = stats[0]
|
||||||
|
inside = stats[1]
|
||||||
|
w5m = stats[2]
|
||||||
|
w15m = stats[3]
|
||||||
|
w25m = stats[4]
|
||||||
|
b25m = stats[5]
|
||||||
|
distinct_sections = stats[6]
|
||||||
|
|
||||||
|
self.log(f" Arbres éligibles au rattachement : {total_matched}")
|
||||||
|
self.log(f" - Directement à l'intérieur (0m) : {inside}")
|
||||||
|
self.log(f" - Proximité immédiate (1 à 5m) : {w5m}")
|
||||||
|
self.log(f" - Bordure de voirie (5 à 15m) : {w15m}")
|
||||||
|
self.log(f" - Zone étendue (15 à 25m) : {w25m}")
|
||||||
|
if b25m:
|
||||||
|
self.log(f" - Au-delà (> 25m) : {b25m}")
|
||||||
|
self.log(f" Nombre de tronçons / sections concernés : {distinct_sections}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.log("Simulation terminée avec succès (aucune écriture en base).")
|
||||||
|
return
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# 3. Application de la mise à jour
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
self.log("3. Application de la mise à jour des liaisons (location_id)...")
|
||||||
|
update_query = f"""
|
||||||
|
WITH candidates AS (
|
||||||
|
SELECT
|
||||||
|
t.id AS tree_id,
|
||||||
|
nearest.id AS loc_id
|
||||||
|
FROM assets_naturetree t
|
||||||
|
CROSS JOIN LATERAL (
|
||||||
|
SELECT l.id, l.code, ST_Distance(t.geom, l.geom) AS dist
|
||||||
|
FROM assets_naturelocation l
|
||||||
|
WHERE l.code ~ '^[A-Z]{{3}}_' AND l.geom IS NOT NULL
|
||||||
|
ORDER BY t.geom <-> l.geom
|
||||||
|
LIMIT 1
|
||||||
|
) nearest
|
||||||
|
WHERE t.geom IS NOT NULL AND t.lat != 0 AND t.lon != 0
|
||||||
|
AND nearest.dist <= %s
|
||||||
|
{section_filter_sql}
|
||||||
|
)
|
||||||
|
UPDATE assets_naturetree t
|
||||||
|
SET location_id = c.loc_id
|
||||||
|
FROM candidates c
|
||||||
|
WHERE t.id = c.tree_id
|
||||||
|
AND (t.location_id IS DISTINCT FROM c.loc_id);
|
||||||
|
"""
|
||||||
|
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute(update_query, params)
|
||||||
|
updated_count = cursor.rowcount
|
||||||
|
|
||||||
|
t1 = time.time()
|
||||||
|
self.log(f"Mise à jour terminée : {updated_count} arbres rattachés en {t1 - t0:.2f} secondes.")
|
||||||
|
|
||||||
|
# Vérification ciblée (par exemple WAT_R22_008)
|
||||||
|
check_code = target_section or 'WAT_R22_008'
|
||||||
|
check_loc = NatureLocation.objects.filter(code=check_code).first()
|
||||||
|
if check_loc:
|
||||||
|
attached_cnt = check_loc.nature_trees.count()
|
||||||
|
self.log(f"Vérification sur {check_code} ({check_loc.name_fr}) : {attached_cnt} arbres rattachés.")
|
||||||
|
|
@ -83,6 +83,11 @@ class Command(BaseCommand):
|
||||||
action='store_true',
|
action='store_true',
|
||||||
help="Ignorer l'importation des interventions"
|
help="Ignorer l'importation des interventions"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--sync-tree-dates',
|
||||||
|
action='store_true',
|
||||||
|
help="Synchroniser uniquement les dates de maintenance sur les arbres (prochaine inspection, diagnostic, périodicité, dernière taille)"
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--limit',
|
'--limit',
|
||||||
type=int,
|
type=int,
|
||||||
|
|
@ -115,6 +120,12 @@ class Command(BaseCommand):
|
||||||
alt = os.path.expanduser('~/Downloads/fulcrum_interventions.geojson')
|
alt = os.path.expanduser('~/Downloads/fulcrum_interventions.geojson')
|
||||||
if os.path.exists(alt): interventions_file = alt
|
if os.path.exists(alt): interventions_file = alt
|
||||||
|
|
||||||
|
if options.get('sync_tree_dates'):
|
||||||
|
self.log("=== SYNCHRONISATION DES DATES DE MAINTENANCE SUR LES ARBRES ===")
|
||||||
|
self.sync_tree_maintenance_dates(gestion_file)
|
||||||
|
self.log("=== FIN DE LA SYNCHRONISATION DES DATES ===")
|
||||||
|
return
|
||||||
|
|
||||||
self.log("=== DÉBUT IMPORTATION DONNÉES FULCRUM (ARBRES & INTERVENTIONS) ===")
|
self.log("=== DÉBUT IMPORTATION DONNÉES FULCRUM (ARBRES & INTERVENTIONS) ===")
|
||||||
self.log(f"Fichier arbres : {arbes_file}")
|
self.log(f"Fichier arbres : {arbes_file}")
|
||||||
self.log(f"Fichier gestion : {gestion_file}")
|
self.log(f"Fichier gestion : {gestion_file}")
|
||||||
|
|
@ -179,6 +190,11 @@ class Command(BaseCommand):
|
||||||
order_map, order_contract_map, tree_uuid_to_id, sit_pos_to_tree_id, limit
|
order_map, order_contract_map, tree_uuid_to_id, sit_pos_to_tree_id, limit
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
# ÉTAPE 4 : SYNCHRONISATION DES DATES SUR LES ARBRES
|
||||||
|
# -------------------------------------------------------------
|
||||||
|
self.sync_tree_maintenance_dates(gestion_file)
|
||||||
|
|
||||||
self.log("=== FIN DE L'IMPORTATION AVEC SUCCÈS ===")
|
self.log("=== FIN DE L'IMPORTATION AVEC SUCCÈS ===")
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
|
|
@ -556,6 +572,15 @@ class Command(BaseCommand):
|
||||||
if matched_id: break
|
if matched_id: break
|
||||||
if matched_id: break
|
if matched_id: break
|
||||||
|
|
||||||
|
# Date de diagnostic / dernière mise à jour
|
||||||
|
maj_date = props.get('maj_date')
|
||||||
|
last_phytosanitary_date = None
|
||||||
|
if maj_date:
|
||||||
|
try:
|
||||||
|
last_phytosanitary_date = datetime.fromisoformat(maj_date.replace('Z', '+00:00')).date()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
sit_pos_key = f"{sit_id}.{pos_id}" if sit_id and pos_id else None
|
sit_pos_key = f"{sit_id}.{pos_id}" if sit_id and pos_id else None
|
||||||
|
|
||||||
if matched_id:
|
if matched_id:
|
||||||
|
|
@ -587,6 +612,7 @@ class Command(BaseCommand):
|
||||||
'development_stage': dev_stage,
|
'development_stage': dev_stage,
|
||||||
'pruning_type': pruning,
|
'pruning_type': pruning,
|
||||||
'planting_date': planting_date,
|
'planting_date': planting_date,
|
||||||
|
'last_phytosanitary_date': last_phytosanitary_date,
|
||||||
'model_id': asset_model.id if asset_model else None,
|
'model_id': asset_model.id if asset_model else None,
|
||||||
'location_id': location.id if location else None,
|
'location_id': location.id if location else None,
|
||||||
'lon': lon,
|
'lon': lon,
|
||||||
|
|
@ -628,6 +654,7 @@ class Command(BaseCommand):
|
||||||
development_stage=dev_stage,
|
development_stage=dev_stage,
|
||||||
pruning_type=pruning,
|
pruning_type=pruning,
|
||||||
planting_date=planting_date,
|
planting_date=planting_date,
|
||||||
|
last_phytosanitary_date=last_phytosanitary_date,
|
||||||
model=asset_model,
|
model=asset_model,
|
||||||
location=location,
|
location=location,
|
||||||
lon=lon,
|
lon=lon,
|
||||||
|
|
@ -668,7 +695,7 @@ class Command(BaseCommand):
|
||||||
'amenity_value', 'carbon_stock', 'cooling_indicator', 'cooling_energy_indicator', 'biodiversity_index',
|
'amenity_value', 'carbon_stock', 'cooling_indicator', 'cooling_energy_indicator', 'biodiversity_index',
|
||||||
'managing_authority', 'management_type', 'status', 'vitality', 'is_dead', 'is_stump',
|
'managing_authority', 'management_type', 'status', 'vitality', 'is_dead', 'is_stump',
|
||||||
'is_remarkable', 'is_chronoxyle', 'development_stage', 'pruning_type',
|
'is_remarkable', 'is_chronoxyle', 'development_stage', 'pruning_type',
|
||||||
'planting_date', 'model_id', 'location_id', 'lon', 'lat', 'geom', 'geojson'
|
'planting_date', 'last_phytosanitary_date', 'model_id', 'location_id', 'lon', 'lat', 'geom', 'geojson'
|
||||||
],
|
],
|
||||||
batch_size=BATCH_SIZE
|
batch_size=BATCH_SIZE
|
||||||
)
|
)
|
||||||
|
|
@ -796,7 +823,7 @@ class Command(BaseCommand):
|
||||||
status = 'to_be_planned'
|
status = 'to_be_planned'
|
||||||
status_order = 5
|
status_order = 5
|
||||||
|
|
||||||
# Date limite
|
# Date limite & Date de diagnostic
|
||||||
deadline_str = props.get('in_deadline')
|
deadline_str = props.get('in_deadline')
|
||||||
expected_end_time = None
|
expected_end_time = None
|
||||||
if deadline_str:
|
if deadline_str:
|
||||||
|
|
@ -805,6 +832,14 @@ class Command(BaseCommand):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
dia_date_str = props.get('dia_date')
|
||||||
|
expected_begin_time = None
|
||||||
|
if dia_date_str:
|
||||||
|
try:
|
||||||
|
expected_begin_time = datetime.fromisoformat(dia_date_str.replace('Z', '+00:00'))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Géométrie MultiPolygon SRID 3812
|
# Géométrie MultiPolygon SRID 3812
|
||||||
pt_3812 = Point(lon, lat, srid=4326).transform(3812, clone=True)
|
pt_3812 = Point(lon, lat, srid=4326).transform(3812, clone=True)
|
||||||
buffered_geom = MultiPolygon(pt_3812.buffer(1))
|
buffered_geom = MultiPolygon(pt_3812.buffer(1))
|
||||||
|
|
@ -828,6 +863,7 @@ class Command(BaseCommand):
|
||||||
existing_itv.type = action_type
|
existing_itv.type = action_type
|
||||||
existing_itv.status = status
|
existing_itv.status = status
|
||||||
existing_itv.status_order = status_order
|
existing_itv.status_order = status_order
|
||||||
|
existing_itv.expected_begin_time = expected_begin_time
|
||||||
existing_itv.expected_end_time = expected_end_time
|
existing_itv.expected_end_time = expected_end_time
|
||||||
existing_itv.order_id = order_id
|
existing_itv.order_id = order_id
|
||||||
existing_itv.contract_id = contract_id
|
existing_itv.contract_id = contract_id
|
||||||
|
|
@ -849,6 +885,7 @@ class Command(BaseCommand):
|
||||||
type=action_type,
|
type=action_type,
|
||||||
status=status,
|
status=status,
|
||||||
status_order=status_order,
|
status_order=status_order,
|
||||||
|
expected_begin_time=expected_begin_time,
|
||||||
expected_end_time=expected_end_time,
|
expected_end_time=expected_end_time,
|
||||||
thematic=thematic_nature,
|
thematic=thematic_nature,
|
||||||
asset_category=cat_arbres,
|
asset_category=cat_arbres,
|
||||||
|
|
@ -876,7 +913,8 @@ class Command(BaseCommand):
|
||||||
chunk,
|
chunk,
|
||||||
fields=[
|
fields=[
|
||||||
'title', 'description', 'address', 'location_code', 'priority',
|
'title', 'description', 'address', 'location_code', 'priority',
|
||||||
'maintain_type', 'type', 'status', 'status_order', 'expected_end_time',
|
'maintain_type', 'type', 'status', 'status_order',
|
||||||
|
'expected_begin_time', 'expected_end_time',
|
||||||
'order_id', 'contract_id', 'source_detail', 'lon', 'lat', 'geom'
|
'order_id', 'contract_id', 'source_detail', 'lon', 'lat', 'geom'
|
||||||
],
|
],
|
||||||
batch_size=BATCH_SIZE
|
batch_size=BATCH_SIZE
|
||||||
|
|
@ -935,3 +973,104 @@ class Command(BaseCommand):
|
||||||
self.log(f" Insertion liens : {min(i + BATCH_SIZE, len(assets_to_create))} / {len(assets_to_create)}")
|
self.log(f" Insertion liens : {min(i + BATCH_SIZE, len(assets_to_create))} / {len(assets_to_create)}")
|
||||||
|
|
||||||
self.log(f"Importation des interventions terminée. Total en base : {Intervention.objects.count()}")
|
self.log(f"Importation des interventions terminée. Total en base : {Intervention.objects.count()}")
|
||||||
|
|
||||||
|
def sync_tree_maintenance_dates(self, gestion_file=None):
|
||||||
|
"""
|
||||||
|
Synchronise les dates et périodicités de maintenance sur les arbres :
|
||||||
|
- next_inspection_date : min(expected_end_time) des interventions en cours
|
||||||
|
- pruning_frequency_years : périodicité extraite du délai/titre (ex: Dans 1 an -> 1)
|
||||||
|
- last_phytosanitary_date : max(expected_begin_time) des interventions de diagnostic
|
||||||
|
- last_pruning_date : date d'exécution de la dernière taille (depuis gestion.csv ou interventions passées)
|
||||||
|
"""
|
||||||
|
self.log("--- Synchronisation des dates de maintenance sur les arbres ---")
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
content_type_tree = ContentType.objects.get_for_model(NatureTree)
|
||||||
|
|
||||||
|
with connection.cursor() as cur:
|
||||||
|
# 1. Prochaine échéance prévisionnelle (next_inspection_date)
|
||||||
|
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 != 'finished'
|
||||||
|
AND i.expected_end_time IS NOT NULL
|
||||||
|
GROUP BY ia.object_id
|
||||||
|
) sub
|
||||||
|
WHERE t.id = sub.tree_id;
|
||||||
|
""", [content_type_tree.id])
|
||||||
|
self.log(f" Prochaine inspection (next_inspection_date) mise à jour : {cur.rowcount} arbres")
|
||||||
|
|
||||||
|
# 2. Périodicité de taille en années (pruning_frequency_years)
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE assets_naturetree t
|
||||||
|
SET pruning_frequency_years = sub.freq
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (ia.object_id)
|
||||||
|
ia.object_id AS tree_id,
|
||||||
|
CAST(SUBSTRING(i.title FROM 'Dans ([0-9]+) an') AS INTEGER) AS freq
|
||||||
|
FROM interventions_interventionasset ia
|
||||||
|
JOIN interventions_intervention i ON i.id = ia.intervention_id
|
||||||
|
WHERE ia.content_type_id = %s
|
||||||
|
AND i.title ~ 'Dans [0-9]+ an'
|
||||||
|
ORDER BY ia.object_id, i.id DESC
|
||||||
|
) sub
|
||||||
|
WHERE t.id = sub.tree_id;
|
||||||
|
""", [content_type_tree.id])
|
||||||
|
self.log(f" Périodicité de taille (pruning_frequency_years) mise à jour : {cur.rowcount} arbres")
|
||||||
|
|
||||||
|
# 3. Dernier diagnostic phytosanitaire depuis les interventions
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE assets_naturetree t
|
||||||
|
SET last_phytosanitary_date = GREATEST(COALESCE(t.last_phytosanitary_date, sub.max_dia), sub.max_dia)
|
||||||
|
FROM (
|
||||||
|
SELECT ia.object_id AS tree_id, MAX(i.expected_begin_time)::date AS max_dia
|
||||||
|
FROM interventions_interventionasset ia
|
||||||
|
JOIN interventions_intervention i ON i.id = ia.intervention_id
|
||||||
|
WHERE ia.content_type_id = %s
|
||||||
|
AND i.expected_begin_time IS NOT NULL
|
||||||
|
GROUP BY ia.object_id
|
||||||
|
) sub
|
||||||
|
WHERE t.id = sub.tree_id;
|
||||||
|
""", [content_type_tree.id])
|
||||||
|
self.log(f" Dernier diagnostic (last_phytosanitary_date) consolidé : {cur.rowcount} arbres")
|
||||||
|
|
||||||
|
# 4. Dernière taille (last_pruning_date) depuis fulcrum_gestion.csv si disponible
|
||||||
|
if gestion_file and os.path.exists(gestion_file):
|
||||||
|
self.log(" Recherche des tailles exécutées dans gestion.csv...")
|
||||||
|
try:
|
||||||
|
pruning_updates = {}
|
||||||
|
with open(gestion_file, mode='r', encoding='utf-8') as f:
|
||||||
|
reader = csv.DictReader(f)
|
||||||
|
for r in reader:
|
||||||
|
if r.get('_status') in ('Execute', 'Controle', 'Reception provisoire', 'Valide'):
|
||||||
|
pos_lk = r.get('com_pos_lk')
|
||||||
|
date_exec = r.get('pla_fin') or r.get('co_dat') or r.get('com_dat')
|
||||||
|
cat = r.get('com_cat') or ''
|
||||||
|
if pos_lk and date_exec and ('TRA' in cat or 'ELAG' in cat.upper()):
|
||||||
|
try:
|
||||||
|
d = datetime.strptime(date_exec[:10], '%Y-%m-%d').date()
|
||||||
|
if pos_lk not in pruning_updates or d > pruning_updates[pos_lk]:
|
||||||
|
pruning_updates[pos_lk] = d
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if pruning_updates:
|
||||||
|
trees_to_update = []
|
||||||
|
for t in NatureTree.objects.filter(external_reference__in=pruning_updates.keys()):
|
||||||
|
pdate = pruning_updates[t.external_reference]
|
||||||
|
if not t.last_pruning_date or pdate > t.last_pruning_date:
|
||||||
|
t.last_pruning_date = pdate
|
||||||
|
trees_to_update.append(t)
|
||||||
|
if trees_to_update:
|
||||||
|
BATCH_SIZE = 2000
|
||||||
|
for i in range(0, len(trees_to_update), BATCH_SIZE):
|
||||||
|
NatureTree.objects.bulk_update(trees_to_update[i:i+BATCH_SIZE], fields=['last_pruning_date'])
|
||||||
|
self.log(f" Dernière taille (last_pruning_date) mise à jour : {len(trees_to_update)} arbres")
|
||||||
|
except Exception as e:
|
||||||
|
self.log(f" Erreur lors de la lecture des tailles dans gestion.csv : {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
{% block tab_actions %}
|
{% block tab_actions %}
|
||||||
<li class="nav-item ms-auto">
|
<li class="nav-item ms-auto">
|
||||||
<a href="{% url 'assets:management_zones_index' %}?thematic=nature" class="btn btn-sm btn-outline-success d-inline-flex align-items-center gap-1 my-1 me-2" title="{% translate 'Gérer les zones de gestion écologique et d'entretien' %}">
|
<a href="{% url 'assets:management_zones_index' %}?thematic=nature" class="btn btn-sm btn-outline-success d-inline-flex align-items-center gap-1 my-1 me-2" title="{% translate "Gérer les zones de gestion écologique et d'entretien" %}">
|
||||||
<i class="bi bi-diagram-3"></i>
|
<i class="bi bi-diagram-3"></i>
|
||||||
<span>{% translate "Zones de gestion" %}</span>
|
<span>{% translate "Zones de gestion" %}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -56,13 +56,12 @@
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
||||||
<nav aria-label="breadcrumb">
|
<nav aria-label="breadcrumb">
|
||||||
<ol class="breadcrumb mb-0">
|
<ol class="breadcrumb mb-0">
|
||||||
<li class="breadcrumb-item"><a href="{% url 'assets:nature_index' %}" class="text-decoration-none">{% translate "Nature" %}</a></li>
|
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}?thematic=nature" class="text-decoration-none">{% translate "Nature" %}</a></li>
|
||||||
<li class="breadcrumb-item"><a href="{% url 'assets:nature_locations' %}" class="text-decoration-none">{% translate "Voiries" %}</a></li>
|
|
||||||
<li class="breadcrumb-item active fw-semibold" aria-current="page">{{ section.code }}</li>
|
<li class="breadcrumb-item active fw-semibold" aria-current="page">{{ section.code }}</li>
|
||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<a href="{% url 'assets:nature_index' %}" class="btn btn-sm btn-outline-secondary">
|
<a href="{% url 'assets:index' %}?thematic=nature" class="btn btn-sm btn-outline-secondary">
|
||||||
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la carte" %}
|
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la carte" %}
|
||||||
</a>
|
</a>
|
||||||
{% if section.lon and section.lat %}
|
{% if section.lon and section.lat %}
|
||||||
|
|
|
||||||
|
|
@ -5665,9 +5665,23 @@ class NatureLocationDetailViewTest(TestCase):
|
||||||
self.assertContains(response, "TR-001")
|
self.assertContains(response, "TR-001")
|
||||||
self.assertContains(response, "SHR-001")
|
self.assertContains(response, "SHR-001")
|
||||||
self.assertContains(response, "BNC-001")
|
self.assertContains(response, "BNC-001")
|
||||||
self.assertContains(response, "Quercus robur")
|
|
||||||
self.assertContains(response, "layer-switcher-control.js")
|
|
||||||
self.assertContains(response, "nature_location_detail.js")
|
self.assertContains(response, "nature_location_detail.js")
|
||||||
|
unified_nature_url = f'{reverse("assets:index")}?thematic=nature'
|
||||||
|
self.assertContains(response, f'href="{unified_nature_url}"')
|
||||||
|
self.assertNotContains(response, f'href="{reverse("assets:nature_index")}"')
|
||||||
|
self.assertNotContains(response, f'href="{reverse("assets:nature_locations")}"')
|
||||||
|
|
||||||
|
def test_nature_index_redirects_to_unified_index(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse('assets:nature_index'))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, f'{reverse("assets:index")}?thematic=nature')
|
||||||
|
|
||||||
|
def test_nature_locations_redirects_to_unified_index(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get(reverse('assets:nature_locations'))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response.url, f'{reverse("assets:index")}?thematic=nature')
|
||||||
|
|
||||||
def test_nature_locations_detail_view_empty(self):
|
def test_nature_locations_detail_view_empty(self):
|
||||||
empty_loc = NatureLocation.objects.create(
|
empty_loc = NatureLocation.objects.create(
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,8 @@ class ImportFulcrumTreesCommandTest(TestCase):
|
||||||
"arb_ont": "Ad",
|
"arb_ont": "Ad",
|
||||||
"ges_conduite": "LI",
|
"ges_conduite": "LI",
|
||||||
"cf_sit": 4.5,
|
"cf_sit": 4.5,
|
||||||
"arb_anpla": "1995"
|
"arb_anpla": "1995",
|
||||||
|
"maj_date": "2024-05-10T00:00:00.000Z"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
@ -88,13 +89,15 @@ class ImportFulcrumTreesCommandTest(TestCase):
|
||||||
},
|
},
|
||||||
"properties": {
|
"properties": {
|
||||||
"_record_id": mock_itv_uuid,
|
"_record_id": mock_itv_uuid,
|
||||||
"_title": "Élagage de formation",
|
"_title": "099.01.001, Formation, Dans 1 an",
|
||||||
"rec_interv_com": "Taille branches basses",
|
"rec_interv_com": "Taille branches basses",
|
||||||
"sit_nom": "Boulevard du Test",
|
"sit_nom": "Boulevard du Test",
|
||||||
"comm_commune": "Bruxelles",
|
"comm_commune": "Bruxelles",
|
||||||
"sit_id": "099.01",
|
"sit_id": "099.01",
|
||||||
"pos_id": "001",
|
"pos_id": "001",
|
||||||
"dia_comma": "BC-TEST-001",
|
"dia_comma": "BC-TEST-001",
|
||||||
|
"dia_date": "2024-05-10T00:00:00.000Z",
|
||||||
|
"in_deadline": "2025-05-10T00:00:00.000Z",
|
||||||
"rec_delai": "Pla-1",
|
"rec_delai": "Pla-1",
|
||||||
"rec_interv": "FO",
|
"rec_interv": "FO",
|
||||||
"in_fin_yn": "no",
|
"in_fin_yn": "no",
|
||||||
|
|
@ -145,6 +148,15 @@ class ImportFulcrumTreesCommandTest(TestCase):
|
||||||
self.assertEqual(itv.order_id, order.id)
|
self.assertEqual(itv.order_id, order.id)
|
||||||
self.assertEqual(itv.priority, "3") # Pla-1 -> 3
|
self.assertEqual(itv.priority, "3") # Pla-1 -> 3
|
||||||
self.assertEqual(itv.status, "to_be_planned")
|
self.assertEqual(itv.status, "to_be_planned")
|
||||||
|
self.assertIsNotNone(itv.expected_begin_time)
|
||||||
|
self.assertEqual(itv.expected_begin_time.date(), date(2024, 5, 10))
|
||||||
|
self.assertIsNotNone(itv.expected_end_time)
|
||||||
|
self.assertEqual(itv.expected_end_time.date(), date(2025, 5, 10))
|
||||||
|
|
||||||
|
# Vérifications dates de maintenance sur l'arbre
|
||||||
|
self.assertEqual(tree.last_phytosanitary_date, date(2024, 5, 10))
|
||||||
|
self.assertEqual(tree.next_inspection_date, date(2025, 5, 10))
|
||||||
|
self.assertEqual(tree.pruning_frequency_years, 1)
|
||||||
|
|
||||||
# Vérification liaison InterventionAsset
|
# Vérification liaison InterventionAsset
|
||||||
links = InterventionAsset.objects.filter(intervention=itv)
|
links = InterventionAsset.objects.filter(intervention=itv)
|
||||||
|
|
@ -181,4 +193,36 @@ class ImportFulcrumTreesCommandTest(TestCase):
|
||||||
call_command('import_fulcrum_regional_trees', skip_trees=True, skip_gestion=True, skip_interventions=True, stdout=out)
|
call_command('import_fulcrum_regional_trees', skip_trees=True, skip_gestion=True, skip_interventions=True, stdout=out)
|
||||||
self.assertIn("FIN DE L'IMPORTATION", out.getvalue())
|
self.assertIn("FIN DE L'IMPORTATION", out.getvalue())
|
||||||
|
|
||||||
|
def test_attach_trees_to_green_sections(self):
|
||||||
|
from assets.models import NatureLocation
|
||||||
|
from django.contrib.gis.geos import Polygon, MultiPolygon
|
||||||
|
# Créer une section verte polygonale
|
||||||
|
poly = Polygon(((648000, 670000), (648100, 670000), (648100, 670100), (648000, 670100), (648000, 670000)), srid=3812)
|
||||||
|
loc = NatureLocation.objects.create(
|
||||||
|
code="WAT_R22_008",
|
||||||
|
name_fr="Boulevard du Souverain",
|
||||||
|
geom=MultiPolygon(poly, srid=3812)
|
||||||
|
)
|
||||||
|
# Créer un arbre à l'intérieur du polygone (sans location)
|
||||||
|
pt = Point(648050, 670050, srid=3812)
|
||||||
|
pt_4326 = pt.clone().transform(4326, clone=True)
|
||||||
|
tree = NatureTree.objects.create(
|
||||||
|
code="TR-ATTACH-TEST",
|
||||||
|
name_fr="Arbre test Souverain",
|
||||||
|
category=self.category,
|
||||||
|
geom=pt,
|
||||||
|
lon=pt_4326.x,
|
||||||
|
lat=pt_4326.y,
|
||||||
|
location=None
|
||||||
|
)
|
||||||
|
self.assertEqual(loc.nature_trees.count(), 0)
|
||||||
|
|
||||||
|
# Exécuter le rattachement
|
||||||
|
call_command('attach_trees_to_green_sections', max_distance=25, skip_sync_munis=True)
|
||||||
|
|
||||||
|
tree.refresh_from_db()
|
||||||
|
self.assertEqual(tree.location_id, loc.id)
|
||||||
|
self.assertEqual(loc.nature_trees.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
from django.shortcuts import render, get_object_or_404
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
from django.http import JsonResponse, Http404
|
from django.http import JsonResponse, Http404
|
||||||
from django.contrib.gis.db.models.functions import Transform
|
from django.contrib.gis.db.models.functions import Transform
|
||||||
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
|
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
|
||||||
|
|
@ -28,119 +28,15 @@ from interventions.views.drafts import InterventionDraftManager
|
||||||
|
|
||||||
@check_thematic_access('nature')
|
@check_thematic_access('nature')
|
||||||
def nature_index(request):
|
def nature_index(request):
|
||||||
thematic = 'nature'
|
query_params = request.GET.copy()
|
||||||
lang = get_language()
|
if 'thematic' not in query_params:
|
||||||
|
query_params['thematic'] = 'nature'
|
||||||
# Get filters from URL
|
return redirect(f"{reverse('assets:index')}?{query_params.urlencode()}")
|
||||||
statuses = request.GET.getlist('status')
|
|
||||||
highway = request.GET.get('highway') == "1"
|
|
||||||
selected_categories = request.GET.getlist('category')
|
|
||||||
selected_models = request.GET.getlist('model')
|
|
||||||
selected_location_type = request.GET.get('location_type', '')
|
|
||||||
|
|
||||||
# Get preventive draft ID (if any) for selecting assets
|
|
||||||
preventive_draft_id = request.GET.get('preventive_draft_id')
|
|
||||||
if preventive_draft_id:
|
|
||||||
drafts = InterventionDraftManager(request.session)
|
|
||||||
draft_data = drafts.get(preventive_draft_id)
|
|
||||||
form_data = draft_data.get('form_data', {})
|
|
||||||
preventive_element_type = form_data.get('type_of_selected_elements')
|
|
||||||
if preventive_element_type == 'assets':
|
|
||||||
selected_assets = draft_data.get('assets', []) or []
|
|
||||||
else:
|
|
||||||
selected_locations_ids = draft_data.get('locations', [])
|
|
||||||
|
|
||||||
|
|
||||||
thematic_choices_qs = (
|
|
||||||
UserThematics.objects
|
|
||||||
.filter(user_config=request.user.config, can_view_assets=True)
|
|
||||||
.select_related('thematic')
|
|
||||||
.order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}')
|
|
||||||
)
|
|
||||||
thematics = [tc.thematic for tc in thematic_choices_qs]
|
|
||||||
|
|
||||||
|
|
||||||
selected_thematic = {
|
|
||||||
"code": thematic,
|
|
||||||
"name": next((t.name for t in thematics if t.code == thematic), None),
|
|
||||||
"icon": next((t.icon for t in thematics if t.code == thematic), None),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
categories = list(AssetCategory.objects.filter(thematic__code = 'nature'))
|
|
||||||
categories.sort(key=lambda m: m.get_name().lower())
|
|
||||||
category_choices = [(m.id, m.get_name()) for m in categories]
|
|
||||||
|
|
||||||
selected_management_types = request.GET.getlist('management_type')
|
|
||||||
from assets.permissions import get_user_accessible_municipalities
|
|
||||||
_munis = list(get_user_accessible_municipalities(request.user))
|
|
||||||
if len(_munis) == 1:
|
|
||||||
_sm = _munis[0]
|
|
||||||
_code = str(getattr(_sm, 'niscode', '') or '').strip()
|
|
||||||
_is_wal = _code[:2] in ('25', '51', '52', '53', '55', '56', '57', '61', '62', '63', '81', '82', '83', '84', '85', '91', '92', '93') or (_code and _code[0] in ('5', '6', '8', '9')) or 'court-saint' in (_sm.name_fr or '').lower()
|
|
||||||
_is_vlg = (_code and _code[0] in ('1', '3', '4', '7')) or _code[:2] in ('23', '24')
|
|
||||||
if _is_wal:
|
|
||||||
_reg_lbl = _("Régional (Région wallonne)")
|
|
||||||
elif _is_vlg:
|
|
||||||
_reg_lbl = _("Régional (Région flamande)")
|
|
||||||
else:
|
|
||||||
_reg_lbl = _("Régional (Bruxelles Mobilité)")
|
|
||||||
management_type_choices = [
|
|
||||||
('municipal', _("Communal (%(name)s)") % {'name': _sm.name}),
|
|
||||||
('regional', _reg_lbl),
|
|
||||||
('community', _("Intercommunal / Communautaire")),
|
|
||||||
('private', _("Privé / Conventionné")),
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
management_type_choices = [
|
|
||||||
('municipal', _("Communal (Commune)")),
|
|
||||||
('regional', _("Régional (Région / Bruxelles Mobilité)")),
|
|
||||||
('community', _("Intercommunal / Communautaire")),
|
|
||||||
('private', _("Privé / Conventionné")),
|
|
||||||
]
|
|
||||||
|
|
||||||
models = list(NatureAssetModel.objects.all())
|
|
||||||
models.sort(key=lambda m: (m.get_name() or "").lower())
|
|
||||||
model_choices = [(m.id, m.get_name()) for m in models]
|
|
||||||
|
|
||||||
category_model_map = defaultdict(list)
|
|
||||||
models = NatureAssetModel.objects.all()
|
|
||||||
for model in models:
|
|
||||||
if model.category:
|
|
||||||
display_name = model.get_name()
|
|
||||||
category_model_map[str(model.category_id)].append({
|
|
||||||
"key": str(model.id),
|
|
||||||
"label": display_name,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
context = {
|
|
||||||
'thematics': thematics,
|
|
||||||
'category_choices': category_choices,
|
|
||||||
'model_choices': model_choices,
|
|
||||||
'category_model_map': dict(category_model_map),
|
|
||||||
'selected_thematic': selected_thematic,
|
|
||||||
'management_type_choices': management_type_choices,
|
|
||||||
'selected_management_types': selected_management_types,
|
|
||||||
'selected_statuses': statuses,
|
|
||||||
'selected_categories': selected_categories,
|
|
||||||
'selected_models': selected_models,
|
|
||||||
'highway': highway,
|
|
||||||
'selected_location_type': selected_location_type,
|
|
||||||
'preventive_draft_id': preventive_draft_id,
|
|
||||||
'preventive_element_type': preventive_element_type if 'preventive_element_type' in locals() else '',
|
|
||||||
'selected_locations_ids': selected_locations_ids if 'selected_locations_ids' in locals() else [],
|
|
||||||
"selected_assets_json": json.dumps(selected_assets) if 'selected_assets' in locals() else [],
|
|
||||||
'locations_tab_title': _("Voiries"),
|
|
||||||
'locations_assets_title': "Assets",
|
|
||||||
}
|
|
||||||
|
|
||||||
return render(request, "assets/nature_index.html", context)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def nature_locations(request):
|
def nature_locations(request):
|
||||||
pass
|
return redirect(f"{reverse('assets:index')}?thematic=nature")
|
||||||
|
|
||||||
|
|
||||||
@check_thematic_access('nature')
|
@check_thematic_access('nature')
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue