Compare commits

..

No commits in common. "74ef7d3a34b658997071b54068666c439b832be4" and "958d8dbc6549219b55613173238e4f1c4ae83896" have entirely different histories.

7 changed files with 121 additions and 433 deletions

View file

@ -1,220 +0,0 @@
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.")

View file

@ -83,11 +83,6 @@ 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,
@ -120,12 +115,6 @@ 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}")
@ -190,11 +179,6 @@ 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 ===")
# ----------------------------------------------------------------- # -----------------------------------------------------------------
@ -572,15 +556,6 @@ 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:
@ -612,7 +587,6 @@ 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,
@ -654,7 +628,6 @@ 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,
@ -695,7 +668,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', 'last_phytosanitary_date', 'model_id', 'location_id', 'lon', 'lat', 'geom', 'geojson' 'planting_date', 'model_id', 'location_id', 'lon', 'lat', 'geom', 'geojson'
], ],
batch_size=BATCH_SIZE batch_size=BATCH_SIZE
) )
@ -823,7 +796,7 @@ class Command(BaseCommand):
status = 'to_be_planned' status = 'to_be_planned'
status_order = 5 status_order = 5
# Date limite & Date de diagnostic # Date limite
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:
@ -832,14 +805,6 @@ 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))
@ -863,7 +828,6 @@ 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
@ -885,7 +849,6 @@ 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,
@ -913,8 +876,7 @@ 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', 'maintain_type', 'type', 'status', 'status_order', 'expected_end_time',
'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
@ -973,104 +935,3 @@ 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}")

View file

@ -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>

View file

@ -56,12 +56,13 @@
<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:index' %}?thematic=nature" class="text-decoration-none">{% translate "Nature" %}</a></li> <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: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:index' %}?thematic=nature" class="btn btn-sm btn-outline-secondary"> <a href="{% url 'assets:nature_index' %}" 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 %}

View file

@ -5665,23 +5665,9 @@ 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(

View file

@ -66,8 +66,7 @@ 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"
} }
} }
] ]
@ -89,15 +88,13 @@ class ImportFulcrumTreesCommandTest(TestCase):
}, },
"properties": { "properties": {
"_record_id": mock_itv_uuid, "_record_id": mock_itv_uuid,
"_title": "099.01.001, Formation, Dans 1 an", "_title": "Élagage de formation",
"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",
@ -148,15 +145,6 @@ 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)
@ -193,36 +181,4 @@ 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)

View file

@ -1,4 +1,4 @@
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404
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,15 +28,119 @@ from interventions.views.drafts import InterventionDraftManager
@check_thematic_access('nature') @check_thematic_access('nature')
def nature_index(request): def nature_index(request):
query_params = request.GET.copy() thematic = 'nature'
if 'thematic' not in query_params: lang = get_language()
query_params['thematic'] = 'nature'
return redirect(f"{reverse('assets:index')}?{query_params.urlencode()}") # Get filters from URL
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):
return redirect(f"{reverse('assets:index')}?thematic=nature") pass
@check_thematic_access('nature') @check_thematic_access('nature')