feat: implement outdoor location management system with dedicated models, views, and detail templates

This commit is contained in:
kdeterme 2026-07-27 15:23:39 +02:00
parent 044e01add8
commit b04b612dd8
12 changed files with 1480 additions and 102 deletions

View file

@ -649,6 +649,7 @@ admin.site.register(CleanLocationContract, AssetAdmin)
from .models import CleanLitterBinPhoto, CleanLitterBinObservation
from .models import PublicBuilding, BuildingAsset, PublicBuildingDocument
from .models import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
@admin.register(PublicBuilding)
class PublicBuildingAdmin(admin.GISModelAdmin):
@ -665,3 +666,19 @@ class BuildingAssetAdmin(admin.GISModelAdmin):
raw_id_fields = ("building", "location", "parent_asset")
admin.site.register(PublicBuildingDocument, AssetAdmin)
@admin.register(OutdoorLocation)
class OutdoorLocationAdmin(admin.GISModelAdmin):
list_display = ("code", "name_fr", "site_type", "parent_location", "owner", "manager", "pmr_accessible", "has_water_point", "has_power_supply", "is_fenced")
list_filter = ("site_type", "pmr_accessible", "has_water_point", "has_power_supply", "is_fenced")
search_fields = ("code", "name_fr", "name_nl", "address", "owner")
raw_id_fields = ("parent_location", "manager")
@admin.register(OutdoorAsset)
class OutdoorAssetAdmin(admin.GISModelAdmin):
list_display = ("code", "name_fr", "asset_type", "site", "location", "norm", "last_annual_inspection", "next_annual_inspection", "status")
list_filter = ("asset_type", "norm", "status", "obsolescence")
search_fields = ("code", "name_fr", "name_nl", "serial_number", "certifying_body")
raw_id_fields = ("site", "location", "parent_asset")
admin.site.register(OutdoorLocationDocument, AssetAdmin)

View file

@ -0,0 +1,121 @@
# Generated by Django 6.0.7 on 2026-07-27 12:54
import assets.models.outdoor
import common.storages
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0006_remove_buildinglevel_building_and_more'),
('documents', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='OutdoorLocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Emprise cartographique')),
('site_type', models.CharField(choices=[('playground', 'Aire / Plaine de jeux'), ('sports_ground', 'Terrain de sport extérieur'), ('multisports_agora', 'Terrain multisports / City-stadium'), ('skatepark_pumptrack', 'Skatepark / Pumptrack / Bowl'), ('outdoor_fitness', 'Fitness plein air / Street Workout'), ('open_air_theater', 'Théâtre de verdure / Scène extérieure'), ('park_recreation', 'Parc & Espace récréatif'), ('dog_park', 'Espace canin / Canisite'), ('cemetery', 'Cimetière communal'), ('event_plaza', 'Place publique / Zone événementielle'), ('other', 'Autre espace plein air')], default='playground', max_length=50, verbose_name='Type de site plein air')),
('is_main_location', models.BooleanField(default=False, verbose_name='Localisation principale')),
('address', models.CharField(blank=True, max_length=500, null=True, verbose_name='Adresse complète')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('owner', models.CharField(blank=True, max_length=255, null=True, verbose_name='Propriétaire')),
('occupant_entity', models.CharField(blank=True, max_length=255, null=True, verbose_name='Direction / Service gestionnaire')),
('surface_m2', models.FloatField(blank=True, null=True, verbose_name='Surface totale (m²)')),
('max_capacity', models.IntegerField(blank=True, null=True, verbose_name="Jauge / Capacité d'accueil")),
('pmr_accessible', models.BooleanField(default=False, verbose_name='Accessible PMR')),
('has_water_point', models.BooleanField(default=False, verbose_name="Point d'eau potable")),
('has_power_supply', models.BooleanField(default=False, verbose_name='Alimentation électrique disponible')),
('is_fenced', models.BooleanField(default=False, verbose_name='Site clôturé / Sécurisé')),
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_outdoor_locations', to=settings.AUTH_USER_MODEL, verbose_name='Gestionnaire référent')),
('parent_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_locations', to='assets.outdoorlocation', verbose_name='Localisation parente')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_location_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
],
options={
'verbose_name': "Espace public d'extérieur",
'verbose_name_plural': "Espaces publics d'extérieur",
'ordering': ['code', 'name_fr'],
},
),
migrations.CreateModel(
name='OutdoorAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=3812)),
('geojson', models.TextField(blank=True, null=True)),
('asset_type', models.CharField(choices=[('play_structure', 'Structure combinée / Tour de jeu'), ('swing', 'Balançoire / Portique'), ('spring_rider', 'Jeu à ressort / Bascule'), ('slide', 'Toboggan'), ('zip_line', 'Tyrolienne'), ('amortizing_surface', 'Sol amortissant / Réception'), ('sports_goal', 'But / Cage / Panneau de basket'), ('fitness_station', 'Agrès de fitness outdoor / Workout'), ('skate_module', 'Module de skate / Rampe / Rail'), ('stage_platform', 'Scène extérieure / Podium'), ('audience_seating', 'Gradins / Bancs de spectateurs'), ('event_power_box', 'Borne foraine / Armoire électrique événementielle'), ('outdoor_lighting', 'Éclairage plein air / Projecteur scénique'), ('bench_table', 'Banc / Table de pique-nique'), ('shade_shelter', "Kiosque / Voile d'ombrage / Abri"), ('drinking_fountain', "Fontaine d'eau potable"), ('fence_gate', 'Clôture / Portillon de sécurité'), ('info_panel', "Panneau d'information / Consignes"), ('other', 'Autre équipement plein air')], default='play_structure', max_length=50, verbose_name="Type d'asset / Équipement")),
('norm', models.CharField(blank=True, choices=[('EN_1176', "EN 1176 - Équipements & sols d'aires de jeux"), ('EN_16630', "EN 16630 - Modules de fitness d'extérieur"), ('EN_14974', 'EN 14974 - Équipements de skatepark'), ('EN_15312', 'EN 15312 - Équipements multisports en accès libre'), ('other', 'Autre norme / Non spécifié')], max_length=50, null=True, verbose_name='Norme de sécurité appliquée')),
('last_control_date', models.DateField(blank=True, null=True, verbose_name='Dernier contrôle visuel/opérationnel')),
('next_control_date', models.DateField(blank=True, null=True, verbose_name='Prochain contrôle requis')),
('last_annual_inspection', models.DateField(blank=True, null=True, verbose_name='Dernier contrôle annuel certifié')),
('next_annual_inspection', models.DateField(blank=True, null=True, verbose_name='Prochain contrôle annuel certifié')),
('certifying_body', models.CharField(blank=True, max_length=255, null=True, verbose_name='Organisme de contrôle certifié')),
('impact_surface_type', models.CharField(blank=True, max_length=150, null=True, verbose_name="Type de sol d'amortissement")),
('critical_fall_height_m', models.FloatField(blank=True, null=True, verbose_name='Hauteur critique de chute (m)')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('parent_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_assets', to='assets.outdoorasset', verbose_name='Asset parent')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.outdoorasset', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='location_assets', to='assets.outdoorlocation', verbose_name='Localisation spécifique')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='all_outdoor_assets', to='assets.outdoorlocation', verbose_name='Site / Espace principal')),
],
options={
'verbose_name': 'Asset / Équipement plein air',
'verbose_name_plural': 'Assets / Équipements plein air',
'ordering': ['site', 'code'],
},
),
migrations.CreateModel(
name='OutdoorLocationDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.TextField(blank=True, null=True, verbose_name='Description du document')),
('upload_date', models.DateTimeField(auto_now=True, verbose_name='Date de téléversement')),
('status', models.CharField(choices=[('active', 'Actif'), ('archived', 'Archivé')], default='active')),
('file', models.FileField(max_length=255, storage=common.storages.PrivateMediaStorage(), upload_to=assets.models.outdoor.upload_to_outdoor, verbose_name='Fichier')),
('document_type', models.CharField(default='other', max_length=50, verbose_name='Type de document')),
('managed_document', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_sources', to='documents.manageddocument')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.outdoorlocation', verbose_name='Espace extérieur')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par')),
],
options={
'verbose_name': "Document d'espace extérieur",
'verbose_name_plural': "Documents d'espace extérieur",
},
),
]

View file

@ -11,3 +11,4 @@ from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSi
from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract
from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract
from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument
from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument

View file

@ -0,0 +1,177 @@
# -----------------------------
# Outdoor Spaces & Assets Models
# -----------------------------
from django.contrib.gis.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
from assets.models import AbstractLocation, AbstractGeoAsset, ASSET_STATUS_CHOICES
from common.models import Document
from common.storages import PrivateMediaStorage
private_storage = PrivateMediaStorage()
SITE_TYPE_CHOICES = (
('playground', _('Aire / Plaine de jeux')),
('sports_ground', _('Terrain de sport extérieur')),
('multisports_agora', _('Terrain multisports / City-stadium')),
('skatepark_pumptrack', _('Skatepark / Pumptrack / Bowl')),
('outdoor_fitness', _('Fitness plein air / Street Workout')),
('open_air_theater', _('Théâtre de verdure / Scène extérieure')),
('park_recreation', _('Parc & Espace récréatif')),
('dog_park', _('Espace canin / Canisite')),
('cemetery', _('Cimetière communal')),
('event_plaza', _('Place publique / Zone événementielle')),
('other', _('Autre espace plein air')),
)
OUTDOOR_ASSET_TYPE_CHOICES = (
('play_structure', _('Structure combinée / Tour de jeu')),
('swing', _('Balançoire / Portique')),
('spring_rider', _('Jeu à ressort / Bascule')),
('slide', _('Toboggan')),
('zip_line', _('Tyrolienne')),
('amortizing_surface', _('Sol amortissant / Réception')),
('sports_goal', _('But / Cage / Panneau de basket')),
('fitness_station', _('Agrès de fitness outdoor / Workout')),
('skate_module', _('Module de skate / Rampe / Rail')),
('stage_platform', _('Scène extérieure / Podium')),
('audience_seating', _('Gradins / Bancs de spectateurs')),
('event_power_box', _('Borne foraine / Armoire électrique événementielle')),
('outdoor_lighting', _('Éclairage plein air / Projecteur scénique')),
('bench_table', _('Banc / Table de pique-nique')),
('shade_shelter', _('Kiosque / Voile d\'ombrage / Abri')),
('drinking_fountain', _('Fontaine d\'eau potable')),
('fence_gate', _('Clôture / Portillon de sécurité')),
('info_panel', _('Panneau d\'information / Consignes')),
('other', _('Autre équipement plein air')),
)
NORM_CHOICES = (
('EN_1176', _('EN 1176 - Équipements & sols d\'aires de jeux')),
('EN_16630', _('EN 16630 - Modules de fitness d\'extérieur')),
('EN_14974', _('EN 14974 - Équipements de skatepark')),
('EN_15312', _('EN 15312 - Équipements multisports en accès libre')),
('other', _('Autre norme / Non spécifié')),
)
class OutdoorLocation(AbstractLocation):
"""
Modèle représentant un Espace Public Extérieur (Plaine de jeux, Terrain de sport, Théâtre plein air, Parc, etc.).
Peut comporter une arborescence (ex: Parc municipal > Aire de jeux > Zone toboggan).
"""
geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise cartographique"))
parent_location = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_locations", verbose_name=_("Localisation parente"))
site_type = models.CharField(max_length=50, choices=SITE_TYPE_CHOICES, default='playground', verbose_name=_("Type de site plein air"))
is_main_location = models.BooleanField(default=False, verbose_name=_("Localisation principale"))
address = models.CharField(max_length=500, null=True, blank=True, verbose_name=_("Adresse complète"))
description = models.TextField(null=True, blank=True, verbose_name=_("Description"))
# Propriété & Gestion
owner = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Propriétaire"))
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="managed_outdoor_locations", verbose_name=_("Gestionnaire référent"))
occupant_entity = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Direction / Service gestionnaire"))
# Caractéristiques physiques & aménagements
surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface totale (m²)"))
max_capacity = models.IntegerField(null=True, blank=True, verbose_name=_("Jauge / Capacité d'accueil"))
pmr_accessible = models.BooleanField(default=False, verbose_name=_("Accessible PMR"))
has_water_point = models.BooleanField(default=False, verbose_name=_("Point d'eau potable"))
has_power_supply = models.BooleanField(default=False, verbose_name=_("Alimentation électrique disponible"))
is_fenced = models.BooleanField(default=False, verbose_name=_("Site clôturé / Sécurisé"))
class Meta:
verbose_name = _("Espace public d'extérieur")
verbose_name_plural = _("Espaces publics d'extérieur")
ordering = ['code', 'name_fr']
def get_child_locations(self):
return self.child_locations.all()
def get_active_assets(self):
def collect_assets(loc):
assets = list(loc.location_assets.filter(status='active'))
for child in loc.child_locations.all():
assets += collect_assets(child)
return assets
return collect_assets(self)
def get_all_assets(self):
def collect_assets(loc):
if loc.parent_location is None:
assets = list(loc.location_assets.select_related('location', 'parent_asset', 'site').all() | loc.all_outdoor_assets.select_related('location', 'parent_asset', 'site').filter(location__isnull=True))
else:
assets = list(loc.location_assets.select_related('location', 'parent_asset', 'site').all())
for child in loc.child_locations.all():
assets += collect_assets(child)
return assets
seen = set()
unique_assets = []
for a in collect_assets(self):
if a.id not in seen:
seen.add(a.id)
unique_assets.append(a)
return unique_assets
def get_direct_child_assets(self):
return self.location_assets.all()
def get_absolute_url(self):
return reverse('assets:outdoor_location_detail', kwargs={'pk': self.pk})
class OutdoorAsset(AbstractGeoAsset):
"""
Modèle représentant un asset ou équipement physique d'extérieur (Structure de jeu, Sol amortissant, Agrès, Cage, Scène...).
Les assets peuvent former une arborescence parent-enfant.
"""
site = models.ForeignKey(OutdoorLocation, on_delete=models.CASCADE, related_name="all_outdoor_assets", verbose_name=_("Site / Espace principal"))
location = models.ForeignKey(OutdoorLocation, on_delete=models.SET_NULL, null=True, blank=True, related_name="location_assets", verbose_name=_("Localisation spécifique"))
parent_asset = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_assets", verbose_name=_("Asset parent"))
asset_type = models.CharField(max_length=50, choices=OUTDOOR_ASSET_TYPE_CHOICES, default='play_structure', verbose_name=_("Type d'asset / Équipement"))
# Norme & Sécurité réglementaire
norm = models.CharField(max_length=50, choices=NORM_CHOICES, blank=True, null=True, verbose_name=_("Norme de sécurité appliquée"))
last_control_date = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle visuel/opérationnel"))
next_control_date = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle requis"))
last_annual_inspection = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle annuel certifié"))
next_annual_inspection = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle annuel certifié"))
certifying_body = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Organisme de contrôle certifié"))
# Sols amortissants & hauteurs d'impact
impact_surface_type = models.CharField(max_length=150, blank=True, null=True, verbose_name=_("Type de sol d'amortissement"))
critical_fall_height_m = models.FloatField(null=True, blank=True, verbose_name=_("Hauteur critique de chute (m)"))
class Meta:
verbose_name = _("Asset / Équipement plein air")
verbose_name_plural = _("Assets / Équipements plein air")
ordering = ['site', 'code']
def get_absolute_url(self):
return reverse('assets:outdoor_assets_detail', kwargs={'asset_model': 'outdoorasset', 'asset_id': self.pk})
def get_child_assets(self):
return self.child_assets.all()
def upload_to_outdoor(instance, filename):
return f"outdoor/{instance.site.code}/{filename}"
class OutdoorLocationDocument(Document):
"""
Documents associés à un espace extérieur (certificats de conformité, registres de sécurité, plans).
"""
site = models.ForeignKey(OutdoorLocation, on_delete=models.CASCADE, related_name="documents", verbose_name=_("Espace extérieur"))
file = models.FileField(upload_to=upload_to_outdoor, storage=private_storage, verbose_name=_("Fichier"), max_length=255)
document_type = models.CharField(max_length=50, default='other', verbose_name=_("Type de document"))
class Meta:
verbose_name = _("Document d'espace extérieur")
verbose_name_plural = _("Documents d'espace extérieur")

View file

@ -44,7 +44,7 @@ var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' |
// MapLibre sources
var mergedLocationsGeojson = { type: 'FeatureCollection', features: [] };
var mergedAssetsGeojson = { type: 'FeatureCollection', features: [] };
var mergedAssetsGeojson = { type: 'FeatureCollection', features: [] };
var currentFetchAbortController = null;
var assetClickPopup = null;
var zoomFocusMarker = null;
@ -414,7 +414,7 @@ function initUnifiedLocationPickerMap() {
setPoint(lng, lat, true);
}
},
() => {},
() => { },
{ enableHighAccuracy: true, timeout: 10000 }
);
});
@ -499,32 +499,35 @@ function zoomFormatter(value, row) {
function buildColumns() {
return [
{ field: 'check', checkbox: true, width: 36 },
{ field: 'actions', title: '', formatter: zoomFormatter, width: 50 },
{ field: 'code', title: gettext('Code'), sortable: true,
formatter: function(v, row) {
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${v || '-'}</a>`;
return v || '-';
}
{ field: 'check', checkbox: true, width: 36 },
{ field: 'actions', title: '', formatter: zoomFormatter, width: 50 },
{
field: 'code', title: gettext('Code'), sortable: true,
formatter: function (v, row) {
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${v || '-'}</a>`;
return v || '-';
}
},
{ field: 'name', title: gettext('Nom'), sortable: true },
{ field: 'element_type', title: gettext('Type'), sortable: true },
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true },
{ field: 'category', title: gettext('Catégorie'), sortable: true, visible: false },
{ field: 'model', title: gettext('Modèle'), sortable: true, visible: false },
{ field: 'status', title: gettext('Statut'), sortable: true, visible: false,
formatter: function(v) { return v ? `<i class="bi bi-circle-fill status_${v}"></i> ${v}` : ''; }
{ field: 'name', title: gettext('Nom'), sortable: true },
{ field: 'element_type', title: gettext('Type'), sortable: true },
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true },
{ field: 'category', title: gettext('Catégorie'), sortable: true, visible: false },
{ field: 'model', title: gettext('Modèle'), sortable: true, visible: false },
{
field: 'status', title: gettext('Statut'), sortable: true, visible: false,
formatter: function (v) { return v ? `<i class="bi bi-circle-fill status_${v}"></i> ${v}` : ''; }
},
{ field: 'validation_status', title: gettext('Statut de validation'), sortable: true, visible: true,
formatter: function(v) {
const labels = {
'to_be_validated': gettext('À valider'),
'validated': gettext('Validé'),
'to_be_completed': gettext('À compléter'),
'rejected': gettext('Rejeté')
};
return labels[v] || v || '-';
}
{
field: 'validation_status', title: gettext('Statut de validation'), sortable: true, visible: true,
formatter: function (v) {
const labels = {
'to_be_validated': gettext('À valider'),
'validated': gettext('Validé'),
'to_be_completed': gettext('À compléter'),
'rejected': gettext('Rejeté')
};
return labels[v] || v || '-';
}
},
];
}
@ -647,7 +650,7 @@ function applyInitialPreferences() {
// Map position — stored for use inside the mapLoaded handler
if (prefs.map_center && prefs.map_zoom) {
window._restoredMapCenter = prefs.map_center;
window._restoredMapZoom = prefs.map_zoom;
window._restoredMapZoom = prefs.map_zoom;
}
} else if (DEFAULT_THEMATIC_CODE && DEFAULT_THEMATIC_CODE in THEMATIC_CONFIG) {
// First visit: pre-select the user's default thematic
@ -677,24 +680,24 @@ function savePreferences() {
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
const prefs = {
thematics: Array.from(activeThematics),
categories: Array.from(activeCategories),
models: Array.from(activeModels),
thematics: Array.from(activeThematics),
categories: Array.from(activeCategories),
models: Array.from(activeModels),
conditional_filters: activeConditionalFilters,
visible_columns: getVisibleColumnsPreference(),
type_filter: currentTypeFilter,
type_filter: currentTypeFilter,
status_filters: statusFilters,
validation_status_filters: validationStatusFilters,
include_no_geom: includeNoGeom,
map_center: (typeof map !== 'undefined' && map.getCenter) ? [map.getCenter().lng, map.getCenter().lat] : null,
map_zoom: (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : null,
map_zoom: (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : null,
};
fetch(SAVE_ASSETS_PREFS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': getCsrfToken() },
body: JSON.stringify(prefs),
keepalive: true,
}).catch(() => {});
}).catch(() => { });
}
// ─── Table init ───────────────────────────────────────────────────────────────
@ -721,11 +724,11 @@ $(document).ready(function () {
ignoreClickToSelectOn: function (element) {
return $(element).closest('.zoom-btn, .unified-code-link').length > 0;
},
onCheck: function(row) { selectedIds.add(row.id); updateSelectionStatus(); },
onUncheck: function(row) { selectedIds.delete(row.id); updateSelectionStatus(); },
onCheckAll: function(rows) { rows.forEach(r => selectedIds.add(r.id)); updateSelectionStatus(); },
onUncheckAll: function(rows) { rows.forEach(r => selectedIds.delete(r.id)); updateSelectionStatus(); },
onClickRow: function(row) {
onCheck: function (row) { selectedIds.add(row.id); updateSelectionStatus(); },
onUncheck: function (row) { selectedIds.delete(row.id); updateSelectionStatus(); },
onCheckAll: function (rows) { rows.forEach(r => selectedIds.add(r.id)); updateSelectionStatus(); },
onUncheckAll: function (rows) { rows.forEach(r => selectedIds.delete(r.id)); updateSelectionStatus(); },
onClickRow: function (row) {
if (!row.lon || !row.lat) return;
const source = row.is_location ? 'locations' : 'assets';
if (typeof highlightMapFeature === 'function') {
@ -1229,6 +1232,10 @@ function hasTriggerFilter() {
const signFilters = activeConditionalFilters.sign || {};
if (Array.isArray(signFilters.municipality) && signFilters.municipality.length) return true;
if (Array.isArray(signFilters.panel_type) && signFilters.panel_type.length) return true;
const outdoorFilters = activeConditionalFilters.outdoorfacilities || activeConditionalFilters.outdoor || {};
if (Array.isArray(outdoorFilters.site_type) && outdoorFilters.site_type.length) return true;
if (Array.isArray(outdoorFilters.norm) && outdoorFilters.norm.length) return true;
if (outdoorFilters.pmr_accessible) return true;
return false;
}
@ -1563,26 +1570,26 @@ function bindUnifiedBulkActions() {
new_value: newValue
})
})
.then(res => res.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getOrCreateInstance(bulkStatusModalEl).hide();
selectedIds.clear();
updateSelectionStatus();
refreshData();
} else {
.then(res => res.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getOrCreateInstance(bulkStatusModalEl).hide();
selectedIds.clear();
updateSelectionStatus();
refreshData();
} else {
if (bulkStatusError) {
bulkStatusError.textContent = data.error || gettext('Une erreur est survenue.');
bulkStatusError.classList.remove('d-none');
}
}
})
.catch(err => {
if (bulkStatusError) {
bulkStatusError.textContent = data.error || gettext('Une erreur est survenue.');
bulkStatusError.textContent = gettext('Une erreur est survenue.');
bulkStatusError.classList.remove('d-none');
}
}
})
.catch(err => {
if (bulkStatusError) {
bulkStatusError.textContent = gettext('Une erreur est survenue.');
bulkStatusError.classList.remove('d-none');
}
});
});
});
}
@ -1623,26 +1630,26 @@ function bindUnifiedBulkActions() {
new_value: newValue
})
})
.then(res => res.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getOrCreateInstance(bulkValModalEl).hide();
selectedIds.clear();
updateSelectionStatus();
refreshData();
} else {
.then(res => res.json())
.then(data => {
if (data.success) {
bootstrap.Modal.getOrCreateInstance(bulkValModalEl).hide();
selectedIds.clear();
updateSelectionStatus();
refreshData();
} else {
if (bulkValError) {
bulkValError.textContent = data.error || gettext('Une erreur est survenue.');
bulkValError.classList.remove('d-none');
}
}
})
.catch(err => {
if (bulkValError) {
bulkValError.textContent = data.error || gettext('Une erreur est survenue.');
bulkValError.textContent = gettext('Une erreur est survenue.');
bulkValError.classList.remove('d-none');
}
}
})
.catch(err => {
if (bulkValError) {
bulkValError.textContent = gettext('Une erreur est survenue.');
bulkValError.classList.remove('d-none');
}
});
});
});
}
}
@ -2009,7 +2016,7 @@ function updateAssetsHint() {
code => !ALWAYS_LOAD_ASSETS_THEMATICS.has(code)
);
const filterActive = hasTriggerFilter();
const bboxActive = typeof map !== 'undefined' && map.getZoom && map.getZoom() >= MIN_ASSETS_ZOOM;
const bboxActive = typeof map !== 'undefined' && map.getZoom && map.getZoom() >= MIN_ASSETS_ZOOM;
const showHint = noThematic || (hasConditional && !filterActive && !bboxActive);
hint.style.visibility = showHint ? 'visible' : 'hidden';
}
@ -2106,8 +2113,8 @@ function refreshData() {
}
const filterActive = hasTriggerFilter();
const mapZoom = (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : 0;
const bboxActive = mapZoom >= MIN_ASSETS_ZOOM;
const mapZoom = (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : 0;
const bboxActive = mapZoom >= MIN_ASSETS_ZOOM;
const fetches = [];
thematics.forEach(code => {
@ -2144,7 +2151,7 @@ function refreshData() {
// ── Assets: conditional loading
const alwaysLoad = ALWAYS_LOAD_ASSETS_THEMATICS.has(code);
const prevMode = assetsLoadModes[code] || null;
const prevMode = assetsLoadModes[code] || null;
if (alwaysLoad) {
// artworks / controlcenters: fetch once, cache forever
@ -2181,16 +2188,16 @@ function refreshData() {
body: JSON.stringify(assetPayload),
signal,
})
.then(data => {
geojsonCache[code].assets = data.features || [];
assetsFetchSignatures[code] = assetSignature;
})
.catch(err => {
if (err.name === 'AbortError') throw err;
if (err.code === 'TOO_LARGE') handleLoadTooLarge();
geojsonCache[code].assets = [];
assetsFetchSignatures[code] = assetSignature;
})
.then(data => {
geojsonCache[code].assets = data.features || [];
assetsFetchSignatures[code] = assetSignature;
})
.catch(err => {
if (err.name === 'AbortError') throw err;
if (err.code === 'TOO_LARGE') handleLoadTooLarge();
geojsonCache[code].assets = [];
assetsFetchSignatures[code] = assetSignature;
})
);
assetsLoadModes[code] = targetMode;
}
@ -2438,7 +2445,7 @@ function hasValidCoordinates(f) {
function rebuildMapSources() {
if (typeof map === 'undefined') return;
const locSrc = map.getSource('locations');
const locSrc = map.getSource('locations');
const asstSrc = map.getSource('assets');
if (!locSrc || !asstSrc) return; // sources not yet added (before mapLoaded)
@ -2467,7 +2474,7 @@ maploader.loadMap('map');
maploader.addEventListener('mapLoaded', () => {
// Create empty sources so setData can be called immediately after data loads
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
// Add layers in render order: fills → lines → circles/symbols (see UNIFIED_LAYERS)
UNIFIED_LAYERS.forEach(layer => {
@ -2619,7 +2626,7 @@ maploader.addEventListener('mapLoaded', () => {
const zoom = map.getZoom();
const anyZoomRelevant = Array.from(activeThematics).some(
code => !ALWAYS_LOAD_ASSETS_THEMATICS.has(code) &&
(zoom >= MIN_ASSETS_ZOOM || assetsLoadModes[code] === 'bbox')
(zoom >= MIN_ASSETS_ZOOM || assetsLoadModes[code] === 'bbox')
);
// Show spinner right away for instant visual feedback — before the debounce delay
@ -2687,7 +2694,7 @@ function hideEmptyState() {
}
function updateFilterBadge() {
const badge = document.getElementById('filter-badge');
const badge = document.getElementById('filter-badge');
const countEl = document.getElementById('filter-count');
if (!badge) return;
const hasFilterValue = (value) => {
@ -3762,9 +3769,9 @@ function getAssetsForTotals() {
if (!selectedIds || selectedIds.size === 0) return assetRows;
const selectedAssetRows = assetRows.filter(row => (
selectedIds.has(row.id) || selectedIds.has(String(row.id))
));
const selectedAssetRows = assetRows.filter(row => (
selectedIds.has(row.id) || selectedIds.has(String(row.id))
));
return selectedAssetRows.length ? selectedAssetRows : assetRows;
}

View file

@ -211,6 +211,10 @@ function loadUnifiedMapIcons(map) {
{ name: 'icon-controlcenters', codepoint: 'f301', color: '#7b1fa2' }, // display-fill — violet
{ name: 'icon-publicbuildings', codepoint: 'f1dd', color: '#2B579A' }, // building — bleu nuit
{ name: 'icon-publicbuilding', codepoint: 'f1dd', color: '#2B579A' }, // building — bleu nuit
{ name: 'icon-outdoorfacilities', codepoint: 'f302', color: '#198754' }, // dribbble/dribbble icon — vert plein air
{ name: 'icon-outdoor', codepoint: 'f302', color: '#198754' },
{ name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' },
{ name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' },
{ name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, // map-fill — bleu-gris
];

View file

@ -0,0 +1,70 @@
{% load i18n %}
<ul class="list-group list-group-flush bg-transparent">
{% for asset_node in asset_nodes %}
<li class="list-group-item bg-transparent border-0 py-1 px-0">
<!-- Row Header -->
<div class="d-flex align-items-center justify-content-between py-1 px-2">
<!-- Contenu gauche (indenté selon la profondeur) -->
<div class="d-flex align-items-center gap-2 flex-wrap" style="padding-left: calc(({{ depth|default:1 }} - 1) * 1.25rem);">
{% if asset_node.children %}
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-asset-{{ asset_node.asset.id }}"
aria-expanded="false">
<i class="bi bi-chevron-right small"></i>
</button>
{% else %}
<span class="px-1"></span>
{% endif %}
{% if asset_node.asset.asset_type == 'play_structure' %}
<i class="bi bi-house-door-fill text-success small" title="{% translate 'Structure de jeu' %}"></i>
{% elif asset_node.asset.asset_type == 'swing' %}
<i class="bi bi-arrow-repeat text-success small" title="{% translate 'Balançoire / Portique' %}"></i>
{% elif asset_node.asset.asset_type == 'slide' %}
<i class="bi bi-sign-turn-right-fill text-success small" title="{% translate 'Toboggan' %}"></i>
{% elif asset_node.asset.asset_type == 'spring_rider' %}
<i class="bi bi-emoji-smile-fill text-success small" title="{% translate 'Jeu à ressort' %}"></i>
{% elif asset_node.asset.asset_type == 'zip_line' %}
<i class="bi bi-arrows-expand text-success small" title="{% translate 'Tyrolienne' %}"></i>
{% elif asset_node.asset.asset_type == 'sports_goal' %}
<i class="bi bi-dribbble text-primary small" title="{% translate 'But / Panneau basket' %}"></i>
{% elif asset_node.asset.asset_type == 'fitness_station' %}
<i class="bi bi-activity text-danger small" title="{% translate 'Agrès fitness' %}"></i>
{% elif asset_node.asset.asset_type == 'skate_module' %}
<i class="bi bi-lightning-fill text-warning small" title="{% translate 'Module skate' %}"></i>
{% elif asset_node.asset.asset_type == 'stage_platform' %}
<i class="bi bi-display text-info small" title="{% translate 'Scène / Podium' %}"></i>
{% elif asset_node.asset.asset_type == 'event_power_box' %}
<i class="bi bi-plug-fill text-warning small" title="{% translate 'Borne électrique' %}"></i>
{% elif asset_node.asset.asset_type == 'drinking_fountain' %}
<i class="bi bi-droplet-fill text-primary small" title="{% translate 'Fontaine d\'eau' %}"></i>
{% elif asset_node.asset.asset_type == 'bench_table' %}
<i class="bi bi-table text-secondary small" title="{% translate 'Banc / Table' %}"></i>
{% elif asset_node.asset.asset_type == 'fence_gate' %}
<i class="bi bi-shield-lock-fill text-secondary small" title="{% translate 'Clôture' %}"></i>
{% else %}
<i class="bi bi-gear-fill text-success small"></i>
{% endif %}
<!-- Code en bleu pour indiquer clairement un lien cliquable -->
<a href="{{ asset_node.asset.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline small">
{{ asset_node.asset.code }}
</a>
<span class="small">{{ asset_node.asset.get_name }}</span>
<span class="badge bg-success-subtle text-success border small ms-1">{{ asset_node.asset.get_asset_type_display }}</span>
{% if asset_node.asset.norm %}
<span class="badge bg-secondary-subtle text-dark border small ms-1">{{ asset_node.asset.get_norm_display }}</span>
{% endif %}
</div>
</div>
{% if asset_node.children %}
<div id="tree-asset-{{ asset_node.asset.id }}" class="collapse mt-1">
{% include "assets/outdoor/outdoorlocation_asset_tree.html" with asset_nodes=asset_node.children depth=depth|default:1|add:1 only %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>

View file

@ -0,0 +1,490 @@
{% extends "base.html" %}
{% load static i18n documents_tags %}
{% block title %}{{ location.code }} - {{ location.get_name }} | StreetUp{% endblock %}
{% block extra_head %}
<!-- MapLibre GL CSS -->
<link href="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Actions -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}">{% translate "Espaces & Équipements plein air" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ location.code }}</li>
</ol>
</nav>
<div class="d-flex gap-2">
<a href="{% url 'assets:index' %}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la liste" %}
</a>
</div>
</div>
<!-- Header Card -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<h2 class="h4 mb-1">
<i class="bi bi-dribbble text-success me-2"></i>{{ location.code }} - {{ location.get_name }}
</h2>
<p class="text-muted mb-2"><i class="bi bi-geo-alt me-1"></i>{{ location.address|default:_("Adresse non renseignée") }}</p>
<div class="d-flex gap-2 flex-wrap">
<span class="badge bg-success-subtle text-success border">{{ location.get_site_type_display }}</span>
{% if location.pmr_accessible %}
<span class="badge bg-info-subtle text-info border"><i class="bi bi-person-wheelchair me-1"></i>PMR Accessible</span>
{% endif %}
{% if location.has_water_point %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi bi-droplet me-1"></i>Eau potable</span>
{% endif %}
{% if location.has_power_supply %}
<span class="badge bg-warning-subtle text-warning border"><i class="bi bi-lightning-charge me-1"></i>Électricité</span>
{% endif %}
{% if location.is_fenced %}
<span class="badge bg-secondary-subtle text-dark border"><i class="bi bi-shield-check me-1"></i>Clôturé</span>
{% endif %}
</div>
</div>
<div class="col-md-4 text-md-end mt-3 mt-md-0">
{% if location.surface_m2 %}
<div class="d-inline-block text-center me-3">
<span class="d-block text-muted small">{% translate "Surface" %}</span>
<span class="badge bg-success fs-5 px-3 py-2">{{ location.surface_m2|floatformat:0 }} m²</span>
</div>
{% endif %}
{% if location.max_capacity %}
<div class="d-inline-block text-center">
<span class="d-block text-muted small">{% translate "Jauge max" %}</span>
<span class="badge bg-primary fs-5 px-3 py-2">{{ location.max_capacity }} pers.</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 3 Core Tabs Navigation -->
<ul class="nav nav-tabs mb-4" id="outdoorTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab">
<i class="bi bi-info-circle me-1"></i>{% translate "Vue d'ensemble" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="equipments-tab" data-bs-toggle="tab" data-bs-target="#equipments" type="button" role="tab">
<i class="bi bi-gear me-1"></i>{% translate "Assets & Équipements" %} ({{ equipments|length }})
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="interventions-tab" data-bs-toggle="tab" data-bs-target="#interventions" type="button" role="tab">
<i class="bi bi-tools me-1"></i>{% translate "Interventions" %} ({{ interventions.count }})
</button>
</li>
</ul>
<!-- Tabs Content -->
<div class="tab-content" id="outdoorTabsContent">
<!-- 1. Vue d'ensemble -->
<div class="tab-pane fade show active" id="overview" role="tabpanel">
<div class="row g-4">
<!-- Left Column: Propriété, Caractéristiques & Arborescence -->
<div class="col-md-7">
<!-- Card 1: Propriété & Responsables -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold"><i class="bi bi-person-badge me-2"></i>{% translate "Propriété & Responsables" %}</div>
<div class="card-body">
<table class="table table-borderless mb-0">
<tr>
<th class="w-50 text-muted">{% translate "Propriétaire" %} :</th>
<td>{{ location.owner|default:"-" }}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Gestionnaire référent" %} :</th>
<td>{% if location.manager %}{{ location.manager.get_full_name|default:location.manager.username }}{% else %}-{% endif %}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Service gestionnaire" %} :</th>
<td>{{ location.occupant_entity|default:"-" }}</td>
</tr>
</table>
</div>
</div>
<!-- Card 2: Caractéristiques physiques & Aménagements -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold"><i class="bi bi-rulers me-2"></i>{% translate "Caractéristiques physiques & Aménagements" %}</div>
<div class="card-body">
<table class="table table-borderless mb-0">
<tr>
<th class="w-50 text-muted">{% translate "Surface totale" %} :</th>
<td>{{ location.surface_m2|default:"-" }} m²</td>
</tr>
<tr>
<th class="text-muted">{% translate "Jauge / Capacité d'accueil" %} :</th>
<td>{% if location.max_capacity %}{{ location.max_capacity }} {% translate "personnes" %}{% else %}-{% endif %}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Accessibilité PMR" %} :</th>
<td>{% if location.pmr_accessible %}<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>{% translate "Oui" %}</span>{% else %}<span class="text-muted">{% translate "Non" %}</span>{% endif %}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Point d'eau potable" %} :</th>
<td>{% if location.has_water_point %}<span class="text-primary"><i class="bi bi-check-circle-fill me-1"></i>{% translate "Oui" %}</span>{% else %}<span class="text-muted">{% translate "Non" %}</span>{% endif %}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Alimentation électrique" %} :</th>
<td>{% if location.has_power_supply %}<span class="text-warning"><i class="bi bi-check-circle-fill me-1"></i>{% translate "Disponible" %}</span>{% else %}<span class="text-muted">{% translate "Non" %}</span>{% endif %}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Clôture & Sécurité" %} :</th>
<td>{% if location.is_fenced %}<span class="text-secondary"><i class="bi bi-shield-check me-1"></i>{% translate "Site clôturé" %}</span>{% else %}<span class="text-muted">{% translate "Accès libre" %}</span>{% endif %}</td>
</tr>
</table>
</div>
</div>
<!-- Card 3: Arborescence Unifiée des Localisations & Assets -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center">
<span><i class="bi bi-diagram-3 me-2"></i>{% translate "Arborescence Hiérarchique" %}</span>
<span class="badge bg-success-subtle text-success border">{{ location_tree.children|length }} {% translate "sous-localisation(s)" %}</span>
</div>
<div class="card-body p-3">
<!-- Localisation Parente (si existante) -->
{% if parent_location %}
<div class="card bg-light border-secondary-subtle p-2 mb-3">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-geo-alt-fill text-danger fs-5"></i>
<div>
<div class="small text-muted fw-semibold">{% translate "Localisation parente" %} :</div>
<a href="{{ parent_location.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline">
{{ parent_location.code }} - {{ parent_location.get_name }}
</a>
<span class="badge bg-secondary-subtle text-dark border small ms-1">{{ parent_location.get_site_type_display }}</span>
</div>
</div>
<a href="{{ parent_location.get_absolute_url }}" class="btn btn-sm btn-outline-secondary py-0 px-2 small">
<i class="bi bi-arrow-up me-1"></i>{% translate "Voir la parente" %}
</a>
</div>
</div>
{% endif %}
<!-- Localisation Courante -->
<div class="p-2 border rounded bg-white mb-2">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
{% if location.site_type == 'playground' %}
<i class="bi bi-balloon-fill text-success fs-5"></i>
{% elif location.site_type == 'sports_ground' %}
<i class="bi bi-trophy-fill text-primary fs-5"></i>
{% elif location.site_type == 'multisports_agora' %}
<i class="bi bi-dribbble text-primary fs-5"></i>
{% elif location.site_type == 'outdoor_fitness' %}
<i class="bi bi-activity text-danger fs-5"></i>
{% elif location.site_type == 'skatepark_pumptrack' %}
<i class="bi bi-scooter text-warning fs-5"></i>
{% elif location.site_type == 'open_air_theater' %}
<i class="bi bi-ticket-detailed-fill text-info fs-5"></i>
{% elif location.site_type == 'park_recreation' %}
<i class="bi bi-tree-fill text-success fs-5"></i>
{% elif location.site_type == 'dog_park' %}
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#fd7e14" class="bi" viewBox="0 0 512 512"><path d="M226.5 92.9c14.3 42.9-.3 86.2-32.6 96.8s-70.1-15.6-84.4-58.5s.3-86.2 32.6-96.8s70.1 15.6 84.4 58.5zM100.4 198.6c18.9 32.4 14.3 70.1-10.2 84.1s-59.7-.9-78.5-33.3S-2.7 179.3 21.8 165.3s59.7.9 78.6 33.3zM69.2 401.2c-7.1-17.4-2.8-37.1 11.6-47.5c22.7-16.4 57.5-12.7 87.2 8.7c31.1 22.3 53.6 56.4 46.5 73.8c-7.1 17.4-32.8 17.3-64 -5c-31.1-22.4-74.2-12.6-81.3 4.8zm342.4-202.6c18.9-32.4 54.1-47.3 78.6-33.3s29.1 51.7 10.2 84.1s-54.1 47.3-78.6 33.3s-29.1-51.7-10.2-84.1zm-56.5-105.7c14.3-42.9 52.1-69.1 84.4-58.5s46.9 53.9 32.6 96.8s-52.1 69.1-84.4 58.5s-46.9-53.9-32.6-96.8zM442.8 401.2c-7.1-17.4-50.2-27.2-81.3-4.8c-31.2 22.3-56.9 22.4-64 5c-7.1-17.4 15.4-51.5 46.5-73.8c29.7-21.4 64.5-25.1 87.2-8.7c14.4 10.4 18.7 30.1 11.6 47.5zM256 224c-53 0-103 34.6-122.6 88.8c-17.7 49 1.1 103.8 44.5 131.7c35.6 22.9 80 23.5 116.2 0c43.4-27.9 62.2-82.7 44.5-131.7C359 258.6 309 224 256 224z"/></svg>
{% elif location.site_type == 'cemetery' %}
<i class="bi bi-flower2 text-secondary fs-5"></i>
{% else %}
<i class="bi bi-geo-alt-fill text-success fs-5"></i>
{% endif %}
<span class="fw-bold fs-6 text-success">{{ location.code }}</span>
<span class="fw-bold fs-6">- {{ location.get_name }}</span>
<span class="badge bg-success text-white ms-1">{{ location.get_site_type_display }}</span>
{% if location_tree.assets %}
<button class="btn btn-sm btn-outline-success py-0 px-2 small ms-2 text-nowrap"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-assets-root"
aria-expanded="false">
<i class="bi bi-tools me-1"></i>Assets ({{ location_tree.assets|length }})
</button>
{% endif %}
</div>
</div>
<!-- Collapsible Assets de la localisation courante -->
{% if location_tree.assets %}
<div id="tree-assets-root" class="collapse mt-2 ms-4">
<div class="card card-body bg-light border-success-subtle p-2 mb-2">
<div class="fw-bold small text-success mb-1"><i class="bi bi-tools me-1"></i>{% translate "Assets rattachés à" %} {{ location.code }} :</div>
{% include "assets/outdoor/outdoorlocation_asset_tree.html" with asset_nodes=location_tree.assets only %}
</div>
</div>
{% endif %}
<!-- Sous-localisations & leurs assets -->
<div class="mt-2">
{% include "assets/outdoor/outdoorlocation_tree.html" with node=location_tree depth=1 only %}
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Localisation & Documents -->
<div class="col-md-5">
<!-- Card Localisation & Carte interactive -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center">
<span><i class="bi bi-geo-alt me-2"></i>{% translate "Localisation & Carte" %}</span>
{% if center_lat and center_lon %}
<a href="https://www.google.com/maps?cbll={{ center_lat|floatformat:'6u'}},{{ center_lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid="
target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-2">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="Street View" height="14" class="me-1">
Street View
</a>
{% endif %}
</div>
<div class="card-body p-0">
<div id="outdoor-map" style="height: 340px; width: 100%; border-radius: 0 0 4px 4px;"></div>
</div>
<div class="card-footer bg-white p-3">
<p class="mb-2 small">
<strong>{% translate "Adresse" %} :</strong> {{ location.address|default:_("Non renseignée") }}
</p>
{% if center_lat and center_lon %}
<div class="small text-muted font-monospace">
WGS84: {{ center_lat|floatformat:6 }}, {{ center_lon|floatformat:6 }}
</div>
{% endif %}
</div>
</div>
<!-- Card Documents associés -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold"><i class="bi bi-file-earmark-text me-2"></i>{% translate "Documents associés" %} ({{ documents.count }})</span>
<a href="{% url 'admin:assets_outdoorlocationdocument_add' %}?site={{ location.pk }}" class="btn btn-sm btn-outline-primary py-0 px-2">
<i class="bi bi-plus-lg me-1"></i>{% translate "Ajouter" %}
</a>
</div>
<div class="card-body">
<div class="row row-cols-1 row-cols-sm-2 g-3">
{% for doc in documents %}
<div class="col">
<div class="card h-100 border text-center p-2">
{% if doc.document.file %}
<i class="bi bi-file-earmark-pdf text-danger fs-2 mb-1"></i>
<div class="small text-truncate w-100 fw-bold" title="{{ doc.document.file.name }}">{{ doc.document.file.name }}</div>
<a href="{{ doc.document.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary mt-2">
<i class="bi bi-download me-1"></i>{% translate "Télécharger" %}
</a>
{% else %}
<i class="bi bi-file-earmark text-secondary fs-2 mb-1"></i>
<span class="text-muted small">{% translate "Fichier indisponible" %}</span>
{% endif %}
</div>
</div>
{% empty %}
<div class="col-12 text-center text-muted py-3">
<i class="bi bi-folder-x fs-4 d-block mb-1"></i>
{% translate "Aucun document associé." %}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 2. Assets & Équipements Tab -->
<div class="tab-pane fade" id="equipments" role="tabpanel">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title h6 mb-0"><i class="bi bi-tools me-2"></i>{% translate "Assets Physiques & Équipements du Site et de ses sous-localisations" %}</h5>
<span class="badge bg-success-subtle text-success border">{{ equipments|length }} {% translate "asset(s)" %}</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Nom" %}</th>
<th>{% translate "Type d'asset" %}</th>
<th>{% translate "Localisation spécifique" %}</th>
<th>{% translate "Norme" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for eq in equipments %}
<tr>
<td class="fw-bold">
<a href="{{ eq.get_absolute_url }}">{{ eq.code }}</a>
</td>
<td>{{ eq.get_name }}</td>
<td>
<span class="badge bg-success-subtle text-success border">{{ eq.get_asset_type_display }}</span>
</td>
<td>
{% if eq.location %}
<a href="{{ eq.location.get_absolute_url }}">{{ eq.location.code }} - {{ eq.location.get_name }}</a>
{% else %}
<span class="text-muted">{% translate "Site principal" %}</span>
{% endif %}
</td>
<td>{{ eq.get_norm_display|default:"-" }}</td>
<td>
<span class="badge bg-secondary">{{ eq.get_status_display }}</span>
</td>
<td class="text-end">
<a href="{{ eq.get_absolute_url }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">
{% translate "Aucun asset physique répertorié pour ce site." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 3. Interventions Tab -->
<div class="tab-pane fade" id="interventions" role="tabpanel">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title h6 mb-0"><i class="bi bi-tools me-2"></i>{% translate "Historique des Interventions" %}</h5>
<span class="badge bg-primary-subtle text-primary border">{{ interventions.count }} {% translate "intervention(s)" %}</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "N° Ticket" %}</th>
<th>{% translate "Titre" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Priorité" %}</th>
<th>{% translate "Créée le" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for intervention in interventions %}
<tr>
<td class="fw-bold">#{{ intervention.pk }}</td>
<td>{{ intervention.title }}</td>
<td><span class="badge bg-info">{{ intervention.get_status_display }}</span></td>
<td><span class="badge bg-warning text-dark">{{ intervention.get_priority_display }}</span></td>
<td>{{ intervention.creation_time|date:"d/m/Y H:i" }}</td>
<td class="text-end">
<a href="{% url 'interventions:intervention_detail' intervention.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center text-muted py-4">
{% translate "Aucune intervention enregistrée sur cet espace." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const centerLat = {{ center_lat|default:"null" }};
const centerLon = {{ center_lon|default:"null" }};
const locationGeojson = {{ location_geojson_json|safe }};
if (centerLat !== null && centerLon !== null) {
const map = new maplibregl.Map({
container: 'outdoor-map',
style: '{{ MAP_STYLE_LIGHT|default:"https://api.maptiler.com/maps/streets-v2-light/style.json" }}',
center: [centerLon, centerLat],
zoom: 16
});
map.addControl(new maplibregl.NavigationControl());
map.on('load', function() {
if (locationGeojson) {
map.addSource('location-shape', {
'type': 'geojson',
'data': {
'type': 'Feature',
'geometry': locationGeojson
}
});
if (locationGeojson.type === 'Polygon' || locationGeojson.type === 'MultiPolygon') {
map.addLayer({
'id': 'location-fill',
'type': 'fill',
'source': 'location-shape',
'paint': {
'fill-color': '#198754',
'fill-opacity': 0.3
}
});
map.addLayer({
'id': 'location-outline',
'type': 'line',
'source': 'location-shape',
'paint': {
'line-color': '#198754',
'line-width': 3
}
});
} else {
new maplibregl.Marker({ color: '#198754' })
.setLngLat([centerLon, centerLat])
.addTo(map);
}
} else {
new maplibregl.Marker({ color: '#198754' })
.setLngLat([centerLon, centerLat])
.addTo(map);
}
});
} else {
document.getElementById('outdoor-map').innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 text-muted"><i class="bi bi-geo-alt-fill me-2"></i>{% translate "Géométrie non disponible" %}</div>';
}
});
</script>
{% endblock content %}

View file

@ -0,0 +1,88 @@
{% load i18n %}
<ul class="list-group list-group-flush border-0">
{% for child_node in node.children %}
<li class="list-group-item border-0 py-1 px-0">
<!-- Row Header -->
<div class="d-flex align-items-center justify-content-between py-1 px-3">
<!-- Contenu gauche (indenté selon la profondeur) -->
<div class="d-flex align-items-center gap-2 flex-wrap" style="padding-left: calc(({{ depth|default:1 }} - 1) * 1.25rem);">
{% if child_node.children or child_node.assets %}
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-loc-{{ child_node.loc.id }}"
aria-expanded="true">
<i class="bi bi-chevron-down small"></i>
</button>
{% else %}
<span class="px-2"></span>
{% endif %}
{% if child_node.loc.site_type == 'playground' %}
<i class="bi bi-balloon-fill text-success fs-6" title="{% translate 'Aire de jeux' %}"></i>
{% elif child_node.loc.site_type == 'sports_ground' %}
<i class="bi bi-trophy-fill text-primary fs-6" title="{% translate 'Terrain de sport' %}"></i>
{% elif child_node.loc.site_type == 'multisports_agora' %}
<i class="bi bi-dribbble text-primary fs-6" title="{% translate 'City-stadium / Agora' %}"></i>
{% elif child_node.loc.site_type == 'outdoor_fitness' %}
<i class="bi bi-activity text-danger fs-6" title="{% translate 'Fitness plein air' %}"></i>
{% elif child_node.loc.site_type == 'skatepark_pumptrack' %}
<i class="bi bi-scooter text-warning fs-6" title="{% translate 'Skatepark & Pumptrack' %}"></i>
{% elif child_node.loc.site_type == 'open_air_theater' %}
<i class="bi bi-ticket-detailed-fill text-info fs-6" title="{% translate 'Théâtre en plein air' %}"></i>
{% elif child_node.loc.site_type == 'park_recreation' %}
<i class="bi bi-tree-fill text-success fs-6" title="{% translate 'Parc récréatif' %}"></i>
{% elif child_node.loc.site_type == 'dog_park' %}
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="#fd7e14" class="bi" viewBox="0 0 512 512" title="{% translate 'Espace canin / Canisite' %}"><path d="M226.5 92.9c14.3 42.9-.3 86.2-32.6 96.8s-70.1-15.6-84.4-58.5s.3-86.2 32.6-96.8s70.1 15.6 84.4 58.5zM100.4 198.6c18.9 32.4 14.3 70.1-10.2 84.1s-59.7-.9-78.5-33.3S-2.7 179.3 21.8 165.3s59.7.9 78.6 33.3zM69.2 401.2c-7.1-17.4-2.8-37.1 11.6-47.5c22.7-16.4 57.5-12.7 87.2 8.7c31.1 22.3 53.6 56.4 46.5 73.8c-7.1 17.4-32.8 17.3-64 -5c-31.1-22.4-74.2-12.6-81.3 4.8zm342.4-202.6c18.9-32.4 54.1-47.3 78.6-33.3s29.1 51.7 10.2 84.1s-54.1 47.3-78.6 33.3s-29.1-51.7-10.2-84.1zm-56.5-105.7c14.3-42.9 52.1-69.1 84.4-58.5s46.9 53.9 32.6 96.8s-52.1 69.1-84.4 58.5s-46.9-53.9-32.6-96.8zM442.8 401.2c-7.1-17.4-50.2-27.2-81.3-4.8c-31.2 22.3-56.9 22.4-64 5c-7.1-17.4 15.4-51.5 46.5-73.8c29.7-21.4 64.5-25.1 87.2-8.7c14.4 10.4 18.7 30.1 11.6 47.5zM256 224c-53 0-103 34.6-122.6 88.8c-17.7 49 1.1 103.8 44.5 131.7c35.6 22.9 80 23.5 116.2 0c43.4-27.9 62.2-82.7 44.5-131.7C359 258.6 309 224 256 224z"/></svg>
{% elif child_node.loc.site_type == 'cemetery' %}
<i class="bi bi-flower2 text-secondary fs-6" title="{% translate 'Cimetière' %}"></i>
{% elif child_node.loc.site_type == 'event_plaza' %}
<i class="bi bi-flag-fill text-primary fs-6" title="{% translate 'Place publique' %}"></i>
{% else %}
<i class="bi bi-geo-alt-fill text-success fs-6"></i>
{% endif %}
<!-- Code en bleu pour indiquer clairement un lien cliquable -->
<a href="{{ child_node.loc.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline small">
{{ child_node.loc.code }}
</a>
<span class="small text-muted">- {{ child_node.loc.get_name }}</span>
<span class="badge bg-success-subtle text-success border small">{{ child_node.loc.get_site_type_display }}</span>
{% if child_node.assets %}
<button class="btn btn-sm btn-outline-success py-0 px-2 small text-nowrap"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-assets-loc-{{ child_node.loc.id }}"
aria-expanded="false">
<i class="bi bi-tools me-1"></i>Assets ({{ child_node.assets|length }})
</button>
{% endif %}
</div>
</div>
<!-- Collapsible Assets pour cette localisation (NON VISIBLES PAR DÉFAUT) -->
{% if child_node.assets %}
<div id="tree-assets-loc-{{ child_node.loc.id }}" class="collapse mt-2" style="padding-left: calc({{ depth|default:1 }} * 1.25rem);">
<div class="card card-body bg-light border-success-subtle p-2 mb-2 me-3">
<div class="fw-bold small text-success mb-1"><i class="bi bi-tools me-1"></i>{% translate "Assets rattachés à" %} {{ child_node.loc.code }} :</div>
{% include "assets/outdoor/outdoorlocation_asset_tree.html" with asset_nodes=child_node.assets depth=depth|default:1|add:1 only %}
</div>
</div>
{% endif %}
<!-- Collapsible Sous-localisations -->
{% if child_node.children %}
<div id="tree-loc-{{ child_node.loc.id }}" class="collapse show">
{% include "assets/outdoor/outdoorlocation_tree.html" with node=child_node depth=depth|default:1|add:1 only %}
</div>
{% endif %}
</li>
{% empty %}
{% if not node.assets %}
<li class="list-group-item border-0 text-muted small fst-italic py-2 ps-3">
{% translate "Aucune sous-localisation." %}
</li>
{% endif %}
{% endfor %}
</ul>

View file

@ -1,6 +1,6 @@
from django.urls import path
from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings
from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor
app_name = "assets"
urlpatterns = [
@ -226,6 +226,12 @@ urlpatterns = [
path('publicbuildings/locations/geojson/', buildings.publicbuildings_locations_geojson, name='publicbuildings_locations_geojson'),
path('publicbuildings/assets/geojson/', buildings.publicbuildings_assets_geojson, name='publicbuildings_assets_geojson'),
# Outdoor facilities GeoJSON & Detail URLs
path('outdoor/locations/geojson/', outdoor.outdoor_locations_geojson, name='outdoor_locations_geojson'),
path('outdoor/locations/<int:pk>/', outdoor.outdoor_location_detail, name='outdoor_location_detail'),
path('outdoor/assets/geojson/', outdoor.outdoor_assets_geojson, name='outdoor_assets_geojson'),
path('outdoor/<str:asset_model>/<int:asset_id>/', outdoor.outdoor_assets_detail, name='outdoor_assets_detail'),
# DXF import URLs
path('trafficlights/intersections/<int:intersection_id>/import-dxf/', trafficlights.import_dxf, name='import_dxf'),

View file

@ -406,6 +406,9 @@ ELEMENT_TYPE_LABELS = {
# --- Bâtiments publics ---
'publicbuilding': _('Bâtiment public'),
'buildingequipment': _('Équipement bâtiment'),
# --- Espaces plein air ---
'outdoorlocation': _('Espace public plein air'),
'outdoorasset': _('Équipement plein air'),
}
@ -475,6 +478,14 @@ def build_thematic_config(thematics):
'locations_geojson_url': reverse('assets:publicbuildings_locations_geojson'),
'assets_geojson_url': reverse('assets:publicbuildings_assets_geojson'),
},
'outdoorfacilities': {
'locations_geojson_url': reverse('assets:outdoor_locations_geojson'),
'assets_geojson_url': reverse('assets:outdoor_assets_geojson'),
},
'outdoor': {
'locations_geojson_url': reverse('assets:outdoor_locations_geojson'),
'assets_geojson_url': reverse('assets:outdoor_assets_geojson'),
},
}
result = {}
for thematic in thematics:
@ -746,6 +757,43 @@ def build_conditional_filter_config(accessible_thematics, user=None):
},
]
# Outdoor facilities specific choices
from assets.models.outdoor import SITE_TYPE_CHOICES, NORM_CHOICES
outdoor_site_type_options = [
{'value': str(key), 'label': str(value)}
for key, value in SITE_TYPE_CHOICES
]
outdoor_norm_options = [
{'value': str(key), 'label': str(value)}
for key, value in NORM_CHOICES
]
for thematic_code in ['outdoorfacilities', 'outdoor']:
if thematic_code in accessible_codes:
config[thematic_code] = [
{
'key': 'site_type',
'label': _("Type de site (Localisation)"),
'type': 'multi',
'options': outdoor_site_type_options,
},
{
'key': 'norm',
'label': _("Norme de sécurité (Asset)"),
'type': 'multi',
'options': outdoor_norm_options,
},
{
'key': 'pmr_accessible',
'label': _('Accessible PMR'),
'type': 'single',
'options': [
{'value': 'true', 'label': _('Oui')},
{'value': 'false', 'label': _('Non')},
],
},
]
# Sign assets specific filter
if 'sign' in accessible_codes:
config.setdefault('sign', []).append({

View file

@ -0,0 +1,349 @@
import json
from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.db.models import Q
from django.http import JsonResponse, HttpResponseForbidden
from django.urls import reverse
from django.utils.translation import gettext as _, get_language
from django.contrib.gis.geos import GEOSGeometry
from assets.models.outdoor import (
OutdoorLocation, OutdoorAsset, OutdoorLocationDocument,
SITE_TYPE_CHOICES, OUTDOOR_ASSET_TYPE_CHOICES, NORM_CHOICES
)
from assets.permissions import can_edit_asset
def outdoor_locations_geojson(request):
"""
Vue GeoJSON renvoyant la collection des localisations d'espaces publics d'extérieur pour la carte unifiée.
"""
lang = get_language() if get_language() in ('fr', 'nl') else 'fr'
if request.method == "POST":
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
site_types = data.get('site_type') or []
if isinstance(site_types, str):
site_types = [site_types]
is_main_loc = data.get('is_main_location')
pmr = data.get('pmr_accessible')
water = data.get('has_water_point')
power = data.get('has_power_supply')
fenced = data.get('is_fenced')
filter_geom_raw = data.get("filter_geom")
if filter_geom_raw:
try:
filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326)
filter_geom.transform(3812)
except Exception as e:
return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400)
else:
filter_geom = None
search_q = (data.get('q') or '').strip()
else:
site_types = request.GET.getlist('site_type', [])
is_main_loc = request.GET.get('is_main_location')
pmr = request.GET.get('pmr_accessible')
water = request.GET.get('has_water_point')
power = request.GET.get('has_power_supply')
fenced = request.GET.get('is_fenced')
filter_geom = None
search_q = request.GET.get('q', '').strip()
qs = OutdoorLocation.objects.all()
if site_types:
qs = qs.filter(site_type__in=site_types)
if is_main_loc in ['true', '1', True]:
qs = qs.filter(is_main_location=True)
elif is_main_loc in ['false', '0', False]:
qs = qs.filter(is_main_location=False)
if pmr in ['true', '1', True]:
qs = qs.filter(pmr_accessible=True)
if water in ['true', '1', True]:
qs = qs.filter(has_water_point=True)
if power in ['true', '1', True]:
qs = qs.filter(has_power_supply=True)
if fenced in ['true', '1', True]:
qs = qs.filter(is_fenced=True)
if filter_geom:
qs = qs.filter(geom__intersects=filter_geom)
if search_q:
qs = qs.filter(
Q(code__icontains=search_q) |
Q(name_fr__icontains=search_q) |
Q(name_nl__icontains=search_q) |
Q(address__icontains=search_q) |
Q(owner__icontains=search_q)
)
features = []
from assets.views.core import get_element_type_label
for loc in qs:
geometry = None
if loc.geojson:
try:
geometry = json.loads(loc.geojson)
except Exception:
pass
if not geometry and loc.geom:
geom_4326 = loc.geom.clone()
geom_4326.transform(4326)
centroid = geom_4326.centroid
geometry = {"type": "Point", "coordinates": [centroid.x, centroid.y]}
elif not geometry and loc.lon is not None and loc.lat is not None:
geometry = {"type": "Point", "coordinates": [loc.lon, loc.lat]}
detail_url = reverse('assets:outdoor_location_detail', kwargs={'pk': loc.pk})
features.append({
"type": "Feature",
"id": loc.id,
"geometry": geometry,
"properties": {
"code": loc.code,
"name": loc.get_name(),
"site_type": loc.site_type,
"site_type_display": loc.get_site_type_display(),
"status": "active",
"owner": loc.owner or '',
"pmr_accessible": loc.pmr_accessible,
"has_water_point": loc.has_water_point,
"has_power_supply": loc.has_power_supply,
"is_fenced": loc.is_fenced,
"lon": loc.lon,
"lat": loc.lat,
"color": "#198754",
"display_color": "#198754",
"asset_type": "outdoorlocation",
"detail_url": detail_url,
"element_type": get_element_type_label("outdoorlocation"),
"thematic": "outdoorfacilities",
"is_location": True,
}
})
return JsonResponse({"type": "FeatureCollection", "features": features})
def outdoor_assets_geojson(request):
"""
Vue GeoJSON renvoyant la collection des assets d'extérieur (structures de jeu, fitness, skate modules...) pour la carte unifiée.
"""
lang = get_language() if get_language() in ('fr', 'nl') else 'fr'
if request.method == "POST":
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
statuses = data.get('asset_status', [])
categories = data.get('category', []) or data.get('categories', [])
if isinstance(categories, (str, int)):
categories = [categories]
asset_types = data.get('asset_type') or []
if isinstance(asset_types, str):
asset_types = [asset_types]
norms = data.get('norm') or []
if isinstance(norms, str):
norms = [norms]
search_q = (data.get('q') or '').strip()
else:
statuses = request.GET.getlist('asset_status')
categories = request.GET.getlist('category')
asset_types = request.GET.getlist('asset_type', [])
norms = request.GET.getlist('norm', [])
search_q = request.GET.get('q', '').strip()
qs = OutdoorAsset.objects.select_related('site', 'location', 'parent_asset', 'category').all()
if categories:
qs = qs.filter(category_id__in=categories)
if asset_types:
qs = qs.filter(asset_type__in=asset_types)
if norms:
qs = qs.filter(norm__in=norms)
if statuses:
qs = qs.filter(status__in=statuses)
if search_q:
qs = qs.filter(
Q(code__icontains=search_q) |
Q(name_fr__icontains=search_q) |
Q(name_nl__icontains=search_q)
)
features = []
from assets.views.core import get_element_type_label
for a in qs:
geometry = None
if a.geojson:
try:
geometry = json.loads(a.geojson)
except Exception:
pass
if not geometry and a.geom:
geom_4326 = a.geom.clone()
geom_4326.transform(4326)
geometry = {"type": "Point", "coordinates": [geom_4326.x, geom_4326.y]}
elif not geometry and a.lon is not None and a.lat is not None:
geometry = {"type": "Point", "coordinates": [a.lon, a.lat]}
elif not geometry and a.location and a.location.lon is not None and a.location.lat is not None:
geometry = {"type": "Point", "coordinates": [a.location.lon, a.location.lat]}
elif not geometry and a.site and a.site.lon is not None and a.site.lat is not None:
geometry = {"type": "Point", "coordinates": [a.site.lon, a.site.lat]}
detail_url = reverse('assets:outdoor_assets_detail', kwargs={'asset_model': 'outdoorasset', 'asset_id': a.pk})
features.append({
"type": "Feature",
"id": a.id,
"geometry": geometry,
"properties": {
"code": a.code,
"name": a.get_name(),
"asset_type": a.asset_type,
"asset_type_display": a.get_asset_type_display(),
"category_id": a.category_id,
"category_name": a.category.get_name() if a.category else '',
"status": a.status,
"status_display": a.get_status_display(),
"obsolescence": a.obsolescence or '',
"obsolescence_display": a.get_obsolescence_display() if hasattr(a, 'get_obsolescence_display') else '',
"norm": a.norm or '',
"norm_display": a.get_norm_display() if a.norm else '',
"site_code": a.site.code if a.site else '',
"site_name": a.site.get_name() if a.site else '',
"location_code": a.location.code if a.location else '',
"location_name": a.location.get_name() if a.location else '',
"lon": a.lon,
"lat": a.lat,
"color": "#198754",
"display_color": "#198754",
"detail_url": detail_url,
"element_type": get_element_type_label("outdoorasset"),
"thematic": "outdoorfacilities",
"is_location": False,
}
})
return JsonResponse({"type": "FeatureCollection", "features": features})
def build_outdoor_asset_node(asset):
return {
'asset': asset,
'children': [build_outdoor_asset_node(c) for c in asset.child_assets.all()]
}
def build_outdoor_location_node(loc):
if loc.parent_location is None:
loc_assets = loc.location_assets.filter(parent_asset__isnull=True) | loc.all_outdoor_assets.filter(location__isnull=True, parent_asset__isnull=True)
loc_assets = loc_assets.distinct().prefetch_related('child_assets')
else:
loc_assets = loc.location_assets.filter(parent_asset__isnull=True).prefetch_related('child_assets')
return {
'loc': loc,
'children': [build_outdoor_location_node(c) for c in loc.child_locations.all().prefetch_related('child_locations', 'location_assets')],
'assets': [build_outdoor_asset_node(a) for a in loc_assets],
}
@login_required
def outdoor_location_detail(request, pk):
"""
Vue détaillée d'une Localisation Espace Public Extérieur avec son arborescence unifiée de localisations et d'assets physiques.
"""
from django.contrib.contenttypes.models import ContentType
from interventions.models import Intervention
from interventions.permissions import filter_viewable_interventions_for_user
location = get_object_or_404(OutdoorLocation.objects.select_related('parent_location', 'manager'), pk=pk)
can_edit = can_edit_asset(request.user, location)
location_tree = build_outdoor_location_node(location)
parent_location = location.parent_location
documents = location.documents.all()
content_type = ContentType.objects.get_for_model(OutdoorLocation)
base_interventions = filter_viewable_interventions_for_user(request.user)
interventions = base_interventions.filter(
interventionasset__object_id=location.id,
interventionasset__content_type=content_type
).distinct().order_by('-creation_time')
location_geojson = None
center_lon, center_lat = None, None
if location.geom:
try:
geom_4326 = location.geom.clone()
geom_4326.transform(4326)
location_geojson = json.loads(geom_4326.json)
centroid = geom_4326.centroid
center_lon, center_lat = centroid.x, centroid.y
except Exception:
pass
elif location.lon is not None and location.lat is not None:
center_lon, center_lat = location.lon, location.lat
context = {
'location': location,
'can_edit': can_edit,
'location_tree': location_tree,
'parent_location': parent_location,
'equipments': location.get_all_assets(),
'documents': documents,
'interventions': interventions,
'content_type': content_type,
'location_geojson_json': json.dumps(location_geojson) if location_geojson else 'null',
'center_lon': center_lon,
'center_lat': center_lat,
}
return render(request, 'assets/outdoor/outdoorlocation_detail.html', context)
@login_required
def outdoor_assets_detail(request, asset_model, asset_id):
from django.contrib.contenttypes.models import ContentType
from assets.views.core import get_asset_edit_context
from interventions.models import Intervention
from interventions.permissions import filter_viewable_interventions_for_user
content_type = get_object_or_404(ContentType, app_label='assets', model=asset_model.lower())
model_class = content_type.model_class()
asset = get_object_or_404(model_class, pk=asset_id)
base_interventions = filter_viewable_interventions_for_user(request.user)
interventions = base_interventions.filter(
interventionasset__object_id=asset.id,
interventionasset__content_type=content_type
).distinct().order_by('-creation_time')
context = {
'asset': asset,
'content_type': content_type,
'interventions': interventions,
}
context.update(get_asset_edit_context(request, asset, content_type))
return render(request, "assets/detail_generic.html", context)