feat: implement outdoor location management system with dedicated models, views, and detail templates
This commit is contained in:
parent
044e01add8
commit
b04b612dd8
12 changed files with 1480 additions and 102 deletions
|
|
@ -649,6 +649,7 @@ admin.site.register(CleanLocationContract, AssetAdmin)
|
||||||
|
|
||||||
from .models import CleanLitterBinPhoto, CleanLitterBinObservation
|
from .models import CleanLitterBinPhoto, CleanLitterBinObservation
|
||||||
from .models import PublicBuilding, BuildingAsset, PublicBuildingDocument
|
from .models import PublicBuilding, BuildingAsset, PublicBuildingDocument
|
||||||
|
from .models import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
|
||||||
|
|
||||||
@admin.register(PublicBuilding)
|
@admin.register(PublicBuilding)
|
||||||
class PublicBuildingAdmin(admin.GISModelAdmin):
|
class PublicBuildingAdmin(admin.GISModelAdmin):
|
||||||
|
|
@ -665,3 +666,19 @@ class BuildingAssetAdmin(admin.GISModelAdmin):
|
||||||
raw_id_fields = ("building", "location", "parent_asset")
|
raw_id_fields = ("building", "location", "parent_asset")
|
||||||
|
|
||||||
admin.site.register(PublicBuildingDocument, AssetAdmin)
|
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)
|
||||||
|
|
@ -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",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -11,3 +11,4 @@ from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSi
|
||||||
from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract
|
from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract
|
||||||
from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract
|
from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract
|
||||||
from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument
|
from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument
|
||||||
|
from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
|
||||||
177
streetup/assets/models/outdoor.py
Normal file
177
streetup/assets/models/outdoor.py
Normal 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")
|
||||||
|
|
@ -501,7 +501,8 @@ function buildColumns() {
|
||||||
return [
|
return [
|
||||||
{ field: 'check', checkbox: true, width: 36 },
|
{ field: 'check', checkbox: true, width: 36 },
|
||||||
{ field: 'actions', title: '', formatter: zoomFormatter, width: 50 },
|
{ field: 'actions', title: '', formatter: zoomFormatter, width: 50 },
|
||||||
{ field: 'code', title: gettext('Code'), sortable: true,
|
{
|
||||||
|
field: 'code', title: gettext('Code'), sortable: true,
|
||||||
formatter: function (v, row) {
|
formatter: function (v, row) {
|
||||||
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${v || '-'}</a>`;
|
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${v || '-'}</a>`;
|
||||||
return v || '-';
|
return v || '-';
|
||||||
|
|
@ -512,10 +513,12 @@ function buildColumns() {
|
||||||
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true },
|
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true },
|
||||||
{ field: 'category', title: gettext('Catégorie'), sortable: true, visible: false },
|
{ field: 'category', title: gettext('Catégorie'), sortable: true, visible: false },
|
||||||
{ field: 'model', title: gettext('Modèle'), sortable: true, visible: false },
|
{ field: 'model', title: gettext('Modèle'), sortable: true, visible: false },
|
||||||
{ field: 'status', title: gettext('Statut'), 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}` : ''; }
|
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,
|
{
|
||||||
|
field: 'validation_status', title: gettext('Statut de validation'), sortable: true, visible: true,
|
||||||
formatter: function (v) {
|
formatter: function (v) {
|
||||||
const labels = {
|
const labels = {
|
||||||
'to_be_validated': gettext('À valider'),
|
'to_be_validated': gettext('À valider'),
|
||||||
|
|
@ -1229,6 +1232,10 @@ function hasTriggerFilter() {
|
||||||
const signFilters = activeConditionalFilters.sign || {};
|
const signFilters = activeConditionalFilters.sign || {};
|
||||||
if (Array.isArray(signFilters.municipality) && signFilters.municipality.length) return true;
|
if (Array.isArray(signFilters.municipality) && signFilters.municipality.length) return true;
|
||||||
if (Array.isArray(signFilters.panel_type) && signFilters.panel_type.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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -211,6 +211,10 @@ function loadUnifiedMapIcons(map) {
|
||||||
{ name: 'icon-controlcenters', codepoint: 'f301', color: '#7b1fa2' }, // display-fill — violet
|
{ name: 'icon-controlcenters', codepoint: 'f301', color: '#7b1fa2' }, // display-fill — violet
|
||||||
{ name: 'icon-publicbuildings', codepoint: 'f1dd', color: '#2B579A' }, // building — bleu nuit
|
{ name: 'icon-publicbuildings', codepoint: 'f1dd', color: '#2B579A' }, // building — bleu nuit
|
||||||
{ name: 'icon-publicbuilding', 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
|
{ name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, // map-fill — bleu-gris
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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 %}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
from django.urls import path
|
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"
|
app_name = "assets"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
|
@ -226,6 +226,12 @@ urlpatterns = [
|
||||||
path('publicbuildings/locations/geojson/', buildings.publicbuildings_locations_geojson, name='publicbuildings_locations_geojson'),
|
path('publicbuildings/locations/geojson/', buildings.publicbuildings_locations_geojson, name='publicbuildings_locations_geojson'),
|
||||||
path('publicbuildings/assets/geojson/', buildings.publicbuildings_assets_geojson, name='publicbuildings_assets_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
|
# DXF import URLs
|
||||||
path('trafficlights/intersections/<int:intersection_id>/import-dxf/', trafficlights.import_dxf, name='import_dxf'),
|
path('trafficlights/intersections/<int:intersection_id>/import-dxf/', trafficlights.import_dxf, name='import_dxf'),
|
||||||
|
|
|
||||||
|
|
@ -406,6 +406,9 @@ ELEMENT_TYPE_LABELS = {
|
||||||
# --- Bâtiments publics ---
|
# --- Bâtiments publics ---
|
||||||
'publicbuilding': _('Bâtiment public'),
|
'publicbuilding': _('Bâtiment public'),
|
||||||
'buildingequipment': _('Équipement bâtiment'),
|
'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'),
|
'locations_geojson_url': reverse('assets:publicbuildings_locations_geojson'),
|
||||||
'assets_geojson_url': reverse('assets:publicbuildings_assets_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 = {}
|
result = {}
|
||||||
for thematic in thematics:
|
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
|
# Sign assets specific filter
|
||||||
if 'sign' in accessible_codes:
|
if 'sign' in accessible_codes:
|
||||||
config.setdefault('sign', []).append({
|
config.setdefault('sign', []).append({
|
||||||
|
|
|
||||||
349
streetup/assets/views/outdoor.py
Normal file
349
streetup/assets/views/outdoor.py
Normal 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)
|
||||||
Loading…
Reference in a new issue