From 24355b2e2ffc0a9f8b94a005dfa6c4a25ed695a6 Mon Sep 17 00:00:00 2001 From: kdeterme Date: Sun, 26 Jul 2026 19:03:31 +0200 Subject: [PATCH] feat: add publicbuildings thematic, models, views, GeoJSON endpoints and unified assets map integration --- streetup/assets/admin.py | 14 +- ...el_publicbuilding_buildingroom_and_more.py | 171 +++++++++++++++ streetup/assets/models/__init__.py | 3 +- streetup/assets/models/buildings.py | 197 +++++++++++++++++ .../assets/static/assets/asset_add_modal.js | 4 +- .../assets/static/assets/unified_index.js | 2 +- .../static/assets/unified_map_layers.js | 6 + .../buildings/publicbuilding_detail.html | 193 +++++++++++++++++ .../assets/buildings/publicbuilding_list.html | 150 +++++++++++++ .../templates/assets/unified_index.html | 4 +- streetup/assets/tests.py | 59 +++++- streetup/assets/urls.py | 10 +- streetup/assets/views/buildings.py | 200 ++++++++++++++++++ streetup/assets/views/core.py | 41 ++-- streetup/common/fixtures/reference_data.json | 13 ++ 15 files changed, 1046 insertions(+), 21 deletions(-) create mode 100644 streetup/assets/migrations/0005_buildinglevel_publicbuilding_buildingroom_and_more.py create mode 100644 streetup/assets/models/buildings.py create mode 100644 streetup/assets/templates/assets/buildings/publicbuilding_detail.html create mode 100644 streetup/assets/templates/assets/buildings/publicbuilding_list.html create mode 100644 streetup/assets/views/buildings.py diff --git a/streetup/assets/admin.py b/streetup/assets/admin.py index 4641362..ec9c7dd 100644 --- a/streetup/assets/admin.py +++ b/streetup/assets/admin.py @@ -648,6 +648,18 @@ admin.site.register(CleanGlassContainer, AssetAdmin) admin.site.register(CleanLocationContract, AssetAdmin) from .models import CleanLitterBinPhoto, CleanLitterBinObservation +from .models import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument admin.site.register(CleanLitterBinPhoto, AssetAdmin) -admin.site.register(CleanLitterBinObservation, AssetAdmin) \ No newline at end of file +admin.site.register(CleanLitterBinObservation, AssetAdmin) + +@admin.register(PublicBuilding) +class PublicBuildingAdmin(admin.GISModelAdmin): + list_display = ("code", "name_fr", "usage_type", "building_status", "owner", "manager", "is_erp", "dpe_rating") + list_filter = ("usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible") + search_fields = ("code", "name_fr", "name_nl", "address", "owner") + +admin.site.register(BuildingLevel, AssetAdmin) +admin.site.register(BuildingRoom, AssetAdmin) +admin.site.register(BuildingEquipment, AssetAdmin) +admin.site.register(PublicBuildingDocument, AssetAdmin) \ No newline at end of file diff --git a/streetup/assets/migrations/0005_buildinglevel_publicbuilding_buildingroom_and_more.py b/streetup/assets/migrations/0005_buildinglevel_publicbuilding_buildingroom_and_more.py new file mode 100644 index 0000000..e5b4d14 --- /dev/null +++ b/streetup/assets/migrations/0005_buildinglevel_publicbuilding_buildingroom_and_more.py @@ -0,0 +1,171 @@ +# Generated by Django 6.0.7 on 2026-07-26 13:20 + +import assets.models.buildings +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', '0004_initial'), + ('documents', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='BuildingLevel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=50, verbose_name='Code du niveau')), + ('name', models.CharField(max_length=200, verbose_name='Nom du niveau')), + ('level_number', models.IntegerField(default=0, verbose_name="Numéro d'étage (0=RDC, 1=1er, -1=Sous-sol)")), + ('floor_plan', models.FileField(blank=True, null=True, storage=common.storages.PrivateMediaStorage(), upload_to='buildings/plans/%Y/%m/', verbose_name="Plan d'étage")), + ], + options={ + 'verbose_name': 'Niveau / Étage', + 'verbose_name_plural': 'Niveaux / Étages', + 'ordering': ['building', 'level_number'], + }, + ), + migrations.CreateModel( + name='PublicBuilding', + 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)), + ('geojson', models.TextField(blank=True, null=True)), + ('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Emprise cartographique')), + ('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 occupant')), + ('usage_type', models.CharField(choices=[('administrative', 'Administratif / Mairie'), ('education', 'Enseignement / École / Crèche'), ('sports', 'Sport & Équipement sportif'), ('culture', 'Culture / Médiathèque / Musée'), ('social', 'Social / Centre communautaire'), ('technical', 'Technique / Ateliers / Stock'), ('housing', 'Logement communal'), ('other', 'Autre bâtiment public')], default='administrative', max_length=50, verbose_name="Type d'usage principal")), + ('building_status', models.CharField(choices=[('in_service', 'En service'), ('under_renovation', 'En travaux / Rénovation'), ('decommissioned', 'Désaffecté'), ('ceded', 'Cédé / Vendu')], default='in_service', max_length=50, verbose_name="Statut d'occupation")), + ('construction_year', models.IntegerField(blank=True, null=True, verbose_name='Année de construction')), + ('floors_count', models.IntegerField(default=1, verbose_name="Nombre d'étages hors-sol")), + ('basement_count', models.IntegerField(default=0, verbose_name='Nombre de niveaux en sous-sol')), + ('total_surface_m2', models.FloatField(blank=True, null=True, verbose_name='Surface brute totale (m²)')), + ('usable_surface_m2', models.FloatField(blank=True, null=True, verbose_name='Surface utile (m²)')), + ('is_erp', models.BooleanField(default=True, verbose_name='Établissement Recevant du Public (ERP)')), + ('erp_category', models.CharField(blank=True, choices=[('1', '1ère catégorie (> 1500 pers.)'), ('2', '2ème catégorie (301 - 1500 pers.)'), ('3', '3ème catégorie (101 - 300 pers.)'), ('4', "4ème catégorie (jusqu'à 100 pers.)"), ('5', '5ème catégorie (seuils spécifiques)')], max_length=10, null=True, verbose_name='Catégorie ERP')), + ('erp_type', models.CharField(blank=True, choices=[('J', "J - Structures d'accueil pour personnes âgées ou handicapées"), ('L', 'L - Salles de spectacles, de réunions, polyvalentes'), ('M', 'M - Magasins, centres commerciaux'), ('N', 'N - Restaurants et débits de boissons'), ('O', 'O - Hôtels et pensions de famille'), ('P', 'P - Salles de danse et salles de jeux'), ('R', "R - Établissements d'éveil, d'enseignement, colonies de vacances"), ('S', 'S - Bibliothèques, centres de documentation'), ('T', "T - Salles d'expositions"), ('U', 'U - Établissements de soins'), ('V', 'V - Établissements de culte'), ('W', 'W - Administrations, banques, bureaux'), ('X', 'X - Établissements sportifs couverts'), ('Y', 'Y - Musées')], max_length=10, null=True, verbose_name='Type ERP')), + ('max_capacity', models.IntegerField(blank=True, null=True, verbose_name="Jauge maximale d'accueil du public")), + ('dpe_rating', models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D'), ('E', 'E'), ('F', 'F'), ('G', 'G')], max_length=2, null=True, verbose_name='Diagnostic de Performance Énergétique (DPE)')), + ('ges_rating', models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D'), ('E', 'E'), ('F', 'F'), ('G', 'G')], max_length=2, null=True, verbose_name='Émission de Gaz à Effet de Serre (GES)')), + ('has_asbestos_diag', models.BooleanField(default=False, verbose_name='Diagnostic Amiante réalisé')), + ('has_lead_diag', models.BooleanField(default=False, verbose_name='Diagnostic Plomb réalisé')), + ('pmr_accessible', models.BooleanField(default=False, verbose_name='Accessible PMR')), + ('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')), + ('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_buildings', to=settings.AUTH_USER_MODEL, verbose_name='Gestionnaire référent')), + ('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')), + ('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.publicbuilding', 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')), + ], + options={ + 'verbose_name': 'Bâtiment public', + 'verbose_name_plural': 'Bâtiments publics', + 'ordering': ['code', 'name_fr'], + }, + ), + migrations.CreateModel( + name='BuildingRoom', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=50, verbose_name='Code du local')), + ('name', models.CharField(max_length=200, verbose_name='Nom de la pièce')), + ('room_type', models.CharField(blank=True, max_length=100, null=True, verbose_name='Type de local (ex: Bureau, Classe, Sanitaires)')), + ('surface_m2', models.FloatField(blank=True, null=True, verbose_name='Surface (m²)')), + ('level', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rooms', to='assets.buildinglevel', verbose_name='Niveau / Étage')), + ('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rooms', to='assets.publicbuilding', verbose_name='Bâtiment')), + ], + options={ + 'verbose_name': 'Pièce / Local', + 'verbose_name_plural': 'Pièces / Locaux', + 'ordering': ['building', 'code'], + }, + ), + migrations.AddField( + model_name='buildinglevel', + name='building', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='levels', to='assets.publicbuilding', verbose_name='Bâtiment'), + ), + migrations.CreateModel( + name='BuildingEquipment', + 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)), + ('equipment_type', models.CharField(choices=[('hvac', 'Chauffage / CVC / Chaudière'), ('elevator', 'Ascenseur / Monte-charge'), ('fire_safety', 'Sécurité incendie / Extincteur / RIA'), ('electricity', 'Tableau électrique / TGBT'), ('alarm', "Alarme / Contrôle d'accès"), ('meter', 'Compteur (Eau / Gaz / Électricité)'), ('sanitary', 'Plomberie / Sanitaires'), ('other', 'Autre équipement technique')], default='hvac', max_length=50, verbose_name="Type d'équipement")), + ('last_control_date', models.DateField(blank=True, null=True, verbose_name='Dernier contrôle réglementaire')), + ('next_control_date', models.DateField(blank=True, null=True, verbose_name='Prochain contrôle réglementaire')), + ('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')), + ('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.buildingequipment', 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')), + ('room', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipments', to='assets.buildingroom', verbose_name='Local')), + ('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='equipments', to='assets.publicbuilding', verbose_name='Bâtiment')), + ], + options={ + 'verbose_name': 'Équipement technique de bâtiment', + 'verbose_name_plural': 'Équipements techniques de bâtiment', + 'ordering': ['building', 'code'], + }, + ), + migrations.CreateModel( + name='PublicBuildingDocument', + 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.buildings.upload_to_building, verbose_name='Fichier')), + ('document_type', models.CharField(default='other', max_length=50, verbose_name='Type de document')), + ('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.publicbuilding', verbose_name='Bâtiment')), + ('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')), + ('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 de bâtiment', + 'verbose_name_plural': 'Documents de bâtiment', + }, + ), + ] diff --git a/streetup/assets/models/__init__.py b/streetup/assets/models/__init__.py index 35e2c7e..d98e884 100644 --- a/streetup/assets/models/__init__.py +++ b/streetup/assets/models/__init__.py @@ -9,4 +9,5 @@ from .controlcenters import ControlCenter, ControlCenterLocation, ControlCenterG from .sign import SignStreet, SignStreetDocument, SignPole, SignPoleModel, SignPanel, SignPanelModel, SignPanelType, ComplementaryPoliceRegulation, SignParkingSpot 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 \ No newline at end of file +from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract +from .buildings import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument \ No newline at end of file diff --git a/streetup/assets/models/buildings.py b/streetup/assets/models/buildings.py new file mode 100644 index 0000000..83b9e55 --- /dev/null +++ b/streetup/assets/models/buildings.py @@ -0,0 +1,197 @@ +# ----------------------------- +# Public Buildings 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 AbstractAsset, AbstractGeoAsset, AbstractAssetModel, ASSET_STATUS_CHOICES +from common.models import Document +from common.storages import PrivateMediaStorage + +private_storage = PrivateMediaStorage() + +USAGE_TYPE_CHOICES = ( + ('administrative', _('Administratif / Mairie')), + ('education', _('Enseignement / École / Crèche')), + ('sports', _('Sport & Équipement sportif')), + ('culture', _('Culture / Médiathèque / Musée')), + ('social', _('Social / Centre communautaire')), + ('technical', _('Technique / Ateliers / Stock')), + ('housing', _('Logement communal')), + ('other', _('Autre bâtiment public')), +) + +BUILDING_STATUS_CHOICES = ( + ('in_service', _('En service')), + ('under_renovation', _('En travaux / Rénovation')), + ('decommissioned', _('Désaffecté')), + ('ceded', _('Cédé / Vendu')), +) + +DPE_RATING_CHOICES = ( + ('A', 'A'), + ('B', 'B'), + ('C', 'C'), + ('D', 'D'), + ('E', 'E'), + ('F', 'F'), + ('G', 'G'), +) + +ERP_CATEGORY_CHOICES = ( + ('1', _('1ère catégorie (> 1500 pers.)')), + ('2', _('2ème catégorie (301 - 1500 pers.)')), + ('3', _('3ème catégorie (101 - 300 pers.)')), + ('4', _('4ème catégorie (jusqu\'à 100 pers.)')), + ('5', _('5ème catégorie (seuils spécifiques)')), +) + +ERP_TYPE_CHOICES = ( + ('J', _('J - Structures d\'accueil pour personnes âgées ou handicapées')), + ('L', _('L - Salles de spectacles, de réunions, polyvalentes')), + ('M', _('M - Magasins, centres commerciaux')), + ('N', _('N - Restaurants et débits de boissons')), + ('O', _('O - Hôtels et pensions de famille')), + ('P', _('P - Salles de danse et salles de jeux')), + ('R', _('R - Établissements d\'éveil, d\'enseignement, colonies de vacances')), + ('S', _('S - Bibliothèques, centres de documentation')), + ('T', _('T - Salles d\'expositions')), + ('U', _('U - Établissements de soins')), + ('V', _('V - Établissements de culte')), + ('W', _('W - Administrations, banques, bureaux')), + ('X', _('X - Établissements sportifs couverts')), + ('Y', _('Y - Musées')), +) + +EQUIPMENT_TYPE_CHOICES = ( + ('hvac', _('Chauffage / CVC / Chaudière')), + ('elevator', _('Ascenseur / Monte-charge')), + ('fire_safety', _('Sécurité incendie / Extincteur / RIA')), + ('electricity', _('Tableau électrique / TGBT')), + ('alarm', _('Alarme / Contrôle d\'accès')), + ('meter', _('Compteur (Eau / Gaz / Électricité)')), + ('sanitary', _('Plomberie / Sanitaires')), + ('other', _('Autre équipement technique')), +) + + +class PublicBuilding(AbstractGeoAsset): + """ + Modèle représentant un Bâtiment Public (Patrimoine bâti). + """ + geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise cartographique")) + 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_buildings", verbose_name=_("Gestionnaire référent")) + occupant_entity = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Direction / Service occupant")) + + # Usage & Statut + usage_type = models.CharField(max_length=50, choices=USAGE_TYPE_CHOICES, default='administrative', verbose_name=_("Type d'usage principal")) + building_status = models.CharField(max_length=50, choices=BUILDING_STATUS_CHOICES, default='in_service', verbose_name=_("Statut d'occupation")) + + # Caractéristiques physiques + construction_year = models.IntegerField(null=True, blank=True, verbose_name=_("Année de construction")) + floors_count = models.IntegerField(default=1, verbose_name=_("Nombre d'étages hors-sol")) + basement_count = models.IntegerField(default=0, verbose_name=_("Nombre de niveaux en sous-sol")) + total_surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface brute totale (m²)")) + usable_surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface utile (m²)")) + + # Réglementation ERP + is_erp = models.BooleanField(default=True, verbose_name=_("Établissement Recevant du Public (ERP)")) + erp_category = models.CharField(max_length=10, choices=ERP_CATEGORY_CHOICES, null=True, blank=True, verbose_name=_("Catégorie ERP")) + erp_type = models.CharField(max_length=10, choices=ERP_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Type ERP")) + max_capacity = models.IntegerField(null=True, blank=True, verbose_name=_("Jauge maximale d'accueil du public")) + + # Diagnostics & Énergie + dpe_rating = models.CharField(max_length=2, choices=DPE_RATING_CHOICES, null=True, blank=True, verbose_name=_("Diagnostic de Performance Énergétique (DPE)")) + ges_rating = models.CharField(max_length=2, choices=DPE_RATING_CHOICES, null=True, blank=True, verbose_name=_("Émission de Gaz à Effet de Serre (GES)")) + has_asbestos_diag = models.BooleanField(default=False, verbose_name=_("Diagnostic Amiante réalisé")) + has_lead_diag = models.BooleanField(default=False, verbose_name=_("Diagnostic Plomb réalisé")) + pmr_accessible = models.BooleanField(default=False, verbose_name=_("Accessible PMR")) + + class Meta: + verbose_name = _("Bâtiment public") + verbose_name_plural = _("Bâtiments publics") + ordering = ['code', 'name_fr'] + + def get_absolute_url(self): + return reverse('assets:publicbuilding_detail', kwargs={'pk': self.pk}) + + +class BuildingLevel(models.Model): + """ + Modèle représentant un niveau / étage au sein d'un bâtiment public. + """ + building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="levels", verbose_name=_("Bâtiment")) + code = models.CharField(max_length=50, verbose_name=_("Code du niveau")) + name = models.CharField(max_length=200, verbose_name=_("Nom du niveau")) + level_number = models.IntegerField(default=0, verbose_name=_("Numéro d'étage (0=RDC, 1=1er, -1=Sous-sol)")) + floor_plan = models.FileField(upload_to='buildings/plans/%Y/%m/', storage=private_storage, null=True, blank=True, verbose_name=_("Plan d'étage")) + + class Meta: + verbose_name = _("Niveau / Étage") + verbose_name_plural = _("Niveaux / Étages") + ordering = ['building', 'level_number'] + + def __str__(self): + return f"{self.building.code} - {self.name} ({self.level_number})" + + +class BuildingRoom(models.Model): + """ + Modèle représentant une pièce / un local dans un bâtiment. + """ + building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="rooms", verbose_name=_("Bâtiment")) + level = models.ForeignKey(BuildingLevel, on_delete=models.SET_NULL, null=True, blank=True, related_name="rooms", verbose_name=_("Niveau / Étage")) + code = models.CharField(max_length=50, verbose_name=_("Code du local")) + name = models.CharField(max_length=200, verbose_name=_("Nom de la pièce")) + room_type = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Type de local (ex: Bureau, Classe, Sanitaires)")) + surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface (m²)")) + + class Meta: + verbose_name = _("Pièce / Local") + verbose_name_plural = _("Pièces / Locaux") + ordering = ['building', 'code'] + + def __str__(self): + return f"{self.building.code} - {self.code} ({self.name})" + + +class BuildingEquipment(AbstractAsset): + """ + Modèle représentant un équipement technique hébergé dans un bâtiment. + """ + building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="equipments", verbose_name=_("Bâtiment")) + room = models.ForeignKey(BuildingRoom, on_delete=models.SET_NULL, null=True, blank=True, related_name="equipments", verbose_name=_("Local")) + equipment_type = models.CharField(max_length=50, choices=EQUIPMENT_TYPE_CHOICES, default='hvac', verbose_name=_("Type d'équipement")) + last_control_date = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle réglementaire")) + next_control_date = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle réglementaire")) + + class Meta: + verbose_name = _("Équipement technique de bâtiment") + verbose_name_plural = _("Équipements techniques de bâtiment") + ordering = ['building', 'code'] + + +def upload_to_building(instance, filename): + return f"buildings/{instance.building.code}/{filename}" + + +class PublicBuildingDocument(Document): + """ + Documents associés à un bâtiment public (plans, notices ERP, DPE). + """ + building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="documents", verbose_name=_("Bâtiment")) + file = models.FileField(upload_to=upload_to_building, 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 de bâtiment") + verbose_name_plural = _("Documents de bâtiment") + diff --git a/streetup/assets/static/assets/asset_add_modal.js b/streetup/assets/static/assets/asset_add_modal.js index d21cdd8..f201b11 100644 --- a/streetup/assets/static/assets/asset_add_modal.js +++ b/streetup/assets/static/assets/asset_add_modal.js @@ -588,7 +588,9 @@ 'controlcenters': 'controlcentergeoasset', 'its': 'itsgeoasset', 'sign': 'signgeoasset', - 'roads': 'roadgeoasset' + 'roads': 'roadgeoasset', + 'publicbuildings': 'publicbuilding', + 'buildings': 'publicbuilding' }; return mapping[thematicCode] || 'geoasset'; } diff --git a/streetup/assets/static/assets/unified_index.js b/streetup/assets/static/assets/unified_index.js index fab0123..733a369 100644 --- a/streetup/assets/static/assets/unified_index.js +++ b/streetup/assets/static/assets/unified_index.js @@ -38,7 +38,7 @@ var locationsFetchSignatures = {}; var assetsFetchSignatures = {}; // Assets: thematics that always load all assets (small datasets) -const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters']); +const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters', 'publicbuildings', 'buildings']); const MIN_ASSETS_ZOOM = 14; // zoom level at which bbox-based asset loading triggers var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox' diff --git a/streetup/assets/static/assets/unified_map_layers.js b/streetup/assets/static/assets/unified_map_layers.js index 40a150d..956a583 100644 --- a/streetup/assets/static/assets/unified_map_layers.js +++ b/streetup/assets/static/assets/unified_map_layers.js @@ -209,6 +209,8 @@ function loadUnifiedMapIcons(map) { { name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' }, // water — bleu fontaine { name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' }, // person-standing — statue { name: 'icon-controlcenters', codepoint: 'f301', color: '#7b1fa2' }, // display-fill — violet + { name: 'icon-publicbuildings', codepoint: 'f1ab', color: '#2B579A' }, // building — bleu nuit + { name: 'icon-publicbuilding', codepoint: 'f1ab', color: '#2B579A' }, // building — bleu nuit { name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, // map-fill — bleu-gris ]; @@ -285,6 +287,8 @@ const THEMATIC_COLOR_EXPR = [ 'clean', '#5e5e5e', // grey — cleanliness 'artworks', '#f24141', // coral — artworks / monuments 'controlcenters', '#7b1fa2', // purple — control centres + 'publicbuildings', '#2B579A', // navy blue — public buildings + 'buildings', '#2B579A', // navy blue — public buildings 'roads', [ 'match', ['get', 'asset_type'], 'roadway', '#4a4a4a', @@ -508,6 +512,8 @@ const _unified_assets_symbol = { 'clean', 'icon-clean', 'artworks', 'icon-artworks', 'controlcenters', 'icon-controlcenters', + 'publicbuildings', 'icon-publicbuildings', + 'publicbuilding', 'icon-publicbuilding', 'roads', 'icon-roads', 'icon-roads' // fallback ] diff --git a/streetup/assets/templates/assets/buildings/publicbuilding_detail.html b/streetup/assets/templates/assets/buildings/publicbuilding_detail.html new file mode 100644 index 0000000..84e0857 --- /dev/null +++ b/streetup/assets/templates/assets/buildings/publicbuilding_detail.html @@ -0,0 +1,193 @@ +{% extends "home/base.html" %} +{% load static i18n %} + +{% block title %}{{ building.code }} - {{ building.get_name }} | StreetUp{% endblock %} + +{% block content %} +
+ + + + +
+
+
+
+

+ {{ building.code }} - {{ building.get_name }} +

+

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

+
+ {{ building.get_usage_type_display }} + {{ building.get_building_status_display }} + {% if building.is_erp %} + ERP (Catégorie {{ building.erp_category|default:"-" }}) + {% endif %} +
+
+
+ {% if building.dpe_rating %} +
+ DPE + Classe {{ building.dpe_rating }} +
+ {% endif %} + {% if building.ges_rating %} +
+ GES + Classe {{ building.ges_rating }} +
+ {% endif %} +
+
+
+
+ + + + + +
+ +
+
+
+
+
{% translate "Propriété & Responsables" %}
+
+ + + + + + + + + + + + + +
{% translate "Propriétaire" %} :{{ building.owner|default:"-" }}
{% translate "Gestionnaire référent" %} :{{ building.manager.get_full_name|default:building.manager.username|default:"-" }}
{% translate "Service occupant" %} :{{ building.occupant_entity|default:"-" }}
+
+
+
+ +
+
+
{% translate "Caractéristiques physiques & ERP" %}
+
+ + + + + + + + + + + + + + + + + + + + + +
{% translate "Année de construction" %} :{{ building.construction_year|default:"-" }}
{% translate "Étages / Sous-sols" %} :{{ building.floors_count }} étage(s) / {{ building.basement_count }} sous-sol(s)
{% translate "Surface Utile / Brut" %} :{{ building.usable_surface_m2|default:"-" }} m² / {{ building.total_surface_m2|default:"-" }} m²
{% translate "Jauge ERP" %} :{{ building.max_capacity|default:"-" }} personnes
{% translate "Accessibilité PMR" %} :{% if building.pmr_accessible %}Oui{% else %}Non{% endif %}
+
+
+
+
+
+ + +
+
+
+ {% for level in levels %} +
+
{{ level.name }} (Étage {{ level.level_number }})
+
+ {% for room in level.rooms.all %} +
+
+
{{ room.code }} - {{ room.name }}
+
{{ room.room_type|default:"" }} {% if room.surface_m2 %}({{ room.surface_m2 }} m²){% endif %}
+
+
+ {% empty %} +
{% translate "Aucune pièce enregistrée pour ce niveau." %}
+ {% endfor %} +
+
+ {% empty %} +

{% translate "Aucun niveau enregistré pour ce bâtiment." %}

+ {% endfor %} +
+
+
+ + +
+
+
+ + + + + + + + + + + {% for eq in equipments %} + + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "Code / Nom" %}{% translate "Type d'équipement" %}{% translate "Local" %}{% translate "Prochain contrôle" %}
{{ eq.code }} - {{ eq.get_name }}{{ eq.get_equipment_type_display }}{{ eq.room.name|default:"-" }}{{ eq.next_control_date|default:"-" }}
{% translate "Aucun équipement technique enregistré." %}
+
+
+
+
+
+{% endblock %} diff --git a/streetup/assets/templates/assets/buildings/publicbuilding_list.html b/streetup/assets/templates/assets/buildings/publicbuilding_list.html new file mode 100644 index 0000000..6af2531 --- /dev/null +++ b/streetup/assets/templates/assets/buildings/publicbuilding_list.html @@ -0,0 +1,150 @@ +{% extends "home/base.html" %} +{% load static i18n %} + +{% block title %}{% translate "Bâtiments publics" %} | StreetUp{% endblock %} + +{% block content %} +
+ +
+
+

{% translate "Gestion des Bâtiments Publics" %}

+

{% translate "Patrimoine bâti, pièces, équipements techniques et diagnostics ERP" %}

+
+
+ + {% translate "Nouveau Bâtiment" %} + +
+
+ + +
+
+
+
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + {% translate "Réinitialiser" %} +
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + {% for b in buildings %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% translate "Code / Nom" %}{% translate "Usage principal" %}{% translate "Propriétaire & Gestionnaire" %}{% translate "ERP" %}{% translate "DPE / GES" %}{% translate "Statut" %}{% translate "Actions" %}
+ + {{ b.code }} + +
{{ b.get_name }}
+
+ + {{ b.get_usage_type_display }} + + + {% if b.owner %} +
{% translate "Propriétaire" %}: {{ b.owner }}
+ {% endif %} + {% if b.manager %} +
{% translate "Gestionnaire" %}: {{ b.manager.get_full_name|default:b.manager.username }}
+ {% endif %} +
+ {% if b.is_erp %} + + ERP Cat {{ b.erp_category|default:"-" }} ({{ b.erp_type|default:"-" }}) + + {% else %} + {% translate "Non ERP" %} + {% endif %} + + {% if b.dpe_rating %} + DPE {{ b.dpe_rating }} + {% endif %} + {% if b.ges_rating %} + GES {{ b.ges_rating }} + {% endif %} + + + {{ b.get_building_status_display }} + + + + + + + + +
+ + {% translate "Aucun bâtiment public ne correspond à vos critères." %} +
+
+
+
+
+{% endblock %} diff --git a/streetup/assets/templates/assets/unified_index.html b/streetup/assets/templates/assets/unified_index.html index a95872b..b9ced78 100644 --- a/streetup/assets/templates/assets/unified_index.html +++ b/streetup/assets/templates/assets/unified_index.html @@ -728,6 +728,6 @@ - - + + {% endblock script %} diff --git a/streetup/assets/tests.py b/streetup/assets/tests.py index 436e612..bd6579b 100644 --- a/streetup/assets/tests.py +++ b/streetup/assets/tests.py @@ -10,6 +10,9 @@ RUN_DXF_TESTS = os.getenv('RUN_DXF_TESTS', '0') == '1' from common.models import Thematic, UserConfig, UserThematics from assets.models import NatureLocation, TrafficLightIntersection, TrafficLightRadarModel, TrafficLightRadar, AssetCategory +from assets.models.buildings import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment + + class NatureLocationsGeoJSONTest(TestCase): def setUp(self): @@ -2376,8 +2379,60 @@ class TrafficLightPoleSignPanelsTest(TestCase): model=radar_pole_model ) - # Verify sorted_signpanels returns an empty list for radar poles - self.assertEqual(list(radar_pole.sorted_signpanels), []) + +class PublicBuildingTestCase(TestCase): + def setUp(self): + self.building = PublicBuilding.objects.create( + code="BAT-001", + name_fr="Hôtel de Ville", + name_nl="Stadhuis", + address="Grand-Place 1, 1000 Bruxelles", + owner="Ville de Bruxelles", + usage_type="administrative", + building_status="in_service", + is_erp=True, + erp_category="1", + erp_type="W", + dpe_rating="B", + ges_rating="A", + geom=MultiPolygon(Polygon(((0, 0), (0, 10), (10, 10), (10, 0), (0, 0)))), + ) + + def test_public_building_creation(self): + self.assertEqual(self.building.code, "BAT-001") + self.assertEqual(self.building.owner, "Ville de Bruxelles") + self.assertTrue(self.building.is_erp) + self.assertEqual(self.building.dpe_rating, "B") + + def test_building_levels_and_rooms(self): + level = BuildingLevel.objects.create( + building=self.building, + code="NIV-0", + name="Rez-de-chaussée", + level_number=0 + ) + room = BuildingRoom.objects.create( + building=self.building, + level=level, + code="BUR-101", + name="Bureau du Bourgmestre", + room_type="Bureau", + surface_m2=45.5 + ) + self.assertEqual(level.building, self.building) + self.assertEqual(room.level, level) + self.assertEqual(room.surface_m2, 45.5) + + def test_building_equipment(self): + eq = BuildingEquipment.objects.create( + building=self.building, + code="CVC-001", + name_fr="Chaudière principale", + equipment_type="hvac" + ) + self.assertEqual(eq.building, self.building) + self.assertEqual(eq.equipment_type, "hvac") + diff --git a/streetup/assets/urls.py b/streetup/assets/urls.py index 11404f7..a566183 100644 --- a/streetup/assets/urls.py +++ b/streetup/assets/urls.py @@ -1,12 +1,15 @@ 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 +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 app_name = "assets" urlpatterns = [ path("", core.unified_assets_index, name="index"), + path("buildings/", buildings.buildings_index, name="buildings_index"), + path("buildings//", buildings.building_detail, name="publicbuilding_detail"), path("export/", export.export_assets, name="export_assets"), + # ------------------------------------------------ # Per-instance & per-type permission management # ------------------------------------------------ @@ -217,6 +220,11 @@ urlpatterns = [ path('water/assets/autocomplete/', water.water_assets_autocomplete, name='water_autocomplete_assets'), path('water///', water.water_assets_detail, name='water_assets_detail'), + # Public buildings GeoJSON URLs + path('publicbuildings/locations/geojson/', buildings.publicbuildings_locations_geojson, name='publicbuildings_locations_geojson'), + path('publicbuildings/assets/geojson/', buildings.publicbuildings_assets_geojson, name='publicbuildings_assets_geojson'), + + # DXF import URLs path('trafficlights/intersections//import-dxf/', trafficlights.import_dxf, name='import_dxf'), path('trafficlights/intersections//stage-dxf/', trafficlights.stage_dxf, name='stage_dxf'), diff --git a/streetup/assets/views/buildings.py b/streetup/assets/views/buildings.py new file mode 100644 index 0000000..140b792 --- /dev/null +++ b/streetup/assets/views/buildings.py @@ -0,0 +1,200 @@ +import json +from django.shortcuts import render, get_object_or_404, redirect +from django.contrib.auth.decorators import login_required +from django.db.models import Q, F +from django.http import JsonResponse +from django.urls import reverse +from django.utils.translation import gettext as _, get_language +from django.contrib.gis.geos import GEOSGeometry + +from common.models import UserThematics +from common.utils import check_thematic_access +from assets.models.buildings import ( + PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument, + USAGE_TYPE_CHOICES, BUILDING_STATUS_CHOICES, DPE_RATING_CHOICES, ERP_CATEGORY_CHOICES +) + + +def publicbuildings_locations_geojson(request): + """ + Vue GeoJSON pour les emplacements des bâtiments publics (vide car les bâtiments sont gérés directement comme assets). + """ + return JsonResponse({"type": "FeatureCollection", "features": []}) + + +def publicbuildings_assets_geojson(request): + """ + Vue GeoJSON renvoyant la collection de bâtiments publics pour la carte unifiée des assets. + """ + 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 [] + if isinstance(categories, str): + categories = [categories] + + 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: + statuses = request.GET.getlist('asset_status') + categories = request.GET.getlist('category', []) + filter_geom = None + search_q = request.GET.get('q', '').strip() + + qs = PublicBuilding.objects.all() + + if categories: + qs = qs.filter(category__id__in=categories) + + if statuses: + qs = qs.filter(building_status__in=statuses) + + 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 b in qs: + geometry = None + if b.geom: + geom_4326 = b.geom.clone() + geom_4326.transform(4326) + geometry = json.loads(geom_4326.json) + elif b.lon is not None and b.lat is not None: + geometry = {"type": "Point", "coordinates": [b.lon, b.lat]} + + detail_url = reverse('assets:publicbuilding_detail', kwargs={'pk': b.pk}) + + features.append({ + "type": "Feature", + "id": b.id, + "geometry": geometry, + "properties": { + "code": b.code, + "name": b.get_name(), + "category": b.category.get_name() if b.category else _("Bâtiment public"), + "category_id": b.category_id, + "status": b.building_status, + "building_status": b.building_status, + "building_status_display": b.get_building_status_display(), + "usage_type": b.usage_type, + "usage_type_display": b.get_usage_type_display(), + "owner": b.owner or '', + "is_erp": b.is_erp, + "erp_category": b.erp_category or '', + "dpe_rating": b.dpe_rating or '', + "lon": b.lon, + "lat": b.lat, + "color": "#2B579A", + "display_color": "#2B579A", + "asset_type": "publicbuilding", + "detail_url": detail_url, + "element_type": get_element_type_label("publicbuilding"), + "thematic": "publicbuildings", + "is_location": False, + } + }) + + return JsonResponse({"type": "FeatureCollection", "features": features}) + + +@login_required +def buildings_index(request): + + """ + Vue principale (Index / Liste) des Bâtiments Publics. + """ + lang = get_language() + buildings = PublicBuilding.objects.all() + + # Search filter + search_query = request.GET.get('q', '').strip() + if search_query: + buildings = buildings.filter( + Q(code__icontains=search_query) | + Q(name_fr__icontains=search_query) | + Q(name_nl__icontains=search_query) | + Q(address__icontains=search_query) | + Q(owner__icontains=search_query) + ) + + # Usage filter + usage_filter = request.GET.get('usage_type') + if usage_filter: + buildings = buildings.filter(usage_type=usage_filter) + + # Status filter + status_filter = request.GET.get('building_status') + if status_filter: + buildings = buildings.filter(building_status=status_filter) + + # ERP filter + erp_filter = request.GET.get('is_erp') + if erp_filter in ['true', '1']: + buildings = buildings.filter(is_erp=True) + elif erp_filter in ['false', '0']: + buildings = buildings.filter(is_erp=False) + + # DPE rating filter + dpe_filter = request.GET.get('dpe_rating') + if dpe_filter: + buildings = buildings.filter(dpe_rating=dpe_filter) + + context = { + 'buildings': buildings, + 'search_query': search_query, + 'usage_type_choices': USAGE_TYPE_CHOICES, + 'building_status_choices': BUILDING_STATUS_CHOICES, + 'dpe_rating_choices': DPE_RATING_CHOICES, + 'selected_usage': usage_filter, + 'selected_status': status_filter, + 'selected_erp': erp_filter, + 'selected_dpe': dpe_filter, + 'active_tab': 'buildings', + } + return render(request, 'assets/buildings/publicbuilding_list.html', context) + + +@login_required +def building_detail(request, pk): + """ + Vue détaillée d'un Bâtiment Public avec ses niveaux, pièces et équipements. + """ + building = get_object_or_404(PublicBuilding, pk=pk) + levels = building.levels.all().prefetch_related('rooms') + rooms = building.rooms.all() + equipments = building.equipments.all() + documents = building.documents.select_related('document').all() + + context = { + 'building': building, + 'levels': levels, + 'rooms': rooms, + 'equipments': equipments, + 'documents': documents, + } + return render(request, 'assets/buildings/publicbuilding_detail.html', context) diff --git a/streetup/assets/views/core.py b/streetup/assets/views/core.py index 9c9e5c2..f2a2661 100644 --- a/streetup/assets/views/core.py +++ b/streetup/assets/views/core.py @@ -408,6 +408,9 @@ ELEMENT_TYPE_LABELS = { # --- Propreté --- 'cleanlitterbin': 'Corbeille', 'cleanglasscontainer': 'Conteneur verre', + # --- Bâtiments publics --- + 'publicbuilding': 'Bâtiment public', + 'buildingequipment': 'Équipement bâtiment', } @@ -468,6 +471,14 @@ def build_thematic_config(thematics): 'locations_geojson_url': reverse('assets:clean_locations_geojson'), 'assets_geojson_url': reverse('assets:clean_assets_geojson'), }, + 'publicbuildings': { + 'locations_geojson_url': reverse('assets:publicbuildings_locations_geojson'), + 'assets_geojson_url': reverse('assets:publicbuildings_assets_geojson'), + }, + 'buildings': { + 'locations_geojson_url': reverse('assets:publicbuildings_locations_geojson'), + 'assets_geojson_url': reverse('assets:publicbuildings_assets_geojson'), + }, } result = {} for thematic in thematics: @@ -776,15 +787,21 @@ def unified_assets_index(request): from interventions.views.drafts import InterventionDraftManager lang = get_language() or 'fr' + user_config, _ = UserConfig.objects.get_or_create(user=request.user) # Thématiques accessibles à l'utilisateur - thematic_qs = ( - UserThematics.objects - .filter(user_config=request.user.config, can_view_assets=True) - .select_related('thematic') - .order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}') - ) - accessible_thematics = [tc.thematic for tc in thematic_qs] + if getattr(request.user, 'is_superuser', False): + accessible_thematics = list(Thematic.objects.order_by(f'{"name_nl" if lang == "nl" else "name_fr"}')) + else: + thematic_qs = ( + UserThematics.objects + .filter(user_config=user_config, can_view_assets=True) + .select_related('thematic') + .order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}') + ) + accessible_thematics = [tc.thematic for tc in thematic_qs] + + # Mode intervention préventive preventive_draft_id = request.GET.get('preventive_draft_id') @@ -830,10 +847,10 @@ def unified_assets_index(request): if locked_thematic_code else '' ) - user_roles = request.user.config.roles.values_list('name', flat=True) if hasattr(request.user, 'config') else [] + user_roles = user_config.roles.values_list('name', flat=True) if user_config else [] is_admin = 'admin' in user_roles or request.user.is_superuser - has_bulk_edit_status_permission = is_admin or UserThematics.objects.filter(user_config=request.user.config, can_edit_assets=True).exists() - has_bulk_edit_validation_status_permission = is_admin or UserThematics.objects.filter(user_config=request.user.config, can_validate_assets=True).exists() + has_bulk_edit_status_permission = is_admin or UserThematics.objects.filter(user_config=user_config, can_edit_assets=True).exists() + has_bulk_edit_validation_status_permission = is_admin or UserThematics.objects.filter(user_config=user_config, can_validate_assets=True).exists() context = { 'accessible_thematics': accessible_thematics, @@ -853,7 +870,7 @@ def unified_assets_index(request): 'selected_assets_json': json.dumps(selected_assets), 'selected_locations_ids': selected_locations_ids, 'link_assets_url': link_assets_url, - 'assets_index_prefs_json': json.dumps(request.user.config.assets_index_preferences or {}), - 'default_thematic_code': (request.user.config.default_thematic.code if request.user.config.default_thematic_id else ''), + 'assets_index_prefs_json': json.dumps(user_config.assets_index_preferences or {}), + 'default_thematic_code': (user_config.default_thematic.code if user_config.default_thematic_id else ''), } return render(request, 'assets/unified_index.html', context) diff --git a/streetup/common/fixtures/reference_data.json b/streetup/common/fixtures/reference_data.json index 4291580..e534b40 100644 --- a/streetup/common/fixtures/reference_data.json +++ b/streetup/common/fixtures/reference_data.json @@ -129,6 +129,19 @@ "uses_occupations": false } }, +{ + "model": "common.thematic", + "pk": 12, + "fields": { + "code": "publicbuildings", + "name_fr": "Bâtiments publics", + "name_nl": "Openbare gebouwen", + "description": "Gestion du patrimoine bâti public (écoles, mairies, équipements sportifs, culturels, etc.)", + "icon": "bi-building", + "color": "#2B579A", + "uses_occupations": false + } +}, { "model": "common.role", "pk": 1,