refactor: unify building location hierarchy and consolidate asset management using a recursive tree structure

This commit is contained in:
kdeterme 2026-07-27 14:44:23 +02:00
parent a4f6769c7d
commit 044e01add8
15 changed files with 921 additions and 265 deletions

View file

@ -648,18 +648,20 @@ 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 from .models import PublicBuilding, BuildingAsset, PublicBuildingDocument
admin.site.register(CleanLitterBinPhoto, AssetAdmin)
admin.site.register(CleanLitterBinObservation, AssetAdmin)
@admin.register(PublicBuilding) @admin.register(PublicBuilding)
class PublicBuildingAdmin(admin.GISModelAdmin): class PublicBuildingAdmin(admin.GISModelAdmin):
list_display = ("code", "name_fr", "usage_type", "building_status", "owner", "manager", "is_erp", "dpe_rating") list_display = ("code", "name_fr", "location_type", "parent_location", "usage_type", "building_status", "owner", "manager", "is_erp", "dpe_rating")
list_filter = ("usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible") list_filter = ("location_type", "usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible")
search_fields = ("code", "name_fr", "name_nl", "address", "owner") search_fields = ("code", "name_fr", "name_nl", "address", "owner")
raw_id_fields = ("parent_location", "manager")
@admin.register(BuildingAsset)
class BuildingAssetAdmin(admin.GISModelAdmin):
list_display = ("code", "name_fr", "asset_type", "building", "location", "parent_asset", "status", "obsolescence")
list_filter = ("asset_type", "status", "obsolescence")
search_fields = ("code", "name_fr", "name_nl", "serial_number")
raw_id_fields = ("building", "location", "parent_asset")
admin.site.register(BuildingLevel, AssetAdmin)
admin.site.register(BuildingRoom, AssetAdmin)
admin.site.register(BuildingEquipment, AssetAdmin)
admin.site.register(PublicBuildingDocument, AssetAdmin) admin.site.register(PublicBuildingDocument, AssetAdmin)

View file

@ -274,17 +274,19 @@ LOCATION_FORM_REGISTRY = {
} }
from assets.models.buildings import PublicBuilding, BuildingAsset
class PublicBuildingForm(forms.ModelForm): class PublicBuildingForm(forms.ModelForm):
"""Formulaire de création et d'édition d'un Bâtiment Public.""" """Formulaire de création et d'édition d'une Localisation de Bâtiment Public."""
class Meta: class Meta:
model = PublicBuilding model = PublicBuilding
fields = [ fields = [
'code', 'name_fr', 'name_nl', 'address', 'description', 'code', 'name_fr', 'name_nl', 'parent_location', 'location_type', 'is_main_location',
'owner', 'manager', 'occupant_entity', 'address', 'description', 'owner', 'manager', 'occupant_entity',
'usage_type', 'building_status', 'usage_type', 'building_status',
'construction_year', 'floors_count', 'basement_count', 'construction_year', 'floors_count', 'basement_count',
'total_surface_m2', 'usable_surface_m2', 'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number',
'is_erp', 'erp_category', 'erp_type', 'max_capacity', 'pmr_accessible', 'is_erp', 'erp_category', 'erp_type', 'max_capacity', 'pmr_accessible',
'dpe_rating', 'ges_rating', 'has_asbestos_diag', 'has_lead_diag', 'dpe_rating', 'ges_rating', 'has_asbestos_diag', 'has_lead_diag',
'lat', 'lon', 'lat', 'lon',
@ -293,6 +295,9 @@ class PublicBuildingForm(forms.ModelForm):
'code': forms.TextInput(attrs={'class': 'form-control'}), 'code': forms.TextInput(attrs={'class': 'form-control'}),
'name_fr': forms.TextInput(attrs={'class': 'form-control'}), 'name_fr': forms.TextInput(attrs={'class': 'form-control'}),
'name_nl': forms.TextInput(attrs={'class': 'form-control'}), 'name_nl': forms.TextInput(attrs={'class': 'form-control'}),
'parent_location': forms.Select(attrs={'class': 'form-select'}),
'location_type': forms.Select(attrs={'class': 'form-select'}),
'is_main_location': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'address': forms.TextInput(attrs={'class': 'form-control'}), 'address': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'owner': forms.TextInput(attrs={'class': 'form-control'}), 'owner': forms.TextInput(attrs={'class': 'form-control'}),
@ -305,6 +310,8 @@ class PublicBuildingForm(forms.ModelForm):
'basement_count': forms.NumberInput(attrs={'class': 'form-control'}), 'basement_count': forms.NumberInput(attrs={'class': 'form-control'}),
'total_surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}), 'total_surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'usable_surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}), 'usable_surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'level_number': forms.NumberInput(attrs={'class': 'form-control'}),
'is_erp': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'is_erp': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'erp_category': forms.Select(attrs={'class': 'form-select'}), 'erp_category': forms.Select(attrs={'class': 'form-select'}),
'erp_type': forms.Select(attrs={'class': 'form-select'}), 'erp_type': forms.Select(attrs={'class': 'form-select'}),
@ -322,6 +329,8 @@ class PublicBuildingForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['name_fr'].required = False self.fields['name_fr'].required = False
self.fields['name_nl'].required = False self.fields['name_nl'].required = False
self.fields['parent_location'].required = False
self.fields['location_type'].required = False
self.fields['address'].required = False self.fields['address'].required = False
self.fields['description'].required = False self.fields['description'].required = False
self.fields['owner'].required = False self.fields['owner'].required = False
@ -332,10 +341,51 @@ class PublicBuildingForm(forms.ModelForm):
self.fields['basement_count'].required = False self.fields['basement_count'].required = False
self.fields['total_surface_m2'].required = False self.fields['total_surface_m2'].required = False
self.fields['usable_surface_m2'].required = False self.fields['usable_surface_m2'].required = False
self.fields['surface_m2'].required = False
self.fields['level_number'].required = False
self.fields['erp_category'].required = False self.fields['erp_category'].required = False
self.fields['erp_type'].required = False self.fields['erp_type'].required = False
self.fields['max_capacity'].required = False self.fields['max_capacity'].required = False
self.fields['dpe_rating'].required = False self.fields['dpe_rating'].required = False
self.fields['ges_rating'].required = False self.fields['ges_rating'].required = False
self.fields['lat'].required = False self.fields['lat'].required = False
self.fields['lon'].required = False self.fields['lon'].required = False
class BuildingAssetForm(forms.ModelForm):
"""Formulaire pour les assets/équipements physiques d'un bâtiment."""
class Meta:
model = BuildingAsset
fields = [
'code', 'name_fr', 'name_nl', 'building', 'location', 'parent_asset',
'asset_type', 'category', 'status', 'obsolescence', 'installation_date',
'serial_number', 'last_control_date', 'next_control_date', 'note'
]
widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}),
'name_fr': forms.TextInput(attrs={'class': 'form-control'}),
'name_nl': forms.TextInput(attrs={'class': 'form-control'}),
'building': forms.Select(attrs={'class': 'form-select'}),
'location': forms.Select(attrs={'class': 'form-select'}),
'parent_asset': forms.Select(attrs={'class': 'form-select'}),
'asset_type': forms.Select(attrs={'class': 'form-select'}),
'category': forms.Select(attrs={'class': 'form-select'}),
'status': forms.Select(attrs={'class': 'form-select'}),
'obsolescence': forms.Select(attrs={'class': 'form-select'}),
'installation_date': forms.DateTimeInput(attrs={'class': 'form-control', 'type': 'datetime-local'}),
'serial_number': forms.TextInput(attrs={'class': 'form-control'}),
'last_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'next_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'note': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name in ['name_fr', 'name_nl', 'location', 'parent_asset', 'category', 'installation_date', 'serial_number', 'last_control_date', 'next_control_date', 'note']:
if field_name in self.fields:
self.fields[field_name].required = False
from common.models import Thematic
from assets.models import AssetCategory
t = Thematic.objects.filter(code='publicbuildings').first()
if t and 'category' in self.fields:
self.fields['category'].queryset = AssetCategory.objects.filter(thematic=t)

View file

@ -0,0 +1,219 @@
# Generated by Django 6.0.7 on 2026-07-27 12:00
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', '0005_buildinglevel_publicbuilding_buildingroom_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='buildinglevel',
name='building',
),
migrations.RemoveField(
model_name='buildingroom',
name='level',
),
migrations.RemoveField(
model_name='buildingroom',
name='building',
),
migrations.AlterModelOptions(
name='publicbuilding',
options={'ordering': ['code', 'name_fr'], 'verbose_name': 'Localisation Bâtiment public', 'verbose_name_plural': 'Localisations Bâtiments publics'},
),
migrations.RemoveField(
model_name='publicbuilding',
name='brand',
),
migrations.RemoveField(
model_name='publicbuilding',
name='category',
),
migrations.RemoveField(
model_name='publicbuilding',
name='installation_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='last_corrective_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='last_inspection_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='last_preventive_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='last_survey_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='marked_as_removed_by',
),
migrations.RemoveField(
model_name='publicbuilding',
name='marked_as_removed_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='note',
),
migrations.RemoveField(
model_name='publicbuilding',
name='obsolescence',
),
migrations.RemoveField(
model_name='publicbuilding',
name='replaced_by',
),
migrations.RemoveField(
model_name='publicbuilding',
name='serial_number',
),
migrations.RemoveField(
model_name='publicbuilding',
name='status',
),
migrations.RemoveField(
model_name='publicbuilding',
name='uninstallation_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='validation_status',
),
migrations.RemoveField(
model_name='publicbuilding',
name='warranty_duration',
),
migrations.AddField(
model_name='publicbuilding',
name='floor_plan',
field=models.FileField(blank=True, null=True, storage=common.storages.PrivateMediaStorage(), upload_to='buildings/plans/%Y/%m/', verbose_name="Plan d'étage"),
),
migrations.AddField(
model_name='publicbuilding',
name='is_main_location',
field=models.BooleanField(default=False, verbose_name='Localisation principale'),
),
migrations.AddField(
model_name='publicbuilding',
name='level_number',
field=models.IntegerField(default=0, verbose_name="Numéro d'étage (ex: 0=RDC, 1=1er, -1=Sous-sol)"),
),
migrations.AddField(
model_name='publicbuilding',
name='location_type',
field=models.CharField(choices=[('site', 'Site / Complexe'), ('building', 'Bâtiment / Bloc'), ('level', 'Niveau / Étage'), ('room', 'Pièce / Local'), ('other', 'Autre localisation')], default='building', max_length=50, verbose_name='Type de localisation'),
),
migrations.AddField(
model_name='publicbuilding',
name='parent_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_locations', to='assets.publicbuilding', verbose_name='Localisation parente'),
),
migrations.AddField(
model_name='publicbuilding',
name='surface_m2',
field=models.FloatField(blank=True, null=True, verbose_name='Surface spécifique (m²)'),
),
migrations.AlterField(
model_name='publicbuilding',
name='dpe_rating',
field=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='Performance Énergétique (PEB / DPE / EPC)'),
),
migrations.AlterField(
model_name='publicbuilding',
name='erp_category',
field=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 d'accueil / ERP"),
),
migrations.AlterField(
model_name='publicbuilding',
name='erp_type',
field=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 d'établissement / ERP"),
),
migrations.AlterField(
model_name='publicbuilding',
name='ges_rating',
field=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='Émissions de Carbone & GES'),
),
migrations.AlterField(
model_name='publicbuilding',
name='is_erp',
field=models.BooleanField(default=True, verbose_name='Accueil & Sécurité du Public (ERP / PMR)'),
),
migrations.AlterField(
model_name='publicbuilding',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_location_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AlterField(
model_name='publicbuilding',
name='usage_type',
field=models.CharField(choices=[('administrative', 'Administrative (Town Hall / Municipal Building)'), ('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"),
),
migrations.CreateModel(
name='BuildingAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=3812)),
('geojson', models.TextField(blank=True, null=True)),
('asset_type', models.CharField(choices=[('civil_engineering', 'Génie civil / Bâti'), ('facade', 'Façade / Mur extérieur'), ('wall', 'Mur intérieur / Cloison'), ('roof', 'Toiture / Couverture'), ('hvac', 'Chauffage / CVC / Chaudière'), ('elevator', 'Ascenseur / Monte-charge'), ('electricity', 'Électricité / TGBT'), ('fire_safety', 'Sécurité incendie / Extincteur / RIA'), ('alarm', "Alarme / Contrôle d'accès"), ('meter', 'Compteur (Eau / Gaz / Électricité)'), ('sanitary', 'Plomberie / Sanitaires'), ('other', 'Autre équipement / asset')], default='civil_engineering', max_length=50, verbose_name="Type d'asset / É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')),
('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='all_building_assets', to='assets.publicbuilding', verbose_name='Bâtiment / Site principal')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='location_assets', to='assets.publicbuilding', verbose_name='Localisation spécifique')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('parent_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_assets', to='assets.buildingasset', verbose_name='Asset parent')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.buildingasset', 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': 'Asset / Équipement de bâtiment',
'verbose_name_plural': 'Assets / Équipements de bâtiment',
'ordering': ['building', 'code'],
},
),
migrations.DeleteModel(
name='BuildingEquipment',
),
migrations.DeleteModel(
name='BuildingLevel',
),
migrations.DeleteModel(
name='BuildingRoom',
),
]

View file

@ -10,4 +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 from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument

View file

@ -7,7 +7,7 @@ from django.urls import reverse
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User from django.contrib.auth.models import User
from assets.models import AbstractAsset, AbstractGeoAsset, AbstractAssetModel, ASSET_STATUS_CHOICES from assets.models import AbstractLocation, AbstractAsset, AbstractGeoAsset, AbstractAssetModel, ASSET_STATUS_CHOICES
from common.models import Document from common.models import Document
from common.storages import PrivateMediaStorage from common.storages import PrivateMediaStorage
@ -76,23 +76,43 @@ ERP_TYPE_CHOICES = (
('Y', _('Y - Musées')), ('Y', _('Y - Musées')),
) )
EQUIPMENT_TYPE_CHOICES = ( LOCATION_TYPE_CHOICES = (
('site', _('Site / Complexe')),
('building', _('Bâtiment / Bloc')),
('level', _('Niveau / Étage')),
('room', _('Pièce / Local')),
('other', _('Autre localisation')),
)
BUILDING_ASSET_TYPE_CHOICES = (
('civil_engineering', _('Génie civil / Bâti')),
('facade', _('Façade / Mur extérieur')),
('wall', _('Mur intérieur / Cloison')),
('roof', _('Toiture / Couverture')),
('hvac', _('Chauffage / CVC / Chaudière')), ('hvac', _('Chauffage / CVC / Chaudière')),
('elevator', _('Ascenseur / Monte-charge')), ('elevator', _('Ascenseur / Monte-charge')),
('electricity', _('Électricité / TGBT')),
('fire_safety', _('Sécurité incendie / Extincteur / RIA')), ('fire_safety', _('Sécurité incendie / Extincteur / RIA')),
('electricity', _('Tableau électrique / TGBT')),
('alarm', _('Alarme / Contrôle d\'accès')), ('alarm', _('Alarme / Contrôle d\'accès')),
('meter', _('Compteur (Eau / Gaz / Électricité)')), ('meter', _('Compteur (Eau / Gaz / Électricité)')),
('sanitary', _('Plomberie / Sanitaires')), ('sanitary', _('Plomberie / Sanitaires')),
('other', _('Autre équipement technique')), ('other', _('Autre équipement / asset')),
) )
# For backward compatibility
EQUIPMENT_TYPE_CHOICES = BUILDING_ASSET_TYPE_CHOICES
class PublicBuilding(AbstractGeoAsset):
class PublicBuilding(AbstractLocation):
""" """
Modèle représentant un Bâtiment Public (Patrimoine bâti). Modèle représentant une Localisation du Patrimoine Bâti (Complexe, Bâtiment, Bloc, Étage, Local).
Les localisations peuvent former une arborescence parent-enfant.
""" """
geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise cartographique")) geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise cartographique"))
parent_location = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_locations", verbose_name=_("Localisation parente"))
location_type = models.CharField(max_length=50, choices=LOCATION_TYPE_CHOICES, default='building', verbose_name=_("Type de localisation"))
is_main_location = models.BooleanField(default=False, verbose_name=_("Localisation principale"))
address = models.CharField(max_length=500, null=True, blank=True, verbose_name=_("Adresse complète")) 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")) description = models.TextField(null=True, blank=True, verbose_name=_("Description"))
@ -111,6 +131,9 @@ class PublicBuilding(AbstractGeoAsset):
basement_count = models.IntegerField(default=0, verbose_name=_("Nombre de niveaux en sous-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²)")) 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²)")) usable_surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface utile (m²)"))
surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface spécifique (m²)"))
level_number = models.IntegerField(default=0, verbose_name=_("Numéro d'étage (ex: 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"))
# Réglementation & Accueil du Public # Réglementation & Accueil du Public
is_erp = models.BooleanField(default=True, verbose_name=_("Accueil & Sécurité du Public (ERP / PMR)")) is_erp = models.BooleanField(default=True, verbose_name=_("Accueil & Sécurité du Public (ERP / PMR)"))
@ -126,8 +149,8 @@ class PublicBuilding(AbstractGeoAsset):
pmr_accessible = models.BooleanField(default=False, verbose_name=_("Accessible PMR")) pmr_accessible = models.BooleanField(default=False, verbose_name=_("Accessible PMR"))
class Meta: class Meta:
verbose_name = _("Bâtiment public") verbose_name = _("Localisation Bâtiment public")
verbose_name_plural = _("Bâtiments publics") verbose_name_plural = _("Localisations Bâtiments publics")
ordering = ['code', 'name_fr'] ordering = ['code', 'name_fr']
def get_absolute_url(self): def get_absolute_url(self):
@ -143,63 +166,65 @@ class PublicBuilding(AbstractGeoAsset):
return f"Administratif ({terms['admin_building']})" return f"Administratif ({terms['admin_building']})"
return dict(USAGE_TYPE_CHOICES).get(self.usage_type, self.usage_type) return dict(USAGE_TYPE_CHOICES).get(self.usage_type, self.usage_type)
def get_child_locations(self):
return self.child_locations.all()
class BuildingLevel(models.Model): def get_active_assets(self):
def collect_assets(loc):
assets = list(loc.location_assets.filter(status='active'))
for child in loc.child_locations.all():
assets += collect_assets(child)
return assets
return collect_assets(self)
def get_all_assets(self):
def collect_assets(loc):
if loc.parent_location is None:
assets = list(loc.location_assets.select_related('location', 'parent_asset', 'building').all() | loc.all_building_assets.select_related('location', 'parent_asset', 'building').filter(location__isnull=True))
else:
assets = list(loc.location_assets.select_related('location', 'parent_asset', 'building').all())
for child in loc.child_locations.all():
assets += collect_assets(child)
return assets
seen = set()
unique_assets = []
for a in collect_assets(self):
if a.id not in seen:
seen.add(a.id)
unique_assets.append(a)
return unique_assets
def get_direct_child_assets(self):
return self.location_assets.all()
class BuildingAsset(AbstractGeoAsset):
""" """
Modèle représentant un niveau / étage au sein d'un bâtiment public. Modèle représentant un asset ou équipement physique du bâtiment (Génie civil / Bâti, Façades, Toitures, CVC, Ascenseurs...).
Les assets peuvent former une arborescence parent-enfant (ex: Génie civil -> Façade Nord, Toiture, Ascenseur).
""" """
building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="levels", verbose_name=_("Bâtiment")) building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="all_building_assets", verbose_name=_("Bâtiment / Site principal"))
code = models.CharField(max_length=50, verbose_name=_("Code du niveau")) location = models.ForeignKey(PublicBuilding, on_delete=models.SET_NULL, null=True, blank=True, related_name="location_assets", verbose_name=_("Localisation spécifique"))
name = models.CharField(max_length=200, verbose_name=_("Nom du niveau")) parent_asset = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_assets", verbose_name=_("Asset parent"))
level_number = models.IntegerField(default=0, verbose_name=_("Numéro d'étage (0=RDC, 1=1er, -1=Sous-sol)")) asset_type = models.CharField(max_length=50, choices=BUILDING_ASSET_TYPE_CHOICES, default='civil_engineering', verbose_name=_("Type d'asset / Équipement"))
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")) 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")) next_control_date = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle réglementaire"))
class Meta: class Meta:
verbose_name = _("Équipement technique de bâtiment") verbose_name = _("Asset / Équipement de bâtiment")
verbose_name_plural = _("Équipements techniques de bâtiment") verbose_name_plural = _("Assets / Équipements de bâtiment")
ordering = ['building', 'code'] ordering = ['building', 'code']
def get_absolute_url(self): def get_absolute_url(self):
return reverse('assets:publicbuildings_assets_detail', kwargs={'asset_model': 'buildingequipment', 'asset_id': self.pk}) return reverse('assets:publicbuildings_assets_detail', kwargs={'asset_model': 'buildingasset', 'asset_id': self.pk})
def get_child_assets(self):
return self.child_assets.all()
# Backward compatibility alias
BuildingEquipment = BuildingAsset
def upload_to_building(instance, filename): def upload_to_building(instance, filename):
@ -217,4 +242,3 @@ class PublicBuildingDocument(Document):
class Meta: class Meta:
verbose_name = _("Document de bâtiment") verbose_name = _("Document de bâtiment")
verbose_name_plural = _("Documents de bâtiment") verbose_name_plural = _("Documents de bâtiment")

View file

@ -316,6 +316,8 @@ def _get_location_thematic(location):
'cleanlocation': 'clean', 'cleanlocation': 'clean',
'publiclightingstreet': 'publiclighting', 'publiclightingstreet': 'publiclighting',
'signstreet': 'sign', 'signstreet': 'sign',
'publicbuilding': 'publicbuildings',
'buildinglocation': 'publicbuildings',
} }
thematic_code = thematic_mappings.get(class_name) thematic_code = thematic_mappings.get(class_name)
if not thematic_code: if not thematic_code:

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', 'publicbuildings', 'buildings']); const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters']);
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

@ -0,0 +1,38 @@
{% load i18n %}
<ul class="list-group list-group-flush bg-transparent">
{% for asset_node in asset_nodes %}
<li class="list-group-item bg-transparent border-0 py-1 px-0">
<!-- Row Header -->
<div class="d-flex align-items-center justify-content-between py-1 px-2">
<!-- Contenu gauche (indenté selon la profondeur) -->
<div class="d-flex align-items-center gap-2 flex-wrap" style="padding-left: calc(({{ depth|default:1 }} - 1) * 1.25rem);">
{% if asset_node.children %}
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-asset-{{ asset_node.asset.id }}"
aria-expanded="false">
<i class="bi bi-chevron-right small"></i>
</button>
{% else %}
<span class="px-1"></span>
{% endif %}
<i class="bi bi-gear-fill text-info small"></i>
<!-- Code en bleu pour indiquer clairement un lien cliquable -->
<a href="{{ asset_node.asset.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline small">
{{ asset_node.asset.code }}
</a>
<span class="small">{{ asset_node.asset.get_name }}</span>
<span class="badge bg-info-subtle text-info border small ms-1">{{ asset_node.asset.get_asset_type_display }}</span>
</div>
</div>
{% if asset_node.children %}
<div id="tree-asset-{{ asset_node.asset.id }}" class="collapse mt-1">
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=asset_node.children depth=depth|default:1|add:1 only %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>

View file

@ -75,7 +75,7 @@
</li> </li>
<li class="nav-item" role="presentation"> <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"> <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" %} ({{ equipments.count }}) <i class="bi bi-gear me-1"></i>{% translate "Assets & Équipements" %} ({{ equipments|length }})
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
@ -145,93 +145,72 @@
</div> </div>
</div> </div>
<!-- Card 3: Arborescence Niveaux, Pièces & Équipements --> <!-- Card 3: Arborescence Unifiée des Localisations & Assets -->
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center"> <div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center">
<span><i class="bi bi-diagram-3 me-2"></i>{% translate "Arborescence du bâtiment" %}</span> <span><i class="bi bi-diagram-3 me-2"></i>{% translate "Arborescence Hiérarchique" %}</span>
<span class="badge bg-primary-subtle text-primary border">{{ levels.count }} {% translate "niveau(x)" %}</span> <span class="badge bg-primary-subtle text-primary border">{{ location_tree.children|length }} {% translate "sous-localisation(s)" %}</span>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-3">
<ul class="list-group list-group-flush">
{% for level in levels %} <!-- Localisation Parente (si existante) -->
<li class="list-group-item p-3"> {% if parent_location %}
<div class="d-flex align-items-center justify-content-between"> <div class="card bg-light border-secondary-subtle p-2 mb-3">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center justify-content-between">
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn" <div class="d-flex align-items-center gap-2">
type="button" <i class="bi bi-geo-alt-fill text-danger fs-5"></i>
data-bs-toggle="collapse"
data-bs-target="#level-collapse-{{ level.id }}"
aria-expanded="true">
<i class="bi bi-chevron-down small"></i>
</button>
<i class="bi bi-layers text-primary fs-5"></i>
<span class="fw-bold fs-6">{{ level.name }}</span>
<span class="text-muted small">({% translate "Niveau" %} {{ level.level_number }})</span>
</div>
<div> <div>
<span class="badge bg-secondary-subtle text-secondary border me-1">{{ level.rooms.all|length }} {% translate "local(aux)" %}</span> <div class="small text-muted fw-semibold">{% translate "Localisation parente" %} :</div>
<a href="{{ parent_location.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline">
{{ parent_location.code }} - {{ parent_location.get_name }}
</a>
<span class="badge bg-secondary-subtle text-dark border small ms-1">{{ parent_location.get_location_type_display }}</span>
</div> </div>
</div> </div>
<a href="{{ parent_location.get_absolute_url }}" class="btn btn-sm btn-outline-secondary py-0 px-2 small">
<i class="bi bi-arrow-up me-1"></i>{% translate "Voir la parente" %}
</a>
</div>
</div>
{% endif %}
<!-- Locaux sous ce niveau --> <!-- Localisation Courante (Racine de l'affichage) -->
<div id="level-collapse-{{ level.id }}" class="collapse show mt-3 ms-4"> <div class="p-2 border rounded bg-white mb-2">
<ul class="list-group list-group-flush border-start border-2 ps-2"> <div class="d-flex align-items-center justify-content-between">
{% for room in level.rooms.all %} <div class="d-flex align-items-center gap-2">
<li class="list-group-item border-0 py-2"> <i class="bi bi-building text-primary fs-5"></i>
<div class="d-flex align-items-center justify-content-between"> <span class="fw-bold fs-6 text-primary">{{ building.code }}</span>
<div class="d-flex align-items-center gap-2"> <span class="fw-bold fs-6">- {{ building.get_name }}</span>
{% if room.equipments.all %} <span class="badge bg-primary text-white ms-1">{{ building.get_location_type_display }}</span>
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#room-collapse-{{ room.id }}"
aria-expanded="false">
<i class="bi bi-chevron-right small"></i>
</button>
{% else %}
<span class="px-2"></span>
{% endif %}
<i class="bi bi-door-open text-secondary"></i>
<strong class="small">{{ room.code }}</strong> - <span class="small">{{ room.name }}</span>
{% if room.surface_m2 %}
<span class="badge bg-light text-dark border small">{{ room.surface_m2 }} m²</span>
{% endif %}
</div>
{% if room.equipments.all %}
<span class="badge bg-info-subtle text-info border small">{{ room.equipments.all|length }} {% translate "équipement(s)" %}</span>
{% endif %}
</div>
<!-- Équipements dans ce local --> {% if location_tree.assets %}
{% if room.equipments.all %} <button class="btn btn-sm btn-outline-info py-0 px-2 small ms-2 text-nowrap"
<div id="room-collapse-{{ room.id }}" class="collapse mt-2 ms-4"> type="button"
<ul class="list-group list-group-flush border-start border-2 border-info ps-2"> data-bs-toggle="collapse"
{% for eq in room.equipments.all %} data-bs-target="#tree-assets-root"
<li class="list-group-item border-0 py-1 d-flex align-items-center justify-content-between"> aria-expanded="false">
<div class="d-flex align-items-center gap-2"> <i class="bi bi-tools me-1"></i>Assets ({{ location_tree.assets|length }})
<i class="bi bi-gear text-info"></i> </button>
<a href="{{ eq.get_absolute_url }}" class="fw-bold small text-decoration-none">{{ eq.code }}</a> {% endif %}
<span class="small">{{ eq.get_name }}</span>
</div>
<a href="{{ eq.get_absolute_url }}" class="btn btn-sm btn-outline-primary py-0 px-2 small">
<i class="bi bi-eye me-1"></i>Détail
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</li>
{% empty %}
<li class="list-group-item border-0 text-muted small fst-italic">{% translate "Aucun local enregistré pour ce niveau." %}</li>
{% endfor %}
</ul>
</div> </div>
</li> </div>
{% empty %}
<li class="list-group-item text-center text-muted py-4 mb-0">{% translate "Aucun niveau enregistré pour ce bâtiment." %}</li> <!-- Collapsible Assets de la localisation courante (NON VISIBLES PAR DÉFAUT) -->
{% endfor %} {% if location_tree.assets %}
</ul> <div id="tree-assets-root" class="collapse mt-2 ms-4">
<div class="card card-body bg-light border-info-subtle p-2 mb-2">
<div class="fw-bold small text-info mb-1"><i class="bi bi-tools me-1"></i>{% translate "Assets rattachés à" %} {{ building.code }} :</div>
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=location_tree.assets only %}
</div>
</div>
{% endif %}
<!-- Sous-localisations & leurs assets -->
<div class="mt-2">
{% include "assets/buildings/publicbuilding_location_tree.html" with node=location_tree depth=1 only %}
</div>
</div>
</div> </div>
</div> </div>
@ -307,42 +286,77 @@
</div> </div>
</div> </div>
<!-- 2. Équipements Tab --> <!-- 2. Assets & Équipements Tab -->
<div class="tab-pane fade" id="equipments" role="tabpanel"> <div class="tab-pane fade" id="equipments" role="tabpanel">
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title h6 mb-0"><i class="bi bi-tools me-2"></i>{% translate "Assets Physiques & Équipements de la Localisation et de ses sous-localisations" %}</h5>
<span class="badge bg-info-subtle text-info border">{{ equipments|length }} {% translate "asset(s)" %}</span>
</div>
<div class="card-body p-0"> <div class="card-body p-0">
<table class="table table-hover align-middle mb-0"> <div class="table-responsive">
<thead class="table-light"> <table class="table table-hover align-middle mb-0">
<tr> <thead class="table-light">
<th style="width: 50px;"></th> <tr>
<th>{% translate "Code / Nom" %}</th> <th>{% translate "Code / Nom" %}</th>
<th>{% translate "Type d'équipement" %}</th> <th>{% translate "Type d'asset" %}</th>
<th>{% translate "Local" %}</th> <th>{% translate "Localisation spécifique" %}</th>
<th>{% translate "Prochain contrôle" %}</th> <th>{% translate "Asset Parent" %}</th>
</tr> <th>{% translate "Statut" %}</th>
</thead> <th>{% translate "Prochain contrôle" %}</th>
<tbody> <th class="text-end pe-3">{% translate "Actions" %}</th>
{% for eq in equipments %} </tr>
<tr> </thead>
<td class="text-center"> <tbody>
<a href="{{ eq.get_absolute_url }}" class="btn btn-sm btn-light border" title="{% translate 'Détail de l\'équipement' %}"> {% for eq in equipments %}
<i class="bi bi-info-circle"></i> <tr>
</a> <td>
</td> <a href="{{ eq.get_absolute_url }}" class="fw-bold text-decoration-none">{{ eq.code }}</a>
<td> <div class="small text-muted">{{ eq.get_name }}</div>
<a href="{{ eq.get_absolute_url }}" class="fw-bold text-decoration-none">{{ eq.code }}</a> - {{ eq.get_name }} </td>
</td> <td>
<td>{{ eq.get_equipment_type_display }}</td> <span class="badge bg-info-subtle text-info border">
<td>{{ eq.room.name|default:"-" }}</td> {{ eq.get_asset_type_display }}
<td>{{ eq.next_control_date|default:"-" }}</td> </span>
</tr> </td>
{% empty %} <td>
<tr> {% if eq.location %}
<td colspan="5" class="text-center text-muted py-4">{% translate "Aucun équipement technique enregistré." %}</td> <a href="{{ eq.location.get_absolute_url }}" class="small text-dark text-decoration-none">
</tr> <i class="bi bi-geo-alt me-1"></i>{{ eq.location.code }} - {{ eq.location.get_name }}
{% endfor %} </a>
</tbody> {% else %}
</table> <span class="small text-muted">{{ eq.building.code }}</span>
{% endif %}
</td>
<td>
{% if eq.parent_asset %}
<a href="{{ eq.parent_asset.get_absolute_url }}" class="small text-decoration-none">
<i class="bi bi-diagram-2 me-1"></i>{{ eq.parent_asset.code }}
</a>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
<span class="badge {% if eq.status == 'active' %}bg-success-subtle text-success border{% else %}bg-secondary-subtle text-secondary border{% endif %}">
{{ eq.get_status_display }}
</span>
</td>
<td>{{ eq.next_control_date|date:"d/m/Y"|default:"-" }}</td>
<td class="text-end pe-3">
<a href="{{ eq.get_absolute_url }}" class="btn btn-sm btn-outline-info py-0 px-2 small text-nowrap" title="{% translate 'Détail de l\'asset' %}">
<i class="bi bi-eye me-1"></i>{% translate "Détail" %}
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">{% translate "Aucun asset ou équipement technique enregistré." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -59,6 +59,22 @@
{{ form.name_nl }} {{ form.name_nl }}
{% if form.name_nl.errors %}<div class="text-danger small mt-1">{{ form.name_nl.errors.0 }}</div>{% endif %} {% if form.name_nl.errors %}<div class="text-danger small mt-1">{{ form.name_nl.errors.0 }}</div>{% endif %}
</div> </div>
<div class="col-md-4">
<label for="{{ form.location_type.id_for_label }}" class="form-label fw-semibold">{% translate "Type de localisation" %}</label>
{{ form.location_type }}
{% if form.location_type.errors %}<div class="text-danger small mt-1">{{ form.location_type.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.parent_location.id_for_label }}" class="form-label fw-semibold">{% translate "Localisation parente" %}</label>
{{ form.parent_location }}
{% if form.parent_location.errors %}<div class="text-danger small mt-1">{{ form.parent_location.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 d-flex align-items-end mb-2">
<div class="form-check">
{{ form.is_main_location }}
<label for="{{ form.is_main_location.id_for_label }}" class="form-check-label fw-semibold">{% translate "Localisation principale" %}</label>
</div>
</div>
<div class="col-md-12"> <div class="col-md-12">
<label for="{{ form.address.id_for_label }}" class="form-label fw-semibold">{% translate "Adresse complète" %}</label> <label for="{{ form.address.id_for_label }}" class="form-label fw-semibold">{% translate "Adresse complète" %}</label>
{{ form.address }} {{ form.address }}

View file

@ -72,6 +72,7 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>{% translate "Code / Nom" %}</th> <th>{% translate "Code / Nom" %}</th>
<th>{% translate "Type & Parent" %}</th>
<th>{% translate "Usage principal" %}</th> <th>{% translate "Usage principal" %}</th>
<th>{% translate "Propriétaire & Gestionnaire" %}</th> <th>{% translate "Propriétaire & Gestionnaire" %}</th>
<th>{% translate "ERP" %}</th> <th>{% translate "ERP" %}</th>
@ -89,6 +90,14 @@
</a> </a>
<div class="small text-muted">{{ b.get_name }}</div> <div class="small text-muted">{{ b.get_name }}</div>
</td> </td>
<td>
<span class="badge bg-primary-subtle text-primary border me-1">
{{ b.get_location_type_display }}
</span>
{% if b.parent_location %}
<div class="small text-muted mt-1"><i class="bi bi-diagram-2 me-1"></i>{{ b.parent_location.code }}</div>
{% endif %}
</td>
<td> <td>
<span class="badge bg-secondary-subtle text-dark border"> <span class="badge bg-secondary-subtle text-dark border">
{{ b.get_usage_type_display }} {{ b.get_usage_type_display }}

View file

@ -0,0 +1,74 @@
{% load i18n %}
<ul class="list-group list-group-flush border-0">
{% for child_node in node.children %}
<li class="list-group-item border-0 py-1 px-0">
<!-- Row Header -->
<div class="d-flex align-items-center justify-content-between py-1 px-3">
<!-- Contenu gauche (indenté selon la profondeur) -->
<div class="d-flex align-items-center gap-2 flex-wrap" style="padding-left: calc(({{ depth|default:1 }} - 1) * 1.25rem);">
{% if child_node.children or child_node.assets %}
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-loc-{{ child_node.loc.id }}"
aria-expanded="true">
<i class="bi bi-chevron-down small"></i>
</button>
{% else %}
<span class="px-2"></span>
{% endif %}
{% if child_node.loc.location_type == 'building' or child_node.loc.location_type == 'site' %}
<i class="bi bi-building text-primary"></i>
{% elif child_node.loc.location_type == 'level' %}
<i class="bi bi-layers text-secondary"></i>
{% elif child_node.loc.location_type == 'room' %}
<i class="bi bi-door-open text-info"></i>
{% else %}
<i class="bi bi-geo-alt text-dark"></i>
{% endif %}
<!-- Code en bleu pour indiquer clairement un lien cliquable -->
<a href="{{ child_node.loc.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline small">
{{ child_node.loc.code }}
</a>
<span class="small text-muted">- {{ child_node.loc.get_name }}</span>
<span class="badge bg-primary-subtle text-primary border small">{{ child_node.loc.get_location_type_display }}</span>
{% if child_node.assets %}
<button class="btn btn-sm btn-outline-info py-0 px-2 small text-nowrap"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-assets-loc-{{ child_node.loc.id }}"
aria-expanded="false">
<i class="bi bi-tools me-1"></i>Assets ({{ child_node.assets|length }})
</button>
{% endif %}
</div>
</div>
<!-- Collapsible Assets for this location (NON VISIBLES PAR DÉFAUT) -->
{% if child_node.assets %}
<div id="tree-assets-loc-{{ child_node.loc.id }}" class="collapse mt-2" style="padding-left: calc({{ depth|default:1 }} * 1.25rem);">
<div class="card card-body bg-light border-info-subtle p-2 mb-2 me-3">
<div class="fw-bold small text-info mb-1"><i class="bi bi-tools me-1"></i>{% translate "Assets rattachés à" %} {{ child_node.loc.code }} :</div>
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=child_node.assets depth=depth|default:1|add:1 only %}
</div>
</div>
{% endif %}
<!-- Collapsible Child Locations -->
{% if child_node.children %}
<div id="tree-loc-{{ child_node.loc.id }}" class="collapse show">
{% include "assets/buildings/publicbuilding_location_tree.html" with node=child_node depth=depth|default:1|add:1 only %}
</div>
{% endif %}
</li>
{% empty %}
{% if not node.assets %}
<li class="list-group-item border-0 text-muted small fst-italic py-2 ps-3">
{% translate "Aucune sous-localisation." %}
</li>
{% endif %}
{% endfor %}
</ul>

View file

@ -10,7 +10,7 @@ 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 from assets.models.buildings import PublicBuilding, BuildingAsset, BuildingEquipment
@ -2380,58 +2380,81 @@ class TrafficLightPoleSignPanelsTest(TestCase):
) )
from assets.models.buildings import BuildingAsset
class PublicBuildingTestCase(TestCase): class PublicBuildingTestCase(TestCase):
def setUp(self): def setUp(self):
self.building = PublicBuilding.objects.create( # Complexe sportif (Localisation Racine)
code="BAT-001", self.complex = PublicBuilding.objects.create(
name_fr="Hôtel de Ville", code="CMP-001",
name_nl="Stadhuis", name_fr="Complexe Sportif Municipal",
address="Grand-Place 1, 1000 Bruxelles", name_nl="Gemeentelijk Sportcomplex",
address="Rue du Sport 1, 1000 Bruxelles",
owner="Ville de Bruxelles", owner="Ville de Bruxelles",
usage_type="administrative", usage_type="sports",
location_type="site",
building_status="in_service", building_status="in_service",
is_erp=True, is_erp=True,
erp_category="1", erp_category="1",
erp_type="W", erp_type="X",
dpe_rating="B",
ges_rating="A",
geom=MultiPolygon(Polygon(((0, 0), (0, 10), (10, 10), (10, 0), (0, 0)))), geom=MultiPolygon(Polygon(((0, 0), (0, 10), (10, 10), (10, 0), (0, 0)))),
) )
# Bloc A (Localisation Enfant)
def test_public_building_creation(self): self.bloc_a = PublicBuilding.objects.create(
self.assertEqual(self.building.code, "BAT-001") code="BLOC-A",
self.assertEqual(self.building.owner, "Ville de Bruxelles") name_fr="Bloc A",
self.assertTrue(self.building.is_erp) parent_location=self.complex,
self.assertEqual(self.building.dpe_rating, "B") location_type="building",
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( # Étage 0 (Localisation Enfant du Bloc A)
building=self.building, self.etage_0 = PublicBuilding.objects.create(
level=level, code="ETG-0",
code="BUR-101", name_fr="Étage 0",
name="Bureau du Bourgmestre", parent_location=self.bloc_a,
room_type="Bureau", location_type="level",
surface_m2=45.5 level_number=0,
) )
self.assertEqual(level.building, self.building)
self.assertEqual(room.level, level)
self.assertEqual(room.surface_m2, 45.5)
def test_building_equipment(self): def test_public_building_location_hierarchy(self):
eq = BuildingEquipment.objects.create( self.assertEqual(self.complex.code, "CMP-001")
building=self.building, self.assertEqual(self.complex.location_type, "site")
code="CVC-001", self.assertEqual(self.bloc_a.parent_location, self.complex)
name_fr="Chaudière principale", self.assertEqual(self.etage_0.parent_location, self.bloc_a)
equipment_type="hvac" self.assertIn(self.bloc_a, self.complex.get_child_locations())
def test_building_asset_tree_with_civil_engineering(self):
# Génie civil / Bâti A (Asset Racine)
civil_asset = BuildingAsset.objects.create(
building=self.complex,
location=self.bloc_a,
code="BATI-A",
name_fr="Bâti Bloc A",
asset_type="civil_engineering"
) )
self.assertEqual(eq.building, self.building) # Asset Enfant: Façade Nord
self.assertEqual(eq.equipment_type, "hvac") facade_asset = BuildingAsset.objects.create(
building=self.complex,
location=self.bloc_a,
parent_asset=civil_asset,
code="FAC-N",
name_fr="Façade Nord",
asset_type="facade"
)
# Asset Enfant: Chaudière
hvac_asset = BuildingAsset.objects.create(
building=self.complex,
location=self.etage_0,
parent_asset=civil_asset,
code="CVC-01",
name_fr="Chaudière",
asset_type="hvac"
)
self.assertEqual(civil_asset.asset_type, "civil_engineering")
self.assertEqual(facade_asset.parent_asset, civil_asset)
self.assertEqual(hvac_asset.parent_asset, civil_asset)
self.assertIn(facade_asset, civil_asset.get_child_assets())
self.assertIn(hvac_asset, civil_asset.get_child_assets())
class PublicBuildingEditViewTest(TestCase): class PublicBuildingEditViewTest(TestCase):
@ -2485,6 +2508,7 @@ class PublicBuildingEditViewTest(TestCase):
url = reverse('assets:publicbuilding_edit', kwargs={'pk': self.building.pk}) url = reverse('assets:publicbuilding_edit', kwargs={'pk': self.building.pk})
post_data = { post_data = {
'code': 'BAT-999', 'code': 'BAT-999',
'location_type': 'building',
'name_fr': 'Bâtiment Modifié', 'name_fr': 'Bâtiment Modifié',
'name_nl': 'Gewijzigd Gebouw', 'name_nl': 'Gewijzigd Gebouw',
'address': 'Rue du Test 123', 'address': 'Rue du Test 123',

View file

@ -11,23 +11,17 @@ from django.contrib.gis.geos import GEOSGeometry
from common.models import UserThematics from common.models import UserThematics
from common.utils import check_thematic_access, get_regional_terms from common.utils import check_thematic_access, get_regional_terms
from assets.permissions import can_edit_asset from assets.permissions import can_edit_asset
from assets.forms import PublicBuildingForm from assets.forms import PublicBuildingForm, BuildingAssetForm
from assets.models.buildings import ( from assets.models.buildings import (
PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument, PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument,
USAGE_TYPE_CHOICES, BUILDING_STATUS_CHOICES, DPE_RATING_CHOICES, ERP_CATEGORY_CHOICES USAGE_TYPE_CHOICES, BUILDING_STATUS_CHOICES, DPE_RATING_CHOICES, ERP_CATEGORY_CHOICES,
LOCATION_TYPE_CHOICES, BUILDING_ASSET_TYPE_CHOICES
) )
def publicbuildings_locations_geojson(request): 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). Vue GeoJSON renvoyant la collection des localisations de bâtiments publics pour la carte unifiée.
"""
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' lang = get_language() if get_language() in ('fr', 'nl') else 'fr'
@ -38,9 +32,14 @@ def publicbuildings_assets_geojson(request):
return JsonResponse({"error": "Invalid JSON"}, status=400) return JsonResponse({"error": "Invalid JSON"}, status=400)
statuses = data.get('asset_status', []) statuses = data.get('asset_status', [])
categories = data.get('category') or [] location_types = data.get('location_type') or []
if isinstance(categories, str): if isinstance(location_types, str):
categories = [categories] location_types = [location_types]
usage_types = data.get('usage_type') or []
if isinstance(usage_types, str):
usage_types = [usage_types]
is_main_loc = data.get('is_main_location')
is_erp_val = data.get('is_erp')
filter_geom_raw = data.get("filter_geom") filter_geom_raw = data.get("filter_geom")
if filter_geom_raw: if filter_geom_raw:
@ -54,14 +53,30 @@ def publicbuildings_assets_geojson(request):
search_q = (data.get('q') or '').strip() search_q = (data.get('q') or '').strip()
else: else:
statuses = request.GET.getlist('asset_status') statuses = request.GET.getlist('asset_status')
categories = request.GET.getlist('category', []) location_types = request.GET.getlist('location_type', [])
usage_types = request.GET.getlist('usage_type', [])
is_main_loc = request.GET.get('is_main_location')
is_erp_val = request.GET.get('is_erp')
filter_geom = None filter_geom = None
search_q = request.GET.get('q', '').strip() search_q = request.GET.get('q', '').strip()
qs = PublicBuilding.objects.all() qs = PublicBuilding.objects.all()
if categories: if location_types:
qs = qs.filter(category__id__in=categories) qs = qs.filter(location_type__in=location_types)
if usage_types:
qs = qs.filter(usage_type__in=usage_types)
if is_main_loc in ['true', '1', True]:
qs = qs.filter(is_main_location=True)
elif is_main_loc in ['false', '0', False]:
qs = qs.filter(is_main_location=False)
if is_erp_val in ['true', '1', True]:
qs = qs.filter(is_erp=True)
elif is_erp_val in ['false', '0', False]:
qs = qs.filter(is_erp=False)
if statuses: if statuses:
qs = qs.filter(building_status__in=statuses) qs = qs.filter(building_status__in=statuses)
@ -83,12 +98,17 @@ def publicbuildings_assets_geojson(request):
for b in qs: for b in qs:
geometry = None geometry = None
if b.geom: if b.geojson:
try:
geometry = json.loads(b.geojson)
except Exception:
pass
if not geometry and b.geom:
geom_4326 = b.geom.clone() geom_4326 = b.geom.clone()
geom_4326.transform(4326) geom_4326.transform(4326)
centroid = geom_4326.centroid centroid = geom_4326.centroid
geometry = {"type": "Point", "coordinates": [centroid.x, centroid.y]} geometry = {"type": "Point", "coordinates": [centroid.x, centroid.y]}
elif b.lon is not None and b.lat is not None: elif not geometry and b.lon is not None and b.lat is not None:
geometry = {"type": "Point", "coordinates": [b.lon, b.lat]} geometry = {"type": "Point", "coordinates": [b.lon, b.lat]}
detail_url = reverse('assets:publicbuilding_detail', kwargs={'pk': b.pk}) detail_url = reverse('assets:publicbuilding_detail', kwargs={'pk': b.pk})
@ -100,8 +120,8 @@ def publicbuildings_assets_geojson(request):
"properties": { "properties": {
"code": b.code, "code": b.code,
"name": b.get_name(), "name": b.get_name(),
"category": b.category.get_name() if b.category else _("Bâtiment public"), "location_type": b.location_type,
"category_id": b.category_id, "location_type_display": b.get_location_type_display(),
"status": b.building_status, "status": b.building_status,
"building_status": b.building_status, "building_status": b.building_status,
"building_status_display": b.get_building_status_display(), "building_status_display": b.get_building_status_display(),
@ -119,6 +139,100 @@ def publicbuildings_assets_geojson(request):
"detail_url": detail_url, "detail_url": detail_url,
"element_type": get_element_type_label("publicbuilding"), "element_type": get_element_type_label("publicbuilding"),
"thematic": "publicbuildings", "thematic": "publicbuildings",
"is_location": True,
}
})
return JsonResponse({"type": "FeatureCollection", "features": features})
def publicbuildings_assets_geojson(request):
"""
Vue GeoJSON renvoyant la collection des assets physiques de bâtiments (Génie civil, Façades, CVC, Ascenseurs...) pour la carte unifiée.
"""
lang = get_language() if get_language() in ('fr', 'nl') else 'fr'
if request.method == "POST":
try:
data = json.loads(request.body)
except json.JSONDecodeError:
return JsonResponse({"error": "Invalid JSON"}, status=400)
statuses = data.get('asset_status', [])
categories = data.get('category', []) or data.get('categories', [])
if isinstance(categories, (str, int)):
categories = [categories]
asset_types = data.get('asset_type') or []
if isinstance(asset_types, str):
asset_types = [asset_types]
search_q = (data.get('q') or '').strip()
else:
statuses = request.GET.getlist('asset_status')
categories = request.GET.getlist('category')
asset_types = request.GET.getlist('asset_type', [])
search_q = request.GET.get('q', '').strip()
qs = BuildingAsset.objects.select_related('building', 'location', 'parent_asset', 'category').all()
if categories:
qs = qs.filter(category_id__in=categories)
if asset_types:
qs = qs.filter(asset_type__in=asset_types)
if statuses:
qs = qs.filter(status__in=statuses)
if search_q:
qs = qs.filter(
Q(code__icontains=search_q) |
Q(name_fr__icontains=search_q) |
Q(name_nl__icontains=search_q)
)
features = []
from assets.views.core import get_element_type_label
for a in qs:
geometry = None
if a.geojson:
try:
geometry = json.loads(a.geojson)
except Exception:
pass
if not geometry and a.geom:
geom_4326 = a.geom.clone()
geom_4326.transform(4326)
geometry = {"type": "Point", "coordinates": [geom_4326.x, geom_4326.y]}
elif not geometry and a.lon is not None and a.lat is not None:
geometry = {"type": "Point", "coordinates": [a.lon, a.lat]}
elif not geometry and a.building and a.building.lon is not None and a.building.lat is not None:
geometry = {"type": "Point", "coordinates": [a.building.lon, a.building.lat]}
detail_url = reverse('assets:publicbuildings_assets_detail', kwargs={'asset_model': 'buildingasset', 'asset_id': a.pk})
features.append({
"type": "Feature",
"id": a.id,
"geometry": geometry,
"properties": {
"code": a.code,
"name": a.get_name(),
"asset_type": a.asset_type,
"asset_type_display": a.get_asset_type_display(),
"category_id": a.category_id,
"category_name": a.category.get_name() if a.category else '',
"status": a.status,
"status_display": a.get_status_display(),
"building_code": a.building.code if a.building else '',
"building_name": a.building.get_name() if a.building else '',
"parent_asset_id": a.parent_asset_id,
"color": "#17a2b8",
"display_color": "#17a2b8",
"detail_url": detail_url,
"element_type": get_element_type_label("buildingasset"),
"thematic": "publicbuildings",
"is_location": False, "is_location": False,
} }
}) })
@ -128,12 +242,11 @@ def publicbuildings_assets_geojson(request):
@login_required @login_required
def buildings_index(request): def buildings_index(request):
""" """
Vue principale (Index / Liste) des Bâtiments Publics. Vue principale (Index / Liste) des Localisations Bâtiments Publics.
""" """
lang = get_language() lang = get_language()
buildings = PublicBuilding.objects.all() buildings = PublicBuilding.objects.select_related('parent_location', 'manager').all()
# Search filter # Search filter
search_query = request.GET.get('q', '').strip() search_query = request.GET.get('q', '').strip()
@ -156,6 +269,11 @@ def buildings_index(request):
if status_filter: if status_filter:
buildings = buildings.filter(building_status=status_filter) buildings = buildings.filter(building_status=status_filter)
# Location type filter
location_type_filter = request.GET.get('location_type')
if location_type_filter:
buildings = buildings.filter(location_type=location_type_filter)
# ERP filter # ERP filter
erp_filter = request.GET.get('is_erp') erp_filter = request.GET.get('is_erp')
if erp_filter in ['true', '1']: if erp_filter in ['true', '1']:
@ -178,9 +296,11 @@ def buildings_index(request):
'search_query': search_query, 'search_query': search_query,
'usage_type_choices': USAGE_TYPE_CHOICES, 'usage_type_choices': USAGE_TYPE_CHOICES,
'building_status_choices': BUILDING_STATUS_CHOICES, 'building_status_choices': BUILDING_STATUS_CHOICES,
'location_type_choices': LOCATION_TYPE_CHOICES,
'dpe_rating_choices': DPE_RATING_CHOICES, 'dpe_rating_choices': DPE_RATING_CHOICES,
'selected_usage': usage_filter, 'selected_usage': usage_filter,
'selected_status': status_filter, 'selected_status': status_filter,
'selected_location_type': location_type_filter,
'selected_erp': erp_filter, 'selected_erp': erp_filter,
'selected_dpe': dpe_filter, 'selected_dpe': dpe_filter,
'active_tab': 'buildings', 'active_tab': 'buildings',
@ -188,22 +308,44 @@ def buildings_index(request):
return render(request, 'assets/buildings/publicbuilding_list.html', context) return render(request, 'assets/buildings/publicbuilding_list.html', context)
def build_asset_node(asset):
return {
'asset': asset,
'children': [build_asset_node(c) for c in asset.child_assets.all()]
}
def build_location_node(loc):
if loc.parent_location is None:
loc_assets = loc.location_assets.filter(parent_asset__isnull=True) | loc.all_building_assets.filter(location__isnull=True, parent_asset__isnull=True)
loc_assets = loc_assets.distinct().prefetch_related('child_assets')
else:
loc_assets = loc.location_assets.filter(parent_asset__isnull=True).prefetch_related('child_assets')
return {
'loc': loc,
'children': [build_location_node(c) for c in loc.child_locations.all().prefetch_related('child_locations', 'location_assets')],
'assets': [build_asset_node(a) for a in loc_assets],
}
@login_required @login_required
def building_detail(request, pk): def building_detail(request, pk):
""" """
Vue détaillée d'un Bâtiment Public avec ses niveaux, pièces, équipements, documents, interventions et cartographie. Vue détaillée d'une Localisation Bâtiment Public avec son arborescence unifiée de localisations et d'assets physiques.
""" """
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from interventions.models import Intervention from interventions.models import Intervention
from interventions.permissions import filter_viewable_interventions_for_user from interventions.permissions import filter_viewable_interventions_for_user
building = get_object_or_404(PublicBuilding, pk=pk) building = get_object_or_404(PublicBuilding.objects.select_related('parent_location', 'manager'), pk=pk)
can_edit = can_edit_asset(request.user, building) can_edit = can_edit_asset(request.user, building)
regional_terms = get_regional_terms(getattr(building, '_regional_standard', 'BE')) regional_terms = get_regional_terms(getattr(building, '_regional_standard', 'BE'))
levels = building.levels.all().prefetch_related('rooms') # Arborescence unifiée (Localisation courante + sous-localisations + assets rattachés)
rooms = building.rooms.all() location_tree = build_location_node(building)
equipments = building.equipments.all() parent_location = building.parent_location
documents = building.documents.all() documents = building.documents.all()
content_type = ContentType.objects.get_for_model(PublicBuilding) content_type = ContentType.objects.get_for_model(PublicBuilding)
@ -219,19 +361,24 @@ def building_detail(request, pk):
building_geojson = None building_geojson = None
center_lon, center_lat = None, None center_lon, center_lat = None, None
if building.geom: if building.geom:
geom_4326 = building.geom.clone() try:
geom_4326.transform(4326) geom_4326 = building.geom.clone()
building_geojson = json.loads(geom_4326.json) geom_4326.transform(4326)
centroid = geom_4326.centroid building_geojson = json.loads(geom_4326.json)
center_lon, center_lat = centroid.x, centroid.y centroid = geom_4326.centroid
center_lon, center_lat = centroid.x, centroid.y
except Exception:
pass
elif building.lon is not None and building.lat is not None:
center_lon, center_lat = building.lon, building.lat
context = { context = {
'building': building, 'building': building,
'can_edit': can_edit, 'can_edit': can_edit,
'regional_terms': regional_terms, 'regional_terms': regional_terms,
'levels': levels, 'location_tree': location_tree,
'rooms': rooms, 'parent_location': parent_location,
'equipments': equipments, 'equipments': building.get_all_assets(),
'documents': documents, 'documents': documents,
'interventions': interventions, 'interventions': interventions,
'content_type': content_type, 'content_type': content_type,
@ -245,7 +392,7 @@ def building_detail(request, pk):
@login_required @login_required
def publicbuilding_edit(request, pk): def publicbuilding_edit(request, pk):
""" """
Vue d'édition d'un Bâtiment Public réservée aux utilisateurs autorisés. Vue d'édition d'une Localisation Bâtiment Public réservée aux utilisateurs autorisés.
""" """
building = get_object_or_404(PublicBuilding, pk=pk) building = get_object_or_404(PublicBuilding, pk=pk)
@ -256,7 +403,7 @@ def publicbuilding_edit(request, pk):
form = PublicBuildingForm(request.POST, instance=building) form = PublicBuildingForm(request.POST, instance=building)
if form.is_valid(): if form.is_valid():
saved_building = form.save() saved_building = form.save()
messages.success(request, _("Le bâtiment '%s' a été mis à jour avec succès.") % saved_building.code) messages.success(request, _("La localisation '%s' a été mise à jour avec succès.") % saved_building.code)
return redirect('assets:publicbuilding_detail', pk=saved_building.pk) return redirect('assets:publicbuilding_detail', pk=saved_building.pk)
else: else:
form = PublicBuildingForm(instance=building) form = PublicBuildingForm(instance=building)
@ -288,7 +435,7 @@ def publicbuilding_edit(request, pk):
@login_required @login_required
def publicbuildings_assets_detail(request, asset_model, asset_id): def publicbuildings_assets_detail(request, asset_model, asset_id):
""" """
Vue générique pour le détail d'un équipement/asset d'un bâtiment public. Vue générique pour le détail d'un asset/équipement physique d'un bâtiment public.
""" """
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from assets.views.core import get_asset_edit_context from assets.views.core import get_asset_edit_context

View file

@ -709,6 +709,43 @@ def build_conditional_filter_config(accessible_thematics, user=None):
'options': quality_level_options, 'options': quality_level_options,
}) })
# Public buildings specific choices
from assets.models.buildings import USAGE_TYPE_CHOICES
building_usage_options = [
{'value': str(key), 'label': str(value)}
for key, value in USAGE_TYPE_CHOICES
]
# Public buildings specific filters
for thematic_code in ['publicbuildings', 'buildings']:
if thematic_code in accessible_codes:
config[thematic_code] = [
{
'key': 'usage_type',
'label': _("Type d'usage (Localisation)"),
'type': 'multi',
'options': building_usage_options,
},
{
'key': 'is_main_location',
'label': _('Localisation principale'),
'type': 'single',
'options': [
{'value': 'true', 'label': _('Oui')},
{'value': 'false', 'label': _('Non')},
],
},
{
'key': 'is_erp',
'label': _('Accueil ERP / PMR'),
'type': 'single',
'options': [
{'value': 'true', 'label': _('Oui')},
{'value': 'false', 'label': _('Non')},
],
},
]
# Sign assets specific filter # Sign assets specific filter
if 'sign' in accessible_codes: if 'sign' in accessible_codes:
config.setdefault('sign', []).append({ config.setdefault('sign', []).append({