feat: add publicbuildings thematic, models, views, GeoJSON endpoints and unified assets map integration
This commit is contained in:
parent
f3207e946c
commit
24355b2e2f
15 changed files with 1046 additions and 21 deletions
|
|
@ -648,6 +648,18 @@ admin.site.register(CleanGlassContainer, AssetAdmin)
|
|||
admin.site.register(CleanLocationContract, AssetAdmin)
|
||||
|
||||
from .models import CleanLitterBinPhoto, CleanLitterBinObservation
|
||||
from .models import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument
|
||||
|
||||
admin.site.register(CleanLitterBinPhoto, AssetAdmin)
|
||||
admin.site.register(CleanLitterBinObservation, AssetAdmin)
|
||||
admin.site.register(CleanLitterBinObservation, AssetAdmin)
|
||||
|
||||
@admin.register(PublicBuilding)
|
||||
class PublicBuildingAdmin(admin.GISModelAdmin):
|
||||
list_display = ("code", "name_fr", "usage_type", "building_status", "owner", "manager", "is_erp", "dpe_rating")
|
||||
list_filter = ("usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible")
|
||||
search_fields = ("code", "name_fr", "name_nl", "address", "owner")
|
||||
|
||||
admin.site.register(BuildingLevel, AssetAdmin)
|
||||
admin.site.register(BuildingRoom, AssetAdmin)
|
||||
admin.site.register(BuildingEquipment, AssetAdmin)
|
||||
admin.site.register(PublicBuildingDocument, AssetAdmin)
|
||||
|
|
@ -0,0 +1,171 @@
|
|||
# Generated by Django 6.0.7 on 2026-07-26 13:20
|
||||
|
||||
import assets.models.buildings
|
||||
import common.storages
|
||||
import django.contrib.gis.db.models.fields
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0004_initial'),
|
||||
('documents', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='BuildingLevel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=50, verbose_name='Code du niveau')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Nom du niveau')),
|
||||
('level_number', models.IntegerField(default=0, verbose_name="Numéro d'étage (0=RDC, 1=1er, -1=Sous-sol)")),
|
||||
('floor_plan', models.FileField(blank=True, null=True, storage=common.storages.PrivateMediaStorage(), upload_to='buildings/plans/%Y/%m/', verbose_name="Plan d'étage")),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Niveau / Étage',
|
||||
'verbose_name_plural': 'Niveaux / Étages',
|
||||
'ordering': ['building', 'level_number'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PublicBuilding',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=50)),
|
||||
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
|
||||
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
|
||||
('installation_date', models.DateTimeField(blank=True, null=True)),
|
||||
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
|
||||
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
|
||||
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
|
||||
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
|
||||
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
|
||||
('last_survey_date', models.DateTimeField(blank=True, null=True)),
|
||||
('brand', models.CharField(blank=True, max_length=500, null=True)),
|
||||
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
|
||||
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
|
||||
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('lon', models.FloatField(blank=True, null=True)),
|
||||
('lat', models.FloatField(blank=True, null=True)),
|
||||
('geojson', models.TextField(blank=True, null=True)),
|
||||
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Emprise cartographique')),
|
||||
('address', models.CharField(blank=True, max_length=500, null=True, verbose_name='Adresse complète')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
|
||||
('owner', models.CharField(blank=True, max_length=255, null=True, verbose_name='Propriétaire')),
|
||||
('occupant_entity', models.CharField(blank=True, max_length=255, null=True, verbose_name='Direction / Service occupant')),
|
||||
('usage_type', models.CharField(choices=[('administrative', 'Administratif / Mairie'), ('education', 'Enseignement / École / Crèche'), ('sports', 'Sport & Équipement sportif'), ('culture', 'Culture / Médiathèque / Musée'), ('social', 'Social / Centre communautaire'), ('technical', 'Technique / Ateliers / Stock'), ('housing', 'Logement communal'), ('other', 'Autre bâtiment public')], default='administrative', max_length=50, verbose_name="Type d'usage principal")),
|
||||
('building_status', models.CharField(choices=[('in_service', 'En service'), ('under_renovation', 'En travaux / Rénovation'), ('decommissioned', 'Désaffecté'), ('ceded', 'Cédé / Vendu')], default='in_service', max_length=50, verbose_name="Statut d'occupation")),
|
||||
('construction_year', models.IntegerField(blank=True, null=True, verbose_name='Année de construction')),
|
||||
('floors_count', models.IntegerField(default=1, verbose_name="Nombre d'étages hors-sol")),
|
||||
('basement_count', models.IntegerField(default=0, verbose_name='Nombre de niveaux en sous-sol')),
|
||||
('total_surface_m2', models.FloatField(blank=True, null=True, verbose_name='Surface brute totale (m²)')),
|
||||
('usable_surface_m2', models.FloatField(blank=True, null=True, verbose_name='Surface utile (m²)')),
|
||||
('is_erp', models.BooleanField(default=True, verbose_name='Établissement Recevant du Public (ERP)')),
|
||||
('erp_category', models.CharField(blank=True, choices=[('1', '1ère catégorie (> 1500 pers.)'), ('2', '2ème catégorie (301 - 1500 pers.)'), ('3', '3ème catégorie (101 - 300 pers.)'), ('4', "4ème catégorie (jusqu'à 100 pers.)"), ('5', '5ème catégorie (seuils spécifiques)')], max_length=10, null=True, verbose_name='Catégorie ERP')),
|
||||
('erp_type', models.CharField(blank=True, choices=[('J', "J - Structures d'accueil pour personnes âgées ou handicapées"), ('L', 'L - Salles de spectacles, de réunions, polyvalentes'), ('M', 'M - Magasins, centres commerciaux'), ('N', 'N - Restaurants et débits de boissons'), ('O', 'O - Hôtels et pensions de famille'), ('P', 'P - Salles de danse et salles de jeux'), ('R', "R - Établissements d'éveil, d'enseignement, colonies de vacances"), ('S', 'S - Bibliothèques, centres de documentation'), ('T', "T - Salles d'expositions"), ('U', 'U - Établissements de soins'), ('V', 'V - Établissements de culte'), ('W', 'W - Administrations, banques, bureaux'), ('X', 'X - Établissements sportifs couverts'), ('Y', 'Y - Musées')], max_length=10, null=True, verbose_name='Type ERP')),
|
||||
('max_capacity', models.IntegerField(blank=True, null=True, verbose_name="Jauge maximale d'accueil du public")),
|
||||
('dpe_rating', models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D'), ('E', 'E'), ('F', 'F'), ('G', 'G')], max_length=2, null=True, verbose_name='Diagnostic de Performance Énergétique (DPE)')),
|
||||
('ges_rating', models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D'), ('E', 'E'), ('F', 'F'), ('G', 'G')], max_length=2, null=True, verbose_name='Émission de Gaz à Effet de Serre (GES)')),
|
||||
('has_asbestos_diag', models.BooleanField(default=False, verbose_name='Diagnostic Amiante réalisé')),
|
||||
('has_lead_diag', models.BooleanField(default=False, verbose_name='Diagnostic Plomb réalisé')),
|
||||
('pmr_accessible', models.BooleanField(default=False, verbose_name='Accessible PMR')),
|
||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
|
||||
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_buildings', to=settings.AUTH_USER_MODEL, verbose_name='Gestionnaire référent')),
|
||||
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
|
||||
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.publicbuilding', verbose_name='Remplacé par')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Bâtiment public',
|
||||
'verbose_name_plural': 'Bâtiments publics',
|
||||
'ordering': ['code', 'name_fr'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BuildingRoom',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=50, verbose_name='Code du local')),
|
||||
('name', models.CharField(max_length=200, verbose_name='Nom de la pièce')),
|
||||
('room_type', models.CharField(blank=True, max_length=100, null=True, verbose_name='Type de local (ex: Bureau, Classe, Sanitaires)')),
|
||||
('surface_m2', models.FloatField(blank=True, null=True, verbose_name='Surface (m²)')),
|
||||
('level', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rooms', to='assets.buildinglevel', verbose_name='Niveau / Étage')),
|
||||
('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rooms', to='assets.publicbuilding', verbose_name='Bâtiment')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Pièce / Local',
|
||||
'verbose_name_plural': 'Pièces / Locaux',
|
||||
'ordering': ['building', 'code'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='buildinglevel',
|
||||
name='building',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='levels', to='assets.publicbuilding', verbose_name='Bâtiment'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='BuildingEquipment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('code', models.CharField(max_length=50)),
|
||||
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
|
||||
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
|
||||
('installation_date', models.DateTimeField(blank=True, null=True)),
|
||||
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
|
||||
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
|
||||
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
|
||||
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
|
||||
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
|
||||
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
|
||||
('last_survey_date', models.DateTimeField(blank=True, null=True)),
|
||||
('brand', models.CharField(blank=True, max_length=500, null=True)),
|
||||
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
|
||||
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
|
||||
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('equipment_type', models.CharField(choices=[('hvac', 'Chauffage / CVC / Chaudière'), ('elevator', 'Ascenseur / Monte-charge'), ('fire_safety', 'Sécurité incendie / Extincteur / RIA'), ('electricity', 'Tableau électrique / TGBT'), ('alarm', "Alarme / Contrôle d'accès"), ('meter', 'Compteur (Eau / Gaz / Électricité)'), ('sanitary', 'Plomberie / Sanitaires'), ('other', 'Autre équipement technique')], default='hvac', max_length=50, verbose_name="Type d'équipement")),
|
||||
('last_control_date', models.DateField(blank=True, null=True, verbose_name='Dernier contrôle réglementaire')),
|
||||
('next_control_date', models.DateField(blank=True, null=True, verbose_name='Prochain contrôle réglementaire')),
|
||||
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
|
||||
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
|
||||
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.buildingequipment', verbose_name='Remplacé par')),
|
||||
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
|
||||
('room', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipments', to='assets.buildingroom', verbose_name='Local')),
|
||||
('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='equipments', to='assets.publicbuilding', verbose_name='Bâtiment')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Équipement technique de bâtiment',
|
||||
'verbose_name_plural': 'Équipements techniques de bâtiment',
|
||||
'ordering': ['building', 'code'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PublicBuildingDocument',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('description', models.TextField(blank=True, null=True, verbose_name='Description du document')),
|
||||
('upload_date', models.DateTimeField(auto_now=True, verbose_name='Date de téléversement')),
|
||||
('status', models.CharField(choices=[('active', 'Actif'), ('archived', 'Archivé')], default='active')),
|
||||
('file', models.FileField(max_length=255, storage=common.storages.PrivateMediaStorage(), upload_to=assets.models.buildings.upload_to_building, verbose_name='Fichier')),
|
||||
('document_type', models.CharField(default='other', max_length=50, verbose_name='Type de document')),
|
||||
('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.publicbuilding', verbose_name='Bâtiment')),
|
||||
('managed_document', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_sources', to='documents.manageddocument')),
|
||||
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Document de bâtiment',
|
||||
'verbose_name_plural': 'Documents de bâtiment',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -9,4 +9,5 @@ from .controlcenters import ControlCenter, ControlCenterLocation, ControlCenterG
|
|||
from .sign import SignStreet, SignStreetDocument, SignPole, SignPoleModel, SignPanel, SignPanelModel, SignPanelType, ComplementaryPoliceRegulation, SignParkingSpot
|
||||
from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSidewalk, RoadSidewalkModel, RoadFurniture, RoadFurnitureModel, RoadwayFunction, RoadConcreteBlockSet, RoadDrainModel, RoadDrain
|
||||
from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract
|
||||
from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract
|
||||
from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract
|
||||
from .buildings import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument
|
||||
197
streetup/assets/models/buildings.py
Normal file
197
streetup/assets/models/buildings.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
# -----------------------------
|
||||
# Public Buildings Models
|
||||
# -----------------------------
|
||||
|
||||
from django.contrib.gis.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.contrib.auth.models import User
|
||||
|
||||
from assets.models import AbstractAsset, AbstractGeoAsset, AbstractAssetModel, ASSET_STATUS_CHOICES
|
||||
from common.models import Document
|
||||
from common.storages import PrivateMediaStorage
|
||||
|
||||
private_storage = PrivateMediaStorage()
|
||||
|
||||
USAGE_TYPE_CHOICES = (
|
||||
('administrative', _('Administratif / Mairie')),
|
||||
('education', _('Enseignement / École / Crèche')),
|
||||
('sports', _('Sport & Équipement sportif')),
|
||||
('culture', _('Culture / Médiathèque / Musée')),
|
||||
('social', _('Social / Centre communautaire')),
|
||||
('technical', _('Technique / Ateliers / Stock')),
|
||||
('housing', _('Logement communal')),
|
||||
('other', _('Autre bâtiment public')),
|
||||
)
|
||||
|
||||
BUILDING_STATUS_CHOICES = (
|
||||
('in_service', _('En service')),
|
||||
('under_renovation', _('En travaux / Rénovation')),
|
||||
('decommissioned', _('Désaffecté')),
|
||||
('ceded', _('Cédé / Vendu')),
|
||||
)
|
||||
|
||||
DPE_RATING_CHOICES = (
|
||||
('A', 'A'),
|
||||
('B', 'B'),
|
||||
('C', 'C'),
|
||||
('D', 'D'),
|
||||
('E', 'E'),
|
||||
('F', 'F'),
|
||||
('G', 'G'),
|
||||
)
|
||||
|
||||
ERP_CATEGORY_CHOICES = (
|
||||
('1', _('1ère catégorie (> 1500 pers.)')),
|
||||
('2', _('2ème catégorie (301 - 1500 pers.)')),
|
||||
('3', _('3ème catégorie (101 - 300 pers.)')),
|
||||
('4', _('4ème catégorie (jusqu\'à 100 pers.)')),
|
||||
('5', _('5ème catégorie (seuils spécifiques)')),
|
||||
)
|
||||
|
||||
ERP_TYPE_CHOICES = (
|
||||
('J', _('J - Structures d\'accueil pour personnes âgées ou handicapées')),
|
||||
('L', _('L - Salles de spectacles, de réunions, polyvalentes')),
|
||||
('M', _('M - Magasins, centres commerciaux')),
|
||||
('N', _('N - Restaurants et débits de boissons')),
|
||||
('O', _('O - Hôtels et pensions de famille')),
|
||||
('P', _('P - Salles de danse et salles de jeux')),
|
||||
('R', _('R - Établissements d\'éveil, d\'enseignement, colonies de vacances')),
|
||||
('S', _('S - Bibliothèques, centres de documentation')),
|
||||
('T', _('T - Salles d\'expositions')),
|
||||
('U', _('U - Établissements de soins')),
|
||||
('V', _('V - Établissements de culte')),
|
||||
('W', _('W - Administrations, banques, bureaux')),
|
||||
('X', _('X - Établissements sportifs couverts')),
|
||||
('Y', _('Y - Musées')),
|
||||
)
|
||||
|
||||
EQUIPMENT_TYPE_CHOICES = (
|
||||
('hvac', _('Chauffage / CVC / Chaudière')),
|
||||
('elevator', _('Ascenseur / Monte-charge')),
|
||||
('fire_safety', _('Sécurité incendie / Extincteur / RIA')),
|
||||
('electricity', _('Tableau électrique / TGBT')),
|
||||
('alarm', _('Alarme / Contrôle d\'accès')),
|
||||
('meter', _('Compteur (Eau / Gaz / Électricité)')),
|
||||
('sanitary', _('Plomberie / Sanitaires')),
|
||||
('other', _('Autre équipement technique')),
|
||||
)
|
||||
|
||||
|
||||
class PublicBuilding(AbstractGeoAsset):
|
||||
"""
|
||||
Modèle représentant un Bâtiment Public (Patrimoine bâti).
|
||||
"""
|
||||
geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise cartographique"))
|
||||
address = models.CharField(max_length=500, null=True, blank=True, verbose_name=_("Adresse complète"))
|
||||
description = models.TextField(null=True, blank=True, verbose_name=_("Description"))
|
||||
|
||||
# Propriété & Gestion
|
||||
owner = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Propriétaire"))
|
||||
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="managed_buildings", verbose_name=_("Gestionnaire référent"))
|
||||
occupant_entity = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Direction / Service occupant"))
|
||||
|
||||
# Usage & Statut
|
||||
usage_type = models.CharField(max_length=50, choices=USAGE_TYPE_CHOICES, default='administrative', verbose_name=_("Type d'usage principal"))
|
||||
building_status = models.CharField(max_length=50, choices=BUILDING_STATUS_CHOICES, default='in_service', verbose_name=_("Statut d'occupation"))
|
||||
|
||||
# Caractéristiques physiques
|
||||
construction_year = models.IntegerField(null=True, blank=True, verbose_name=_("Année de construction"))
|
||||
floors_count = models.IntegerField(default=1, verbose_name=_("Nombre d'étages hors-sol"))
|
||||
basement_count = models.IntegerField(default=0, verbose_name=_("Nombre de niveaux en sous-sol"))
|
||||
total_surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface brute totale (m²)"))
|
||||
usable_surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface utile (m²)"))
|
||||
|
||||
# Réglementation ERP
|
||||
is_erp = models.BooleanField(default=True, verbose_name=_("Établissement Recevant du Public (ERP)"))
|
||||
erp_category = models.CharField(max_length=10, choices=ERP_CATEGORY_CHOICES, null=True, blank=True, verbose_name=_("Catégorie ERP"))
|
||||
erp_type = models.CharField(max_length=10, choices=ERP_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Type ERP"))
|
||||
max_capacity = models.IntegerField(null=True, blank=True, verbose_name=_("Jauge maximale d'accueil du public"))
|
||||
|
||||
# Diagnostics & Énergie
|
||||
dpe_rating = models.CharField(max_length=2, choices=DPE_RATING_CHOICES, null=True, blank=True, verbose_name=_("Diagnostic de Performance Énergétique (DPE)"))
|
||||
ges_rating = models.CharField(max_length=2, choices=DPE_RATING_CHOICES, null=True, blank=True, verbose_name=_("Émission de Gaz à Effet de Serre (GES)"))
|
||||
has_asbestos_diag = models.BooleanField(default=False, verbose_name=_("Diagnostic Amiante réalisé"))
|
||||
has_lead_diag = models.BooleanField(default=False, verbose_name=_("Diagnostic Plomb réalisé"))
|
||||
pmr_accessible = models.BooleanField(default=False, verbose_name=_("Accessible PMR"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Bâtiment public")
|
||||
verbose_name_plural = _("Bâtiments publics")
|
||||
ordering = ['code', 'name_fr']
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('assets:publicbuilding_detail', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
class BuildingLevel(models.Model):
|
||||
"""
|
||||
Modèle représentant un niveau / étage au sein d'un bâtiment public.
|
||||
"""
|
||||
building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="levels", verbose_name=_("Bâtiment"))
|
||||
code = models.CharField(max_length=50, verbose_name=_("Code du niveau"))
|
||||
name = models.CharField(max_length=200, verbose_name=_("Nom du niveau"))
|
||||
level_number = models.IntegerField(default=0, verbose_name=_("Numéro d'étage (0=RDC, 1=1er, -1=Sous-sol)"))
|
||||
floor_plan = models.FileField(upload_to='buildings/plans/%Y/%m/', storage=private_storage, null=True, blank=True, verbose_name=_("Plan d'étage"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Niveau / Étage")
|
||||
verbose_name_plural = _("Niveaux / Étages")
|
||||
ordering = ['building', 'level_number']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.building.code} - {self.name} ({self.level_number})"
|
||||
|
||||
|
||||
class BuildingRoom(models.Model):
|
||||
"""
|
||||
Modèle représentant une pièce / un local dans un bâtiment.
|
||||
"""
|
||||
building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="rooms", verbose_name=_("Bâtiment"))
|
||||
level = models.ForeignKey(BuildingLevel, on_delete=models.SET_NULL, null=True, blank=True, related_name="rooms", verbose_name=_("Niveau / Étage"))
|
||||
code = models.CharField(max_length=50, verbose_name=_("Code du local"))
|
||||
name = models.CharField(max_length=200, verbose_name=_("Nom de la pièce"))
|
||||
room_type = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Type de local (ex: Bureau, Classe, Sanitaires)"))
|
||||
surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface (m²)"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Pièce / Local")
|
||||
verbose_name_plural = _("Pièces / Locaux")
|
||||
ordering = ['building', 'code']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.building.code} - {self.code} ({self.name})"
|
||||
|
||||
|
||||
class BuildingEquipment(AbstractAsset):
|
||||
"""
|
||||
Modèle représentant un équipement technique hébergé dans un bâtiment.
|
||||
"""
|
||||
building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="equipments", verbose_name=_("Bâtiment"))
|
||||
room = models.ForeignKey(BuildingRoom, on_delete=models.SET_NULL, null=True, blank=True, related_name="equipments", verbose_name=_("Local"))
|
||||
equipment_type = models.CharField(max_length=50, choices=EQUIPMENT_TYPE_CHOICES, default='hvac', verbose_name=_("Type d'équipement"))
|
||||
last_control_date = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle réglementaire"))
|
||||
next_control_date = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle réglementaire"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Équipement technique de bâtiment")
|
||||
verbose_name_plural = _("Équipements techniques de bâtiment")
|
||||
ordering = ['building', 'code']
|
||||
|
||||
|
||||
def upload_to_building(instance, filename):
|
||||
return f"buildings/{instance.building.code}/{filename}"
|
||||
|
||||
|
||||
class PublicBuildingDocument(Document):
|
||||
"""
|
||||
Documents associés à un bâtiment public (plans, notices ERP, DPE).
|
||||
"""
|
||||
building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="documents", verbose_name=_("Bâtiment"))
|
||||
file = models.FileField(upload_to=upload_to_building, storage=private_storage, verbose_name=_("Fichier"), max_length=255)
|
||||
document_type = models.CharField(max_length=50, default='other', verbose_name=_("Type de document"))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("Document de bâtiment")
|
||||
verbose_name_plural = _("Documents de bâtiment")
|
||||
|
||||
|
|
@ -588,7 +588,9 @@
|
|||
'controlcenters': 'controlcentergeoasset',
|
||||
'its': 'itsgeoasset',
|
||||
'sign': 'signgeoasset',
|
||||
'roads': 'roadgeoasset'
|
||||
'roads': 'roadgeoasset',
|
||||
'publicbuildings': 'publicbuilding',
|
||||
'buildings': 'publicbuilding'
|
||||
};
|
||||
return mapping[thematicCode] || 'geoasset';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ var locationsFetchSignatures = {};
|
|||
var assetsFetchSignatures = {};
|
||||
|
||||
// Assets: thematics that always load all assets (small datasets)
|
||||
const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters']);
|
||||
const ALWAYS_LOAD_ASSETS_THEMATICS = new Set(['artworks', 'controlcenters', 'publicbuildings', 'buildings']);
|
||||
const MIN_ASSETS_ZOOM = 14; // zoom level at which bbox-based asset loading triggers
|
||||
var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' | 'bbox'
|
||||
|
||||
|
|
|
|||
|
|
@ -209,6 +209,8 @@ function loadUnifiedMapIcons(map) {
|
|||
{ name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' }, // water — bleu fontaine
|
||||
{ name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' }, // person-standing — statue
|
||||
{ name: 'icon-controlcenters', codepoint: 'f301', color: '#7b1fa2' }, // display-fill — violet
|
||||
{ name: 'icon-publicbuildings', codepoint: 'f1ab', color: '#2B579A' }, // building — bleu nuit
|
||||
{ name: 'icon-publicbuilding', codepoint: 'f1ab', color: '#2B579A' }, // building — bleu nuit
|
||||
{ name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' }, // map-fill — bleu-gris
|
||||
];
|
||||
|
||||
|
|
@ -285,6 +287,8 @@ const THEMATIC_COLOR_EXPR = [
|
|||
'clean', '#5e5e5e', // grey — cleanliness
|
||||
'artworks', '#f24141', // coral — artworks / monuments
|
||||
'controlcenters', '#7b1fa2', // purple — control centres
|
||||
'publicbuildings', '#2B579A', // navy blue — public buildings
|
||||
'buildings', '#2B579A', // navy blue — public buildings
|
||||
'roads', [
|
||||
'match', ['get', 'asset_type'],
|
||||
'roadway', '#4a4a4a',
|
||||
|
|
@ -508,6 +512,8 @@ const _unified_assets_symbol = {
|
|||
'clean', 'icon-clean',
|
||||
'artworks', 'icon-artworks',
|
||||
'controlcenters', 'icon-controlcenters',
|
||||
'publicbuildings', 'icon-publicbuildings',
|
||||
'publicbuilding', 'icon-publicbuilding',
|
||||
'roads', 'icon-roads',
|
||||
'icon-roads' // fallback
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,193 @@
|
|||
{% extends "home/base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{{ building.code }} - {{ building.get_name }} | StreetUp{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Breadcrumb & Actions -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb mb-0">
|
||||
<li class="breadcrumb-item"><a href="{% url 'assets:buildings_index' %}">{% translate "Bâtiments publics" %}</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{ building.code }}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div>
|
||||
<a href="{% url 'admin:assets_publicbuilding_change' building.pk %}" class="btn btn-outline-secondary btn-sm">
|
||||
<i class="bi bi-pencil me-1"></i>{% translate "Modifier dans l'admin" %}
|
||||
</a>
|
||||
<a href="{% url 'assets:buildings_index' %}" class="btn btn-secondary btn-sm">
|
||||
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la liste" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Card -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<h2 class="h4 mb-1">
|
||||
<i class="bi bi-building text-primary me-2"></i>{{ building.code }} - {{ building.get_name }}
|
||||
</h2>
|
||||
<p class="text-muted mb-2"><i class="bi bi-geo-alt me-1"></i>{{ building.address|default:_("Adresse non renseignée") }}</p>
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<span class="badge bg-primary-subtle text-primary border">{{ building.get_usage_type_display }}</span>
|
||||
<span class="badge bg-success-subtle text-success border">{{ building.get_building_status_display }}</span>
|
||||
{% if building.is_erp %}
|
||||
<span class="badge bg-info-subtle text-info border">ERP (Catégorie {{ building.erp_category|default:"-" }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4 text-md-end mt-3 mt-md-0">
|
||||
{% if building.dpe_rating %}
|
||||
<div class="d-inline-block text-center me-3">
|
||||
<span class="d-block text-muted small">DPE</span>
|
||||
<span class="badge bg-success fs-5 px-3 py-2">Classe {{ building.dpe_rating }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if building.ges_rating %}
|
||||
<div class="d-inline-block text-center">
|
||||
<span class="d-block text-muted small">GES</span>
|
||||
<span class="badge bg-primary fs-5 px-3 py-2">Classe {{ building.ges_rating }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tabs Navigation -->
|
||||
<ul class="nav nav-tabs mb-4" id="buildingTabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab"><i class="bi bi-info-circle me-1"></i>{% translate "Vue d'ensemble" %}</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="levels-tab" data-bs-toggle="tab" data-bs-target="#levels" type="button" role="tab"><i class="bi bg-layers me-1"></i>{% translate "Étages & Pièces" %} ({{ levels.count }})</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="equipments-tab" data-bs-toggle="tab" data-bs-target="#equipments" type="button" role="tab"><i class="bi bi-gear me-1"></i>{% translate "Équipements techniques" %} ({{ equipments.count }})</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Tabs Content -->
|
||||
<div class="tab-content" id="buildingTabsContent">
|
||||
<!-- Overview Tab -->
|
||||
<div class="tab-pane fade show active" id="overview" role="tabpanel">
|
||||
<div class="row g-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light fw-bold"><i class="bi bi-person-badge me-2"></i>{% translate "Propriété & Responsables" %}</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-borderless mb-0">
|
||||
<tr>
|
||||
<th class="w-50 text-muted">{% translate "Propriétaire" %} :</th>
|
||||
<td>{{ building.owner|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-muted">{% translate "Gestionnaire référent" %} :</th>
|
||||
<td>{{ building.manager.get_full_name|default:building.manager.username|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-muted">{% translate "Service occupant" %} :</th>
|
||||
<td>{{ building.occupant_entity|default:"-" }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm h-100">
|
||||
<div class="card-header bg-light fw-bold"><i class="bi bi-rulers me-2"></i>{% translate "Caractéristiques physiques & ERP" %}</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-borderless mb-0">
|
||||
<tr>
|
||||
<th class="w-50 text-muted">{% translate "Année de construction" %} :</th>
|
||||
<td>{{ building.construction_year|default:"-" }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-muted">{% translate "Étages / Sous-sols" %} :</th>
|
||||
<td>{{ building.floors_count }} étage(s) / {{ building.basement_count }} sous-sol(s)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-muted">{% translate "Surface Utile / Brut" %} :</th>
|
||||
<td>{{ building.usable_surface_m2|default:"-" }} m² / {{ building.total_surface_m2|default:"-" }} m²</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-muted">{% translate "Jauge ERP" %} :</th>
|
||||
<td>{{ building.max_capacity|default:"-" }} personnes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th class="text-muted">{% translate "Accessibilité PMR" %} :</th>
|
||||
<td>{% if building.pmr_accessible %}<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>Oui</span>{% else %}<span class="text-muted">Non</span>{% endif %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Levels & Rooms Tab -->
|
||||
<div class="tab-pane fade" id="levels" role="tabpanel">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
{% for level in levels %}
|
||||
<div class="mb-4 pb-3 border-bottom">
|
||||
<h5 class="h6 text-primary"><i class="bi bi-layers me-2"></i>{{ level.name }} (Étage {{ level.level_number }})</h5>
|
||||
<div class="row row-cols-1 row-cols-md-3 g-2 mt-2">
|
||||
{% for room in level.rooms.all %}
|
||||
<div class="col">
|
||||
<div class="p-2 border rounded bg-light">
|
||||
<div class="fw-bold">{{ room.code }} - {{ room.name }}</div>
|
||||
<div class="small text-muted">{{ room.room_type|default:"" }} {% if room.surface_m2 %}({{ room.surface_m2 }} m²){% endif %}</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12 text-muted small">{% translate "Aucune pièce enregistrée pour ce niveau." %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<p class="text-muted text-center py-4 mb-0">{% translate "Aucun niveau enregistré pour ce bâtiment." %}</p>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equipments Tab -->
|
||||
<div class="tab-pane fade" id="equipments" role="tabpanel">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% translate "Code / Nom" %}</th>
|
||||
<th>{% translate "Type d'équipement" %}</th>
|
||||
<th>{% translate "Local" %}</th>
|
||||
<th>{% translate "Prochain contrôle" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for eq in equipments %}
|
||||
<tr>
|
||||
<td><strong>{{ eq.code }}</strong> - {{ eq.get_name }}</td>
|
||||
<td>{{ eq.get_equipment_type_display }}</td>
|
||||
<td>{{ eq.room.name|default:"-" }}</td>
|
||||
<td>{{ eq.next_control_date|default:"-" }}</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">{% translate "Aucun équipement technique enregistré." %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
{% extends "home/base.html" %}
|
||||
{% load static i18n %}
|
||||
|
||||
{% block title %}{% translate "Bâtiments publics" %} | StreetUp{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid py-4">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h1 class="h3 mb-1"><i class="bi bi-building me-2 text-primary"></i>{% translate "Gestion des Bâtiments Publics" %}</h1>
|
||||
<p class="text-muted mb-0">{% translate "Patrimoine bâti, pièces, équipements techniques et diagnostics ERP" %}</p>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{% url 'admin:assets_publicbuilding_add' %}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-lg me-1"></i>{% translate "Nouveau Bâtiment" %}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters Card -->
|
||||
<div class="card shadow-sm mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small text-muted">{% translate "Recherche" %}</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="q" class="form-control" placeholder="{% translate 'Code, nom, adresse, propriétaire...' %}" value="{{ search_query }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted">{% translate "Usage" %}</label>
|
||||
<select name="usage_type" class="form-select form-select-sm">
|
||||
<option value="">-- {% translate "Tous les usages" %} --</option>
|
||||
{% for key, label in usage_type_choices %}
|
||||
<option value="{{ key }}" {% if selected_usage == key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted">{% translate "Statut" %}</label>
|
||||
<select name="building_status" class="form-select form-select-sm">
|
||||
<option value="">-- {% translate "Tous les statuts" %} --</option>
|
||||
{% for key, label in building_status_choices %}
|
||||
<option value="{{ key }}" {% if selected_status == key %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label small text-muted">{% translate "Diagnostic DPE" %}</label>
|
||||
<select name="dpe_rating" class="form-select form-select-sm">
|
||||
<option value="">-- {% translate "Tous DPE" %} --</option>
|
||||
{% for key, label in dpe_rating_choices %}
|
||||
<option value="{{ key }}" {% if selected_dpe == key %}selected{% endif %}>DPE {{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end gap-2">
|
||||
<button type="submit" class="btn btn-sm btn-primary w-100"><i class="bi bi-filter me-1"></i>{% translate "Filtrer" %}</button>
|
||||
<a href="{% url 'assets:buildings_index' %}" class="btn btn-sm btn-outline-secondary w-100">{% translate "Réinitialiser" %}</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table of Public Buildings -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>{% translate "Code / Nom" %}</th>
|
||||
<th>{% translate "Usage principal" %}</th>
|
||||
<th>{% translate "Propriétaire & Gestionnaire" %}</th>
|
||||
<th>{% translate "ERP" %}</th>
|
||||
<th>{% translate "DPE / GES" %}</th>
|
||||
<th>{% translate "Statut" %}</th>
|
||||
<th class="text-end">{% translate "Actions" %}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for b in buildings %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'assets:publicbuilding_detail' b.pk %}" class="fw-bold text-decoration-none">
|
||||
{{ b.code }}
|
||||
</a>
|
||||
<div class="small text-muted">{{ b.get_name }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary-subtle text-dark border">
|
||||
{{ b.get_usage_type_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if b.owner %}
|
||||
<div class="small"><i class="bi bi-shield-check text-muted me-1"></i><strong>{% translate "Propriétaire" %}:</strong> {{ b.owner }}</div>
|
||||
{% endif %}
|
||||
{% if b.manager %}
|
||||
<div class="small text-muted"><i class="bi bi-person me-1"></i><strong>{% translate "Gestionnaire" %}:</strong> {{ b.manager.get_full_name|default:b.manager.username }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if b.is_erp %}
|
||||
<span class="badge bg-info-subtle text-info border">
|
||||
<i class="bi bi-people me-1"></i>ERP Cat {{ b.erp_category|default:"-" }} ({{ b.erp_type|default:"-" }})
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="badge bg-light text-muted border">{% translate "Non ERP" %}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if b.dpe_rating %}
|
||||
<span class="badge bg-success border me-1">DPE {{ b.dpe_rating }}</span>
|
||||
{% endif %}
|
||||
{% if b.ges_rating %}
|
||||
<span class="badge bg-primary border">GES {{ b.ges_rating }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge {% if b.building_status == 'in_service' %}bg-success-subtle text-success{% else %}bg-warning-subtle text-warning{% endif %} border">
|
||||
{{ b.get_building_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<a href="{% url 'assets:publicbuilding_detail' b.pk %}" class="btn btn-sm btn-outline-primary" title="{% translate 'Voir détails' %}">
|
||||
<i class="bi bi-eye"></i>
|
||||
</a>
|
||||
<a href="{% url 'admin:assets_publicbuilding_change' b.pk %}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Éditer' %}">
|
||||
<i class="bi bi-pencil"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="7" class="text-center py-5 text-muted">
|
||||
<i class="bi bi-building-exclamation fs-1 d-block mb-2"></i>
|
||||
{% translate "Aucun bâtiment public ne correspond à vos critères." %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -728,6 +728,6 @@
|
|||
<script src="{% static 'common/base_script.js' %}"></script>
|
||||
<script src="{% static 'common/base_map.js' %}"></script>
|
||||
<script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
|
||||
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260703c"></script>
|
||||
<script src="{% static 'assets/unified_index.js' %}?v=20260703c"></script>
|
||||
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260726_pb"></script>
|
||||
<script src="{% static 'assets/unified_index.js' %}?v=20260726_pb"></script>
|
||||
{% endblock script %}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ RUN_DXF_TESTS = os.getenv('RUN_DXF_TESTS', '0') == '1'
|
|||
|
||||
from common.models import Thematic, UserConfig, UserThematics
|
||||
from assets.models import NatureLocation, TrafficLightIntersection, TrafficLightRadarModel, TrafficLightRadar, AssetCategory
|
||||
from assets.models.buildings import PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment
|
||||
|
||||
|
||||
|
||||
class NatureLocationsGeoJSONTest(TestCase):
|
||||
def setUp(self):
|
||||
|
|
@ -2376,8 +2379,60 @@ class TrafficLightPoleSignPanelsTest(TestCase):
|
|||
model=radar_pole_model
|
||||
)
|
||||
|
||||
# Verify sorted_signpanels returns an empty list for radar poles
|
||||
self.assertEqual(list(radar_pole.sorted_signpanels), [])
|
||||
|
||||
class PublicBuildingTestCase(TestCase):
|
||||
def setUp(self):
|
||||
self.building = PublicBuilding.objects.create(
|
||||
code="BAT-001",
|
||||
name_fr="Hôtel de Ville",
|
||||
name_nl="Stadhuis",
|
||||
address="Grand-Place 1, 1000 Bruxelles",
|
||||
owner="Ville de Bruxelles",
|
||||
usage_type="administrative",
|
||||
building_status="in_service",
|
||||
is_erp=True,
|
||||
erp_category="1",
|
||||
erp_type="W",
|
||||
dpe_rating="B",
|
||||
ges_rating="A",
|
||||
geom=MultiPolygon(Polygon(((0, 0), (0, 10), (10, 10), (10, 0), (0, 0)))),
|
||||
)
|
||||
|
||||
def test_public_building_creation(self):
|
||||
self.assertEqual(self.building.code, "BAT-001")
|
||||
self.assertEqual(self.building.owner, "Ville de Bruxelles")
|
||||
self.assertTrue(self.building.is_erp)
|
||||
self.assertEqual(self.building.dpe_rating, "B")
|
||||
|
||||
def test_building_levels_and_rooms(self):
|
||||
level = BuildingLevel.objects.create(
|
||||
building=self.building,
|
||||
code="NIV-0",
|
||||
name="Rez-de-chaussée",
|
||||
level_number=0
|
||||
)
|
||||
room = BuildingRoom.objects.create(
|
||||
building=self.building,
|
||||
level=level,
|
||||
code="BUR-101",
|
||||
name="Bureau du Bourgmestre",
|
||||
room_type="Bureau",
|
||||
surface_m2=45.5
|
||||
)
|
||||
self.assertEqual(level.building, self.building)
|
||||
self.assertEqual(room.level, level)
|
||||
self.assertEqual(room.surface_m2, 45.5)
|
||||
|
||||
def test_building_equipment(self):
|
||||
eq = BuildingEquipment.objects.create(
|
||||
building=self.building,
|
||||
code="CVC-001",
|
||||
name_fr="Chaudière principale",
|
||||
equipment_type="hvac"
|
||||
)
|
||||
self.assertEqual(eq.building, self.building)
|
||||
self.assertEqual(eq.equipment_type, "hvac")
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
from django.urls import path
|
||||
|
||||
from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water
|
||||
from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings
|
||||
|
||||
app_name = "assets"
|
||||
urlpatterns = [
|
||||
path("", core.unified_assets_index, name="index"),
|
||||
path("buildings/", buildings.buildings_index, name="buildings_index"),
|
||||
path("buildings/<int:pk>/", buildings.building_detail, name="publicbuilding_detail"),
|
||||
path("export/", export.export_assets, name="export_assets"),
|
||||
|
||||
|
||||
# ------------------------------------------------
|
||||
# Per-instance & per-type permission management
|
||||
# ------------------------------------------------
|
||||
|
|
@ -217,6 +220,11 @@ urlpatterns = [
|
|||
path('water/assets/autocomplete/', water.water_assets_autocomplete, name='water_autocomplete_assets'),
|
||||
path('water/<str:asset_model>/<int:asset_id>/', water.water_assets_detail, name='water_assets_detail'),
|
||||
|
||||
# Public buildings GeoJSON URLs
|
||||
path('publicbuildings/locations/geojson/', buildings.publicbuildings_locations_geojson, name='publicbuildings_locations_geojson'),
|
||||
path('publicbuildings/assets/geojson/', buildings.publicbuildings_assets_geojson, name='publicbuildings_assets_geojson'),
|
||||
|
||||
|
||||
# DXF import URLs
|
||||
path('trafficlights/intersections/<int:intersection_id>/import-dxf/', trafficlights.import_dxf, name='import_dxf'),
|
||||
path('trafficlights/intersections/<int:intersection_id>/stage-dxf/', trafficlights.stage_dxf, name='stage_dxf'),
|
||||
|
|
|
|||
200
streetup/assets/views/buildings.py
Normal file
200
streetup/assets/views/buildings.py
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import json
|
||||
from django.shortcuts import render, get_object_or_404, redirect
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q, F
|
||||
from django.http import JsonResponse
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _, get_language
|
||||
from django.contrib.gis.geos import GEOSGeometry
|
||||
|
||||
from common.models import UserThematics
|
||||
from common.utils import check_thematic_access
|
||||
from assets.models.buildings import (
|
||||
PublicBuilding, BuildingLevel, BuildingRoom, BuildingEquipment, PublicBuildingDocument,
|
||||
USAGE_TYPE_CHOICES, BUILDING_STATUS_CHOICES, DPE_RATING_CHOICES, ERP_CATEGORY_CHOICES
|
||||
)
|
||||
|
||||
|
||||
def publicbuildings_locations_geojson(request):
|
||||
"""
|
||||
Vue GeoJSON pour les emplacements des bâtiments publics (vide car les bâtiments sont gérés directement comme assets).
|
||||
"""
|
||||
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||||
|
||||
|
||||
def publicbuildings_assets_geojson(request):
|
||||
"""
|
||||
Vue GeoJSON renvoyant la collection de bâtiments publics pour la carte unifiée des assets.
|
||||
"""
|
||||
lang = get_language() if get_language() in ('fr', 'nl') else 'fr'
|
||||
|
||||
if request.method == "POST":
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({"error": "Invalid JSON"}, status=400)
|
||||
|
||||
statuses = data.get('asset_status', [])
|
||||
categories = data.get('category') or []
|
||||
if isinstance(categories, str):
|
||||
categories = [categories]
|
||||
|
||||
filter_geom_raw = data.get("filter_geom")
|
||||
if filter_geom_raw:
|
||||
try:
|
||||
filter_geom = GEOSGeometry(json.dumps(filter_geom_raw['geometry']), srid=4326)
|
||||
filter_geom.transform(3812)
|
||||
except Exception as e:
|
||||
return JsonResponse({"error": f"Invalid geometry: {str(e)}"}, status=400)
|
||||
else:
|
||||
filter_geom = None
|
||||
search_q = (data.get('q') or '').strip()
|
||||
else:
|
||||
statuses = request.GET.getlist('asset_status')
|
||||
categories = request.GET.getlist('category', [])
|
||||
filter_geom = None
|
||||
search_q = request.GET.get('q', '').strip()
|
||||
|
||||
qs = PublicBuilding.objects.all()
|
||||
|
||||
if categories:
|
||||
qs = qs.filter(category__id__in=categories)
|
||||
|
||||
if statuses:
|
||||
qs = qs.filter(building_status__in=statuses)
|
||||
|
||||
if filter_geom:
|
||||
qs = qs.filter(geom__intersects=filter_geom)
|
||||
|
||||
if search_q:
|
||||
qs = qs.filter(
|
||||
Q(code__icontains=search_q) |
|
||||
Q(name_fr__icontains=search_q) |
|
||||
Q(name_nl__icontains=search_q) |
|
||||
Q(address__icontains=search_q) |
|
||||
Q(owner__icontains=search_q)
|
||||
)
|
||||
|
||||
features = []
|
||||
from assets.views.core import get_element_type_label
|
||||
|
||||
for b in qs:
|
||||
geometry = None
|
||||
if b.geom:
|
||||
geom_4326 = b.geom.clone()
|
||||
geom_4326.transform(4326)
|
||||
geometry = json.loads(geom_4326.json)
|
||||
elif b.lon is not None and b.lat is not None:
|
||||
geometry = {"type": "Point", "coordinates": [b.lon, b.lat]}
|
||||
|
||||
detail_url = reverse('assets:publicbuilding_detail', kwargs={'pk': b.pk})
|
||||
|
||||
features.append({
|
||||
"type": "Feature",
|
||||
"id": b.id,
|
||||
"geometry": geometry,
|
||||
"properties": {
|
||||
"code": b.code,
|
||||
"name": b.get_name(),
|
||||
"category": b.category.get_name() if b.category else _("Bâtiment public"),
|
||||
"category_id": b.category_id,
|
||||
"status": b.building_status,
|
||||
"building_status": b.building_status,
|
||||
"building_status_display": b.get_building_status_display(),
|
||||
"usage_type": b.usage_type,
|
||||
"usage_type_display": b.get_usage_type_display(),
|
||||
"owner": b.owner or '',
|
||||
"is_erp": b.is_erp,
|
||||
"erp_category": b.erp_category or '',
|
||||
"dpe_rating": b.dpe_rating or '',
|
||||
"lon": b.lon,
|
||||
"lat": b.lat,
|
||||
"color": "#2B579A",
|
||||
"display_color": "#2B579A",
|
||||
"asset_type": "publicbuilding",
|
||||
"detail_url": detail_url,
|
||||
"element_type": get_element_type_label("publicbuilding"),
|
||||
"thematic": "publicbuildings",
|
||||
"is_location": False,
|
||||
}
|
||||
})
|
||||
|
||||
return JsonResponse({"type": "FeatureCollection", "features": features})
|
||||
|
||||
|
||||
@login_required
|
||||
def buildings_index(request):
|
||||
|
||||
"""
|
||||
Vue principale (Index / Liste) des Bâtiments Publics.
|
||||
"""
|
||||
lang = get_language()
|
||||
buildings = PublicBuilding.objects.all()
|
||||
|
||||
# Search filter
|
||||
search_query = request.GET.get('q', '').strip()
|
||||
if search_query:
|
||||
buildings = buildings.filter(
|
||||
Q(code__icontains=search_query) |
|
||||
Q(name_fr__icontains=search_query) |
|
||||
Q(name_nl__icontains=search_query) |
|
||||
Q(address__icontains=search_query) |
|
||||
Q(owner__icontains=search_query)
|
||||
)
|
||||
|
||||
# Usage filter
|
||||
usage_filter = request.GET.get('usage_type')
|
||||
if usage_filter:
|
||||
buildings = buildings.filter(usage_type=usage_filter)
|
||||
|
||||
# Status filter
|
||||
status_filter = request.GET.get('building_status')
|
||||
if status_filter:
|
||||
buildings = buildings.filter(building_status=status_filter)
|
||||
|
||||
# ERP filter
|
||||
erp_filter = request.GET.get('is_erp')
|
||||
if erp_filter in ['true', '1']:
|
||||
buildings = buildings.filter(is_erp=True)
|
||||
elif erp_filter in ['false', '0']:
|
||||
buildings = buildings.filter(is_erp=False)
|
||||
|
||||
# DPE rating filter
|
||||
dpe_filter = request.GET.get('dpe_rating')
|
||||
if dpe_filter:
|
||||
buildings = buildings.filter(dpe_rating=dpe_filter)
|
||||
|
||||
context = {
|
||||
'buildings': buildings,
|
||||
'search_query': search_query,
|
||||
'usage_type_choices': USAGE_TYPE_CHOICES,
|
||||
'building_status_choices': BUILDING_STATUS_CHOICES,
|
||||
'dpe_rating_choices': DPE_RATING_CHOICES,
|
||||
'selected_usage': usage_filter,
|
||||
'selected_status': status_filter,
|
||||
'selected_erp': erp_filter,
|
||||
'selected_dpe': dpe_filter,
|
||||
'active_tab': 'buildings',
|
||||
}
|
||||
return render(request, 'assets/buildings/publicbuilding_list.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def building_detail(request, pk):
|
||||
"""
|
||||
Vue détaillée d'un Bâtiment Public avec ses niveaux, pièces et équipements.
|
||||
"""
|
||||
building = get_object_or_404(PublicBuilding, pk=pk)
|
||||
levels = building.levels.all().prefetch_related('rooms')
|
||||
rooms = building.rooms.all()
|
||||
equipments = building.equipments.all()
|
||||
documents = building.documents.select_related('document').all()
|
||||
|
||||
context = {
|
||||
'building': building,
|
||||
'levels': levels,
|
||||
'rooms': rooms,
|
||||
'equipments': equipments,
|
||||
'documents': documents,
|
||||
}
|
||||
return render(request, 'assets/buildings/publicbuilding_detail.html', context)
|
||||
|
|
@ -408,6 +408,9 @@ ELEMENT_TYPE_LABELS = {
|
|||
# --- Propreté ---
|
||||
'cleanlitterbin': 'Corbeille',
|
||||
'cleanglasscontainer': 'Conteneur verre',
|
||||
# --- Bâtiments publics ---
|
||||
'publicbuilding': 'Bâtiment public',
|
||||
'buildingequipment': 'Équipement bâtiment',
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -468,6 +471,14 @@ def build_thematic_config(thematics):
|
|||
'locations_geojson_url': reverse('assets:clean_locations_geojson'),
|
||||
'assets_geojson_url': reverse('assets:clean_assets_geojson'),
|
||||
},
|
||||
'publicbuildings': {
|
||||
'locations_geojson_url': reverse('assets:publicbuildings_locations_geojson'),
|
||||
'assets_geojson_url': reverse('assets:publicbuildings_assets_geojson'),
|
||||
},
|
||||
'buildings': {
|
||||
'locations_geojson_url': reverse('assets:publicbuildings_locations_geojson'),
|
||||
'assets_geojson_url': reverse('assets:publicbuildings_assets_geojson'),
|
||||
},
|
||||
}
|
||||
result = {}
|
||||
for thematic in thematics:
|
||||
|
|
@ -776,15 +787,21 @@ def unified_assets_index(request):
|
|||
from interventions.views.drafts import InterventionDraftManager
|
||||
|
||||
lang = get_language() or 'fr'
|
||||
user_config, _ = UserConfig.objects.get_or_create(user=request.user)
|
||||
|
||||
# Thématiques accessibles à l'utilisateur
|
||||
thematic_qs = (
|
||||
UserThematics.objects
|
||||
.filter(user_config=request.user.config, can_view_assets=True)
|
||||
.select_related('thematic')
|
||||
.order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}')
|
||||
)
|
||||
accessible_thematics = [tc.thematic for tc in thematic_qs]
|
||||
if getattr(request.user, 'is_superuser', False):
|
||||
accessible_thematics = list(Thematic.objects.order_by(f'{"name_nl" if lang == "nl" else "name_fr"}'))
|
||||
else:
|
||||
thematic_qs = (
|
||||
UserThematics.objects
|
||||
.filter(user_config=user_config, can_view_assets=True)
|
||||
.select_related('thematic')
|
||||
.order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}')
|
||||
)
|
||||
accessible_thematics = [tc.thematic for tc in thematic_qs]
|
||||
|
||||
|
||||
|
||||
# Mode intervention préventive
|
||||
preventive_draft_id = request.GET.get('preventive_draft_id')
|
||||
|
|
@ -830,10 +847,10 @@ def unified_assets_index(request):
|
|||
if locked_thematic_code else ''
|
||||
)
|
||||
|
||||
user_roles = request.user.config.roles.values_list('name', flat=True) if hasattr(request.user, 'config') else []
|
||||
user_roles = user_config.roles.values_list('name', flat=True) if user_config else []
|
||||
is_admin = 'admin' in user_roles or request.user.is_superuser
|
||||
has_bulk_edit_status_permission = is_admin or UserThematics.objects.filter(user_config=request.user.config, can_edit_assets=True).exists()
|
||||
has_bulk_edit_validation_status_permission = is_admin or UserThematics.objects.filter(user_config=request.user.config, can_validate_assets=True).exists()
|
||||
has_bulk_edit_status_permission = is_admin or UserThematics.objects.filter(user_config=user_config, can_edit_assets=True).exists()
|
||||
has_bulk_edit_validation_status_permission = is_admin or UserThematics.objects.filter(user_config=user_config, can_validate_assets=True).exists()
|
||||
|
||||
context = {
|
||||
'accessible_thematics': accessible_thematics,
|
||||
|
|
@ -853,7 +870,7 @@ def unified_assets_index(request):
|
|||
'selected_assets_json': json.dumps(selected_assets),
|
||||
'selected_locations_ids': selected_locations_ids,
|
||||
'link_assets_url': link_assets_url,
|
||||
'assets_index_prefs_json': json.dumps(request.user.config.assets_index_preferences or {}),
|
||||
'default_thematic_code': (request.user.config.default_thematic.code if request.user.config.default_thematic_id else ''),
|
||||
'assets_index_prefs_json': json.dumps(user_config.assets_index_preferences or {}),
|
||||
'default_thematic_code': (user_config.default_thematic.code if user_config.default_thematic_id else ''),
|
||||
}
|
||||
return render(request, 'assets/unified_index.html', context)
|
||||
|
|
|
|||
|
|
@ -129,6 +129,19 @@
|
|||
"uses_occupations": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "common.thematic",
|
||||
"pk": 12,
|
||||
"fields": {
|
||||
"code": "publicbuildings",
|
||||
"name_fr": "Bâtiments publics",
|
||||
"name_nl": "Openbare gebouwen",
|
||||
"description": "Gestion du patrimoine bâti public (écoles, mairies, équipements sportifs, culturels, etc.)",
|
||||
"icon": "bi-building",
|
||||
"color": "#2B579A",
|
||||
"uses_occupations": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "common.role",
|
||||
"pk": 1,
|
||||
|
|
|
|||
Loading…
Reference in a new issue