fix(assets): fix js coord syntax error and add interactive multi-floor plans viewer
This commit is contained in:
parent
512f6786e3
commit
6802d1e15f
9 changed files with 678 additions and 73 deletions
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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'}),
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -253,37 +253,167 @@
|
||||||
|
|
||||||
</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 center_lat and center_lon %}
|
{% if level_specific_mode %}
|
||||||
<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="
|
<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>
|
||||||
target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-2">
|
{% else %}
|
||||||
<img src="{% static 'common/google-street-view-logo.png' %}" alt="Street View" height="14" class="me-1">
|
<span class="fw-bold text-dark"><i class="bi bi-geo-alt text-primary me-2"></i>{% translate "Localisation & Plans" %}</span>
|
||||||
Street View
|
{% endif %}
|
||||||
</a>
|
</div>
|
||||||
{% endif %}
|
|
||||||
|
<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 %}
|
||||||
|
<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" title="Street View">
|
||||||
|
<img src="{% static 'common/google-street-view-logo.png' %}" alt="Street View" height="14" class="me-1">
|
||||||
|
Street View
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-0">
|
|
||||||
<div id="building-map" style="height: 340px; width: 100%; border-radius: 0 0 4px 4px;"></div>
|
<!-- 1. VUE PLANS D'ÉTAGE -->
|
||||||
</div>
|
<div id="section-plans" class="card-body p-0">
|
||||||
<div class="card-footer bg-white p-3">
|
{% if not level_specific_mode and floors_data %}
|
||||||
<p class="mb-2 small">
|
<!-- Sélecteur d'étage horizontal -->
|
||||||
<strong>{% translate "Adresse" %} :</strong> {{ building.address|default:_("Non renseignée") }}
|
<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;">
|
||||||
</p>
|
<span class="text-muted small fw-semibold me-2"><i class="bi bi-filter me-1"></i>{% translate "Niveaux" %} :</span>
|
||||||
{% if center_lat and center_lon %}
|
{% for f in floors_data %}
|
||||||
<div class="small text-muted font-monospace">
|
<button type="button"
|
||||||
WGS84: {{ center_lat|floatformat:6 }}, {{ center_lon|floatformat:6 }}
|
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>
|
</div>
|
||||||
{% endif %}
|
{% 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">
|
||||||
|
<strong>{% translate "Adresse" %} :</strong> {{ building.address|default:_("Non renseignée") }}
|
||||||
|
</p>
|
||||||
|
{% if center_lat and center_lon %}
|
||||||
|
<div class="small text-muted font-monospace">
|
||||||
|
WGS84: {{ center_lat|floatformat:6 }}, {{ center_lon|floatformat:6 }}
|
||||||
|
</div>
|
||||||
|
{% 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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
initMap();
|
// 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");
|
||||||
|
|
||||||
const overviewTab = document.getElementById('overview-tab');
|
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();
|
||||||
|
} else {
|
||||||
|
map.resize();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
btnTogglePlans?.classList.add("active");
|
||||||
|
btnToggleMap?.classList.remove("active");
|
||||||
|
if (sectionPlans) sectionPlans.style.display = "block";
|
||||||
|
if (sectionMap) sectionMap.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue