diff --git a/streetup/assets/admin.py b/streetup/assets/admin.py index 861974f..3028c35 100644 --- a/streetup/assets/admin.py +++ b/streetup/assets/admin.py @@ -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): @@ -664,4 +665,20 @@ class BuildingAssetAdmin(admin.GISModelAdmin): search_fields = ("code", "name_fr", "name_nl", "serial_number") raw_id_fields = ("building", "location", "parent_asset") -admin.site.register(PublicBuildingDocument, AssetAdmin) \ No newline at end of file +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) \ No newline at end of file diff --git a/streetup/assets/migrations/0007_outdoorlocation_outdoorasset_outdoorlocationdocument.py b/streetup/assets/migrations/0007_outdoorlocation_outdoorasset_outdoorlocationdocument.py new file mode 100644 index 0000000..a4abbbe --- /dev/null +++ b/streetup/assets/migrations/0007_outdoorlocation_outdoorasset_outdoorlocationdocument.py @@ -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", + }, + ), + ] diff --git a/streetup/assets/models/__init__.py b/streetup/assets/models/__init__.py index 9e189b9..7853568 100644 --- a/streetup/assets/models/__init__.py +++ b/streetup/assets/models/__init__.py @@ -10,4 +10,5 @@ from .sign import SignStreet, SignStreetDocument, SignPole, SignPoleModel, SignP from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSidewalk, RoadSidewalkModel, RoadFurniture, RoadFurnitureModel, RoadwayFunction, RoadConcreteBlockSet, RoadDrainModel, RoadDrain 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 \ No newline at end of file +from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument +from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument \ No newline at end of file diff --git a/streetup/assets/models/outdoor.py b/streetup/assets/models/outdoor.py new file mode 100644 index 0000000..0b0bbd5 --- /dev/null +++ b/streetup/assets/models/outdoor.py @@ -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") diff --git a/streetup/assets/static/assets/unified_index.js b/streetup/assets/static/assets/unified_index.js index fab0123..d015fe3 100644 --- a/streetup/assets/static/assets/unified_index.js +++ b/streetup/assets/static/assets/unified_index.js @@ -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 `${v || '-'}`; - 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 `${v || '-'}`; + 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 ? ` ${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 ? ` ${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; } @@ -1328,7 +1335,7 @@ function buildLocationsGeojsonPostBody(thematicCode) { function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) { const includeNoGeomCheckbox = document.getElementById('include-no-geom'); const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true; - + const payload = { include_no_geom: includeNoGeom, }; @@ -1550,7 +1557,7 @@ function bindUnifiedBulkActions() { } const newValue = document.getElementById('bulk-status-select').value; - + fetch(bulkEditAssetsUrl, { method: 'POST', headers: { @@ -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'); - } - }); + }); }); } @@ -1610,7 +1617,7 @@ function bindUnifiedBulkActions() { } const newValue = document.getElementById('bulk-validation-status-select').value; - + fetch(bulkEditAssetsUrl, { method: 'POST', headers: { @@ -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'; } @@ -2050,14 +2057,14 @@ function getViewportFeatures(features) { function fitMapToFeatures(features) { if (typeof map === 'undefined' || !map.fitBounds || !features || features.length === 0) return; - + let minLng = Infinity, minLat = Infinity, maxLng = -Infinity, maxLat = -Infinity; let hasCoords = false; features.forEach(f => { const geom = f.geometry; if (!geom || !geom.coordinates) return; - + function processCoords(coords) { if (!Array.isArray(coords)) return; if (typeof coords[0] === 'number') { @@ -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) => { @@ -3192,7 +3199,7 @@ function renderSignPanelTypeConditionalFilter(thematicCode, def, selectedValues) const accordionItemsHtml = groupKeys.map((group, index) => { const groupLabel = groupLabels.get(group) || group; const options = grouped.get(group) || []; - + const itemsHtml = options.map(option => { const title = option.name ? `${option.code} - ${option.name}` : option.code; return ` @@ -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; } diff --git a/streetup/assets/static/assets/unified_map_layers.js b/streetup/assets/static/assets/unified_map_layers.js index 946cff6..b99c7fd 100644 --- a/streetup/assets/static/assets/unified_map_layers.js +++ b/streetup/assets/static/assets/unified_map_layers.js @@ -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 ]; diff --git a/streetup/assets/templates/assets/outdoor/outdoorlocation_asset_tree.html b/streetup/assets/templates/assets/outdoor/outdoorlocation_asset_tree.html new file mode 100644 index 0000000..8e11ba6 --- /dev/null +++ b/streetup/assets/templates/assets/outdoor/outdoorlocation_asset_tree.html @@ -0,0 +1,70 @@ +{% load i18n %} + diff --git a/streetup/assets/templates/assets/outdoor/outdoorlocation_detail.html b/streetup/assets/templates/assets/outdoor/outdoorlocation_detail.html new file mode 100644 index 0000000..bfe6b44 --- /dev/null +++ b/streetup/assets/templates/assets/outdoor/outdoorlocation_detail.html @@ -0,0 +1,490 @@ +{% extends "base.html" %} +{% load static i18n documents_tags %} + +{% block title %}{{ location.code }} - {{ location.get_name }} | StreetUp{% endblock %} + +{% block extra_head %} + + + +{% endblock %} + +{% block content %} +
+ +
+ + +
+ + +
+
+
+
+

+ {{ location.code }} - {{ location.get_name }} +

+

{{ location.address|default:_("Adresse non renseignée") }}

+
+ {{ location.get_site_type_display }} + {% if location.pmr_accessible %} + PMR Accessible + {% endif %} + {% if location.has_water_point %} + Eau potable + {% endif %} + {% if location.has_power_supply %} + Électricité + {% endif %} + {% if location.is_fenced %} + Clôturé + {% endif %} +
+
+
+ {% if location.surface_m2 %} +
+ {% translate "Surface" %} + {{ location.surface_m2|floatformat:0 }} m² +
+ {% endif %} + {% if location.max_capacity %} +
+ {% translate "Jauge max" %} + {{ location.max_capacity }} pers. +
+ {% endif %} +
+
+
+
+ + + + + +
+ + +
+
+ + +
+ + +
+
{% translate "Propriété & Responsables" %}
+
+ + + + + + + + + + + + + +
{% translate "Propriétaire" %} :{{ location.owner|default:"-" }}
{% translate "Gestionnaire référent" %} :{% if location.manager %}{{ location.manager.get_full_name|default:location.manager.username }}{% else %}-{% endif %}
{% translate "Service gestionnaire" %} :{{ location.occupant_entity|default:"-" }}
+
+
+ + +
+
{% translate "Caractéristiques physiques & Aménagements" %}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
{% translate "Surface totale" %} :{{ location.surface_m2|default:"-" }} m²
{% translate "Jauge / Capacité d'accueil" %} :{% if location.max_capacity %}{{ location.max_capacity }} {% translate "personnes" %}{% else %}-{% endif %}
{% translate "Accessibilité PMR" %} :{% if location.pmr_accessible %}{% translate "Oui" %}{% else %}{% translate "Non" %}{% endif %}
{% translate "Point d'eau potable" %} :{% if location.has_water_point %}{% translate "Oui" %}{% else %}{% translate "Non" %}{% endif %}
{% translate "Alimentation électrique" %} :{% if location.has_power_supply %}{% translate "Disponible" %}{% else %}{% translate "Non" %}{% endif %}
{% translate "Clôture & Sécurité" %} :{% if location.is_fenced %}{% translate "Site clôturé" %}{% else %}{% translate "Accès libre" %}{% endif %}
+
+
+ + +
+
+ {% translate "Arborescence Hiérarchique" %} + {{ location_tree.children|length }} {% translate "sous-localisation(s)" %} +
+
+ + + {% if parent_location %} +
+
+
+ +
+
{% translate "Localisation parente" %} :
+ + {{ parent_location.code }} - {{ parent_location.get_name }} + + {{ parent_location.get_site_type_display }} +
+
+ + {% translate "Voir la parente" %} + +
+
+ {% endif %} + + +
+
+
+ {% if location.site_type == 'playground' %} + + {% elif location.site_type == 'sports_ground' %} + + {% elif location.site_type == 'multisports_agora' %} + + {% elif location.site_type == 'outdoor_fitness' %} + + {% elif location.site_type == 'skatepark_pumptrack' %} + + {% elif location.site_type == 'open_air_theater' %} + + {% elif location.site_type == 'park_recreation' %} + + {% elif location.site_type == 'dog_park' %} + + {% elif location.site_type == 'cemetery' %} + + {% else %} + + {% endif %} + {{ location.code }} + - {{ location.get_name }} + {{ location.get_site_type_display }} + + {% if location_tree.assets %} + + {% endif %} +
+
+ + + {% if location_tree.assets %} +
+
+
{% translate "Assets rattachés à" %} {{ location.code }} :
+ {% include "assets/outdoor/outdoorlocation_asset_tree.html" with asset_nodes=location_tree.assets only %} +
+
+ {% endif %} + + +
+ {% include "assets/outdoor/outdoorlocation_tree.html" with node=location_tree depth=1 only %} +
+
+ +
+
+ +
+ + +
+ + +
+
+ {% translate "Localisation & Carte" %} + {% if center_lat and center_lon %} + + Street View + Street View + + {% endif %} +
+
+
+
+ +
+ + +
+
+ {% translate "Documents associés" %} ({{ documents.count }}) + + {% translate "Ajouter" %} + +
+
+
+ {% for doc in documents %} +
+
+ {% if doc.document.file %} + +
{{ doc.document.file.name }}
+ + {% translate "Télécharger" %} + + {% else %} + + {% translate "Fichier indisponible" %} + {% endif %} +
+
+ {% empty %} +
+ + {% translate "Aucun document associé." %} +
+ {% endfor %} +
+
+
+ +
+ +
+
+ + +
+
+
+
{% translate "Assets Physiques & Équipements du Site et de ses sous-localisations" %}
+ {{ equipments|length }} {% translate "asset(s)" %} +
+
+
+ + + + + + + + + + + + + + {% for eq in equipments %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "Code" %}{% translate "Nom" %}{% translate "Type d'asset" %}{% translate "Localisation spécifique" %}{% translate "Norme" %}{% translate "Statut" %}{% translate "Actions" %}
+ {{ eq.code }} + {{ eq.get_name }} + {{ eq.get_asset_type_display }} + + {% if eq.location %} + {{ eq.location.code }} - {{ eq.location.get_name }} + {% else %} + {% translate "Site principal" %} + {% endif %} + {{ eq.get_norm_display|default:"-" }} + {{ eq.get_status_display }} + + + + +
+ {% translate "Aucun asset physique répertorié pour ce site." %} +
+
+
+
+
+ + +
+
+
+
{% translate "Historique des Interventions" %}
+ {{ interventions.count }} {% translate "intervention(s)" %} +
+
+
+ + + + + + + + + + + + + {% for intervention in interventions %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "N° Ticket" %}{% translate "Titre" %}{% translate "Statut" %}{% translate "Priorité" %}{% translate "Créée le" %}{% translate "Actions" %}
#{{ intervention.pk }}{{ intervention.title }}{{ intervention.get_status_display }}{{ intervention.get_priority_display }}{{ intervention.creation_time|date:"d/m/Y H:i" }} + + + +
+ {% translate "Aucune intervention enregistrée sur cet espace." %} +
+
+
+
+
+ +
+
+ + +{% endblock content %} diff --git a/streetup/assets/templates/assets/outdoor/outdoorlocation_tree.html b/streetup/assets/templates/assets/outdoor/outdoorlocation_tree.html new file mode 100644 index 0000000..d76b436 --- /dev/null +++ b/streetup/assets/templates/assets/outdoor/outdoorlocation_tree.html @@ -0,0 +1,88 @@ +{% load i18n %} + diff --git a/streetup/assets/urls.py b/streetup/assets/urls.py index cd3a2d5..91a841b 100644 --- a/streetup/assets/urls.py +++ b/streetup/assets/urls.py @@ -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//', outdoor.outdoor_location_detail, name='outdoor_location_detail'), + path('outdoor/assets/geojson/', outdoor.outdoor_assets_geojson, name='outdoor_assets_geojson'), + path('outdoor///', outdoor.outdoor_assets_detail, name='outdoor_assets_detail'), + # DXF import URLs path('trafficlights/intersections//import-dxf/', trafficlights.import_dxf, name='import_dxf'), diff --git a/streetup/assets/views/core.py b/streetup/assets/views/core.py index 616e584..685a893 100644 --- a/streetup/assets/views/core.py +++ b/streetup/assets/views/core.py @@ -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({ diff --git a/streetup/assets/views/outdoor.py b/streetup/assets/views/outdoor.py new file mode 100644 index 0000000..c590f3f --- /dev/null +++ b/streetup/assets/views/outdoor.py @@ -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)