git add loko/assets/models/buildings.py \

loko/assets/forms.py \
        loko/assets/admin.py \
        loko/assets/templates/assets/buildings/ \
        loko/assets/tests.py \
        loko/assets/migrations/0028_buildingasset_contractor_name_and_more.py

git commit -m "feat(assets): extend building locations and technical equipment models

- Add comprehensive building locations (stairwells, technical rooms, boiler rooms, offices, etc.)
- Add building physical & technical fields (room numbers, floor types, heated status, dimensions)
- Add complete building envelope & technical equipment types (roofs, facades, windows, doors, boilers, heat pumps, etc.)
- Enrich admin, forms, trees, and detail templates with icons, macro-lots, and maintenance tracking
- Add migration 0028 and unit test coverage"
This commit is contained in:
kdeterme 2026-10-05 09:49:08 +02:00
parent 93f3a49983
commit 63c4a8e539
10 changed files with 735 additions and 66 deletions

View file

@ -682,16 +682,16 @@ from .models import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
@admin.register(PublicBuilding)
class PublicBuildingAdmin(admin.GISModelAdmin):
list_display = ("code", "name_fr", "location_type", "parent_location", "usage_type", "building_status", "owner", "manager", "is_erp", "dpe_rating")
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")
list_display = ("code", "name_fr", "location_type", "parent_location", "room_number", "level_number", "usage_type", "building_status", "is_heated", "is_erp", "dpe_rating")
list_filter = ("location_type", "floor_type", "is_heated", "usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible")
search_fields = ("code", "name_fr", "name_nl", "address", "owner", "room_number")
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")
list_display = ("code", "name_fr", "asset_type", "building", "location", "parent_asset", "material", "power_kw", "status", "obsolescence", "next_control_date")
list_filter = ("asset_type", "energy_source", "glazing_type", "opening_type", "status", "obsolescence")
search_fields = ("code", "name_fr", "name_nl", "serial_number", "material", "maintenance_contract_ref", "contractor_name")
raw_id_fields = ("building", "location", "parent_asset")
admin.site.register(PublicBuildingDocument, AssetAdmin)

View file

@ -287,6 +287,7 @@ class PublicBuildingForm(forms.ModelForm):
'usage_type', 'building_status',
'construction_year', 'floors_count', 'basement_count',
'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number',
'room_number', 'ceiling_height_m', 'is_heated', 'fire_compartment', 'floor_type',
'is_erp', 'erp_category', 'erp_type', 'max_capacity', 'pmr_accessible',
'dpe_rating', 'ges_rating', 'has_asbestos_diag', 'has_lead_diag',
'lat', 'lon',
@ -312,6 +313,11 @@ class PublicBuildingForm(forms.ModelForm):
'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'}),
'room_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: B-204, RDC-01'}),
'ceiling_height_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'is_heated': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'fire_compartment': forms.TextInput(attrs={'class': 'form-control'}),
'floor_type': forms.Select(attrs={'class': 'form-select'}),
'is_erp': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'erp_category': forms.Select(attrs={'class': 'form-select'}),
'erp_type': forms.Select(attrs={'class': 'form-select'}),
@ -327,29 +333,15 @@ class PublicBuildingForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name_fr'].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['description'].required = False
self.fields['owner'].required = False
self.fields['manager'].required = False
self.fields['occupant_entity'].required = False
self.fields['construction_year'].required = False
self.fields['floors_count'].required = False
self.fields['basement_count'].required = False
self.fields['total_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_type'].required = False
self.fields['max_capacity'].required = False
self.fields['dpe_rating'].required = False
self.fields['ges_rating'].required = False
self.fields['lat'].required = False
self.fields['lon'].required = False
for field_name in [
'name_fr', 'name_nl', 'parent_location', 'location_type', 'address', 'description',
'owner', 'manager', 'occupant_entity', 'construction_year', 'floors_count',
'basement_count', 'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number',
'room_number', 'ceiling_height_m', 'fire_compartment', 'floor_type',
'erp_category', 'erp_type', 'max_capacity', 'dpe_rating', 'ges_rating', 'lat', 'lon'
]:
if field_name in self.fields:
self.fields[field_name].required = False
class BuildingAssetForm(forms.ModelForm):
@ -358,8 +350,14 @@ class BuildingAssetForm(forms.ModelForm):
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'
'asset_type', 'category', 'status', 'obsolescence',
'dimensions', 'surface_m2', 'material',
'power_kw', 'energy_source', 'refrigerant_type', 'refrigerant_charge_kg',
'glazing_type', 'fire_rating', 'opening_type',
'installation_date', 'serial_number',
'maintenance_contract_ref', 'contractor_name',
'last_control_date', 'next_control_date', 'regulatory_periodicity_months',
'replacement_cost_estimate', 'note'
]
widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}),
@ -372,16 +370,37 @@ class BuildingAssetForm(forms.ModelForm):
'category': forms.Select(attrs={'class': 'form-select'}),
'status': forms.Select(attrs={'class': 'form-select'}),
'obsolescence': forms.Select(attrs={'class': 'form-select'}),
'dimensions': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: 120x140 cm, 350 m²'}),
'surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'material': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: Aluminium, PVC, Zinc, Acier'}),
'power_kw': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
'energy_source': forms.Select(attrs={'class': 'form-select'}),
'refrigerant_type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: R32, R410A'}),
'refrigerant_charge_kg': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'glazing_type': forms.Select(attrs={'class': 'form-select'}),
'fire_rating': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: EI30, EI60, Coupe-feu 1h'}),
'opening_type': 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'}),
'maintenance_contract_ref': forms.TextInput(attrs={'class': 'form-control'}),
'contractor_name': 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'}),
'regulatory_periodicity_months': forms.NumberInput(attrs={'class': 'form-control'}),
'replacement_cost_estimate': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'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']:
for field_name in [
'name_fr', 'name_nl', 'location', 'parent_asset', 'category',
'dimensions', 'surface_m2', 'material', 'power_kw', 'energy_source',
'refrigerant_type', 'refrigerant_charge_kg', 'glazing_type', 'fire_rating', 'opening_type',
'installation_date', 'serial_number', 'maintenance_contract_ref', 'contractor_name',
'last_control_date', 'next_control_date', 'regulatory_periodicity_months',
'replacement_cost_estimate', 'note'
]:
if field_name in self.fields:
self.fields[field_name].required = False
from common.models import Thematic

View file

@ -0,0 +1,118 @@
# Generated by Django 6.0.7 on 2026-10-05 07:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0027_alter_structure_structure_type'),
]
operations = [
migrations.AddField(
model_name='buildingasset',
name='contractor_name',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Prestataire / Mainteneur'),
),
migrations.AddField(
model_name='buildingasset',
name='dimensions',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Dimensions / Format'),
),
migrations.AddField(
model_name='buildingasset',
name='energy_source',
field=models.CharField(blank=True, choices=[('natural_gas', 'Gaz naturel'), ('electricity', 'Électricité'), ('fuel_oil', 'Fioul domestique'), ('biomass_wood', 'Biomasse / Bois / Pellets'), ('heat_network', 'Réseau de chaleur urbain'), ('heat_pump_air', 'Aérothermie (PAC air-eau / air-air)'), ('heat_pump_geo', 'Géothermie (PAC eau-eau / sol-eau)'), ('solar_thermal', 'Solaire thermique'), ('other', 'Autre source')], max_length=50, null=True, verbose_name="Source d'énergie"),
),
migrations.AddField(
model_name='buildingasset',
name='fire_rating',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Classement au feu / Coupe-feu'),
),
migrations.AddField(
model_name='buildingasset',
name='glazing_type',
field=models.CharField(blank=True, choices=[('single', 'Simple vitrage'), ('double_standard', 'Double vitrage standard'), ('double_low_e', 'Double vitrage ITR / Haute isolation'), ('triple', 'Triple vitrage'), ('laminated_safety', 'Vitrage feuilleté de sécurité'), ('other', 'Autre vitrage')], max_length=50, null=True, verbose_name='Type de vitrage'),
),
migrations.AddField(
model_name='buildingasset',
name='maintenance_contract_ref',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Réf. contrat maintenance'),
),
migrations.AddField(
model_name='buildingasset',
name='material',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Matériau principal'),
),
migrations.AddField(
model_name='buildingasset',
name='opening_type',
field=models.CharField(blank=True, choices=[('hinged', 'Battant / À la française'), ('sliding', 'Coulissant'), ('tilt_and_turn', 'Oscillo-battant'), ('double_action', 'Va-et-vient'), ('sectional_overhead', 'Sectionnel / Basculant'), ('motorized_automatic', 'Automatique / Motorisé'), ('fixed', 'Châssis fixe / Non ouvrant'), ('other', 'Autre type')], max_length=50, null=True, verbose_name="Type d'ouverture"),
),
migrations.AddField(
model_name='buildingasset',
name='power_kw',
field=models.FloatField(blank=True, null=True, verbose_name='Puissance nominale (kW)'),
),
migrations.AddField(
model_name='buildingasset',
name='refrigerant_charge_kg',
field=models.FloatField(blank=True, null=True, verbose_name='Charge fluide frigorigène (kg)'),
),
migrations.AddField(
model_name='buildingasset',
name='refrigerant_type',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Fluide frigorigène'),
),
migrations.AddField(
model_name='buildingasset',
name='regulatory_periodicity_months',
field=models.IntegerField(blank=True, null=True, verbose_name='Périodicité contrôle réglementaire (mois)'),
),
migrations.AddField(
model_name='buildingasset',
name='replacement_cost_estimate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Coût estimé de renouvellement (€)'),
),
migrations.AddField(
model_name='buildingasset',
name='surface_m2',
field=models.FloatField(blank=True, null=True, verbose_name='Surface unitaire (m²)'),
),
migrations.AddField(
model_name='publicbuilding',
name='ceiling_height_m',
field=models.FloatField(blank=True, null=True, verbose_name='Hauteur sous plafond (m)'),
),
migrations.AddField(
model_name='publicbuilding',
name='fire_compartment',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Secteur / Compartiment coupe-feu'),
),
migrations.AddField(
model_name='publicbuilding',
name='floor_type',
field=models.CharField(blank=True, choices=[('tiles', 'Carrelage / Faïence'), ('parquet', 'Parquet / Bois'), ('linoleum_pvc', 'Linoléum / Vinyle / PVC'), ('concrete', 'Béton lissé / Chape / Résine'), ('carpet', 'Moquette / Textile'), ('other', 'Autre revêtement')], max_length=50, null=True, verbose_name='Revêtement de sol dominant'),
),
migrations.AddField(
model_name='publicbuilding',
name='is_heated',
field=models.BooleanField(default=True, verbose_name='Local chauffé'),
),
migrations.AddField(
model_name='publicbuilding',
name='room_number',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Numéro de local / porte'),
),
migrations.AlterField(
model_name='buildingasset',
name='asset_type',
field=models.CharField(choices=[('roof', 'Toiture / Couverture / Étanchéité'), ('roof_drainage', 'Zinguerie & Évacuation EP (Gouttière, chéneau)'), ('facade', 'Façade / Bardage / Mur extérieur'), ('window', 'Fenêtre / Baie vitrée / Châssis'), ('skylight', 'Lanterneau / Verrière / Puits de lumière'), ('external_door', "Porte extérieure / Porte d'entrée / Portail"), ('shutter_blind', 'Volet / Store / Brise-soleil (BSO)'), ('insulation', 'Isolation thermique (ITE / ITI)'), ('wall', 'Mur intérieur / Cloison'), ('interior_door', 'Porte intérieure / Bloc-porte'), ('fire_door', 'Porte coupe-feu / Bloc pare-flamme'), ('ceiling', 'Faux-plafond / Plafond suspendu'), ('flooring', 'Revêtement de sol'), ('stairs', 'Escalier / Garde-corps / Main courante'), ('boiler', 'Chaudière (Gaz / Fioul / Biomasse / Pellets)'), ('heat_pump', 'Pompe à chaleur (PAC) / Climatisation réversible'), ('heat_substation', "Sous-station d'échangeur thermique / Réseau urbain"), ('radiator_emitter', 'Émetteur de chaleur (Radiateur, convecteur, plancher)'), ('air_handling_unit', "Centrale de traitement d'air (CTA) / VMC"), ('air_conditioning', 'Climatisation / Groupe froid / Chiller / VRV'), ('water_heater', 'Production Eau Chaude Sanitaire (Ballon ECS)'), ('hvac', 'Chauffage / CVC / Régulation générale'), ('electricity', 'Électricité / TGBT / Tableau divisionnaire'), ('inverter_generator', 'Onduleur / Groupe électrogène de secours'), ('lighting', 'Éclairage intérieur / Luminaires'), ('emergency_lighting', 'Éclairage de sécurité / BAES / BAEH'), ('meter', 'Compteur (Eau / Gaz / Électricité / Calories)'), ('solar_panel', 'Panneaux solaires (Photovoltaïque / Thermique)'), ('ev_charging', 'Borne de recharge véhicule électrique'), ('sanitary', 'Plomberie / Sanitaires (WC, lavabo, douche)'), ('plumbing_piping', "Réseau d'eau / Vanne d'arrêt / Disconnecteur"), ('sump_pump', 'Pompe de relevage (Eaux usées / Eaux pluviales)'), ('rainwater_harvesting', "Récupération d'eau de pluie / Cuve de stockage"), ('fire_safety', 'Sécurité incendie / Centrale SSI / CMSI'), ('smoke_detector', 'Détecteur de fumée / Déclencheur manuel (DM)'), ('fire_extinguisher', "Extincteur / Robinet d'Incendie Armé (RIA)"), ('smoke_exhaust', 'Désenfumage / Trappe / Skydome'), ('access_control', "Contrôle d'accès (Badge, interphone, gâche)"), ('alarm', 'Alarme anti-intrusion / Détecteur de présence'), ('cctv_surveillance', 'Vidéosurveillance / Caméra CCTV'), ('elevator', 'Ascenseur / Monte-charge'), ('disabled_lift', 'Élévateur PMR / Plateforme élévatrice'), ('network_rack', 'Baie de brassage / Rack informatique / RJ45'), ('bms_system', 'Gestion Technique du Bâtiment (GTB / GTC)'), ('civil_engineering', 'Génie civil / Bâti / Gros œuvre'), ('other', 'Autre équipement / asset')], default='civil_engineering', max_length=50, verbose_name="Type d'asset / Équipement"),
),
migrations.AlterField(
model_name='publicbuilding',
name='location_type',
field=models.CharField(choices=[('site', 'Site / Complexe'), ('building', 'Bâtiment / Bloc'), ('wing', 'Aile / Corps de bâtiment'), ('level', 'Niveau / Étage'), ('basement', 'Sous-sol / Cave'), ('attic', 'Combles / Grenier / Toiture-terrasse'), ('mezzanine', 'Mezzanine'), ('stairwell', "Cage d'escalier"), ('corridor', 'Couloir / Coursive / Dégagement'), ('lobby', "Hall d'accueil / Entrée / Sas"), ('emergency_exit', "Issue de secours / Sas d'évacuation"), ('elevator_shaft', "Gaine / Palier d'ascenseur"), ('technical_room', 'Local technique général'), ('boiler_room', 'Chaufferie / Sous-station thermique'), ('electrical_room', 'Local TGBT / Électrique'), ('ventilation_room', 'Local ventilation / CTA'), ('elevator_machine_room', 'Local machinerie ascenseur'), ('server_room', 'Salle serveurs / Baie informatique'), ('room', 'Pièce / Local polyvalent'), ('office', 'Bureau / Espace de travail'), ('meeting_room', 'Salle de réunion / Conférence'), ('classroom', 'Salle de classe / Formation'), ('workshop', 'Atelier / Laboratoire'), ('sanitary_room', 'Sanitaires / Vestiaires / Douches'), ('storage_room', 'Réserve / Stockage / Archives'), ('canteen_kitchen', 'Cuisine / Réfectoire / Restauration'), ('cleaning_room', 'Local ménage / Entretien'), ('waste_room', 'Local poubelles / Déchets'), ('indoor_parking', 'Parking couvert / Garage'), ('outdoor_area', 'Abords / Cour / Parvis / Terrasse'), ('other', 'Autre localisation')], default='building', max_length=50, verbose_name='Type de localisation'),
),
]

View file

@ -77,31 +77,148 @@ ERP_TYPE_CHOICES = (
)
LOCATION_TYPE_CHOICES = (
# Ensembles & Bâtiments
('site', _('Site / Complexe')),
('building', _('Bâtiment / Bloc')),
('wing', _('Aile / Corps de bâtiment')),
# Niveaux & Hauteurs
('level', _('Niveau / Étage')),
('room', _('Pièce / Local')),
('basement', _('Sous-sol / Cave')),
('attic', _('Combles / Grenier / Toiture-terrasse')),
('mezzanine', _('Mezzanine')),
# Circulations & Dégagements
('stairwell', _('Cage d\'escalier')),
('corridor', _('Couloir / Coursive / Dégagement')),
('lobby', _('Hall d\'accueil / Entrée / Sas')),
('emergency_exit', _('Issue de secours / Sas d\'évacuation')),
('elevator_shaft', _('Gaine / Palier d\'ascenseur')),
# Locaux techniques
('technical_room', _('Local technique général')),
('boiler_room', _('Chaufferie / Sous-station thermique')),
('electrical_room', _('Local TGBT / Électrique')),
('ventilation_room', _('Local ventilation / CTA')),
('elevator_machine_room', _('Local machinerie ascenseur')),
('server_room', _('Salle serveurs / Baie informatique')),
# Locaux de travail, d'accueil & services
('room', _('Pièce / Local polyvalent')),
('office', _('Bureau / Espace de travail')),
('meeting_room', _('Salle de réunion / Conférence')),
('classroom', _('Salle de classe / Formation')),
('workshop', _('Atelier / Laboratoire')),
('sanitary_room', _('Sanitaires / Vestiaires / Douches')),
('storage_room', _('Réserve / Stockage / Archives')),
('canteen_kitchen', _('Cuisine / Réfectoire / Restauration')),
('cleaning_room', _('Local ménage / Entretien')),
('waste_room', _('Local poubelles / Déchets')),
('indoor_parking', _('Parking couvert / Garage')),
# Espaces extérieurs immédiats
('outdoor_area', _('Abords / Cour / Parvis / Terrasse')),
# Autre
('other', _('Autre localisation')),
)
FLOOR_TYPE_CHOICES = (
('tiles', _('Carrelage / Faïence')),
('parquet', _('Parquet / Bois')),
('linoleum_pvc', _('Linoléum / Vinyle / PVC')),
('concrete', _('Béton lissé / Chape / Résine')),
('carpet', _('Moquette / Textile')),
('other', _('Autre revêtement')),
)
BUILDING_ASSET_TYPE_CHOICES = (
('civil_engineering', _('Génie civil / Bâti')),
('facade', _('Façade / Mur extérieur')),
# Clos, couvert & enveloppe
('roof', _('Toiture / Couverture / Étanchéité')),
('roof_drainage', _('Zinguerie & Évacuation EP (Gouttière, chéneau)')),
('facade', _('Façade / Bardage / Mur extérieur')),
('window', _('Fenêtre / Baie vitrée / Châssis')),
('skylight', _('Lanterneau / Verrière / Puits de lumière')),
('external_door', _('Porte extérieure / Porte d\'entrée / Portail')),
('shutter_blind', _('Volet / Store / Brise-soleil (BSO)')),
('insulation', _('Isolation thermique (ITE / ITI)')),
# Second œuvre & aménagements intérieurs
('wall', _('Mur intérieur / Cloison')),
('roof', _('Toiture / Couverture')),
('hvac', _('Chauffage / CVC / Chaudière')),
('interior_door', _('Porte intérieure / Bloc-porte')),
('fire_door', _('Porte coupe-feu / Bloc pare-flamme')),
('ceiling', _('Faux-plafond / Plafond suspendu')),
('flooring', _('Revêtement de sol')),
('stairs', _('Escalier / Garde-corps / Main courante')),
# Génie climatique & thermique (CVC)
('boiler', _('Chaudière (Gaz / Fioul / Biomasse / Pellets)')),
('heat_pump', _('Pompe à chaleur (PAC) / Climatisation réversible')),
('heat_substation', _('Sous-station d\'échangeur thermique / Réseau urbain')),
('radiator_emitter', _('Émetteur de chaleur (Radiateur, convecteur, plancher)')),
('air_handling_unit', _('Centrale de traitement d\'air (CTA) / VMC')),
('air_conditioning', _('Climatisation / Groupe froid / Chiller / VRV')),
('water_heater', _('Production Eau Chaude Sanitaire (Ballon ECS)')),
('hvac', _('Chauffage / CVC / Régulation générale')),
# Électricité, Énergie & Réseaux
('electricity', _('Électricité / TGBT / Tableau divisionnaire')),
('inverter_generator', _('Onduleur / Groupe électrogène de secours')),
('lighting', _('Éclairage intérieur / Luminaires')),
('emergency_lighting', _('Éclairage de sécurité / BAES / BAEH')),
('meter', _('Compteur (Eau / Gaz / Électricité / Calories)')),
('solar_panel', _('Panneaux solaires (Photovoltaïque / Thermique)')),
('ev_charging', _('Borne de recharge véhicule électrique')),
# Plomberie & Sanitaires
('sanitary', _('Plomberie / Sanitaires (WC, lavabo, douche)')),
('plumbing_piping', _('Réseau d\'eau / Vanne d\'arrêt / Disconnecteur')),
('sump_pump', _('Pompe de relevage (Eaux usées / Eaux pluviales)')),
('rainwater_harvesting', _('Récupération d\'eau de pluie / Cuve de stockage')),
# Sécurité incendie, sûreté & contrôle d'accès
('fire_safety', _('Sécurité incendie / Centrale SSI / CMSI')),
('smoke_detector', _('Détecteur de fumée / Déclencheur manuel (DM)')),
('fire_extinguisher', _('Extincteur / Robinet d\'Incendie Armé (RIA)')),
('smoke_exhaust', _('Désenfumage / Trappe / Skydome')),
('access_control', _('Contrôle d\'accès (Badge, interphone, gâche)')),
('alarm', _('Alarme anti-intrusion / Détecteur de présence')),
('cctv_surveillance', _('Vidéosurveillance / Caméra CCTV')),
# Mobilité verticale
('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')),
('disabled_lift', _('Élévateur PMR / Plateforme élévatrice')),
# Réseaux, GTB & Automatismes
('network_rack', _('Baie de brassage / Rack informatique / RJ45')),
('bms_system', _('Gestion Technique du Bâtiment (GTB / GTC)')),
# Gros œuvre & Divers
('civil_engineering', _('Génie civil / Bâti / Gros œuvre')),
('other', _('Autre équipement / asset')),
)
# For backward compatibility
EQUIPMENT_TYPE_CHOICES = BUILDING_ASSET_TYPE_CHOICES
ENERGY_SOURCE_CHOICES = (
('natural_gas', _('Gaz naturel')),
('electricity', _('Électricité')),
('fuel_oil', _('Fioul domestique')),
('biomass_wood', _('Biomasse / Bois / Pellets')),
('heat_network', _('Réseau de chaleur urbain')),
('heat_pump_air', _('Aérothermie (PAC air-eau / air-air)')),
('heat_pump_geo', _('Géothermie (PAC eau-eau / sol-eau)')),
('solar_thermal', _('Solaire thermique')),
('other', _('Autre source')),
)
GLAZING_TYPE_CHOICES = (
('single', _('Simple vitrage')),
('double_standard', _('Double vitrage standard')),
('double_low_e', _('Double vitrage ITR / Haute isolation')),
('triple', _('Triple vitrage')),
('laminated_safety', _('Vitrage feuilleté de sécurité')),
('other', _('Autre vitrage')),
)
OPENING_TYPE_CHOICES = (
('hinged', _('Battant / À la française')),
('sliding', _('Coulissant')),
('tilt_and_turn', _('Oscillo-battant')),
('double_action', _('Va-et-vient')),
('sectional_overhead', _('Sectionnel / Basculant')),
('motorized_automatic', _('Automatique / Motorisé')),
('fixed', _('Châssis fixe / Non ouvrant')),
('other', _('Autre type')),
)
class PublicBuilding(AbstractLocation):
"""
@ -133,6 +250,11 @@ class PublicBuilding(AbstractLocation):
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)"))
room_number = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Numéro de local / porte"))
ceiling_height_m = models.FloatField(null=True, blank=True, verbose_name=_("Hauteur sous plafond (m)"))
is_heated = models.BooleanField(default=True, verbose_name=_("Local chauffé"))
fire_compartment = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Secteur / Compartiment coupe-feu"))
floor_type = models.CharField(max_length=50, choices=FLOOR_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Revêtement de sol dominant"))
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
@ -166,6 +288,44 @@ class PublicBuilding(AbstractLocation):
return f"Administratif ({terms['admin_building']})"
return dict(USAGE_TYPE_CHOICES).get(self.usage_type, self.usage_type)
def get_icon_class(self):
"""
Retourne la classe d'icône Bootstrap adaptée au type de localisation.
"""
mapping = {
'site': 'bi-diagram-3',
'building': 'bi-building',
'wing': 'bi-building-add',
'level': 'bi-layers',
'basement': 'bi-layer-backward',
'attic': 'bi-triangle',
'mezzanine': 'bi-border-inner',
'stairwell': 'bi-stairs',
'corridor': 'bi-arrow-left-right',
'lobby': 'bi-door-open-fill',
'emergency_exit': 'bi-box-arrow-right',
'elevator_shaft': 'bi-arrow-down-up',
'technical_room': 'bi-tools',
'boiler_room': 'bi-fire',
'electrical_room': 'bi-lightning-charge',
'ventilation_room': 'bi-fan',
'elevator_machine_room': 'bi-gear-wide-connected',
'server_room': 'bi-hdd-network',
'room': 'bi-door-open',
'office': 'bi-briefcase',
'meeting_room': 'bi-people',
'classroom': 'bi-mortarboard',
'workshop': 'bi-hammer',
'sanitary_room': 'bi-badge-wc',
'storage_room': 'bi-archive',
'canteen_kitchen': 'bi-cup-hot',
'cleaning_room': 'bi-bucket',
'waste_room': 'bi-trash',
'indoor_parking': 'bi-car-front',
'outdoor_area': 'bi-tree',
}
return mapping.get(self.location_type, 'bi-geo-alt')
def get_child_locations(self):
return self.child_locations.all()
@ -202,14 +362,36 @@ class PublicBuilding(AbstractLocation):
class BuildingAsset(AbstractGeoAsset):
"""
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).
Les assets peuvent former une arborescence parent-enfant (ex: Bâti -> Façade -> Châssis, ou Chaudière -> Brûleur).
"""
building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="all_building_assets", verbose_name=_("Bâtiment / Site principal"))
location = models.ForeignKey(PublicBuilding, on_delete=models.SET_NULL, null=True, blank=True, related_name="location_assets", verbose_name=_("Localisation spécifique"))
parent_asset = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_assets", verbose_name=_("Asset parent"))
asset_type = models.CharField(max_length=50, choices=BUILDING_ASSET_TYPE_CHOICES, default='civil_engineering', verbose_name=_("Type d'asset / Équipement"))
# Caractéristiques physiques & dimensionnelles
dimensions = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Dimensions / Format"))
surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface unitaire (m²)"))
material = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Matériau principal"))
# Caractéristiques thermiques, énergétiques & techniques
power_kw = models.FloatField(null=True, blank=True, verbose_name=_("Puissance nominale (kW)"))
energy_source = models.CharField(max_length=50, choices=ENERGY_SOURCE_CHOICES, null=True, blank=True, verbose_name=_("Source d'énergie"))
refrigerant_type = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Fluide frigorigène"))
refrigerant_charge_kg = models.FloatField(null=True, blank=True, verbose_name=_("Charge fluide frigorigène (kg)"))
# Menuiseries, ouvrants & protection incendie
glazing_type = models.CharField(max_length=50, choices=GLAZING_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Type de vitrage"))
fire_rating = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Classement au feu / Coupe-feu"))
opening_type = models.CharField(max_length=50, choices=OPENING_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Type d'ouverture"))
# Maintenance, exploitation & suivi réglementaire
maintenance_contract_ref = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Réf. contrat maintenance"))
contractor_name = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Prestataire / Mainteneur"))
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"))
regulatory_periodicity_months = models.IntegerField(null=True, blank=True, verbose_name=_("Périodicité contrôle réglementaire (mois)"))
replacement_cost_estimate = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True, verbose_name=_("Coût estimé de renouvellement (€)"))
def clean(self):
super().clean()
@ -246,6 +428,123 @@ class BuildingAsset(AbstractGeoAsset):
def get_child_assets(self):
return self.child_assets.all()
def get_macro_lot(self):
"""
Retourne le macro-lot / domaine technique de l'équipement.
"""
lots = {
# Clos & Couvert
'roof': _('Clos & Couvert'),
'roof_drainage': _('Clos & Couvert'),
'facade': _('Clos & Couvert'),
'window': _('Clos & Couvert'),
'skylight': _('Clos & Couvert'),
'external_door': _('Clos & Couvert'),
'shutter_blind': _('Clos & Couvert'),
'insulation': _('Clos & Couvert'),
# Aménagements & Second œuvre
'wall': _('Aménagements & Second œuvre'),
'interior_door': _('Aménagements & Second œuvre'),
'fire_door': _('Aménagements & Second œuvre'),
'ceiling': _('Aménagements & Second œuvre'),
'flooring': _('Aménagements & Second œuvre'),
'stairs': _('Aménagements & Second œuvre'),
# Génie climatique & CVC
'boiler': _('Génie climatique & CVC'),
'heat_pump': _('Génie climatique & CVC'),
'heat_substation': _('Génie climatique & CVC'),
'radiator_emitter': _('Génie climatique & CVC'),
'air_handling_unit': _('Génie climatique & CVC'),
'air_conditioning': _('Génie climatique & CVC'),
'water_heater': _('Génie climatique & CVC'),
'hvac': _('Génie climatique & CVC'),
# Électricité & Énergie
'electricity': _('Électricité & Énergie'),
'inverter_generator': _('Électricité & Énergie'),
'lighting': _('Électricité & Éclairage'),
'emergency_lighting': _('Électricité & Sécurité'),
'meter': _('Fluides & Comptage'),
'solar_panel': _('Énergies renouvelables'),
'ev_charging': _('Mobilité électrique'),
# Plomberie & Sanitaires
'sanitary': _('Plomberie & Sanitaires'),
'plumbing_piping': _('Plomberie & Réseaux'),
'sump_pump': _('Assainissement & Relevage'),
'rainwater_harvesting': _('Gestion des eaux pluviales'),
# Sécurité incendie & Sûreté
'fire_safety': _('Sécurité incendie'),
'smoke_detector': _('Sécurité incendie'),
'fire_extinguisher': _('Sécurité incendie'),
'smoke_exhaust': _('Sécurité incendie'),
'access_control': _('Sûreté & Contrôle d\'accès'),
'alarm': _('Sûreté & Contrôle d\'accès'),
'cctv_surveillance': _('Vidéosurveillance'),
# Mobilité verticale
'elevator': _('Ascenseurs & Élévateurs'),
'disabled_lift': _('Ascenseurs & Élévateurs'),
# Réseaux & Numérique
'network_rack': _('Réseaux & Télécom'),
'bms_system': _('Gestion Technique du Bâtiment (GTB)'),
# Gros œuvre & Divers
'civil_engineering': _('Génie civil & Structure'),
'other': _('Autre équipement'),
}
return lots.get(self.asset_type, _('Autre'))
def get_icon_class(self):
"""
Retourne l'icône Bootstrap adaptée au type d'asset.
"""
icons = {
'roof': 'bi-house-gear',
'roof_drainage': 'bi-water',
'facade': 'bi-border-style',
'window': 'bi-window',
'skylight': 'bi-sun',
'external_door': 'bi-door-closed',
'shutter_blind': 'bi-blinds',
'insulation': 'bi-shield',
'wall': 'bi-bricks',
'interior_door': 'bi-door-open',
'fire_door': 'bi-shield-shaded',
'ceiling': 'bi-grid-3x3',
'flooring': 'bi-square',
'stairs': 'bi-stairs',
'boiler': 'bi-fire',
'heat_pump': 'bi-snow',
'heat_substation': 'bi-shuffle',
'radiator_emitter': 'bi-thermometer-high',
'air_handling_unit': 'bi-fan',
'air_conditioning': 'bi-wind',
'water_heater': 'bi-droplet-half',
'hvac': 'bi-thermometer-half',
'electricity': 'bi-lightning-charge',
'inverter_generator': 'bi-battery-charging',
'lighting': 'bi-lightbulb',
'emergency_lighting': 'bi-exclamation-triangle',
'meter': 'bi-speedometer2',
'solar_panel': 'bi-sun-fill',
'ev_charging': 'bi-ev-station',
'sanitary': 'bi-badge-wc',
'plumbing_piping': 'bi-pipette',
'sump_pump': 'bi-arrow-up-circle',
'rainwater_harvesting': 'bi-cloud-rain',
'fire_safety': 'bi-shield-shaded',
'smoke_detector': 'bi-bell',
'fire_extinguisher': 'bi-cone-striped',
'smoke_exhaust': 'bi-cloud-arrow-up',
'access_control': 'bi-key',
'alarm': 'bi-shield-exclamation',
'cctv_surveillance': 'bi-camera-video',
'elevator': 'bi-arrow-down-up',
'disabled_lift': 'bi-person-wheelchair',
'network_rack': 'bi-hdd-rack',
'bms_system': 'bi-cpu',
'civil_engineering': 'bi-building-gear',
'other': 'bi-gear',
}
return icons.get(self.asset_type, 'bi-gear')
# Backward compatibility alias
BuildingEquipment = BuildingAsset
@ -266,3 +565,4 @@ class PublicBuildingDocument(Document):
class Meta:
verbose_name = _("Document de bâtiment")
verbose_name_plural = _("Documents de bâtiment")

View file

@ -17,13 +17,14 @@
{% else %}
<span class="px-1"></span>
{% endif %}
<i class="bi bi-gear-fill text-info small"></i>
<i class="bi {{ asset_node.asset.get_icon_class }} 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-primary-subtle text-primary border small ms-1">{{ asset_node.asset.get_macro_lot }}</span>
<span class="badge bg-info-subtle text-info border small ms-1">{{ asset_node.asset.get_asset_type_display }}</span>
</div>
</div>

View file

@ -133,6 +133,40 @@
<th class="text-muted">{% translate "Surface Utile / Brut" %} :</th>
<td>{{ building.usable_surface_m2|default:"-" }} m² / {{ building.total_surface_m2|default:"-" }} m²</td>
</tr>
{% if building.room_number %}
<tr>
<th class="text-muted">{% translate "Numéro de local / porte" %} :</th>
<td><span class="badge bg-secondary-subtle text-secondary border font-monospace"><i class="bi bi-tag me-1"></i>{{ building.room_number }}</span></td>
</tr>
{% endif %}
{% if building.surface_m2 %}
<tr>
<th class="text-muted">{% translate "Surface spécifique" %} :</th>
<td>{{ building.surface_m2 }} m²</td>
</tr>
{% endif %}
{% if building.ceiling_height_m %}
<tr>
<th class="text-muted">{% translate "Hauteur sous plafond" %} :</th>
<td>{{ building.ceiling_height_m }} m</td>
</tr>
{% endif %}
<tr>
<th class="text-muted">{% translate "Chauffage du local" %} :</th>
<td>{% if building.is_heated %}<span class="text-success"><i class="bi bi-fire me-1"></i>{% translate "Chauffé" %}</span>{% else %}<span class="text-muted"><i class="bi bi-slash-circle me-1"></i>{% translate "Non chauffé" %}</span>{% endif %}</td>
</tr>
{% if building.floor_type %}
<tr>
<th class="text-muted">{% translate "Revêtement de sol" %} :</th>
<td>{{ building.get_floor_type_display }}</td>
</tr>
{% endif %}
{% if building.fire_compartment %}
<tr>
<th class="text-muted">{% translate "Compartiment coupe-feu" %} :</th>
<td>{{ building.fire_compartment }}</td>
</tr>
{% endif %}
<tr>
<th class="text-muted">{{ regional_terms.public_access_category }} :</th>
<td>{{ building.erp_category|default:"-" }}</td>
@ -178,8 +212,11 @@
<div class="p-2 border rounded bg-white mb-2">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-building text-primary fs-5"></i>
<i class="bi {{ building.get_icon_class }} text-primary fs-5"></i>
<span class="fw-bold fs-6 text-primary">{{ building.code }}</span>
{% if building.room_number %}
<span class="badge bg-secondary-subtle text-secondary border ms-1 font-monospace"><i class="bi bi-tag me-1"></i>{{ building.room_number }}</span>
{% endif %}
<span class="fw-bold fs-6">- {{ building.get_name }}</span>
<span class="badge bg-primary text-white ms-1">{{ building.get_location_type_display }}</span>
@ -299,11 +336,13 @@
<thead class="table-light">
<tr>
<th>{% translate "Code / Nom" %}</th>
<th>{% translate "Type d'asset" %}</th>
<th>{% translate "Lot / Type d'asset" %}</th>
<th>{% translate "Caractéristiques & Spécifications" %}</th>
<th>{% translate "Localisation spécifique" %}</th>
<th>{% translate "Asset Parent" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Prochain contrôle" %}</th>
<th>{% translate "Maintenance & Prestataire" %}</th>
<th class="text-end pe-3">{% translate "Actions" %}</th>
</tr>
</thead>
@ -311,18 +350,39 @@
{% for eq in equipments %}
<tr>
<td>
<a href="{{ eq.get_absolute_url }}" class="fw-bold text-decoration-none">{{ eq.code }}</a>
<a href="{{ eq.get_absolute_url }}" class="fw-bold text-decoration-none">
<i class="bi {{ eq.get_icon_class }} me-1 text-primary"></i>{{ eq.code }}
</a>
<div class="small text-muted">{{ eq.get_name }}</div>
</td>
<td>
<span class="badge bg-info-subtle text-info border">
<span class="badge bg-primary-subtle text-primary border me-1">{{ eq.get_macro_lot }}</span>
<span class="badge bg-info-subtle text-info border d-block mt-1">
{{ eq.get_asset_type_display }}
</span>
</td>
<td>
{% if eq.dimensions or eq.surface_m2 %}
<div class="small fw-semibold">{% if eq.dimensions %}{{ eq.dimensions }}{% endif %}{% if eq.surface_m2 %} ({{ eq.surface_m2 }} m²){% endif %}</div>
{% endif %}
{% if eq.power_kw or eq.energy_source %}
<div class="small text-muted">
{% if eq.power_kw %}<i class="bi bi-lightning-charge me-1"></i>{{ eq.power_kw }} kW{% endif %}
{% if eq.energy_source %}<span class="badge bg-warning-subtle text-dark border ms-1">{{ eq.get_energy_source_display }}</span>{% endif %}
</div>
{% endif %}
{% if eq.material or eq.fire_rating or eq.glazing_type %}
<div class="small text-muted">
{% if eq.material %}{{ eq.material }}{% endif %}
{% if eq.glazing_type %} - {{ eq.get_glazing_type_display }}{% endif %}
{% if eq.fire_rating %}<span class="badge bg-danger-subtle text-danger border ms-1">{{ eq.fire_rating }}</span>{% endif %}
</div>
{% endif %}
</td>
<td>
{% if eq.location %}
<a href="{{ eq.location.get_absolute_url }}" class="small text-dark text-decoration-none">
<i class="bi bi-geo-alt me-1"></i>{{ eq.location.code }} - {{ eq.location.get_name }}
<i class="bi {{ eq.location.get_icon_class }} me-1"></i>{{ eq.location.code }} - {{ eq.location.get_name }}
</a>
{% else %}
<span class="small text-muted">{{ eq.building.code }}</span>
@ -331,7 +391,7 @@
<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 }}
<i class="bi {{ eq.parent_asset.get_icon_class }} me-1"></i>{{ eq.parent_asset.code }}
</a>
{% else %}
<span class="text-muted small">-</span>
@ -343,6 +403,16 @@
</span>
</td>
<td>{{ eq.next_control_date|date:"d/m/Y"|default:"-" }}</td>
<td>
{% if eq.contractor_name %}
<div class="small fw-semibold"><i class="bi bi-building me-1"></i>{{ eq.contractor_name }}</div>
{% endif %}
{% if eq.maintenance_contract_ref %}
<div class="small text-muted font-monospace">Réf: {{ eq.maintenance_contract_ref }}</div>
{% elif not eq.contractor_name %}
<span class="text-muted small">-</span>
{% endif %}
</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" %}
@ -351,7 +421,7 @@
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">{% translate "Aucun asset ou équipement technique enregistré." %}</td>
<td colspan="9" class="text-center text-muted py-4">{% translate "Aucun asset ou équipement technique enregistré." %}</td>
</tr>
{% endfor %}
</tbody>

View file

@ -157,6 +157,47 @@
{{ form.usable_surface_m2 }}
{% if form.usable_surface_m2.errors %}<div class="text-danger small mt-1">{{ form.usable_surface_m2.errors.0 }}</div>{% endif %}
</div>
<div class="col-12 mt-3">
<h6 class="border-bottom pb-2 text-primary fw-semibold"><i class="bi bi-door-open me-2"></i>{% translate "Spécificités du local ou du niveau (si applicable)" %}</h6>
</div>
<div class="col-md-3">
<label for="{{ form.room_number.id_for_label }}" class="form-label fw-semibold">{% translate "Numéro de local / porte" %}</label>
{{ form.room_number }}
{% if form.room_number.errors %}<div class="text-danger small mt-1">{{ form.room_number.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-3">
<label for="{{ form.level_number.id_for_label }}" class="form-label fw-semibold">{% translate "Numéro d'étage" %}</label>
{{ form.level_number }}
{% if form.level_number.errors %}<div class="text-danger small mt-1">{{ form.level_number.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-3">
<label for="{{ form.surface_m2.id_for_label }}" class="form-label fw-semibold">{% translate "Surface spécifique (m²)" %}</label>
{{ form.surface_m2 }}
{% if form.surface_m2.errors %}<div class="text-danger small mt-1">{{ form.surface_m2.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-3">
<label for="{{ form.ceiling_height_m.id_for_label }}" class="form-label fw-semibold">{% translate "Hauteur sous plafond (m)" %}</label>
{{ form.ceiling_height_m }}
{% if form.ceiling_height_m.errors %}<div class="text-danger small mt-1">{{ form.ceiling_height_m.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.floor_type.id_for_label }}" class="form-label fw-semibold">{% translate "Revêtement de sol" %}</label>
{{ form.floor_type }}
{% if form.floor_type.errors %}<div class="text-danger small mt-1">{{ form.floor_type.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.fire_compartment.id_for_label }}" class="form-label fw-semibold">{% translate "Compartiment coupe-feu" %}</label>
{{ form.fire_compartment }}
{% if form.fire_compartment.errors %}<div class="text-danger small mt-1">{{ form.fire_compartment.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 d-flex align-items-center mt-4">
<div class="form-check">
{{ form.is_heated }}
<label for="{{ form.is_heated.id_for_label }}" class="form-check-label fw-semibold">
{% translate "Local chauffé" %}
</label>
</div>
</div>
</div>
</div>
</div>

View file

@ -38,6 +38,15 @@
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Type de localisation" %}</label>
<select name="location_type" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les types" %} --</option>
{% for key, label in location_type_choices %}
<option value="{{ key }}" {% if selected_location_type == 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">
@ -47,16 +56,16 @@
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Diagnostic DPE" %}</label>
<div class="col-md-1">
<label class="form-label small text-muted">{% translate "DPE" %}</label>
<select name="dpe_rating" class="form-select form-select-sm">
<option value="">-- {% translate "Tous DPE" %} --</option>
<option value="">-- {% translate "Tous" %} --</option>
{% for key, label in dpe_rating_choices %}
<option value="{{ key }}" {% if selected_dpe == key %}selected{% endif %}>DPE {{ label }}</option>
<option value="{{ key }}" {% if selected_dpe == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3 d-flex align-items-end gap-2">
<div class="col-md-2 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>
@ -86,8 +95,11 @@
<tr>
<td>
<a href="{% url 'assets:publicbuilding_detail' b.pk %}" class="fw-bold text-decoration-none">
{{ b.code }}
<i class="bi {{ b.get_icon_class }} me-1 text-primary"></i>{{ b.code }}
</a>
{% if b.room_number %}
<span class="badge bg-secondary-subtle text-secondary border small font-monospace"><i class="bi bi-tag me-1"></i>{{ b.room_number }}</span>
{% endif %}
<div class="small text-muted">{{ b.get_name }}</div>
</td>
<td>

View file

@ -18,20 +18,15 @@
<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 %}
<i class="bi {{ child_node.loc.get_icon_class }} text-primary"></i>
<!-- 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>
{% if child_node.loc.room_number %}
<span class="badge bg-secondary-subtle text-secondary border small font-monospace"><i class="bi bi-tag me-1"></i>{{ child_node.loc.room_number }}</span>
{% endif %}
<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>

View file

@ -4043,6 +4043,119 @@ class PublicBuildingTestCase(TestCase):
self.assertIn(facade_asset, civil_asset.get_child_assets())
self.assertIn(hvac_asset, civil_asset.get_child_assets())
def test_extended_building_locations(self):
# Cage d'escalier
stairwell = PublicBuilding.objects.create(
code="ESC-01",
name_fr="Cage d'escalier Nord",
parent_location=self.bloc_a,
location_type="stairwell",
level_number=0
)
# Chaufferie / Local technique
boiler_room = PublicBuilding.objects.create(
code="CHAUFF-01",
name_fr="Chaufferie Centrale",
parent_location=self.bloc_a,
location_type="boiler_room",
level_number=-1,
floor_type="concrete",
fire_compartment="Zone-CF-01",
is_heated=True
)
# Bureau avec numéro de porte et hauteur
office = PublicBuilding.objects.create(
code="BUR-101",
name_fr="Bureau Directeur",
parent_location=self.etage_0,
location_type="office",
room_number="B-101",
ceiling_height_m=2.85,
surface_m2=24.5,
floor_type="parquet",
is_heated=True
)
self.assertEqual(stairwell.location_type, "stairwell")
self.assertEqual(stairwell.get_icon_class(), "bi-stairs")
self.assertEqual(boiler_room.location_type, "boiler_room")
self.assertEqual(boiler_room.get_icon_class(), "bi-fire")
self.assertEqual(boiler_room.floor_type, "concrete")
self.assertEqual(office.room_number, "B-101")
self.assertEqual(office.ceiling_height_m, 2.85)
self.assertEqual(office.get_icon_class(), "bi-briefcase")
def test_extended_building_assets_envelope_and_technical(self):
# Toiture
roof = BuildingAsset.objects.create(
building=self.complex,
location=self.bloc_a,
code="TOIT-01",
name_fr="Toiture bac acier et zinc",
asset_type="roof",
surface_m2=450.0,
material="Zinc & Bac acier"
)
# Fenêtre
window = BuildingAsset.objects.create(
building=self.complex,
location=self.etage_0,
parent_asset=roof,
code="FEN-101",
name_fr="Châssis double vitrage",
asset_type="window",
dimensions="120x140 cm",
material="Aluminium",
glazing_type="double_low_e",
opening_type="tilt_and_turn"
)
# Chaudière
boiler = BuildingAsset.objects.create(
building=self.complex,
location=self.etage_0,
code="CHAUD-01",
name_fr="Chaudière à condensation",
asset_type="boiler",
power_kw=65.0,
energy_source="natural_gas",
contractor_name="Maintenance Thermique SA",
maintenance_contract_ref="CTR-2026-CHAUD",
regulatory_periodicity_months=12
)
# Pompe à chaleur
heat_pump = BuildingAsset.objects.create(
building=self.complex,
location=self.etage_0,
code="PAC-01",
name_fr="Pompe à chaleur réversible",
asset_type="heat_pump",
power_kw=22.0,
energy_source="heat_pump_air",
refrigerant_type="R32",
refrigerant_charge_kg=4.5
)
# Ascenseur
elevator = BuildingAsset.objects.create(
building=self.complex,
location=self.bloc_a,
code="ASC-01",
name_fr="Ascenseur 8 personnes PMR",
asset_type="elevator",
contractor_name="Otis",
regulatory_periodicity_months=6
)
# Vérifications macro-lots et icônes
self.assertEqual(roof.get_macro_lot(), "Clos & Couvert")
self.assertEqual(roof.get_icon_class(), "bi-house-gear")
self.assertEqual(window.get_macro_lot(), "Clos & Couvert")
self.assertEqual(window.get_icon_class(), "bi-window")
self.assertEqual(boiler.get_macro_lot(), "Génie climatique & CVC")
self.assertEqual(boiler.get_icon_class(), "bi-fire")
self.assertEqual(heat_pump.refrigerant_type, "R32")
self.assertEqual(elevator.get_macro_lot(), "Ascenseurs & Élévateurs")
self.assertEqual(elevator.get_icon_class(), "bi-arrow-down-up")
class PublicBuildingEditViewTest(TestCase):
def setUp(self):