fix(assets): fix js coord syntax error and add interactive multi-floor plans viewer

This commit is contained in:
kdeterme 2026-10-05 10:38:35 +02:00
parent 512f6786e3
commit 6802d1e15f
9 changed files with 678 additions and 73 deletions

View file

@ -680,12 +680,20 @@ from .models import CleanLitterBinPhoto, CleanLitterBinObservation
from .models import PublicBuilding, BuildingAsset, PublicBuildingDocument from .models import PublicBuilding, BuildingAsset, PublicBuildingDocument
from .models import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument from .models import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
class PublicBuildingDocumentInline(admin.TabularInline):
model = PublicBuildingDocument
extra = 1
fk_name = "building"
fields = ("document_type", "floor_location", "file", "description")
raw_id_fields = ("floor_location",)
@admin.register(PublicBuilding) @admin.register(PublicBuilding)
class PublicBuildingAdmin(admin.GISModelAdmin): class PublicBuildingAdmin(admin.GISModelAdmin):
list_display = ("code", "name_fr", "location_type", "parent_location", "room_number", "level_number", "usage_type", "building_status", "is_heated", "is_erp", "dpe_rating") list_display = ("code", "name_fr", "location_type", "parent_location", "room_number", "level_number", "usage_type", "building_status", "is_heated", "is_erp", "dpe_rating")
list_filter = ("location_type", "floor_type", "is_heated", "usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible") list_filter = ("location_type", "floor_type", "is_heated", "usage_type", "building_status", "is_erp", "erp_category", "dpe_rating", "pmr_accessible")
search_fields = ("code", "name_fr", "name_nl", "address", "owner", "room_number") search_fields = ("code", "name_fr", "name_nl", "address", "owner", "room_number")
raw_id_fields = ("parent_location", "manager") raw_id_fields = ("parent_location", "manager")
inlines = [PublicBuildingDocumentInline]
@admin.register(BuildingAsset) @admin.register(BuildingAsset)
class BuildingAssetAdmin(admin.GISModelAdmin): class BuildingAssetAdmin(admin.GISModelAdmin):
@ -694,7 +702,12 @@ class BuildingAssetAdmin(admin.GISModelAdmin):
search_fields = ("code", "name_fr", "name_nl", "serial_number", "material", "maintenance_contract_ref", "contractor_name") search_fields = ("code", "name_fr", "name_nl", "serial_number", "material", "maintenance_contract_ref", "contractor_name")
raw_id_fields = ("building", "location", "parent_asset") raw_id_fields = ("building", "location", "parent_asset")
admin.site.register(PublicBuildingDocument, AssetAdmin) @admin.register(PublicBuildingDocument)
class PublicBuildingDocumentAdmin(admin.ModelAdmin):
list_display = ("id", "building", "floor_location", "document_type", "file", "upload_date")
list_filter = ("document_type",)
search_fields = ("description", "building__code", "building__name_fr", "floor_location__code")
raw_id_fields = ("building", "floor_location")
@admin.register(OutdoorLocation) @admin.register(OutdoorLocation)
class OutdoorLocationAdmin(admin.GISModelAdmin): class OutdoorLocationAdmin(admin.GISModelAdmin):

View file

@ -54,4 +54,15 @@ class AssetsConfig(AppConfig):
return False return False
return True return True
register('assets', 'InspectionDocument', 'file', guard_inspection_document) register("assets", "InspectionDocument", "file", guard_inspection_document)
# Guards for PublicBuilding floor plans and documents
def guard_publicbuilding_floor_plan(obj, user):
return user.is_authenticated
register("assets", "PublicBuilding", "floor_plan", guard_publicbuilding_floor_plan)
def guard_publicbuilding_document(obj, user):
return user.is_authenticated
register("assets", "PublicBuildingDocument", "file", guard_publicbuilding_document)

View file

@ -287,7 +287,7 @@ class PublicBuildingForm(forms.ModelForm):
'usage_type', 'building_status', 'usage_type', 'building_status',
'construction_year', 'floors_count', 'basement_count', 'construction_year', 'floors_count', 'basement_count',
'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number', 'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number',
'room_number', 'ceiling_height_m', 'is_heated', 'fire_compartment', 'floor_type', 'room_number', 'ceiling_height_m', 'is_heated', 'fire_compartment', 'floor_type', 'floor_plan',
'is_erp', 'erp_category', 'erp_type', 'max_capacity', 'pmr_accessible', 'is_erp', 'erp_category', 'erp_type', 'max_capacity', 'pmr_accessible',
'dpe_rating', 'ges_rating', 'has_asbestos_diag', 'has_lead_diag', 'dpe_rating', 'ges_rating', 'has_asbestos_diag', 'has_lead_diag',
'lat', 'lon', 'lat', 'lon',
@ -315,6 +315,7 @@ class PublicBuildingForm(forms.ModelForm):
'level_number': forms.NumberInput(attrs={'class': 'form-control'}), 'level_number': forms.NumberInput(attrs={'class': 'form-control'}),
'room_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: B-204, RDC-01'}), 'room_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: B-204, RDC-01'}),
'ceiling_height_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}), 'ceiling_height_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'floor_plan': forms.FileInput(attrs={'class': 'form-control', 'accept': '.png,.jpg,.jpeg,.svg,.pdf,.webp'}),
'is_heated': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'is_heated': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'fire_compartment': forms.TextInput(attrs={'class': 'form-control'}), 'fire_compartment': forms.TextInput(attrs={'class': 'form-control'}),
'floor_type': forms.Select(attrs={'class': 'form-select'}), 'floor_type': forms.Select(attrs={'class': 'form-select'}),

View file

@ -0,0 +1,24 @@
# Generated by Django 6.0.7 on 2026-10-05 08:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0028_buildingasset_contractor_name_and_more'),
]
operations = [
migrations.AddField(
model_name='publicbuildingdocument',
name='floor_location',
field=models.ForeignKey(blank=True, help_text='Niveau ou local auquel ce document/plan se rapporte.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='floor_documents', to='assets.publicbuilding', verbose_name='Niveau / Étage concerné'),
),
migrations.AlterField(
model_name='publicbuildingdocument',
name='document_type',
field=models.CharField(choices=[('floor_plan', "Plan d'étage / Plan architectural"), ('as_built', 'Plan de récolement (As-Built)'), ('erp_notice', 'Notice de sécurité / Dossier ERP'), ('dpe', 'Diagnostic énergétique (DPE / PEB)'), ('technical_notice', "Notice technique / Manuel d'exploitation"), ('other', 'Autre document')], default='other', max_length=50, verbose_name='Type de document'),
),
]

View file

@ -275,9 +275,26 @@ class PublicBuilding(AbstractLocation):
verbose_name_plural = _("Localisations Bâtiments publics") verbose_name_plural = _("Localisations Bâtiments publics")
ordering = ['code', 'name_fr'] ordering = ['code', 'name_fr']
@property
def name(self):
return self.name_fr or self.name_nl or self.code
def get_absolute_url(self): def get_absolute_url(self):
return reverse('assets:publicbuilding_detail', kwargs={'pk': self.pk}) return reverse('assets:publicbuilding_detail', kwargs={'pk': self.pk})
def get_floor_plan_url(self):
"""
Renvoie l'URL du plan d'étage s'il existe (via floor_plan ou document associé de type floor_plan).
"""
if self.floor_plan and self.floor_plan.name:
return reverse('assets:publicbuilding_plan_file', kwargs={'pk': self.pk})
docs = getattr(self, 'floor_documents', None)
if docs:
first_doc = docs.filter(document_type='floor_plan').first()
if first_doc and first_doc.file and first_doc.file.name:
return reverse('assets:publicbuilding_plan_file', kwargs={'pk': self.pk})
return None
def get_usage_type_display(self, standard=None): def get_usage_type_display(self, standard=None):
""" """
Renvoie le libellé du type d'usage adapté dynamiquement à la norme régionale (BE, FR, INT). Renvoie le libellé du type d'usage adapté dynamiquement à la norme régionale (BE, FR, INT).
@ -422,6 +439,10 @@ class BuildingAsset(AbstractGeoAsset):
verbose_name_plural = _("Assets / Équipements de bâtiment") verbose_name_plural = _("Assets / Équipements de bâtiment")
ordering = ['building', 'code'] ordering = ['building', 'code']
@property
def name(self):
return self.name_fr or self.name_nl or self.code
def get_absolute_url(self): def get_absolute_url(self):
return reverse('assets:publicbuildings_assets_detail', kwargs={'asset_model': 'buildingasset', 'asset_id': self.pk}) return reverse('assets:publicbuildings_assets_detail', kwargs={'asset_model': 'buildingasset', 'asset_id': self.pk})
@ -554,13 +575,32 @@ def upload_to_building(instance, filename):
return f"buildings/{instance.building.code}/{filename}" return f"buildings/{instance.building.code}/{filename}"
BUILDING_DOCUMENT_TYPE_CHOICES = (
('floor_plan', _("Plan d'étage / Plan architectural")),
('as_built', _("Plan de récolement (As-Built)")),
('erp_notice', _("Notice de sécurité / Dossier ERP")),
('dpe', _("Diagnostic énergétique (DPE / PEB)")),
('technical_notice', _("Notice technique / Manuel d'exploitation")),
('other', _("Autre document")),
)
class PublicBuildingDocument(Document): class PublicBuildingDocument(Document):
""" """
Documents associés à un bâtiment public (plans, notices ERP, DPE). 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")) building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="documents", verbose_name=_("Bâtiment"))
floor_location = models.ForeignKey(
PublicBuilding,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="floor_documents",
verbose_name=_("Niveau / Étage concerné"),
help_text=_("Niveau ou local auquel ce document/plan se rapporte.")
)
file = models.FileField(upload_to=upload_to_building, storage=private_storage, verbose_name=_("Fichier"), max_length=255) 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")) document_type = models.CharField(max_length=50, choices=BUILDING_DOCUMENT_TYPE_CHOICES, default='other', verbose_name=_("Type de document"))
class Meta: class Meta:
verbose_name = _("Document de bâtiment") verbose_name = _("Document de bâtiment")

View file

@ -253,25 +253,154 @@
</div> </div>
<!-- Right Column: Localisation & Documents (sous la carte) --> <!-- Right Column: Localisation & Plans d'étage / Carte interactive -->
<div class="col-md-5"> <div class="col-md-5">
<!-- Card Localisation & Carte interactive --> <!-- Card Localisation, Carte interactive & Plans d'étage -->
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4" id="card-location-plans">
<div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center"> <div class="card-header bg-light d-flex justify-content-between align-items-center flex-wrap gap-2 py-2">
<span><i class="bi bi-geo-alt me-2"></i>{% translate "Localisation & Carte" %}</span> <div class="d-flex align-items-center">
{% if level_specific_mode %}
<span class="fw-bold text-dark"><i class="bi bi-layers text-primary me-2"></i>{% translate "Plan du niveau" %} : <span class="text-primary">{{ active_floor.name|default:building.name }}</span></span>
{% else %}
<span class="fw-bold text-dark"><i class="bi bi-geo-alt text-primary me-2"></i>{% translate "Localisation & Plans" %}</span>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group" id="view-mode-toggle">
<button type="button" class="btn btn-outline-primary active" id="btn-toggle-plans" title="{% translate "Afficher les plans d'étage" %}">
<i class="bi bi-layers me-1"></i>{% translate "Plans" %}
</button>
<button type="button" class="btn btn-outline-primary" id="btn-toggle-map" title="{% translate "Afficher la carte du site" %}">
<i class="bi bi-map me-1"></i>{% translate "Carte" %}
</button>
</div>
{% if center_lat and center_lon %} {% if center_lat and center_lon %}
<a href="https://www.google.com/maps?cbll={{ center_lat|floatformat:'6u'}},{{ center_lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid=" <a href="https://www.google.com/maps?cbll={{ center_lat_json }},{{ center_lon_json }}&cbp=12,0,0,0,5&layer=c&panoid="
target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-2"> target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-2" title="Street View">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="Street View" height="14" class="me-1"> <img src="{% static 'common/google-street-view-logo.png' %}" alt="Street View" height="14" class="me-1">
Street View Street View
</a> </a>
{% endif %} {% endif %}
</div> </div>
<div class="card-body p-0">
<div id="building-map" style="height: 340px; width: 100%; border-radius: 0 0 4px 4px;"></div>
</div> </div>
<div class="card-footer bg-white p-3">
<!-- 1. VUE PLANS D'ÉTAGE -->
<div id="section-plans" class="card-body p-0">
{% if not level_specific_mode and floors_data %}
<!-- Sélecteur d'étage horizontal -->
<div class="bg-light border-bottom px-3 py-2 d-flex align-items-center gap-1 overflow-auto" id="floors-nav-bar" style="white-space: nowrap;">
<span class="text-muted small fw-semibold me-2"><i class="bi bi-filter me-1"></i>{% translate "Niveaux" %} :</span>
{% for f in floors_data %}
<button type="button"
class="btn btn-sm floor-nav-btn {% if f.id == active_floor.id %}btn-primary text-white shadow-sm{% else %}btn-outline-secondary{% endif %}"
data-floor-id="{{ f.id }}"
data-plan-url="{{ f.plan_url }}"
data-has-plan="{{ f.has_plan|yesno:'true,false' }}"
data-floor-name="{{ f.name }}"
data-surface="{{ f.surface_m2|default:'' }}"
data-rooms-count="{{ f.rooms_count }}"
data-assets-count="{{ f.assets_count }}"
data-floor-url="{{ f.url }}">
{{ f.name }}
{% if f.has_plan %}
<span class="badge bg-white text-primary ms-1" title="{% translate "Plan architectural disponible" %}">📐</span>
{% endif %}
</button>
{% endfor %}
</div>
{% endif %}
<!-- Conteneur du viewer interactif -->
<div id="plan-viewer-wrapper" style="position: relative; height: 380px; width: 100%; background: #0F172A05; overflow: hidden; display: flex; align-items: center; justify-content: center;">
<!-- Floating Controls (Zoom / Pan / Fullscreen / Download) -->
<div class="btn-group-vertical btn-group-sm shadow-sm" style="position: absolute; top: 12px; right: 12px; z-index: 10; background: rgba(255, 255, 255, 0.92); backdrop-filter: blur(4px); border-radius: 6px; border: 1px solid #CBD5E1;">
<button type="button" class="btn btn-light py-1 px-2" id="plan-zoom-in" title="{% translate "Zoom avant (+)" %}">
<i class="bi bi-zoom-in"></i>
</button>
<button type="button" class="btn btn-light py-1 px-2" id="plan-zoom-out" title="{% translate "Zoom arrière (-)" %}">
<i class="bi bi-zoom-out"></i>
</button>
<button type="button" class="btn btn-light py-1 px-2" id="plan-reset" title="{% translate "Recentrer / Ajuster (100%)" %}">
<i class="bi bi-arrows-angle-contract"></i>
</button>
<button type="button" class="btn btn-light py-1 px-2" id="plan-fullscreen" title="{% translate "Plein écran" %}">
<i class="bi bi-arrows-fullscreen"></i>
</button>
<a href="{{ active_floor.plan_url|default:'#' }}" target="_blank" class="btn btn-light py-1 px-2" id="plan-download-link" title="{% translate "Ouvrir / Télécharger le plan" %}" {% if not active_floor.has_plan %}style="display:none;"{% endif %}>
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<!-- Indicateur du niveau affiché -->
<div class="badge bg-dark bg-opacity-75 text-white px-2 py-1" style="position: absolute; bottom: 12px; left: 12px; z-index: 10; font-size: 0.8rem; pointer-events: none;" id="plan-level-indicator">
<i class="bi bi-layers me-1"></i><span id="plan-active-name">{{ active_floor.name|default:building.name }}</span>
</div>
<!-- Pan/Zoom Canvas -->
<div id="plan-viewport" style="width: 100%; height: 100%; cursor: grab; display: flex; align-items: center; justify-content: center; transform-origin: center center; transition: transform 0.08s ease-out;">
<img id="plan-image"
src="{{ active_floor.plan_url|default:'' }}"
alt="{% translate "Plan d'étage" %}"
style="max-width: 95%; max-height: 95%; object-fit: contain; user-select: none; pointer-events: none; {% if not active_floor.has_plan %}display: none;{% endif %}">
</div>
<!-- Empty State when floor has no plan -->
<div id="plan-empty-state" class="text-center p-4" style="{% if active_floor.has_plan %}display: none;{% else %}display: block;{% endif %}">
<i class="bi bi-file-earmark-image fs-1 text-muted d-block mb-2"></i>
<h6 class="text-muted mb-1">{% translate "Aucun plan associé à ce niveau" %}</h6>
<p class="text-muted small mb-3">{% translate "Vous pouvez téléverser un plan d'étage (SVG, image ou PDF) pour ce niveau." %}</p>
{% if can_edit %}
<a href="{% url 'assets:publicbuilding_edit' building.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-upload me-1"></i>{% translate "Associer un plan" %}
</a>
{% endif %}
</div>
</div>
<!-- Footer info du niveau actif (locaux & caractéristiques) -->
<div class="card-footer bg-white p-3 border-top" id="plan-floor-info">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div>
<strong class="text-dark" id="info-floor-title">{{ active_floor.name|default:building.name }}</strong>
<span class="text-muted small ms-2" id="info-floor-code">({{ active_floor.code|default:building.code }})</span>
</div>
<div class="d-flex gap-2">
<span class="badge bg-light text-dark border" id="info-floor-surface">
{% if active_floor.surface_m2 %}
<i class="bi bi-bounding-box me-1 text-primary"></i>{{ active_floor.surface_m2|floatformat:1 }} m²
{% endif %}
</span>
<span class="badge bg-light text-dark border" id="info-floor-rooms">
<i class="bi bi-door-closed me-1 text-primary"></i><span id="info-rooms-count">{{ active_floor.rooms_count|default:0 }}</span> {% translate "local/locaux" %}
</span>
<span class="badge bg-light text-dark border" id="info-floor-assets">
<i class="bi bi-tools me-1 text-primary"></i><span id="info-assets-count">{{ active_floor.assets_count|default:0 }}</span> {% translate "équipement(s)" %}
</span>
</div>
</div>
<!-- Chips des locaux / pièces situés sur cet étage -->
<div id="info-floor-rooms-chips" class="d-flex flex-wrap gap-1 mt-2">
{% for r in active_floor.rooms %}
<a href="{{ r.url }}" class="badge bg-light text-dark border text-decoration-none py-1 px-2" title="{{ r.location_type_display }}">
<i class="bi bi-door-open me-1 text-primary"></i><strong>{{ r.room_number|default:r.code }}</strong> - {{ r.name }}
</a>
{% empty %}
<span class="text-muted small fst-italic">{% translate "Aucun sous-local direct répertorié sur ce niveau." %}</span>
{% endfor %}
</div>
</div>
</div>
<!-- 2. VUE CARTE DU SITE / EMPRISE (MapLibre) -->
<div id="section-map" style="display: none;">
<div class="card-body p-0">
<div id="building-map" style="height: 380px; width: 100%;"></div>
</div>
<div class="card-footer bg-white p-3 border-top">
<p class="mb-2 small"> <p class="mb-2 small">
<strong>{% translate "Adresse" %} :</strong> {{ building.address|default:_("Non renseignée") }} <strong>{% translate "Adresse" %} :</strong> {{ building.address|default:_("Non renseignée") }}
</p> </p>
@ -282,8 +411,9 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
</div>
<!-- Card Documents associés (placée sous la localisation) --> <!-- Card Documents associés (placée sous les plans et la carte) -->
<div class="card shadow-sm mb-4"> <div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center"> <div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold"><i class="bi bi-file-earmark-text me-2"></i>{% translate "Documents associés" %} ({{ documents.count }})</span> <span class="fw-bold"><i class="bi bi-file-earmark-text me-2"></i>{% translate "Documents associés" %} ({{ documents.count }})</span>
@ -295,15 +425,30 @@
<div class="row row-cols-1 row-cols-sm-2 g-3"> <div class="row row-cols-1 row-cols-sm-2 g-3">
{% for doc in documents %} {% for doc in documents %}
<div class="col"> <div class="col">
<div class="card h-100 border text-center p-2"> <div class="card h-100 border p-2 text-center position-relative">
{% if doc.document.file %} {% if doc.document_type == 'floor_plan' %}
<span class="badge bg-primary text-white position-absolute top-0 end-0 m-1" style="font-size: 0.65rem;">{% translate "Plan" %}</span>
<i class="bi bi-layers text-primary fs-2 mb-1"></i>
{% else %}
<span class="badge bg-secondary text-white position-absolute top-0 end-0 m-1" style="font-size: 0.65rem;">{{ doc.get_document_type_display }}</span>
<i class="bi bi-file-earmark-pdf text-danger fs-2 mb-1"></i> <i class="bi bi-file-earmark-pdf text-danger fs-2 mb-1"></i>
<div class="small text-truncate w-100 fw-bold" title="{{ doc.document.file.name }}">{{ doc.document.file.name }}</div> {% endif %}
<a href="{{ doc.document.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary mt-2">
<div class="small text-truncate w-100 fw-bold" title="{{ doc.description|default:doc.file.name }}">
{{ doc.description|default:doc.file.name }}
</div>
{% if doc.floor_location %}
<div class="small text-muted text-truncate mb-1" style="font-size: 0.75rem;">
<i class="bi bi-layers-half me-1"></i>{{ doc.floor_location.name }}
</div>
{% endif %}
{% if doc.file %}
<a href="{% url 'private_file_download' %}?app=assets&model=PublicBuildingDocument&pk={{ doc.pk }}&field=file" target="_blank" class="btn btn-sm btn-outline-secondary mt-auto py-0 px-2" style="font-size: 0.8rem;">
<i class="bi bi-download me-1"></i>{% translate "Télécharger" %} <i class="bi bi-download me-1"></i>{% translate "Télécharger" %}
</a> </a>
{% else %} {% else %}
<i class="bi bi-file-earmark text-secondary fs-2 mb-1"></i>
<span class="text-muted small">{% translate "Fichier indisponible" %}</span> <span class="text-muted small">{% translate "Fichier indisponible" %}</span>
{% endif %} {% endif %}
</div> </div>
@ -318,7 +463,7 @@
</div> </div>
</div> </div>
</div> </div> </div>
</div> </div>
</div> </div>
@ -484,83 +629,281 @@
{% block script %} {% block script %}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener("DOMContentLoaded", function() {
const buildingGeom = {{ building_geojson_json|safe }}; const buildingGeom = {{ building_geojson_json|safe }};
const centerLon = {{ center_lon|default:4.3572 }}; const centerLon = {{ center_lon_json|safe }} || 4.3572;
const centerLat = {{ center_lat|default:50.8477 }}; const centerLat = {{ center_lat_json|safe }} || 50.8477;
const floorsData = {{ floors_data_json|safe }} || [];
let currentFloor = {{ active_floor_json|safe }};
const hasAnyPlan = {{ has_any_plan|yesno:"true,false" }};
const levelSpecificMode = {{ level_specific_mode|yesno:"true,false" }};
let map = null; let map = null;
// 1. Initialisation de la carte MapLibre
function initMap() { function initMap() {
const container = document.getElementById('building-map'); const container = document.getElementById("building-map");
if (!container) return; if (!container || map) return;
map = new maplibregl.Map({ map = new maplibregl.Map({
container: 'building-map', container: "building-map",
style: '{{ MAP_STYLE_LIGHT|default:"https://api.maptiler.com/maps/streets-v2-light/style.json" }}', style: "{{ MAP_STYLE_LIGHT|default:'https://api.maptiler.com/maps/streets-v2-light/style.json' }}",
center: [centerLon, centerLat], center: [centerLon, centerLat],
zoom: 16 zoom: 16
}); });
map.addControl(new maplibregl.NavigationControl()); map.addControl(new maplibregl.NavigationControl());
map.on('load', function() { map.on("load", function() {
if (buildingGeom) { if (buildingGeom) {
map.addSource('building-footprint', { map.addSource("building-footprint", {
'type': 'geojson', "type": "geojson",
'data': buildingGeom "data": buildingGeom
}); });
map.addLayer({ map.addLayer({
'id': 'building-fill', "id": "building-fill",
'type': 'fill', "type": "fill",
'source': 'building-footprint', "source": "building-footprint",
'paint': { "paint": {
'fill-color': '#2B579A', "fill-color": "#2B579A",
'fill-opacity': 0.4 "fill-opacity": 0.4
} }
}); });
map.addLayer({ map.addLayer({
'id': 'building-outline', "id": "building-outline",
'type': 'line', "type": "line",
'source': 'building-footprint', "source": "building-footprint",
'paint': { "paint": {
'line-color': '#2B579A', "line-color": "#2B579A",
'line-width': 3 "line-width": 3
} }
}); });
} }
}); });
} }
// 2. Bascule entre Mode Plans d'étage et Mode Carte
const btnTogglePlans = document.getElementById("btn-toggle-plans");
const btnToggleMap = document.getElementById("btn-toggle-map");
const sectionPlans = document.getElementById("section-plans");
const sectionMap = document.getElementById("section-map");
function setViewMode(mode) {
if (mode === "map") {
btnToggleMap?.classList.add("active");
btnTogglePlans?.classList.remove("active");
if (sectionPlans) sectionPlans.style.display = "none";
if (sectionMap) sectionMap.style.display = "block";
if (!map) {
initMap(); initMap();
} else {
map.resize();
}
} else {
btnTogglePlans?.classList.add("active");
btnToggleMap?.classList.remove("active");
if (sectionPlans) sectionPlans.style.display = "block";
if (sectionMap) sectionMap.style.display = "none";
}
}
const overviewTab = document.getElementById('overview-tab'); btnTogglePlans?.addEventListener("click", function() { setViewMode("plans"); });
btnToggleMap?.addEventListener("click", function() { setViewMode("map"); });
// Par défaut, si aucun plan n'existe et qu'on n'est pas en mode spécifique à un niveau, afficher la carte
if (!hasAnyPlan && !levelSpecificMode && (buildingGeom || (centerLon && centerLat))) {
setViewMode("map");
} else {
setViewMode("plans");
}
// 3. Pan & Zoom interactif sur le viewer de plan
const planViewport = document.getElementById("plan-viewport");
const planImage = document.getElementById("plan-image");
const planWrapper = document.getElementById("plan-viewer-wrapper");
const planEmptyState = document.getElementById("plan-empty-state");
const planDownloadLink = document.getElementById("plan-download-link");
const planActiveName = document.getElementById("plan-active-name");
let scale = 1;
let translateX = 0;
let translateY = 0;
let isDragging = false;
let startX = 0;
let startY = 0;
function updateTransform() {
if (planViewport) {
planViewport.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
}
}
function resetPlanView() {
scale = 1;
translateX = 0;
translateY = 0;
updateTransform();
}
document.getElementById("plan-zoom-in")?.addEventListener("click", function() {
scale = Math.min(scale * 1.3, 6);
updateTransform();
});
document.getElementById("plan-zoom-out")?.addEventListener("click", function() {
scale = Math.max(scale / 1.3, 0.35);
updateTransform();
});
document.getElementById("plan-reset")?.addEventListener("click", resetPlanView);
document.getElementById("plan-fullscreen")?.addEventListener("click", function() {
if (!document.fullscreenElement) {
planWrapper?.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
});
// Zoom molette
planWrapper?.addEventListener("wheel", function(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.88 : 1.14;
scale = Math.min(Math.max(scale * delta, 0.35), 6);
updateTransform();
}, { passive: false });
// Déplacement souris (pan)
planWrapper?.addEventListener("mousedown", function(e) {
if (e.button !== 0) return;
isDragging = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
if (planViewport) planViewport.style.cursor = "grabbing";
});
window.addEventListener("mousemove", function(e) {
if (!isDragging) return;
translateX = e.clientX - startX;
translateY = e.clientY - startY;
updateTransform();
});
window.addEventListener("mouseup", function() {
if (isDragging) {
isDragging = false;
if (planViewport) planViewport.style.cursor = "grab";
}
});
// 4. Navigation entre niveaux / étages
function displayFloor(floor) {
currentFloor = floor;
resetPlanView();
if (planActiveName) planActiveName.textContent = floor.name;
// Mise à jour de l'image du plan
if (floor.has_plan && floor.plan_url) {
if (planImage) {
planImage.src = floor.plan_url;
planImage.style.display = "block";
}
if (planEmptyState) planEmptyState.style.display = "none";
if (planDownloadLink) {
planDownloadLink.href = floor.plan_url;
planDownloadLink.style.display = "inline-block";
}
} else {
if (planImage) planImage.style.display = "none";
if (planEmptyState) planEmptyState.style.display = "block";
if (planDownloadLink) planDownloadLink.style.display = "none";
}
// Mise à jour du bandeau d'information sous le plan
const infoTitle = document.getElementById("info-floor-title");
const infoCode = document.getElementById("info-floor-code");
const infoSurface = document.getElementById("info-floor-surface");
const infoRoomsCount = document.getElementById("info-rooms-count");
const infoAssetsCount = document.getElementById("info-assets-count");
const chipsContainer = document.getElementById("info-floor-rooms-chips");
if (infoTitle) infoTitle.textContent = floor.name;
if (infoCode) infoCode.textContent = `(${floor.code})`;
if (infoSurface) {
if (floor.surface_m2) {
infoSurface.innerHTML = `<i class="bi bi-bounding-box me-1 text-primary"></i>${parseFloat(floor.surface_m2).toFixed(1)} m²`;
infoSurface.style.display = "inline-block";
} else {
infoSurface.style.display = "none";
}
}
if (infoRoomsCount) infoRoomsCount.textContent = floor.rooms_count || 0;
if (infoAssetsCount) infoAssetsCount.textContent = floor.assets_count || 0;
// Locaux de cet étage
if (chipsContainer) {
chipsContainer.innerHTML = "";
if (floor.rooms && floor.rooms.length > 0) {
floor.rooms.forEach(function(room) {
const chip = document.createElement("a");
chip.href = room.url;
chip.className = "badge bg-light text-dark border text-decoration-none py-1 px-2";
chip.title = room.location_type_display || "";
chip.innerHTML = `<i class="bi bi-door-open me-1 text-primary"></i><strong>${room.room_number || room.code}</strong> - ${room.name}`;
chipsContainer.appendChild(chip);
});
} else {
chipsContainer.innerHTML = '<span class="text-muted small fst-italic">{% translate "Aucun sous-local direct répertorié sur ce niveau." %}</span>';
}
}
}
// Écouteurs sur les boutons d'étages
document.querySelectorAll(".floor-nav-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
const floorId = parseInt(btn.getAttribute("data-floor-id"));
const targetFloor = floorsData.find(f => f.id === floorId);
if (!targetFloor) return;
document.querySelectorAll(".floor-nav-btn").forEach(b => {
b.classList.remove("btn-primary", "text-white", "shadow-sm");
b.classList.add("btn-outline-secondary");
});
btn.classList.remove("btn-outline-secondary");
btn.classList.add("btn-primary", "text-white", "shadow-sm");
displayFloor(targetFloor);
});
});
const overviewTab = document.getElementById("overview-tab");
if (overviewTab) { if (overviewTab) {
overviewTab.addEventListener('shown.bs.tab', function () { overviewTab.addEventListener("shown.bs.tab", function () {
if (map) map.resize(); if (map) map.resize();
}); });
} }
// Gestion du chevron plié / déplié dans l'arborescence // Gestion du chevron plié / déplié dans l'arborescence
document.querySelectorAll('.tree-toggle-btn').forEach(function(btn) { document.querySelectorAll(".tree-toggle-btn").forEach(function(btn) {
const targetId = btn.getAttribute('data-bs-target'); const targetId = btn.getAttribute("data-bs-target");
const target = document.querySelector(targetId); const target = document.querySelector(targetId);
if (target) { if (target) {
target.addEventListener('show.bs.collapse', function (e) { target.addEventListener("show.bs.collapse", function (e) {
if (e.target !== target) return; if (e.target !== target) return;
const icon = btn.querySelector('i'); const icon = btn.querySelector("i");
if (icon) { if (icon) {
icon.classList.remove('bi-chevron-right'); icon.classList.remove("bi-chevron-right");
icon.classList.add('bi-chevron-down'); icon.classList.add("bi-chevron-down");
} }
}); });
target.addEventListener('hide.bs.collapse', function (e) { target.addEventListener("hide.bs.collapse", function (e) {
if (e.target !== target) return; if (e.target !== target) return;
const icon = btn.querySelector('i'); const icon = btn.querySelector("i");
if (icon) { if (icon) {
icon.classList.remove('bi-chevron-down'); icon.classList.remove("bi-chevron-down");
icon.classList.add('bi-chevron-right'); icon.classList.add("bi-chevron-right");
} }
}); });
} }

View file

@ -14,6 +14,7 @@ urlpatterns = [
path("buildings/", buildings.buildings_index, name="buildings_index"), path("buildings/", buildings.buildings_index, name="buildings_index"),
path("buildings/<int:pk>/", buildings.building_detail, name="publicbuilding_detail"), path("buildings/<int:pk>/", buildings.building_detail, name="publicbuilding_detail"),
path("buildings/<int:pk>/edit/", buildings.publicbuilding_edit, name="publicbuilding_edit"), path("buildings/<int:pk>/edit/", buildings.publicbuilding_edit, name="publicbuilding_edit"),
path("buildings/<int:pk>/floor-plan/", buildings.publicbuilding_plan_file, name="publicbuilding_plan_file"),
path("buildings/<str:asset_model>/<int:asset_id>/", buildings.publicbuildings_assets_detail, name="publicbuildings_assets_detail"), path("buildings/<str:asset_model>/<int:asset_id>/", buildings.publicbuildings_assets_detail, name="publicbuildings_assets_detail"),
path("export/", export.export_assets, name="export_assets"), path("export/", export.export_assets, name="export_assets"),

View file

@ -1,9 +1,11 @@
import os
import mimetypes
import json import json
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.db.models import Q, F from django.db.models import Q, F
from django.http import JsonResponse, HttpResponseForbidden from django.http import JsonResponse, HttpResponseForbidden, FileResponse, Http404
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _, get_language from django.utils.translation import gettext as _, get_language
from django.contrib.gis.geos import GEOSGeometry from django.contrib.gis.geos import GEOSGeometry
@ -352,10 +354,62 @@ def build_location_node(loc):
} }
@login_required
def publicbuilding_plan_file(request, pk):
"""
Sert directement le fichier de plan d'étage (SVG, PNG, JPG, PDF) associé à ce bâtiment ou niveau.
"""
from django.utils.encoding import smart_str
building = get_object_or_404(PublicBuilding, pk=pk)
file_field = None
if building.floor_plan and building.floor_plan.name:
file_field = building.floor_plan
else:
# Check direct floor document
doc = building.floor_documents.filter(document_type='floor_plan').first()
if not doc:
doc = building.documents.filter(document_type='floor_plan').first()
if doc and doc.file and doc.file.name:
file_field = doc.file
# Fallback to parent level if this is a room without direct plan
if not file_field and building.parent_location:
curr = building.parent_location
while curr and not file_field:
if curr.floor_plan and curr.floor_plan.name:
file_field = curr.floor_plan
break
pdoc = curr.floor_documents.filter(document_type='floor_plan').first()
if not pdoc:
pdoc = curr.documents.filter(document_type='floor_plan').first()
if pdoc and pdoc.file and pdoc.file.name:
file_field = pdoc.file
break
curr = curr.parent_location
if not file_field or not file_field.storage.exists(file_field.name):
raise Http404(_("Aucun plan d'étage disponible."))
content_type, _ = mimetypes.guess_type(file_field.name)
if not content_type:
if file_field.name.lower().endswith('.svg'):
content_type = 'image/svg+xml'
else:
content_type = 'application/octet-stream'
filename = os.path.basename(file_field.name)
response = FileResponse(file_field.open('rb'), content_type=content_type)
response['Content-Disposition'] = f'inline; filename="{smart_str(filename)}"'
response['X-Frame-Options'] = 'ALLOWALL'
return response
@login_required @login_required
def building_detail(request, pk): def building_detail(request, pk):
""" """
Vue détaillée d'une Localisation Bâtiment Public avec son arborescence unifiée de localisations et d'assets physiques. Vue détaillée d'une Localisation Bâtiment Public avec son arborescence unifiée,
sa cartographie et sa navigation multi-niveaux / plans d'étage.
""" """
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from interventions.models import Intervention from interventions.models import Intervention
@ -369,7 +423,7 @@ def building_detail(request, pk):
location_tree = build_location_node(building) location_tree = build_location_node(building)
parent_location = building.parent_location parent_location = building.parent_location
documents = building.documents.all() documents = building.documents.all().select_related('floor_location')
content_type = ContentType.objects.get_for_model(PublicBuilding) content_type = ContentType.objects.get_for_model(PublicBuilding)
@ -394,6 +448,116 @@ def building_detail(request, pk):
pass pass
elif building.lon is not None and building.lat is not None: elif building.lon is not None and building.lat is not None:
center_lon, center_lat = building.lon, building.lat center_lon, center_lat = building.lon, building.lat
elif building.parent_location:
# Fallback to parent coords if room/floor without specific coords
curr_p = building.parent_location
while curr_p and (center_lon is None or center_lat is None):
if curr_p.lon is not None and curr_p.lat is not None:
center_lon, center_lat = curr_p.lon, curr_p.lat
break
elif curr_p.geom:
try:
g4326 = curr_p.geom.clone()
g4326.transform(4326)
center_lon, center_lat = g4326.centroid.x, g4326.centroid.y
break
except Exception:
pass
curr_p = curr_p.parent_location
# Sécurisation des coordonnées JSON pour éviter les erreurs de syntaxe JS causées par la virgule en locale fr
center_lon_json = json.dumps(center_lon) if center_lon is not None else 'null'
center_lat_json = json.dumps(center_lat) if center_lat is not None else 'null'
# Détection du mode spécifique à un niveau / local
is_level = building.location_type in ('level', 'basement', 'attic', 'mezzanine')
is_sublocation = bool(building.parent_location is not None)
level_specific_mode = is_level or (is_sublocation and building.location_type not in ('site', 'building', 'wing'))
def serialize_floor_info(loc):
plan_url = loc.get_floor_plan_url()
rooms = []
for r in loc.child_locations.all():
rooms.append({
'id': r.id,
'code': r.code,
'name': r.name_fr or r.name or r.code,
'room_number': r.room_number or '',
'location_type_display': str(r.get_location_type_display()),
'url': r.get_absolute_url(),
})
return {
'id': loc.id,
'code': loc.code,
'name': loc.name_fr or loc.name or loc.code,
'level_number': loc.level_number if loc.level_number is not None else 0,
'location_type': loc.location_type,
'location_type_display': str(loc.get_location_type_display()),
'surface_m2': float(loc.surface_m2 or loc.usable_surface_m2 or 0) or None,
'has_plan': bool(plan_url),
'plan_url': plan_url or '',
'url': loc.get_absolute_url(),
'rooms': rooms,
'rooms_count': len(rooms),
'assets_count': loc.location_assets.count(),
}
floors_data = []
active_floor = None
if level_specific_mode:
# Dans le détail d'un niveau, on ne retrouve que le plan lié, qui est directement affiché
if is_level or not building.parent_location:
target_floor = building
else:
target_floor = building
curr = building.parent_location
while curr:
if curr.location_type in ('level', 'basement', 'attic', 'mezzanine', 'wing'):
target_floor = curr
break
curr = curr.parent_location
active_floor_info = serialize_floor_info(target_floor)
floors_data = [active_floor_info]
active_floor = active_floor_info
else:
# Vue du bâtiment dans son ensemble: collecte de tous les niveaux
candidates = list(building.child_locations.filter(
Q(location_type__in=['level', 'basement', 'attic', 'mezzanine', 'wing', 'room']) |
Q(level_number__isnull=False)
).select_related('parent_location').prefetch_related('child_locations', 'location_assets', 'documents', 'floor_documents'))
for cand in list(candidates):
if cand.location_type == 'wing':
sub_levels = cand.child_locations.filter(
Q(location_type__in=['level', 'basement', 'attic', 'mezzanine', 'room']) |
Q(level_number__isnull=False)
).select_related('parent_location').prefetch_related('child_locations', 'location_assets', 'documents', 'floor_documents')
candidates.extend(list(sub_levels))
seen_ids = set()
unique_floors = []
for f in candidates:
if f.id not in seen_ids:
seen_ids.add(f.id)
unique_floors.append(f)
unique_floors.sort(key=lambda x: (x.level_number if x.level_number is not None else 99, x.code))
floors_data = [serialize_floor_info(f) for f in unique_floors]
if floors_data:
with_plan = [f for f in floors_data if f['has_plan']]
if with_plan:
active_floor = with_plan[0]
else:
level_0 = [f for f in floors_data if f['level_number'] == 0]
active_floor = level_0[0] if level_0 else floors_data[0]
elif building.get_floor_plan_url():
b_info = serialize_floor_info(building)
floors_data = [b_info]
active_floor = b_info
has_any_plan = any(f['has_plan'] for f in floors_data)
context = { context = {
'building': building, 'building': building,
@ -408,6 +572,14 @@ def building_detail(request, pk):
'building_geojson_json': json.dumps(building_geojson) if building_geojson else 'null', 'building_geojson_json': json.dumps(building_geojson) if building_geojson else 'null',
'center_lon': center_lon, 'center_lon': center_lon,
'center_lat': center_lat, 'center_lat': center_lat,
'center_lon_json': center_lon_json,
'center_lat_json': center_lat_json,
'floors_data': floors_data,
'floors_data_json': json.dumps(floors_data),
'active_floor': active_floor,
'active_floor_json': json.dumps(active_floor) if active_floor else 'null',
'level_specific_mode': level_specific_mode,
'has_any_plan': has_any_plan,
} }
return render(request, 'assets/buildings/publicbuilding_detail.html', context) return render(request, 'assets/buildings/publicbuilding_detail.html', context)

View file

@ -1125,7 +1125,7 @@ class Document(models.Model):
except Exception: except Exception:
pass # Fichier non-image (PDF, etc.) pass # Fichier non-image (PDF, etc.)
if not self.thumbnail: if hasattr(self, "thumbnail") and not self.thumbnail:
self.generate_thumbnail() self.generate_thumbnail()