diff --git a/streetup/assets/admin.py b/streetup/assets/admin.py index ec9c7dd..861974f 100644 --- a/streetup/assets/admin.py +++ b/streetup/assets/admin.py @@ -648,18 +648,20 @@ admin.site.register(CleanGlassContainer, AssetAdmin) admin.site.register(CleanLocationContract, AssetAdmin) from .models import CleanLitterBinPhoto, CleanLitterBinObservation -from .models import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument - -admin.site.register(CleanLitterBinPhoto, AssetAdmin) -admin.site.register(CleanLitterBinObservation, AssetAdmin) +from .models import PublicBuilding, BuildingAsset, PublicBuildingDocument @admin.register(PublicBuilding) class PublicBuildingAdmin(admin.GISModelAdmin): - list_display = ("code", "name_fr", "usage_type", "building_status", "owner", "manager", "is_erp", "dpe_rating") - list_filter = ("usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible") + 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") + 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) \ No newline at end of file diff --git a/streetup/assets/forms.py b/streetup/assets/forms.py index c1040a4..2d15324 100644 --- a/streetup/assets/forms.py +++ b/streetup/assets/forms.py @@ -274,17 +274,19 @@ LOCATION_FORM_REGISTRY = { } +from assets.models.buildings import PublicBuilding, BuildingAsset + 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: model = PublicBuilding fields = [ - 'code', 'name_fr', 'name_nl', 'address', 'description', - 'owner', 'manager', 'occupant_entity', + 'code', 'name_fr', 'name_nl', 'parent_location', 'location_type', 'is_main_location', + 'address', 'description', 'owner', 'manager', 'occupant_entity', 'usage_type', 'building_status', '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', 'dpe_rating', 'ges_rating', 'has_asbestos_diag', 'has_lead_diag', 'lat', 'lon', @@ -293,6 +295,9 @@ class PublicBuildingForm(forms.ModelForm): 'code': forms.TextInput(attrs={'class': 'form-control'}), 'name_fr': 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'}), 'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), 'owner': forms.TextInput(attrs={'class': 'form-control'}), @@ -305,6 +310,8 @@ class PublicBuildingForm(forms.ModelForm): 'basement_count': forms.NumberInput(attrs={'class': 'form-control'}), 'total_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'}), 'erp_category': 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) 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 @@ -332,10 +341,51 @@ class PublicBuildingForm(forms.ModelForm): 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 \ No newline at end of file + 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) \ No newline at end of file diff --git a/streetup/assets/migrations/0006_remove_buildinglevel_building_and_more.py b/streetup/assets/migrations/0006_remove_buildinglevel_building_and_more.py new file mode 100644 index 0000000..65d809e --- /dev/null +++ b/streetup/assets/migrations/0006_remove_buildinglevel_building_and_more.py @@ -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', + ), + ] diff --git a/streetup/assets/models/__init__.py b/streetup/assets/models/__init__.py index d98e884..9e189b9 100644 --- a/streetup/assets/models/__init__.py +++ b/streetup/assets/models/__init__.py @@ -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 .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract -from .buildings import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument \ No newline at end of file +from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument \ No newline at end of file diff --git a/streetup/assets/models/buildings.py b/streetup/assets/models/buildings.py index 892a551..07ff187 100644 --- a/streetup/assets/models/buildings.py +++ b/streetup/assets/models/buildings.py @@ -7,7 +7,7 @@ from django.urls import reverse from django.utils.translation import gettext_lazy as _ from django.contrib.auth.models import User -from assets.models import AbstractAsset, AbstractGeoAsset, AbstractAssetModel, ASSET_STATUS_CHOICES +from assets.models import AbstractLocation, AbstractAsset, AbstractGeoAsset, AbstractAssetModel, ASSET_STATUS_CHOICES from common.models import Document from common.storages import PrivateMediaStorage @@ -76,23 +76,43 @@ ERP_TYPE_CHOICES = ( ('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')), ('elevator', _('Ascenseur / Monte-charge')), + ('electricity', _('Électricité / TGBT')), ('fire_safety', _('Sécurité incendie / Extincteur / RIA')), - ('electricity', _('Tableau électrique / TGBT')), ('alarm', _('Alarme / Contrôle d\'accès')), ('meter', _('Compteur (Eau / Gaz / Électricité)')), ('sanitary', _('Plomberie / Sanitaires')), - ('other', _('Autre équipement technique')), + ('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")) + 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")) 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")) 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²)")) + 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 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")) class Meta: - verbose_name = _("Bâtiment public") - verbose_name_plural = _("Bâtiments publics") + verbose_name = _("Localisation Bâtiment public") + verbose_name_plural = _("Localisations Bâtiments publics") ordering = ['code', 'name_fr'] def get_absolute_url(self): @@ -143,63 +166,65 @@ class PublicBuilding(AbstractGeoAsset): return f"Administratif ({terms['admin_building']})" 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")) - code = models.CharField(max_length=50, verbose_name=_("Code du niveau")) - name = models.CharField(max_length=200, verbose_name=_("Nom du niveau")) - level_number = models.IntegerField(default=0, verbose_name=_("Numéro d'étage (0=RDC, 1=1er, -1=Sous-sol)")) - floor_plan = models.FileField(upload_to='buildings/plans/%Y/%m/', storage=private_storage, null=True, blank=True, verbose_name=_("Plan d'étage")) - - class Meta: - verbose_name = _("Niveau / Étage") - verbose_name_plural = _("Niveaux / Étages") - ordering = ['building', 'level_number'] - - def __str__(self): - return f"{self.building.code} - {self.name} ({self.level_number})" - - -class BuildingRoom(models.Model): - """ - Modèle représentant une pièce / un local dans un bâtiment. - """ - building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="rooms", verbose_name=_("Bâtiment")) - level = models.ForeignKey(BuildingLevel, on_delete=models.SET_NULL, null=True, blank=True, related_name="rooms", verbose_name=_("Niveau / Étage")) - code = models.CharField(max_length=50, verbose_name=_("Code du local")) - name = models.CharField(max_length=200, verbose_name=_("Nom de la pièce")) - room_type = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Type de local (ex: Bureau, Classe, Sanitaires)")) - surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface (m²)")) - - class Meta: - verbose_name = _("Pièce / Local") - verbose_name_plural = _("Pièces / Locaux") - ordering = ['building', 'code'] - - def __str__(self): - return f"{self.building.code} - {self.code} ({self.name})" - - -class BuildingEquipment(AbstractAsset): - """ - Modèle représentant un équipement technique hébergé dans un bâtiment. - """ - building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="equipments", verbose_name=_("Bâtiment")) - room = models.ForeignKey(BuildingRoom, on_delete=models.SET_NULL, null=True, blank=True, related_name="equipments", verbose_name=_("Local")) - equipment_type = models.CharField(max_length=50, choices=EQUIPMENT_TYPE_CHOICES, default='hvac', verbose_name=_("Type d'équipement")) + 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")) last_control_date = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle réglementaire")) next_control_date = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle réglementaire")) class Meta: - verbose_name = _("Équipement technique de bâtiment") - verbose_name_plural = _("Équipements techniques de bâtiment") + verbose_name = _("Asset / Équipement de bâtiment") + verbose_name_plural = _("Assets / Équipements de bâtiment") ordering = ['building', 'code'] 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): @@ -217,4 +242,3 @@ class PublicBuildingDocument(Document): class Meta: verbose_name = _("Document de bâtiment") verbose_name_plural = _("Documents de bâtiment") - diff --git a/streetup/assets/permissions.py b/streetup/assets/permissions.py index 3a134d7..1724a46 100644 --- a/streetup/assets/permissions.py +++ b/streetup/assets/permissions.py @@ -316,6 +316,8 @@ def _get_location_thematic(location): 'cleanlocation': 'clean', 'publiclightingstreet': 'publiclighting', 'signstreet': 'sign', + 'publicbuilding': 'publicbuildings', + 'buildinglocation': 'publicbuildings', } thematic_code = thematic_mappings.get(class_name) if not thematic_code: diff --git a/streetup/assets/static/assets/unified_index.js b/streetup/assets/static/assets/unified_index.js index 733a369..fab0123 100644 --- a/streetup/assets/static/assets/unified_index.js +++ b/streetup/assets/static/assets/unified_index.js @@ -38,7 +38,7 @@ var locationsFetchSignatures = {}; var assetsFetchSignatures = {}; // Assets: thematics that always load all assets (small datasets) -const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters', '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 var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox' diff --git a/streetup/assets/templates/assets/buildings/publicbuilding_asset_tree.html b/streetup/assets/templates/assets/buildings/publicbuilding_asset_tree.html new file mode 100644 index 0000000..989e22b --- /dev/null +++ b/streetup/assets/templates/assets/buildings/publicbuilding_asset_tree.html @@ -0,0 +1,38 @@ +{% load i18n %} + diff --git a/streetup/assets/templates/assets/buildings/publicbuilding_detail.html b/streetup/assets/templates/assets/buildings/publicbuilding_detail.html index 7d62db1..572ca3d 100644 --- a/streetup/assets/templates/assets/buildings/publicbuilding_detail.html +++ b/streetup/assets/templates/assets/buildings/publicbuilding_detail.html @@ -75,7 +75,7 @@