feat: add publicbuildings thematic, models, views, GeoJSON endpoints and unified assets map integration

This commit is contained in:
kdeterme 2026-07-26 19:03:31 +02:00
parent f3207e946c
commit 24355b2e2f
15 changed files with 1046 additions and 21 deletions

View file

@ -648,6 +648,18 @@ admin.site.register(CleanGlassContainer, AssetAdmin)
admin.site.register(CleanLocationContract, AssetAdmin) admin.site.register(CleanLocationContract, AssetAdmin)
from .models import CleanLitterBinPhoto, CleanLitterBinObservation from .models import CleanLitterBinPhoto, CleanLitterBinObservation
from .models import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument
admin.site.register(CleanLitterBinPhoto, AssetAdmin) admin.site.register(CleanLitterBinPhoto, AssetAdmin)
admin.site.register(CleanLitterBinObservation, AssetAdmin) 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)

View file

@ -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',
},
),
]

View file

@ -10,3 +10,4 @@ from .sign import SignStreet, SignStreetDocument, SignPole, SignPoleModel, SignP
from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSidewalk, RoadSidewalkModel, RoadFurniture, RoadFurnitureModel, RoadwayFunction, RoadConcreteBlockSet, RoadDrainModel, RoadDrain 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 .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract
from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract
from .buildings import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument

View file

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

View file

@ -588,7 +588,9 @@
'controlcenters': 'controlcentergeoasset', 'controlcenters': 'controlcentergeoasset',
'its': 'itsgeoasset', 'its': 'itsgeoasset',
'sign': 'signgeoasset', 'sign': 'signgeoasset',
'roads': 'roadgeoasset' 'roads': 'roadgeoasset',
'publicbuildings': 'publicbuilding',
'buildings': 'publicbuilding'
}; };
return mapping[thematicCode] || 'geoasset'; return mapping[thematicCode] || 'geoasset';
} }

View file

@ -38,7 +38,7 @@ var locationsFetchSignatures = {};
var assetsFetchSignatures = {}; var assetsFetchSignatures = {};
// Assets: thematics that always load all assets (small datasets) // 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 const MIN_ASSETS_ZOOM = 14; // zoom level at which bbox-based asset loading triggers
var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox' var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox'

View file

@ -209,6 +209,8 @@ function loadUnifiedMapIcons(map) {
{ name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' }, // water — bleu fontaine { name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' }, // water — bleu fontaine
{ name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' }, // person-standing — statue { name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' }, // person-standing — statue
{ name: 'icon-controlcenters', codepoint: 'f301', color: '#7b1fa2' }, // display-fill — violet { 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 { name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, // map-fill — bleu-gris
]; ];
@ -285,6 +287,8 @@ const THEMATIC_COLOR_EXPR = [
'clean', '#5e5e5e', // grey — cleanliness 'clean', '#5e5e5e', // grey — cleanliness
'artworks', '#f24141', // coral — artworks / monuments 'artworks', '#f24141', // coral — artworks / monuments
'controlcenters', '#7b1fa2', // purple — control centres 'controlcenters', '#7b1fa2', // purple — control centres
'publicbuildings', '#2B579A', // navy blue — public buildings
'buildings', '#2B579A', // navy blue — public buildings
'roads', [ 'roads', [
'match', ['get', 'asset_type'], 'match', ['get', 'asset_type'],
'roadway', '#4a4a4a', 'roadway', '#4a4a4a',
@ -508,6 +512,8 @@ const _unified_assets_symbol = {
'clean', 'icon-clean', 'clean', 'icon-clean',
'artworks', 'icon-artworks', 'artworks', 'icon-artworks',
'controlcenters', 'icon-controlcenters', 'controlcenters', 'icon-controlcenters',
'publicbuildings', 'icon-publicbuildings',
'publicbuilding', 'icon-publicbuilding',
'roads', 'icon-roads', 'roads', 'icon-roads',
'icon-roads' // fallback 'icon-roads' // fallback
] ]

View file

@ -0,0 +1,193 @@
{% extends "home/base.html" %}
{% load static i18n %}
{% block title %}{{ building.code }} - {{ building.get_name }} | StreetUp{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Actions -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{% url 'assets:buildings_index' %}">{% translate "Bâtiments publics" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ building.code }}</li>
</ol>
</nav>
<div>
<a href="{% url 'admin:assets_publicbuilding_change' building.pk %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier dans l'admin" %}
</a>
<a href="{% url 'assets:buildings_index' %}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la liste" %}
</a>
</div>
</div>
<!-- Header Card -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<h2 class="h4 mb-1">
<i class="bi bi-building text-primary me-2"></i>{{ building.code }} - {{ building.get_name }}
</h2>
<p class="text-muted mb-2"><i class="bi bi-geo-alt me-1"></i>{{ building.address|default:_("Adresse non renseignée") }}</p>
<div class="d-flex gap-2 flex-wrap">
<span class="badge bg-primary-subtle text-primary border">{{ building.get_usage_type_display }}</span>
<span class="badge bg-success-subtle text-success border">{{ building.get_building_status_display }}</span>
{% if building.is_erp %}
<span class="badge bg-info-subtle text-info border">ERP (Catégorie {{ building.erp_category|default:"-" }})</span>
{% endif %}
</div>
</div>
<div class="col-md-4 text-md-end mt-3 mt-md-0">
{% if building.dpe_rating %}
<div class="d-inline-block text-center me-3">
<span class="d-block text-muted small">DPE</span>
<span class="badge bg-success fs-5 px-3 py-2">Classe {{ building.dpe_rating }}</span>
</div>
{% endif %}
{% if building.ges_rating %}
<div class="d-inline-block text-center">
<span class="d-block text-muted small">GES</span>
<span class="badge bg-primary fs-5 px-3 py-2">Classe {{ building.ges_rating }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Tabs Navigation -->
<ul class="nav nav-tabs mb-4" id="buildingTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab"><i class="bi bi-info-circle me-1"></i>{% translate "Vue d'ensemble" %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="levels-tab" data-bs-toggle="tab" data-bs-target="#levels" type="button" role="tab"><i class="bi bg-layers me-1"></i>{% translate "Étages & Pièces" %} ({{ levels.count }})</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="equipments-tab" data-bs-toggle="tab" data-bs-target="#equipments" type="button" role="tab"><i class="bi bi-gear me-1"></i>{% translate "Équipements techniques" %} ({{ equipments.count }})</button>
</li>
</ul>
<!-- Tabs Content -->
<div class="tab-content" id="buildingTabsContent">
<!-- Overview Tab -->
<div class="tab-pane fade show active" id="overview" role="tabpanel">
<div class="row g-4">
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold"><i class="bi bi-person-badge me-2"></i>{% translate "Propriété & Responsables" %}</div>
<div class="card-body">
<table class="table table-borderless mb-0">
<tr>
<th class="w-50 text-muted">{% translate "Propriétaire" %} :</th>
<td>{{ building.owner|default:"-" }}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Gestionnaire référent" %} :</th>
<td>{{ building.manager.get_full_name|default:building.manager.username|default:"-" }}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Service occupant" %} :</th>
<td>{{ building.occupant_entity|default:"-" }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold"><i class="bi bi-rulers me-2"></i>{% translate "Caractéristiques physiques & ERP" %}</div>
<div class="card-body">
<table class="table table-borderless mb-0">
<tr>
<th class="w-50 text-muted">{% translate "Année de construction" %} :</th>
<td>{{ building.construction_year|default:"-" }}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Étages / Sous-sols" %} :</th>
<td>{{ building.floors_count }} étage(s) / {{ building.basement_count }} sous-sol(s)</td>
</tr>
<tr>
<th class="text-muted">{% translate "Surface Utile / Brut" %} :</th>
<td>{{ building.usable_surface_m2|default:"-" }} m² / {{ building.total_surface_m2|default:"-" }} m²</td>
</tr>
<tr>
<th class="text-muted">{% translate "Jauge ERP" %} :</th>
<td>{{ building.max_capacity|default:"-" }} personnes</td>
</tr>
<tr>
<th class="text-muted">{% translate "Accessibilité PMR" %} :</th>
<td>{% if building.pmr_accessible %}<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Oui</span>{% else %}<span class="text-muted">Non</span>{% endif %}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Levels & Rooms Tab -->
<div class="tab-pane fade" id="levels" role="tabpanel">
<div class="card shadow-sm">
<div class="card-body">
{% for level in levels %}
<div class="mb-4 pb-3 border-bottom">
<h5 class="h6 text-primary"><i class="bi bi-layers me-2"></i>{{ level.name }} (Étage {{ level.level_number }})</h5>
<div class="row row-cols-1 row-cols-md-3 g-2 mt-2">
{% for room in level.rooms.all %}
<div class="col">
<div class="p-2 border rounded bg-light">
<div class="fw-bold">{{ room.code }} - {{ room.name }}</div>
<div class="small text-muted">{{ room.room_type|default:"" }} {% if room.surface_m2 %}({{ room.surface_m2 }} m²){% endif %}</div>
</div>
</div>
{% empty %}
<div class="col-12 text-muted small">{% translate "Aucune pièce enregistrée pour ce niveau." %}</div>
{% endfor %}
</div>
</div>
{% empty %}
<p class="text-muted text-center py-4 mb-0">{% translate "Aucun niveau enregistré pour ce bâtiment." %}</p>
{% endfor %}
</div>
</div>
</div>
<!-- Equipments Tab -->
<div class="tab-pane fade" id="equipments" role="tabpanel">
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "Code / Nom" %}</th>
<th>{% translate "Type d'équipement" %}</th>
<th>{% translate "Local" %}</th>
<th>{% translate "Prochain contrôle" %}</th>
</tr>
</thead>
<tbody>
{% for eq in equipments %}
<tr>
<td><strong>{{ eq.code }}</strong> - {{ eq.get_name }}</td>
<td>{{ eq.get_equipment_type_display }}</td>
<td>{{ eq.room.name|default:"-" }}</td>
<td>{{ eq.next_control_date|default:"-" }}</td>
</tr>
{% empty %}
<tr>
<td colspan="4" class="text-center text-muted py-4">{% translate "Aucun équipement technique enregistré." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,150 @@
{% extends "home/base.html" %}
{% load static i18n %}
{% block title %}{% translate "Bâtiments publics" %} | StreetUp{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1"><i class="bi bi-building me-2 text-primary"></i>{% translate "Gestion des Bâtiments Publics" %}</h1>
<p class="text-muted mb-0">{% translate "Patrimoine bâti, pièces, équipements techniques et diagnostics ERP" %}</p>
</div>
<div>
<a href="{% url 'admin:assets_publicbuilding_add' %}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>{% translate "Nouveau Bâtiment" %}
</a>
</div>
</div>
<!-- Filters Card -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-3">
<label class="form-label small text-muted">{% translate "Recherche" %}</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="q" class="form-control" placeholder="{% translate 'Code, nom, adresse, propriétaire...' %}" value="{{ search_query }}">
</div>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Usage" %}</label>
<select name="usage_type" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les usages" %} --</option>
{% for key, label in usage_type_choices %}
<option value="{{ key }}" {% if selected_usage == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Statut" %}</label>
<select name="building_status" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les statuts" %} --</option>
{% for key, label in building_status_choices %}
<option value="{{ key }}" {% if selected_status == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Diagnostic DPE" %}</label>
<select name="dpe_rating" class="form-select form-select-sm">
<option value="">-- {% translate "Tous DPE" %} --</option>
{% for key, label in dpe_rating_choices %}
<option value="{{ key }}" {% if selected_dpe == key %}selected{% endif %}>DPE {{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 d-flex align-items-end gap-2">
<button type="submit" class="btn btn-sm btn-primary w-100"><i class="bi bi-filter me-1"></i>{% translate "Filtrer" %}</button>
<a href="{% url 'assets:buildings_index' %}" class="btn btn-sm btn-outline-secondary w-100">{% translate "Réinitialiser" %}</a>
</div>
</form>
</div>
</div>
<!-- Table of Public Buildings -->
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "Code / Nom" %}</th>
<th>{% translate "Usage principal" %}</th>
<th>{% translate "Propriétaire & Gestionnaire" %}</th>
<th>{% translate "ERP" %}</th>
<th>{% translate "DPE / GES" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for b in buildings %}
<tr>
<td>
<a href="{% url 'assets:publicbuilding_detail' b.pk %}" class="fw-bold text-decoration-none">
{{ b.code }}
</a>
<div class="small text-muted">{{ b.get_name }}</div>
</td>
<td>
<span class="badge bg-secondary-subtle text-dark border">
{{ b.get_usage_type_display }}
</span>
</td>
<td>
{% if b.owner %}
<div class="small"><i class="bi bi-shield-check text-muted me-1"></i><strong>{% translate "Propriétaire" %}:</strong> {{ b.owner }}</div>
{% endif %}
{% if b.manager %}
<div class="small text-muted"><i class="bi bi-person me-1"></i><strong>{% translate "Gestionnaire" %}:</strong> {{ b.manager.get_full_name|default:b.manager.username }}</div>
{% endif %}
</td>
<td>
{% if b.is_erp %}
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-people me-1"></i>ERP Cat {{ b.erp_category|default:"-" }} ({{ b.erp_type|default:"-" }})
</span>
{% else %}
<span class="badge bg-light text-muted border">{% translate "Non ERP" %}</span>
{% endif %}
</td>
<td>
{% if b.dpe_rating %}
<span class="badge bg-success border me-1">DPE {{ b.dpe_rating }}</span>
{% endif %}
{% if b.ges_rating %}
<span class="badge bg-primary border">GES {{ b.ges_rating }}</span>
{% endif %}
</td>
<td>
<span class="badge {% if b.building_status == 'in_service' %}bg-success-subtle text-success{% else %}bg-warning-subtle text-warning{% endif %} border">
{{ b.get_building_status_display }}
</span>
</td>
<td class="text-end">
<a href="{% url 'assets:publicbuilding_detail' b.pk %}" class="btn btn-sm btn-outline-primary" title="{% translate 'Voir détails' %}">
<i class="bi bi-eye"></i>
</a>
<a href="{% url 'admin:assets_publicbuilding_change' b.pk %}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Éditer' %}">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-5 text-muted">
<i class="bi bi-building-exclamation fs-1 d-block mb-2"></i>
{% translate "Aucun bâtiment public ne correspond à vos critères." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -728,6 +728,6 @@
<script src="{% static 'common/base_script.js' %}"></script> <script src="{% static 'common/base_script.js' %}"></script>
<script src="{% static 'common/base_map.js' %}"></script> <script src="{% static 'common/base_map.js' %}"></script>
<script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script> <script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260703c"></script> <script src="{% static 'assets/unified_map_layers.js' %}?v=20260726_pb"></script>
<script src="{% static 'assets/unified_index.js' %}?v=20260703c"></script> <script src="{% static 'assets/unified_index.js' %}?v=20260726_pb"></script>
{% endblock script %} {% endblock script %}

View file

@ -10,6 +10,9 @@ RUN_DXF_TESTS = os.getenv('RUN_DXF_TESTS', '0') == '1'
from common.models import Thematic, UserConfig, UserThematics from common.models import Thematic, UserConfig, UserThematics
from assets.models import NatureLocation, TrafficLightIntersection, TrafficLightRadarModel, TrafficLightRadar, AssetCategory from assets.models import NatureLocation, TrafficLightIntersection, TrafficLightRadarModel, TrafficLightRadar, AssetCategory
from assets.models.buildings import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment
class NatureLocationsGeoJSONTest(TestCase): class NatureLocationsGeoJSONTest(TestCase):
def setUp(self): def setUp(self):
@ -2376,8 +2379,60 @@ class TrafficLightPoleSignPanelsTest(TestCase):
model=radar_pole_model 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")

View file

@ -1,12 +1,15 @@
from django.urls import path from django.urls import path
from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water 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" app_name = "assets"
urlpatterns = [ urlpatterns = [
path("", core.unified_assets_index, name="index"), path("", core.unified_assets_index, name="index"),
path("buildings/", buildings.buildings_index, name="buildings_index"),
path("buildings/<int:pk>/", buildings.building_detail, name="publicbuilding_detail"),
path("export/", export.export_assets, name="export_assets"), path("export/", export.export_assets, name="export_assets"),
# ------------------------------------------------ # ------------------------------------------------
# Per-instance & per-type permission management # 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/assets/autocomplete/', water.water_assets_autocomplete, name='water_autocomplete_assets'),
path('water/<str:asset_model>/<int:asset_id>/', water.water_assets_detail, name='water_assets_detail'), path('water/<str:asset_model>/<int:asset_id>/', 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 # DXF import URLs
path('trafficlights/intersections/<int:intersection_id>/import-dxf/', trafficlights.import_dxf, name='import_dxf'), path('trafficlights/intersections/<int:intersection_id>/import-dxf/', trafficlights.import_dxf, name='import_dxf'),
path('trafficlights/intersections/<int:intersection_id>/stage-dxf/', trafficlights.stage_dxf, name='stage_dxf'), path('trafficlights/intersections/<int:intersection_id>/stage-dxf/', trafficlights.stage_dxf, name='stage_dxf'),

View file

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

View file

@ -408,6 +408,9 @@ ELEMENT_TYPE_LABELS = {
# --- Propreté --- # --- Propreté ---
'cleanlitterbin': 'Corbeille', 'cleanlitterbin': 'Corbeille',
'cleanglasscontainer': 'Conteneur verre', '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'), 'locations_geojson_url': reverse('assets:clean_locations_geojson'),
'assets_geojson_url': reverse('assets:clean_assets_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 = {} result = {}
for thematic in thematics: for thematic in thematics:
@ -776,16 +787,22 @@ def unified_assets_index(request):
from interventions.views.drafts import InterventionDraftManager from interventions.views.drafts import InterventionDraftManager
lang = get_language() or 'fr' lang = get_language() or 'fr'
user_config, _ = UserConfig.objects.get_or_create(user=request.user)
# Thématiques accessibles à l'utilisateur # Thématiques accessibles à l'utilisateur
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 = ( thematic_qs = (
UserThematics.objects UserThematics.objects
.filter(user_config=request.user.config, can_view_assets=True) .filter(user_config=user_config, can_view_assets=True)
.select_related('thematic') .select_related('thematic')
.order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}') .order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}')
) )
accessible_thematics = [tc.thematic for tc in thematic_qs] accessible_thematics = [tc.thematic for tc in thematic_qs]
# Mode intervention préventive # Mode intervention préventive
preventive_draft_id = request.GET.get('preventive_draft_id') preventive_draft_id = request.GET.get('preventive_draft_id')
locked_thematic_code = request.GET.get('thematic_code', '') locked_thematic_code = request.GET.get('thematic_code', '')
@ -830,10 +847,10 @@ def unified_assets_index(request):
if locked_thematic_code else '' 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 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_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=request.user.config, can_validate_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 = { context = {
'accessible_thematics': accessible_thematics, 'accessible_thematics': accessible_thematics,
@ -853,7 +870,7 @@ def unified_assets_index(request):
'selected_assets_json': json.dumps(selected_assets), 'selected_assets_json': json.dumps(selected_assets),
'selected_locations_ids': selected_locations_ids, 'selected_locations_ids': selected_locations_ids,
'link_assets_url': link_assets_url, 'link_assets_url': link_assets_url,
'assets_index_prefs_json': json.dumps(request.user.config.assets_index_preferences or {}), 'assets_index_prefs_json': json.dumps(user_config.assets_index_preferences or {}),
'default_thematic_code': (request.user.config.default_thematic.code if request.user.config.default_thematic_id else ''), 'default_thematic_code': (user_config.default_thematic.code if user_config.default_thematic_id else ''),
} }
return render(request, 'assets/unified_index.html', context) return render(request, 'assets/unified_index.html', context)

View file

@ -129,6 +129,19 @@
"uses_occupations": false "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", "model": "common.role",
"pk": 1, "pk": 1,