Compare commits

..

No commits in common. "main" and "1.2.0" have entirely different histories.
main ... 1.2.0

364 changed files with 5555 additions and 47884 deletions

View file

@ -46,8 +46,15 @@ ENV PROJ_DATA=/opt/conda/envs/loko-env/share/proj
# Argument de build pour activer l'installation des librairies IA (ultralytics, OpenCV...) # Argument de build pour activer l'installation des librairies IA (ultralytics, OpenCV...)
# Passer INSTALL_AI_LIBS=true dans le docker-compose pour les inclure # Passer INSTALL_AI_LIBS=true dans le docker-compose pour les inclure
ARG INSTALL_AI_LIBS=false ARG INSTALL_AI_LIBS=false
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
ARG LOKO_VERSION=1.0.0
# Copie et installation des dépendances Python (mis en cache tant que requirements/ ne change pas) ENV GIT_COMMIT_SHA=${GIT_COMMIT}
ENV BUILD_DATE=${BUILD_DATE}
ENV LOKO_VERSION=${LOKO_VERSION}
# Copie et installation des dépendances Python
COPY requirements/ /app/requirements/ COPY requirements/ /app/requirements/
RUN pip install --no-cache-dir -r requirements/base.txt && \ RUN pip install --no-cache-dir -r requirements/base.txt && \
if [ "$INSTALL_AI_LIBS" = "true" ]; then \ if [ "$INSTALL_AI_LIBS" = "true" ]; then \
@ -101,15 +108,6 @@ COPY loko/road_detection/templates/ /app/road_detection/templates/
COPY loko/*.py /app/ COPY loko/*.py /app/
COPY loko/generated_passwords.txt* /app/ COPY loko/generated_passwords.txt* /app/
# Métadonnées de build dynamiques (placées à la fin pour préserver le cache de build Docker)
ARG GIT_COMMIT=unknown
ARG BUILD_DATE=unknown
ARG LOKO_VERSION=1.0.0
ENV GIT_COMMIT_SHA=${GIT_COMMIT}
ENV BUILD_DATE=${BUILD_DATE}
ENV LOKO_VERSION=${LOKO_VERSION}
# Exposer le port par défaut # Exposer le port par défaut
EXPOSE 8000 EXPOSE 8000

View file

@ -451,10 +451,7 @@ admin.site.register(ComplementaryPoliceRegulation, AssetAdmin)
admin.site.register(SignParkingSpot, AssetAdmin) admin.site.register(SignParkingSpot, AssetAdmin)
from .models import ( from .models import ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract
ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract,
ITSBollardSystem, ITSBollardPlot, ITSBollardSystemAsset, ITSBollardSystemContract
)
admin.site.register(ITSAssetModel, AssetAdmin) admin.site.register(ITSAssetModel, AssetAdmin)
@ -471,44 +468,6 @@ class ITSGeoAssetContractAdmin(AssetAdmin):
list_filter = ("contract",) list_filter = ("contract",)
class ITSBollardPlotInline(admin.TabularInline):
model = ITSBollardPlot
extra = 0
fields = ("code", "position", "bollard_type", "model", "serial_number", "status")
show_change_link = True
class ITSBollardSystemAssetInline(admin.TabularInline):
model = ITSBollardSystemAsset
extra = 0
fields = ("asset", "role", "comment")
autocomplete_fields = ("asset",)
show_change_link = True
@admin.register(ITSBollardSystem)
class ITSBollardSystemAdmin(AssetGISAdmin, AssetAdmin):
search_fields = ("code", "name_fr", "name_nl", "location__code", "location__name_fr")
list_display = ("code", "name_fr", "location", "width", "direction", "status", "plots_count")
list_filter = ("status", "direction", "location")
inlines = [ITSBollardPlotInline, ITSBollardSystemAssetInline]
@admin.register(ITSBollardPlot)
class ITSBollardPlotAdmin(AssetGISAdmin, AssetAdmin):
search_fields = ("code", "name_fr", "system__code", "serial_number")
list_display = ("code", "system", "position", "bollard_type", "model", "status")
list_filter = ("bollard_type", "status", "system")
@admin.register(ITSBollardSystemContract)
class ITSBollardSystemContractAdmin(AssetAdmin):
search_fields = ("system__code", "contract__contract_number")
list_display = ("system", "contract", "status", "maintenance_type")
list_filter = ("status", "maintenance_type", "contract")
from .models import ITSLocation, ITSLocationDocument, ITSLocationContract from .models import ITSLocation, ITSLocationDocument, ITSLocationContract
admin.site.register(ITSLocation, AssetAdmin) admin.site.register(ITSLocation, AssetAdmin)
@ -721,34 +680,21 @@ 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", "usage_type", "building_status", "owner", "manager", "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", "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")
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):
list_display = ("code", "name_fr", "asset_type", "building", "location", "parent_asset", "material", "power_kw", "status", "obsolescence", "next_control_date") list_display = ("code", "name_fr", "asset_type", "building", "location", "parent_asset", "status", "obsolescence")
list_filter = ("asset_type", "energy_source", "glazing_type", "opening_type", "status", "obsolescence") list_filter = ("asset_type", "status", "obsolescence")
search_fields = ("code", "name_fr", "name_nl", "serial_number", "material", "maintenance_contract_ref", "contractor_name") search_fields = ("code", "name_fr", "name_nl", "serial_number")
raw_id_fields = ("building", "location", "parent_asset") raw_id_fields = ("building", "location", "parent_asset")
@admin.register(PublicBuildingDocument) admin.site.register(PublicBuildingDocument, AssetAdmin)
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):
@ -786,59 +732,4 @@ class ParkingSpotAdmin(AssetGISAdmin):
) )
search_fields = ("code", "name_fr", "name_nl", "note") search_fields = ("code", "name_fr", "name_nl", "note")
filter_horizontal = ("poles", "panels") filter_horizontal = ("poles", "panels")
raw_id_fields = ("location", "street") raw_id_fields = ("location", "street")
# ─── Management Zones ──────────────────────────────────────────────────────────
from django.utils.html import format_html
from django.contrib.contenttypes.admin import GenericTabularInline
from .models import ManagementZoneType, ManagementZone, ManagementZoneAsset, ManagementZoneDocument
class ManagementZoneDocumentInline(admin.TabularInline):
model = ManagementZoneDocument
extra = 1
class ManagementZoneAssetInline(GenericTabularInline):
model = ManagementZoneAsset
ct_field = 'content_type'
ct_fk_field = 'object_id'
extra = 1
@admin.register(ManagementZoneType)
class ManagementZoneTypeAdmin(admin.ModelAdmin):
list_display = ('code', 'name_fr', 'name_nl', 'thematic', 'color_badge')
list_filter = ('thematic',)
search_fields = ('code', 'name_fr', 'name_nl', 'description')
def color_badge(self, obj):
color = obj.default_color or "#198754"
return format_html(
'<span style="display:inline-block;width:16px;height:16px;background-color:{};border:1px solid #999;border-radius:3px;vertical-align:middle;margin-right:6px;"></span>{}',
color, color
)
color_badge.short_description = "Couleur"
@admin.register(ManagementZone)
class ManagementZoneAdmin(AssetGISAdmin):
list_display = ('code', 'name_fr', 'thematic', 'zone_type', 'color_badge', 'target_frequency_days', 'is_active', 'has_geom')
list_filter = ('thematic', 'zone_type', 'is_active')
search_fields = ('code', 'name_fr', 'name_nl', 'instructions', 'description', 'seasonality_notes')
readonly_fields = ('created_at', 'updated_at')
inlines = [ManagementZoneDocumentInline, ManagementZoneAssetInline]
def color_badge(self, obj):
color = obj.get_color()
return format_html(
'<span style="display:inline-block;width:16px;height:16px;background-color:{};border:1px solid #999;border-radius:3px;vertical-align:middle;margin-right:6px;"></span>{}',
color, color
)
color_badge.short_description = "Couleur"
def has_geom(self, obj):
return bool(obj.geom)
has_geom.boolean = True
has_geom.short_description = "Géométrie"

View file

@ -54,15 +54,4 @@ 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

@ -1,4 +1,3 @@
from django.contrib.gis.geos import GEOSGeometry, Polygon, MultiPolygon
from django import forms from django import forms
from assets.models import TrafficLightIntersectionDocument, PublicLightingStreetDocument, StructureDocument, ControlCenterDocument from assets.models import TrafficLightIntersectionDocument, PublicLightingStreetDocument, StructureDocument, ControlCenterDocument
@ -288,7 +287,6 @@ 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', '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',
@ -314,12 +312,6 @@ class PublicBuildingForm(forms.ModelForm):
'usable_surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}), 'usable_surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}), 'surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'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'}),
'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'}),
'fire_compartment': forms.TextInput(attrs={'class': 'form-control'}),
'floor_type': forms.Select(attrs={'class': 'form-select'}),
'is_erp': forms.CheckboxInput(attrs={'class': 'form-check-input'}), 'is_erp': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'erp_category': forms.Select(attrs={'class': 'form-select'}), 'erp_category': forms.Select(attrs={'class': 'form-select'}),
'erp_type': forms.Select(attrs={'class': 'form-select'}), 'erp_type': forms.Select(attrs={'class': 'form-select'}),
@ -335,15 +327,29 @@ class PublicBuildingForm(forms.ModelForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for field_name in [ self.fields['name_fr'].required = False
'name_fr', 'name_nl', 'parent_location', 'location_type', 'address', 'description', self.fields['name_nl'].required = False
'owner', 'manager', 'occupant_entity', 'construction_year', 'floors_count', self.fields['parent_location'].required = False
'basement_count', 'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number', self.fields['location_type'].required = False
'room_number', 'ceiling_height_m', 'fire_compartment', 'floor_type', self.fields['address'].required = False
'erp_category', 'erp_type', 'max_capacity', 'dpe_rating', 'ges_rating', 'lat', 'lon' self.fields['description'].required = False
]: self.fields['owner'].required = False
if field_name in self.fields: self.fields['manager'].required = False
self.fields[field_name].required = False self.fields['occupant_entity'].required = False
self.fields['construction_year'].required = False
self.fields['floors_count'].required = False
self.fields['basement_count'].required = False
self.fields['total_surface_m2'].required = False
self.fields['usable_surface_m2'].required = False
self.fields['surface_m2'].required = False
self.fields['level_number'].required = False
self.fields['erp_category'].required = False
self.fields['erp_type'].required = False
self.fields['max_capacity'].required = False
self.fields['dpe_rating'].required = False
self.fields['ges_rating'].required = False
self.fields['lat'].required = False
self.fields['lon'].required = False
class BuildingAssetForm(forms.ModelForm): class BuildingAssetForm(forms.ModelForm):
@ -352,14 +358,8 @@ class BuildingAssetForm(forms.ModelForm):
model = BuildingAsset model = BuildingAsset
fields = [ fields = [
'code', 'name_fr', 'name_nl', 'building', 'location', 'parent_asset', 'code', 'name_fr', 'name_nl', 'building', 'location', 'parent_asset',
'asset_type', 'category', 'status', 'obsolescence', 'asset_type', 'category', 'status', 'obsolescence', 'installation_date',
'dimensions', 'surface_m2', 'material', 'serial_number', 'last_control_date', 'next_control_date', 'note'
'power_kw', 'energy_source', 'refrigerant_type', 'refrigerant_charge_kg',
'glazing_type', 'fire_rating', 'opening_type',
'installation_date', 'serial_number',
'maintenance_contract_ref', 'contractor_name',
'last_control_date', 'next_control_date', 'regulatory_periodicity_months',
'replacement_cost_estimate', 'note'
] ]
widgets = { widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}), 'code': forms.TextInput(attrs={'class': 'form-control'}),
@ -372,37 +372,16 @@ class BuildingAssetForm(forms.ModelForm):
'category': forms.Select(attrs={'class': 'form-select'}), 'category': forms.Select(attrs={'class': 'form-select'}),
'status': forms.Select(attrs={'class': 'form-select'}), 'status': forms.Select(attrs={'class': 'form-select'}),
'obsolescence': forms.Select(attrs={'class': 'form-select'}), 'obsolescence': forms.Select(attrs={'class': 'form-select'}),
'dimensions': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: 120x140 cm, 350 m²'}),
'surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'material': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: Aluminium, PVC, Zinc, Acier'}),
'power_kw': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
'energy_source': forms.Select(attrs={'class': 'form-select'}),
'refrigerant_type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: R32, R410A'}),
'refrigerant_charge_kg': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'glazing_type': forms.Select(attrs={'class': 'form-select'}),
'fire_rating': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: EI30, EI60, Coupe-feu 1h'}),
'opening_type': forms.Select(attrs={'class': 'form-select'}),
'installation_date': forms.DateTimeInput(attrs={'class': 'form-control', 'type': 'datetime-local'}), 'installation_date': forms.DateTimeInput(attrs={'class': 'form-control', 'type': 'datetime-local'}),
'serial_number': forms.TextInput(attrs={'class': 'form-control'}), 'serial_number': forms.TextInput(attrs={'class': 'form-control'}),
'maintenance_contract_ref': forms.TextInput(attrs={'class': 'form-control'}),
'contractor_name': forms.TextInput(attrs={'class': 'form-control'}),
'last_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), 'last_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'next_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}), 'next_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'regulatory_periodicity_months': forms.NumberInput(attrs={'class': 'form-control'}),
'replacement_cost_estimate': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'note': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}), 'note': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
} }
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for field_name in [ for field_name in ['name_fr', 'name_nl', 'location', 'parent_asset', 'category', 'installation_date', 'serial_number', 'last_control_date', 'next_control_date', 'note']:
'name_fr', 'name_nl', 'location', 'parent_asset', 'category',
'dimensions', 'surface_m2', 'material', 'power_kw', 'energy_source',
'refrigerant_type', 'refrigerant_charge_kg', 'glazing_type', 'fire_rating', 'opening_type',
'installation_date', 'serial_number', 'maintenance_contract_ref', 'contractor_name',
'last_control_date', 'next_control_date', 'regulatory_periodicity_months',
'replacement_cost_estimate', 'note'
]:
if field_name in self.fields: if field_name in self.fields:
self.fields[field_name].required = False self.fields[field_name].required = False
from common.models import Thematic from common.models import Thematic
@ -460,99 +439,4 @@ class StructureExceptionalTransportForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for name, field in self.fields.items(): for name, field in self.fields.items():
if not isinstance(field.widget, forms.CheckboxInput): if not isinstance(field.widget, forms.CheckboxInput):
field.required = False field.required = False
# ─── Management Zones Forms ───────────────────────────────────────────────────
from assets.models import ManagementZone, ManagementZoneType, ManagementZoneDocument
class ManagementZoneForm(forms.ModelForm):
"""Formulaire de création et d'édition d'une Zone de gestion."""
geom_geojson = forms.CharField(required=False, widget=forms.HiddenInput())
class Meta:
model = ManagementZone
fields = [
'code', 'name_fr', 'name_nl', 'thematic', 'zone_type', 'color',
'target_frequency_days', 'seasonality_notes', 'instructions', 'description', 'is_active'
]
widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}),
'name_fr': forms.TextInput(attrs={'class': 'form-control'}),
'name_nl': forms.TextInput(attrs={'class': 'form-control'}),
'thematic': forms.Select(attrs={'class': 'form-select'}),
'zone_type': forms.Select(attrs={'class': 'form-select'}),
'color': forms.TextInput(attrs={'class': 'form-control form-control-color', 'type': 'color'}),
'target_frequency_days': forms.NumberInput(attrs={'class': 'form-control', 'min': 1}),
'seasonality_notes': forms.TextInput(attrs={'class': 'form-control', 'placeholder': "Ex: Tonte d'avril à octobre; fauche tardive en automne"}),
'instructions': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': "Consignes d'exploitation, précautions tramway, biodiversité..."}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
for field in ['name_nl', 'zone_type', 'color', 'target_frequency_days', 'seasonality_notes', 'instructions', 'description']:
if field in self.fields:
self.fields[field].required = False
if not self.instance.pk and not self.initial.get('color'):
self.initial['color'] = '#198754'
if self.instance and self.instance.pk and self.instance.geom:
geom_wgs84 = self.instance.geom.transform(4326, clone=True)
self.initial['geom_geojson'] = geom_wgs84.geojson
if user:
from assets.permissions import get_editable_thematics_for_management_zones
editable_thematics = get_editable_thematics_for_management_zones(user)
self.fields['thematic'].queryset = editable_thematics
if not self.instance.pk and editable_thematics.count() == 1:
self.fields['thematic'].initial = editable_thematics.first()
thematic_id = None
if self.data.get('thematic'):
thematic_id = self.data.get('thematic')
elif self.instance.pk and self.instance.thematic_id:
thematic_id = self.instance.thematic_id
elif self.initial.get('thematic'):
thematic_id = self.initial.get('thematic')
if hasattr(thematic_id, 'pk'):
thematic_id = thematic_id.pk
if thematic_id:
self.fields['zone_type'].queryset = ManagementZoneType.objects.filter(thematic_id=thematic_id)
else:
self.fields['zone_type'].queryset = ManagementZoneType.objects.all()
def save(self, commit=True):
zone = super().save(commit=False)
geojson_str = (self.cleaned_data.get('geom_geojson') or '').strip()
if geojson_str:
try:
geom = GEOSGeometry(geojson_str, srid=4326)
if isinstance(geom, Polygon):
geom = MultiPolygon(geom, srid=4326)
geom.transform(3812)
zone.geom = geom
except Exception:
pass
elif 'geom_geojson' in self.cleaned_data and not geojson_str:
zone.geom = None
if commit:
zone.save()
return zone
class ManagementZoneDocumentForm(forms.ModelForm):
documents = MultipleFileField(required=True, label="Documents")
class Meta:
model = ManagementZoneDocument
fields = ['documents']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['documents'].widget.attrs.update({'class': 'form-control'})

View file

@ -4833,8 +4833,8 @@ msgid "Description / Remarques"
msgstr "Description / Remarks" msgstr "Description / Remarks"
#: assets/templates/assets/components/asset_interventions.html:169 #: assets/templates/assets/components/asset_interventions.html:169
msgid "Détails ou remarques globales..." msgid "Détails ou remarques initiales..."
msgstr "Details or global remarks..." msgstr "Details or initial remarks..."
#: assets/templates/assets/components/asset_interventions.html:176 #: assets/templates/assets/components/asset_interventions.html:176
#: assets/templates/assets/components/location_edit_modals.html:98 #: assets/templates/assets/components/location_edit_modals.html:98
@ -4966,7 +4966,7 @@ msgstr "Remarks / Observations"
#: assets/templates/assets/components/inspection_form_modal.html:129 #: assets/templates/assets/components/inspection_form_modal.html:129
msgid "Saisir vos remarques ou détails..." msgid "Saisir vos remarques ou détails..."
msgstr "Details or global remarks..." msgstr "Details or initial remarks..."
#: assets/templates/assets/components/inspection_form_modal.html:138 #: assets/templates/assets/components/inspection_form_modal.html:138
msgid "Valider l'inspection" msgid "Valider l'inspection"
@ -8168,7 +8168,7 @@ msgstr "Accessory"
#: assets/views/core.py:447 #: assets/views/core.py:447
msgid "Surface verte" msgid "Surface verte"
msgstr "Green surface" msgstr "Slippery surface"
#: assets/views/core.py:449 #: assets/views/core.py:449
msgid "Arbuste" msgid "Arbuste"
@ -8573,126 +8573,6 @@ msgstr "GIEP zones"
msgid "Aucune localisation GIEP trouvée à proximité" msgid "Aucune localisation GIEP trouvée à proximité"
msgstr "No location found" msgstr "No location found"
msgid "Supprimer de la DB"
msgstr "Delete from DB"
msgid "Supprimer l'existant de la DB"
msgstr "Delete existing from DB"
msgid "Ne pas créer (Ignorer)"
msgstr "Do not create (Ignore)"
msgid "Archiver l'existant (Ne pas recréer)"
msgstr "Archive existing (do not recreate)"
msgid "Archiver l'existant"
msgstr "Archive existing"
msgid "Aide au tracé des câbles"
msgstr "Cable tracing help"
msgid "Tracer"
msgstr "Trace"
msgid "Double-clic sur la carte pour ajouter un point."
msgstr "Double-click on the map to add a point."
msgid "Ajouter un nœud"
msgstr "Add a node"
msgid "Double-clic sur un segment du câble."
msgstr "Double-click on a cable segment."
msgid "Supprimer un nœud"
msgstr "Delete a node"
msgid "Double-clic sur un nœud (poignée orange)."
msgstr "Double-click on a node (orange handle)."
msgid "Déplacer"
msgstr "Move"
msgid "Glisser-déposer les poignées pour ajuster."
msgstr "Drag and drop handles to adjust."
msgid "Copier tracé"
msgstr "Copy trace"
msgid ""
"Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgstr ""
"Copy the trace of another cable following the same path (e.g. 48 and tel.)."
msgid "Copier tracé…"
msgstr "Copy trace…"
msgid "Copier le tracé d'un autre câble"
msgstr "Copy another cable's trace"
msgid "Appliquer aux autres câbles…"
msgstr "Apply to other cables…"
msgid ""
"Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud "
"pour le supprimer • Glisser pour déplacer"
msgstr ""
"Double-click a segment to add a node • Double-click a node to delete • Drag "
"to move"
msgid "Appliquer le tracé à d'autres câbles"
msgstr "Apply trace to other cables"
msgid "Copier le tracé de"
msgstr "Copy trace from"
msgid "vers les câbles sélectionnés ci-dessous :"
msgstr "to the selected cables below:"
msgid "Copier le tracé"
msgstr "Copy trace"
msgid "Le câble source ne possède aucun tracé valide à copier."
msgstr "The source cable has no valid trace to copy."
msgid "Aucun tracé disponible"
msgstr "No trace available"
msgid "Tracé copié de"
msgstr "Trace copied from"
msgid "vers"
msgstr "to"
msgid ""
"Veuillez d'abord tracer au moins deux points pour ce câble avant de le "
"dupliquer."
msgstr "Please trace at least two points for this cable before duplicating."
msgid "Aucun autre câble disponible dans ce carrefour."
msgstr "No other cable available at this intersection."
msgid "Veuillez sélectionner au moins un câble cible."
msgstr "Please select at least one target cable."
msgid "câble(s) mis à jour avec ce tracé !"
msgstr "cable(s) updated with this trace!"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout replier"
msgstr "Collapse all"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout déplier"
msgstr "Expand all"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets physiques rattachés"
msgstr "Linked physical assets"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets rattachés directement au bâtiment"
msgstr "Assets directly linked to building"
#~ msgid "Absent du fichier Excel (Sera conservé ou archivé)" #~ msgid "Absent du fichier Excel (Sera conservé ou archivé)"
#~ msgstr "Missing from Excel file (Will be kept or archived)" #~ msgstr "Missing from Excel file (Will be kept or archived)"
@ -8710,57 +8590,3 @@ msgstr "Assets directly linked to building"
#~ msgid "Arceaux cargo constatés" #~ msgid "Arceaux cargo constatés"
#~ msgstr "Observed cargo bike racks" #~ msgstr "Observed cargo bike racks"
msgid "Type de zone de gestion"
msgstr "Management zone type"
msgid "Types de zones de gestion"
msgstr "Management zone types"
msgid "Zone de gestion"
msgstr "Management zone"
msgid "Zones de gestion"
msgstr "Management zones"
msgid "Consignes et points d'attention"
msgstr "Instructions and points of attention"
msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..."
msgstr "Operational instructions, points of vigilance, safety rules..."
msgid "Périmètre géographique"
msgstr "Geographical perimeter"
msgid "Couleur d'affichage"
msgstr "Display color"
msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone."
msgstr "Hexadecimal code (e.g. #198754). If empty, the zone type color is used."
msgid "Fréquence cible (jours)"
msgstr "Target frequency (days)"
msgid "Intervalle indicatif entre interventions en jours."
msgstr "Indicative interval between interventions in days."
msgid "Saisonnalité / Calendrier"
msgstr "Seasonality / Calendar"
msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps."
msgstr "E.g.: Mowing from April to October; late mowing in September; pruning prohibited in spring."
msgid "Asset de zone de gestion"
msgstr "Management zone asset"
msgid "Assets de zones de gestion"
msgstr "Management zone assets"
msgid "Document de zone de gestion"
msgstr "Management zone document"
msgid "Documents de zones de gestion"
msgstr "Management zone documents"
msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre."
msgstr "If checked, this asset is excluded from the zone even if its geometry intersects the perimeter."

View file

@ -613,134 +613,3 @@ msgstr "Collapse models"
#: loko/assets/static/assets/unified_index.js:3726 #: loko/assets/static/assets/unified_index.js:3726
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options." msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
msgstr "" msgstr ""
#: loko/assets/static/assets/inspection_map.js:93
msgid "Rechercher une adresse…"
msgstr "Search an address…"
msgid "Rechercher une adresse..."
msgstr "Search an address..."
#: loko/assets/static/assets/inspection_map.js:748
msgid "Géolocalisation..."
msgstr "Geolocation..."
#: loko/assets/static/assets/inspection_map.js:769
msgid "Position actualisée"
msgstr "Position updated"
#: loko/assets/static/assets/inspection_map.js:773
msgid "GPS indisponible"
msgstr "GPS unavailable"
#: loko/assets/static/assets/inspection_map.js:935
msgid "Inspections (Désactivé)"
msgstr "Inspections (Disabled)"
#: loko/assets/static/assets/inspection_map.js:936
msgid "Inspections (1 mois)"
msgstr "Inspections (1 month)"
#: loko/assets/static/assets/inspection_map.js:937
msgid "Inspections (3 mois)"
msgstr "Inspections (3 months)"
#: loko/assets/static/assets/inspection_map.js:938
msgid "Inspections (6 mois)"
msgstr "Inspections (6 months)"
#: loko/assets/static/assets/inspection_map.js:939
msgid "Inspections (1 an)"
msgstr "Inspections (1 year)"
#: loko/assets/static/assets/inspection_map.js:940
msgid "Inspections (Tout)"
msgstr "Inspections (All)"
msgid "Panneau"
msgstr "Sign"
msgid "Supprimer ce panneau"
msgstr "Delete this sign"
msgid "Mention (ex: Sauf riverains)"
msgstr "Mention (e.g. Except local traffic)"
msgid "Propreté :"
msgstr "Cleanliness:"
msgid "Propre"
msgstr "Clean"
msgid "Sale"
msgstr "Dirty"
msgid "Végétation"
msgstr "Vegetation"
msgid "Dégradé"
msgstr "Damaged"
msgid "Coordonnées GPS manquantes."
msgstr "Missing GPS coordinates."
msgid "Veuillez ajouter au moins un panneau."
msgstr "Please add at least one sign."
msgid "Panneaux créés avec succès !"
msgstr "Signs created successfully!"
msgid "Erreur : "
msgstr "Error: "
msgid "Impossible de créer les panneaux."
msgstr "Unable to create signs."
msgid "Erreur lors de la création : "
msgstr "Error during creation: "
msgid "Créer et Enregistrer"
msgstr "Create and Save"
msgid "Inspection enregistrée !"
msgstr "Inspection saved!"
msgid "Intervention"
msgstr "Intervention"
msgid "créée."
msgstr "created."
msgid "Erreur lors de la validation."
msgstr "Validation error."
msgid "Erreur réseau."
msgstr "Network error."
msgid "Zones de gestion"
msgstr "Management zones"
msgid "Aucune zone de gestion définie pour cette thématique."
msgstr "No management zone defined for this thematic."
msgid "zones"
msgstr "zones"
msgid "Afficher / masquer toutes les zones"
msgstr "Show / hide all zones"
msgid "Opacité"
msgstr "Opacity"
msgid "Fréquence cible :"
msgstr "Target frequency:"
msgid "jours"
msgstr "days"
msgid "Consignes & points d'attention"
msgstr "Instructions & points of attention"
msgid "Centrer sur la zone"
msgstr "Center on zone"

View file

@ -4839,8 +4839,8 @@ msgid "Description / Remarques"
msgstr "Description / Remarques" msgstr "Description / Remarques"
#: assets/templates/assets/components/asset_interventions.html:169 #: assets/templates/assets/components/asset_interventions.html:169
msgid "Détails ou remarques globales..." msgid "Détails ou remarques initiales..."
msgstr "Détails ou remarques globales..." msgstr "Détails ou remarques initiales..."
#: assets/templates/assets/components/asset_interventions.html:176 #: assets/templates/assets/components/asset_interventions.html:176
#: assets/templates/assets/components/location_edit_modals.html:98 #: assets/templates/assets/components/location_edit_modals.html:98
@ -4972,7 +4972,7 @@ msgstr "Remarques / Observations"
#: assets/templates/assets/components/inspection_form_modal.html:129 #: assets/templates/assets/components/inspection_form_modal.html:129
msgid "Saisir vos remarques ou détails..." msgid "Saisir vos remarques ou détails..."
msgstr "Détails ou remarques globales..." msgstr "Détails ou remarques initiales..."
#: assets/templates/assets/components/inspection_form_modal.html:138 #: assets/templates/assets/components/inspection_form_modal.html:138
msgid "Valider l'inspection" msgid "Valider l'inspection"
@ -8624,128 +8624,6 @@ msgstr "Zones GIEP"
msgid "Aucune localisation GIEP trouvée à proximité" msgid "Aucune localisation GIEP trouvée à proximité"
msgstr "Aucune localisation GIEP trouvée à proximité" msgstr "Aucune localisation GIEP trouvée à proximité"
msgid "Supprimer de la DB"
msgstr "Supprimer de la DB"
msgid "Supprimer l'existant de la DB"
msgstr "Supprimer l'existant de la DB"
msgid "Ne pas créer (Ignorer)"
msgstr "Ne pas créer (Ignorer)"
msgid "Archiver l'existant (Ne pas recréer)"
msgstr "Archiver l'existant (Ne pas recréer)"
msgid "Archiver l'existant"
msgstr "Archiver l'existant"
msgid "Aide au tracé des câbles"
msgstr "Aide au tracé des câbles"
msgid "Tracer"
msgstr "Tracer"
msgid "Double-clic sur la carte pour ajouter un point."
msgstr "Double-clic sur la carte pour ajouter un point."
msgid "Ajouter un nœud"
msgstr "Ajouter un nœud"
msgid "Double-clic sur un segment du câble."
msgstr "Double-clic sur un segment du câble."
msgid "Supprimer un nœud"
msgstr "Supprimer un nœud"
msgid "Double-clic sur un nœud (poignée orange)."
msgstr "Double-clic sur un nœud (poignée orange)."
msgid "Déplacer"
msgstr "Déplacer"
msgid "Glisser-déposer les poignées pour ajuster."
msgstr "Glisser-déposer les poignées pour ajuster."
msgid "Copier tracé"
msgstr "Copier tracé"
msgid ""
"Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgstr ""
"Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgid "Copier tracé…"
msgstr "Copier tracé…"
msgid "Copier le tracé d'un autre câble"
msgstr "Copier le tracé d'un autre câble"
msgid "Appliquer aux autres câbles…"
msgstr "Appliquer aux autres câbles…"
msgid ""
"Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud "
"pour le supprimer • Glisser pour déplacer"
msgstr ""
"Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud "
"pour le supprimer • Glisser pour déplacer"
msgid "Appliquer le tracé à d'autres câbles"
msgstr "Appliquer le tracé à d'autres câbles"
msgid "Copier le tracé de"
msgstr "Copier le tracé de"
msgid "vers les câbles sélectionnés ci-dessous :"
msgstr "vers les câbles sélectionnés ci-dessous :"
msgid "Copier le tracé"
msgstr "Copier le tracé"
msgid "Le câble source ne possède aucun tracé valide à copier."
msgstr "Le câble source ne possède aucun tracé valide à copier."
msgid "Aucun tracé disponible"
msgstr "Aucun tracé disponible"
msgid "Tracé copié de"
msgstr "Tracé copié de"
msgid "vers"
msgstr "vers"
msgid ""
"Veuillez d'abord tracer au moins deux points pour ce câble avant de le "
"dupliquer."
msgstr ""
"Veuillez d'abord tracer au moins deux points pour ce câble avant de le "
"dupliquer."
msgid "Aucun autre câble disponible dans ce carrefour."
msgstr "Aucun autre câble disponible dans ce carrefour."
msgid "Veuillez sélectionner au moins un câble cible."
msgstr "Veuillez sélectionner au moins un câble cible."
msgid "câble(s) mis à jour avec ce tracé !"
msgstr "câble(s) mis à jour avec ce tracé !"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout replier"
msgstr "Tout replier"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout déplier"
msgstr "Tout déplier"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets physiques rattachés"
msgstr "Assets physiques rattachés"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets rattachés directement au bâtiment"
msgstr "Assets rattachés directement au bâtiment"
#~ msgid "Absent du fichier Excel (Sera conservé ou archivé)" #~ msgid "Absent du fichier Excel (Sera conservé ou archivé)"
#~ msgstr "Absent du fichier Excel (Sera conservé ou archivé)" #~ msgstr "Absent du fichier Excel (Sera conservé ou archivé)"
@ -8763,57 +8641,3 @@ msgstr "Assets rattachés directement au bâtiment"
#~ msgid "Arceaux cargo constatés" #~ msgid "Arceaux cargo constatés"
#~ msgstr "Arceaux cargo constatés" #~ msgstr "Arceaux cargo constatés"
msgid "Type de zone de gestion"
msgstr "Type de zone de gestion"
msgid "Types de zones de gestion"
msgstr "Types de zones de gestion"
msgid "Zone de gestion"
msgstr "Zone de gestion"
msgid "Zones de gestion"
msgstr "Zones de gestion"
msgid "Consignes et points d'attention"
msgstr "Consignes et points d'attention"
msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..."
msgstr "Consignes d'exploitation, points de vigilance, règles de sécurité..."
msgid "Périmètre géographique"
msgstr "Périmètre géographique"
msgid "Couleur d'affichage"
msgstr "Couleur d'affichage"
msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone."
msgstr "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone."
msgid "Fréquence cible (jours)"
msgstr "Fréquence cible (jours)"
msgid "Intervalle indicatif entre interventions en jours."
msgstr "Intervalle indicatif entre interventions en jours."
msgid "Saisonnalité / Calendrier"
msgstr "Saisonnalité / Calendrier"
msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps."
msgstr "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps."
msgid "Asset de zone de gestion"
msgstr "Asset de zone de gestion"
msgid "Assets de zones de gestion"
msgstr "Assets de zones de gestion"
msgid "Document de zone de gestion"
msgstr "Document de zone de gestion"
msgid "Documents de zones de gestion"
msgstr "Documents de zones de gestion"
msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre."
msgstr "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre."

View file

@ -618,134 +618,3 @@ msgstr "Replier les modèles"
#: loko/assets/static/assets/unified_index.js:3726 #: loko/assets/static/assets/unified_index.js:3726
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options." msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
msgstr "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options." msgstr "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
#: loko/assets/static/assets/inspection_map.js:93
msgid "Rechercher une adresse…"
msgstr "Rechercher une adresse…"
msgid "Rechercher une adresse..."
msgstr "Rechercher une adresse..."
#: loko/assets/static/assets/inspection_map.js:748
msgid "Géolocalisation..."
msgstr "Géolocalisation..."
#: loko/assets/static/assets/inspection_map.js:769
msgid "Position actualisée"
msgstr "Position actualisée"
#: loko/assets/static/assets/inspection_map.js:773
msgid "GPS indisponible"
msgstr "GPS indisponible"
#: loko/assets/static/assets/inspection_map.js:935
msgid "Inspections (Désactivé)"
msgstr "Inspections (Désactivé)"
#: loko/assets/static/assets/inspection_map.js:936
msgid "Inspections (1 mois)"
msgstr "Inspections (1 mois)"
#: loko/assets/static/assets/inspection_map.js:937
msgid "Inspections (3 mois)"
msgstr "Inspections (3 mois)"
#: loko/assets/static/assets/inspection_map.js:938
msgid "Inspections (6 mois)"
msgstr "Inspections (6 mois)"
#: loko/assets/static/assets/inspection_map.js:939
msgid "Inspections (1 an)"
msgstr "Inspections (1 an)"
#: loko/assets/static/assets/inspection_map.js:940
msgid "Inspections (Tout)"
msgstr "Inspections (Tout)"
msgid "Panneau"
msgstr "Panneau"
msgid "Supprimer ce panneau"
msgstr "Supprimer ce panneau"
msgid "Mention (ex: Sauf riverains)"
msgstr "Mention (ex: Sauf riverains)"
msgid "Propreté :"
msgstr "Propreté :"
msgid "Propre"
msgstr "Propre"
msgid "Sale"
msgstr "Sale"
msgid "Végétation"
msgstr "Végétation"
msgid "Dégradé"
msgstr "Dégradé"
msgid "Coordonnées GPS manquantes."
msgstr "Coordonnées GPS manquantes."
msgid "Veuillez ajouter au moins un panneau."
msgstr "Veuillez ajouter au moins un panneau."
msgid "Panneaux créés avec succès !"
msgstr "Panneaux créés avec succès !"
msgid "Erreur : "
msgstr "Erreur : "
msgid "Impossible de créer les panneaux."
msgstr "Impossible de créer les panneaux."
msgid "Erreur lors de la création : "
msgstr "Erreur lors de la création : "
msgid "Créer et Enregistrer"
msgstr "Créer et Enregistrer"
msgid "Inspection enregistrée !"
msgstr "Inspection enregistrée !"
msgid "Intervention"
msgstr "Intervention"
msgid "créée."
msgstr "créée."
msgid "Erreur lors de la validation."
msgstr "Erreur lors de la validation."
msgid "Erreur réseau."
msgstr "Erreur réseau."
msgid "Zones de gestion"
msgstr "Zones de gestion"
msgid "Aucune zone de gestion définie pour cette thématique."
msgstr "Aucune zone de gestion définie pour cette thématique."
msgid "zones"
msgstr "zones"
msgid "Afficher / masquer toutes les zones"
msgstr "Afficher / masquer toutes les zones"
msgid "Opacité"
msgstr "Opacité"
msgid "Fréquence cible :"
msgstr "Fréquence cible :"
msgid "jours"
msgstr "jours"
msgid "Consignes & points d'attention"
msgstr "Consignes & points d'attention"
msgid "Centrer sur la zone"
msgstr "Centrer sur la zone"

View file

@ -4834,8 +4834,8 @@ msgid "Description / Remarques"
msgstr "Hardwarebeschrijving" msgstr "Hardwarebeschrijving"
#: assets/templates/assets/components/asset_interventions.html:169 #: assets/templates/assets/components/asset_interventions.html:169
msgid "Détails ou remarques globales..." msgid "Détails ou remarques initiales..."
msgstr "Details of globale opmerkingen..." msgstr "Details of eerste opmerkingen..."
#: assets/templates/assets/components/asset_interventions.html:176 #: assets/templates/assets/components/asset_interventions.html:176
#: assets/templates/assets/components/location_edit_modals.html:98 #: assets/templates/assets/components/location_edit_modals.html:98
@ -4967,7 +4967,7 @@ msgstr "Observaties"
#: assets/templates/assets/components/inspection_form_modal.html:129 #: assets/templates/assets/components/inspection_form_modal.html:129
msgid "Saisir vos remarques ou détails..." msgid "Saisir vos remarques ou détails..."
msgstr "Details of globale opmerkingen..." msgstr "Details of eerste opmerkingen..."
#: assets/templates/assets/components/inspection_form_modal.html:138 #: assets/templates/assets/components/inspection_form_modal.html:138
msgid "Valider l'inspection" msgid "Valider l'inspection"
@ -8572,128 +8572,6 @@ msgstr "GIEP-zones"
msgid "Aucune localisation GIEP trouvée à proximité" msgid "Aucune localisation GIEP trouvée à proximité"
msgstr "Geen locatie gevonden." msgstr "Geen locatie gevonden."
msgid "Supprimer de la DB"
msgstr "Verwijderen uit de database"
msgid "Supprimer l'existant de la DB"
msgstr "Bestaande verwijderen uit de database"
msgid "Ne pas créer (Ignorer)"
msgstr "Niet aanmaken (Negeren)"
msgid "Archiver l'existant (Ne pas recréer)"
msgstr "Bestaande archiveren (niet opnieuw aanmaken)"
msgid "Archiver l'existant"
msgstr "Bestaande archiveren"
msgid "Aide au tracé des câbles"
msgstr "Hulp bij het traceren van kabels"
msgid "Tracer"
msgstr "Traceren"
msgid "Double-clic sur la carte pour ajouter un point."
msgstr "Dubbelklik op de kaart om een punt toe te voegen."
msgid "Ajouter un nœud"
msgstr "Knooppunt toevoegen"
msgid "Double-clic sur un segment du câble."
msgstr "Dubbelklik op een segment van de kabel."
msgid "Supprimer un nœud"
msgstr "Knooppunt verwijderen"
msgid "Double-clic sur un nœud (poignée orange)."
msgstr "Dubbelklik op een knooppunt (oranje handgreep)."
msgid "Déplacer"
msgstr "Verplaatsen"
msgid "Glisser-déposer les poignées pour ajuster."
msgstr "Sleep de handgrepen om aan te passen."
msgid "Copier tracé"
msgstr "Tracé kopiëren"
msgid ""
"Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)."
msgstr ""
"Kopieer het tracé van een andere kabel die hetzelfde traject volgt (bv. 48 "
"en tel.)."
msgid "Copier tracé…"
msgstr "Tracé kopiëren…"
msgid "Copier le tracé d'un autre câble"
msgstr "Het tracé van een andere kabel kopiëren"
msgid "Appliquer aux autres câbles…"
msgstr "Toepassen op andere kabels…"
msgid ""
"Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud "
"pour le supprimer • Glisser pour déplacer"
msgstr ""
"Dubbelklik op een segment om een knooppunt toe te voegen • Dubbelklik op een"
" knooppunt om te verwijderen • Sleep om te verplaatsen"
msgid "Appliquer le tracé à d'autres câbles"
msgstr "Tracé toepassen op andere kabels"
msgid "Copier le tracé de"
msgstr "Tracé kopiëren van"
msgid "vers les câbles sélectionnés ci-dessous :"
msgstr "naar de onderstaande geselecteerde kabels:"
msgid "Copier le tracé"
msgstr "Tracé kopiëren"
msgid "Le câble source ne possède aucun tracé valide à copier."
msgstr "De bronkabel heeft geen geldig tracé om te kopiëren."
msgid "Aucun tracé disponible"
msgstr "Geen tracé beschikbaar"
msgid "Tracé copié de"
msgstr "Tracé gekopieerd van"
msgid "vers"
msgstr "naar"
msgid ""
"Veuillez d'abord tracer au moins deux points pour ce câble avant de le "
"dupliquer."
msgstr ""
"Teken eerst ten minste twee punten voor deze kabel alvorens te dupliceren."
msgid "Aucun autre câble disponible dans ce carrefour."
msgstr "Geen andere kabel beschikbaar op dit kruispunt."
msgid "Veuillez sélectionner au moins un câble cible."
msgstr "Selecteer ten minste één doelkabel."
msgid "câble(s) mis à jour avec ce tracé !"
msgstr "kabel(s) bijgewerkt met dit tracé!"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout replier"
msgstr "Alles inklappen"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Tout déplier"
msgstr "Alles uitklappen"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets physiques rattachés"
msgstr "Gekoppelde fysieke assets"
#: assets/templates/assets/buildings/publicbuilding_detail.html
msgid "Assets rattachés directement au bâtiment"
msgstr "Assets direct gekoppeld aan gebouw"
#~ msgid "Absent du fichier Excel (Sera conservé ou archivé)" #~ msgid "Absent du fichier Excel (Sera conservé ou archivé)"
#~ msgstr "Ontbreekt in het Excel-bestand (Wordt bewaard of gearchiveerd)" #~ msgstr "Ontbreekt in het Excel-bestand (Wordt bewaard of gearchiveerd)"
@ -8711,57 +8589,3 @@ msgstr "Assets direct gekoppeld aan gebouw"
#~ msgid "Arceaux cargo constatés" #~ msgid "Arceaux cargo constatés"
#~ msgstr "Vastgestelde cargobeugels" #~ msgstr "Vastgestelde cargobeugels"
msgid "Type de zone de gestion"
msgstr "Beheerszonetype"
msgid "Types de zones de gestion"
msgstr "Beheerszonetypes"
msgid "Zone de gestion"
msgstr "Beheerszone"
msgid "Zones de gestion"
msgstr "Beheerszones"
msgid "Consignes et points d'attention"
msgstr "Instructies en aandachtspunten"
msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..."
msgstr "Exploitatie-instructies, aandachtspunten, veiligheidsregels..."
msgid "Périmètre géographique"
msgstr "Geografische perimeter"
msgid "Couleur d'affichage"
msgstr "Weergavekleur"
msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone."
msgstr "Hexadecimale code (bijv. #198754). Indien leeg, wordt de kleur van het zonetype gebruikt."
msgid "Fréquence cible (jours)"
msgstr "Doelfrequentie (dagen)"
msgid "Intervalle indicatif entre interventions en jours."
msgstr "Indicatief interval tussen interventies in dagen."
msgid "Saisonnalité / Calendrier"
msgstr "Seizoensgebondenheid / Kalender"
msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps."
msgstr "Bijv.: Maaien van april tot oktober; laat maaien in september; snoeiverbod in het voorjaar."
msgid "Asset de zone de gestion"
msgstr "Beheerszone-asset"
msgid "Assets de zones de gestion"
msgstr "Beheerszone-assets"
msgid "Document de zone de gestion"
msgstr "Beheerszonedocument"
msgid "Documents de zones de gestion"
msgstr "Beheerszonedocumenten"
msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre."
msgstr "Indien aangevinkt, wordt dit asset uitgesloten van de zone, zelfs als de geometrie de perimeter doorsnijdt."

View file

@ -615,134 +615,3 @@ msgstr "Modellen inklappen"
#: loko/assets/static/assets/unified_index.js:3726 #: loko/assets/static/assets/unified_index.js:3726
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options." msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
msgstr "" msgstr ""
#: loko/assets/static/assets/inspection_map.js:93
msgid "Rechercher une adresse…"
msgstr "Zoek een adres…"
msgid "Rechercher une adresse..."
msgstr "Zoek een adres..."
#: loko/assets/static/assets/inspection_map.js:748
msgid "Géolocalisation..."
msgstr "Geolocatie..."
#: loko/assets/static/assets/inspection_map.js:769
msgid "Position actualisée"
msgstr "Positie bijgewerkt"
#: loko/assets/static/assets/inspection_map.js:773
msgid "GPS indisponible"
msgstr "GPS niet beschikbaar"
#: loko/assets/static/assets/inspection_map.js:935
msgid "Inspections (Désactivé)"
msgstr "Inspecties (Uitgeschakeld)"
#: loko/assets/static/assets/inspection_map.js:936
msgid "Inspections (1 mois)"
msgstr "Inspecties (1 maand)"
#: loko/assets/static/assets/inspection_map.js:937
msgid "Inspections (3 mois)"
msgstr "Inspecties (3 maanden)"
#: loko/assets/static/assets/inspection_map.js:938
msgid "Inspections (6 mois)"
msgstr "Inspecties (6 maanden)"
#: loko/assets/static/assets/inspection_map.js:939
msgid "Inspections (1 an)"
msgstr "Inspecties (1 jaar)"
#: loko/assets/static/assets/inspection_map.js:940
msgid "Inspections (Tout)"
msgstr "Inspecties (Alles)"
msgid "Panneau"
msgstr "Bord"
msgid "Supprimer ce panneau"
msgstr "Dit bord verwijderen"
msgid "Mention (ex: Sauf riverains)"
msgstr "Vermelding (bijv. Uitgezonderd aangelanden)"
msgid "Propreté :"
msgstr "Netheid:"
msgid "Propre"
msgstr "Schoon"
msgid "Sale"
msgstr "Vuil"
msgid "Végétation"
msgstr "Vegetatie"
msgid "Dégradé"
msgstr "Beschadigd"
msgid "Coordonnées GPS manquantes."
msgstr "Ontbrekende gps-coördinaten."
msgid "Veuillez ajouter au moins un panneau."
msgstr "Voeg minimaal één bord toe."
msgid "Panneaux créés avec succès !"
msgstr "Borden succesvol aangemaakt!"
msgid "Erreur : "
msgstr "Fout: "
msgid "Impossible de créer les panneaux."
msgstr "Kan de borden niet aanmaken."
msgid "Erreur lors de la création : "
msgstr "Fout bij aanmaken: "
msgid "Créer et Enregistrer"
msgstr "Aanmaken en opslaan"
msgid "Inspection enregistrée !"
msgstr "Inspectie opgeslagen!"
msgid "Intervention"
msgstr "Interventie"
msgid "créée."
msgstr "aangemaakt."
msgid "Erreur lors de la validation."
msgstr "Fout bij validatie."
msgid "Erreur réseau."
msgstr "Netwerkfout."
msgid "Zones de gestion"
msgstr "Beheerszones"
msgid "Aucune zone de gestion définie pour cette thématique."
msgstr "Geen beheerszone gedefinieerd voor dit thema."
msgid "zones"
msgstr "zones"
msgid "Afficher / masquer toutes les zones"
msgstr "Alle zones tonen / verbergen"
msgid "Opacité"
msgstr "Dekking"
msgid "Fréquence cible :"
msgstr "Doelfrequentie:"
msgid "jours"
msgstr "dagen"
msgid "Consignes & points d'attention"
msgstr "Instructies & aandachtspunten"
msgid "Centrer sur la zone"
msgstr "Centreren op zone"

View file

@ -1,220 +0,0 @@
import time
from datetime import datetime
from django.core.management.base import BaseCommand
from django.db import connection, transaction
from assets.models import NatureLocation, NatureTree
from common.models import Municipality
MUNICIPALITY_PREFIXES = {
'AND': 'Anderlecht',
'AUD': 'Auderghem',
'BRK': 'Berchem-Sainte-Agathe',
'BSA': 'Berchem-Sainte-Agathe',
'BXL': 'Bruxelles',
'BRU': 'Bruxelles',
'ETT': 'Etterbeek',
'EVE': 'Evere',
'FOR': 'Forest',
'GAN': 'Ganshoren',
'IXL': 'Ixelles',
'IXE': 'Ixelles',
'JET': 'Jette',
'KOE': 'Koekelberg',
'MOL': 'Molenbeek-Saint-Jean',
'STG': 'Saint-Gilles',
'STJ': 'Saint-Josse-ten-Noode',
'SCH': 'Schaerbeek',
'UCC': 'Uccle',
'WAT': 'Watermael-Boitsfort',
'WLU': 'Woluwe-Saint-Lambert',
'WSL': 'Woluwe-Saint-Lambert',
'WSP': 'Woluwe-Saint-Pierre',
}
class Command(BaseCommand):
help = "Rattache spatialement les arbres (NatureTree) aux tronçons des sections vertes (NatureLocation, ex: WAT_R22_008)."
def add_arguments(self, parser):
parser.add_argument(
'--max-distance',
type=float,
default=25.0,
help="Distance maximale en mètres pour le rattachement (défaut: 25.0m, 0 pour intersection stricte)"
)
parser.add_argument(
'--section-code',
type=str,
default=None,
help="Code d'un tronçon spécifique à cibler (ex: WAT_R22_008)"
)
parser.add_argument(
'--dry-run',
action='store_true',
help="Simuler le rattachement sans modifier la base de données"
)
parser.add_argument(
'--skip-sync-munis',
action='store_true',
help="Ne pas synchroniser la commune sur les sections vertes"
)
def log(self, msg):
self.stdout.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")
def handle(self, *args, **options):
max_dist = options['max_distance']
target_section = options['section_code']
dry_run = options['dry_run']
sync_munis = not options['skip_sync_munis']
self.log("=== RATTACHEMENT SPATIAL DES ARBRES AUX TRONÇONS DE SECTIONS VERTES ===")
self.log(f"Distance max : {max_dist} m {'(Intersection stricte)' if max_dist == 0 else ''}")
if target_section:
self.log(f"Section ciblée : {target_section}")
if dry_run:
self.log("[SIMULATION / DRY-RUN] Aucune modification ne sera enregistrée.")
# -----------------------------------------------------------------
# 1. Synchronisation des communes sur les sections vertes
# -----------------------------------------------------------------
if sync_munis and not dry_run:
self.log("1. Synchronisation des communes sur les sections vertes...")
munis_by_name = {m.name_fr.lower(): m for m in Municipality.objects.all()}
updated_munis = 0
with connection.cursor() as cursor:
# Intersections géométriques
cursor.execute("""
UPDATE assets_naturelocation l
SET municipality_id = m.id
FROM common_municipality m
WHERE l.code ~ '^[A-Z]{3}_'
AND l.municipality_id IS NULL
AND l.geom IS NOT NULL
AND ST_Intersects(ST_PointOnSurface(l.geom), m.geom);
""")
updated_munis += cursor.rowcount
# Complément par préfixe de code pour les cas frontaliers
locs_without_muni = NatureLocation.objects.filter(code__regex=r'^[A-Z]{3}_', municipality__isnull=True)
for loc in locs_without_muni:
pfx = loc.code[:3].upper()
muni_name = MUNICIPALITY_PREFIXES.get(pfx)
if muni_name and muni_name.lower() in munis_by_name:
loc.municipality = munis_by_name[muni_name.lower()]
loc.save(update_fields=['municipality'])
updated_munis += 1
self.log(f" Communes renseignées sur {updated_munis} sections vertes.")
# -----------------------------------------------------------------
# 2. Analyse et Rattachement des Arbres
# -----------------------------------------------------------------
t0 = time.time()
self.log("2. Analyse géospatiale du rattachement arbres -> sections vertes...")
section_filter_sql = ""
params = [max_dist]
if target_section:
section_filter_sql = "AND nearest.code = %s"
params.append(target_section)
count_query = f"""
WITH nearest AS (
SELECT
t.id AS tree_id,
nearest_loc.id AS loc_id,
nearest_loc.code AS loc_code,
nearest_loc.name_fr AS loc_name,
nearest_loc.dist
FROM assets_naturetree t
CROSS JOIN LATERAL (
SELECT l.id, l.code, l.name_fr, ST_Distance(t.geom, l.geom) AS dist
FROM assets_naturelocation l
WHERE l.code ~ '^[A-Z]{{3}}_' AND l.geom IS NOT NULL
ORDER BY t.geom <-> l.geom
LIMIT 1
) nearest_loc
WHERE t.geom IS NOT NULL AND t.lat != 0 AND t.lon != 0
AND nearest_loc.dist <= %s
)
SELECT
COUNT(*) AS total_matched,
COUNT(*) FILTER (WHERE dist = 0) AS inside,
COUNT(*) FILTER (WHERE dist > 0 AND dist <= 5) AS within_5m,
COUNT(*) FILTER (WHERE dist > 5 AND dist <= 15) AS within_15m,
COUNT(*) FILTER (WHERE dist > 15 AND dist <= 25) AS within_25m,
COUNT(*) FILTER (WHERE dist > 25) AS beyond_25m,
COUNT(DISTINCT loc_id) AS distinct_sections
FROM nearest
WHERE 1=1 {section_filter_sql};
"""
with connection.cursor() as cursor:
cursor.execute(count_query, params)
stats = cursor.fetchone()
total_matched = stats[0]
inside = stats[1]
w5m = stats[2]
w15m = stats[3]
w25m = stats[4]
b25m = stats[5]
distinct_sections = stats[6]
self.log(f" Arbres éligibles au rattachement : {total_matched}")
self.log(f" - Directement à l'intérieur (0m) : {inside}")
self.log(f" - Proximité immédiate (1 à 5m) : {w5m}")
self.log(f" - Bordure de voirie (5 à 15m) : {w15m}")
self.log(f" - Zone étendue (15 à 25m) : {w25m}")
if b25m:
self.log(f" - Au-delà (> 25m) : {b25m}")
self.log(f" Nombre de tronçons / sections concernés : {distinct_sections}")
if dry_run:
self.log("Simulation terminée avec succès (aucune écriture en base).")
return
# -----------------------------------------------------------------
# 3. Application de la mise à jour
# -----------------------------------------------------------------
self.log("3. Application de la mise à jour des liaisons (location_id)...")
update_query = f"""
WITH candidates AS (
SELECT
t.id AS tree_id,
nearest.id AS loc_id
FROM assets_naturetree t
CROSS JOIN LATERAL (
SELECT l.id, l.code, ST_Distance(t.geom, l.geom) AS dist
FROM assets_naturelocation l
WHERE l.code ~ '^[A-Z]{{3}}_' AND l.geom IS NOT NULL
ORDER BY t.geom <-> l.geom
LIMIT 1
) nearest
WHERE t.geom IS NOT NULL AND t.lat != 0 AND t.lon != 0
AND nearest.dist <= %s
{section_filter_sql}
)
UPDATE assets_naturetree t
SET location_id = c.loc_id
FROM candidates c
WHERE t.id = c.tree_id
AND (t.location_id IS DISTINCT FROM c.loc_id);
"""
with connection.cursor() as cursor:
cursor.execute(update_query, params)
updated_count = cursor.rowcount
t1 = time.time()
self.log(f"Mise à jour terminée : {updated_count} arbres rattachés en {t1 - t0:.2f} secondes.")
# Vérification ciblée (par exemple WAT_R22_008)
check_code = target_section or 'WAT_R22_008'
check_loc = NatureLocation.objects.filter(code=check_code).first()
if check_loc:
attached_cnt = check_loc.nature_trees.count()
self.log(f"Vérification sur {check_code} ({check_loc.name_fr}) : {attached_cnt} arbres rattachés.")

View file

@ -1,279 +0,0 @@
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db import connection, transaction
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from assets.models import NatureTree
from interventions.models import Intervention, SourceCategory
class Command(BaseCommand):
help = (
"Annule les interventions arboricoles caduques ou périmées :\n"
"1. Interventions prescrites avant un diagnostic plus récent sur l'arbre (remplacées par ré-inspection).\n"
"2. Prescriptions actives dont la date limite est échue depuis plus de X mois (défaut : 12 mois).\n"
"Met à jour les événements de timeline et recalcule automatiquement les dates de prochaine inspection."
)
def add_arguments(self, parser):
parser.add_argument(
'--months',
type=int,
default=12,
help="Délai en mois après échéance au-delà duquel une prescription non exécutée est annulée (défaut : 12).",
)
parser.add_argument(
'--dry-run',
action='store_true',
help="Simuler l'opération et afficher les comptages sans modifier la base de données.",
)
parser.add_argument(
'--no-recalc-trees',
action='store_true',
help="Ne pas recalculer next_inspection_date sur les arbres après l'annulation.",
)
def handle(self, *args, **options):
months = options['months']
dry_run = options['dry_run']
recalc_trees = not options['no_recalc_trees']
now = timezone.now()
# Calcul de la date seuil d'échéance (ex: il y a 12 mois)
cutoff_date = (now - timedelta(days=int(months * 30.4375))).date()
self.stdout.write(self.style.MIGRATE_HEADING(
f"=== Nettoyage des interventions d'arbres caduques et échues ==="
))
self.stdout.write(f"Date de référence : {now.strftime('%d/%m/%Y %H:%M')}")
self.stdout.write(f"Seuil d'échéance ({months} mois) : interventions échues avant le {cutoff_date.strftime('%d/%m/%Y')}\n")
if dry_run:
self.stdout.write(self.style.WARNING("[MODE SIMULATION] Aucune modification ne sera enregistrée.\n"))
content_type_tree = ContentType.objects.get_for_model(NatureTree)
source_fulcrum = SourceCategory.objects.filter(name_fr="Fulcrum").first()
source_fulcrum_id = source_fulcrum.id if source_fulcrum else None
with connection.cursor() as cur:
# -------------------------------------------------------------
# COMPTAGE RÈGLE 1 : Diagnostic plus récent sur l'arbre
# -------------------------------------------------------------
cur.execute("""
SELECT COUNT(DISTINCT i.id)
FROM interventions_intervention i
JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
JOIN assets_naturetree t ON t.id = ia.object_id
WHERE ia.content_type_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND t.last_phytosanitary_date IS NOT NULL
AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
);
""", [content_type_tree.id])
count_rule_diag = cur.fetchone()[0]
# -------------------------------------------------------------
# COMPTAGE RÈGLE 2 : Échéance dépassée > X mois (sans être dans règle 1)
# -------------------------------------------------------------
cur.execute("""
SELECT COUNT(DISTINCT i.id)
FROM interventions_intervention i
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
WHERE i.status NOT IN ('finished', 'canceled')
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND i.expected_end_time IS NOT NULL
AND i.expected_end_time::date < %s
AND NOT (
t.last_phytosanitary_date IS NOT NULL
AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time::date < t.last_phytosanitary_date)
)
);
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
count_rule_expired = cur.fetchone()[0]
# Total combiné
cur.execute("""
SELECT COUNT(DISTINCT i.id)
FROM interventions_intervention i
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
WHERE i.status NOT IN ('finished', 'canceled')
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND (
(t.last_phytosanitary_date IS NOT NULL AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
))
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < %s)
);
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
total_to_cancel = cur.fetchone()[0]
self.stdout.write(f"1. Prescriptions caduques (arbre ré-inspecté ultérieurement) : {count_rule_diag}")
self.stdout.write(f"2. Prescriptions échues depuis plus de {months} mois sans nouveau diag : {count_rule_expired}")
self.stdout.write(self.style.MIGRATE_LABEL(f"Total des interventions à annuler : {total_to_cancel}\n"))
if total_to_cancel == 0:
self.stdout.write(self.style.SUCCESS("Aucune intervention à annuler. La base est déjà assainie."))
return
if dry_run:
self.stdout.write(self.style.WARNING(
f"[MODE SIMULATION] {total_to_cancel} interventions auraient été passées au statut 'canceled'.\n"
"Exécutez sans --dry-run pour appliquer."
))
return
# -------------------------------------------------------------
# EXÉCUTION RÉELLE EN TRANSACTION
# -------------------------------------------------------------
self.stdout.write("Application des annulations...")
with transaction.atomic():
with connection.cursor() as cur:
# 1a. Timeline events pour la règle 1 (diagnostic plus récent)
cur.execute("""
INSERT INTO interventions_interventiontimeline (
intervention_id, event_time, event_type, from_status, to_status,
event_description, field_changes
)
SELECT
i.id,
%s,
'status_change',
i.status,
'canceled',
'Intervention annulée automatiquement : prescription caduque suite au nouveau diagnostic de l''arbre du ' ||
TO_CHAR(t.last_phytosanitary_date, 'DD/MM/YYYY') || '.',
('{"status": ["' || i.status || '", "canceled"]}')::json
FROM interventions_intervention i
JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
JOIN assets_naturetree t ON t.id = ia.object_id
WHERE ia.content_type_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND t.last_phytosanitary_date IS NOT NULL
AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
)
AND NOT EXISTS (
SELECT 1 FROM interventions_interventiontimeline tl
WHERE tl.intervention_id = i.id
AND tl.to_status = 'canceled'
);
""", [now, content_type_tree.id])
tl_diag_count = cur.rowcount
# 1b. Timeline events pour la règle 2 (échues > X mois)
cur.execute("""
INSERT INTO interventions_interventiontimeline (
intervention_id, event_time, event_type, from_status, to_status,
event_description, field_changes
)
SELECT
i.id,
%s,
'status_change',
i.status,
'canceled',
'Intervention annulée automatiquement : prescription échue depuis plus de ' || %s || ' mois (' ||
TO_CHAR(i.expected_end_time, 'DD/MM/YYYY') || ') sans nouveau diagnostic.',
('{"status": ["' || i.status || '", "canceled"]}')::json
FROM interventions_intervention i
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
WHERE i.status NOT IN ('finished', 'canceled')
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND i.expected_end_time IS NOT NULL
AND i.expected_end_time::date < %s
AND NOT EXISTS (
SELECT 1 FROM interventions_interventiontimeline tl
WHERE tl.intervention_id = i.id
AND tl.to_status = 'canceled'
);
""", [now, months, content_type_tree.id, source_fulcrum_id, cutoff_date])
tl_expired_count = cur.rowcount
# 2. Mise à jour effective du statut vers 'canceled'
cur.execute("""
UPDATE interventions_intervention i
SET status = 'canceled',
status_order = 200,
cancellation_type = 'non_relevant'
FROM interventions_interventionasset ia
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
WHERE ia.intervention_id = i.id
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND i.status NOT IN ('finished', 'canceled')
AND (
(t.last_phytosanitary_date IS NOT NULL AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
))
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < %s)
);
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
updated_count = cur.rowcount
# Traitement des interventions orphelines sans lien ia (ex: source Fulcrum sans arbre matché)
cur.execute("""
UPDATE interventions_intervention i
SET status = 'canceled',
status_order = 200,
cancellation_type = 'non_relevant'
WHERE i.source_category_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND i.expected_end_time IS NOT NULL
AND i.expected_end_time::date < %s;
""", [source_fulcrum_id, cutoff_date])
updated_count += cur.rowcount
self.stdout.write(self.style.SUCCESS(
f" {updated_count} interventions passées au statut 'canceled'."
))
self.stdout.write(f" {tl_diag_count + tl_expired_count} événements d'historique créés dans la timeline.")
# 3. Recalcul des prochaines dates d'inspection sur les arbres
if recalc_trees:
self.stdout.write("Recalcul des prochaines inspections sur les arbres...")
# 3a. Arbres avec intervention active résiduelle
cur.execute("""
UPDATE assets_naturetree t
SET next_inspection_date = sub.min_date
FROM (
SELECT ia.object_id AS tree_id, MIN(i.expected_end_time)::date AS min_date
FROM interventions_interventionasset ia
JOIN interventions_intervention i ON i.id = ia.intervention_id
WHERE ia.content_type_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND i.expected_end_time IS NOT NULL
GROUP BY ia.object_id
) sub
WHERE t.id = sub.tree_id;
""", [content_type_tree.id])
trees_active = cur.rowcount
# 3b. Arbres sans intervention active : formule théorique = dernier diagnostic + périodicité
cur.execute("""
UPDATE assets_naturetree t
SET next_inspection_date = (t.last_phytosanitary_date + (COALESCE(t.pruning_frequency_years, 3) || ' years')::interval)::date
WHERE t.last_phytosanitary_date IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM interventions_interventionasset ia
JOIN interventions_intervention i ON i.id = ia.intervention_id
WHERE ia.content_type_id = %s
AND ia.object_id = t.id
AND i.status NOT IN ('finished', 'canceled')
AND i.expected_end_time IS NOT NULL
);
""", [content_type_tree.id])
trees_theo = cur.rowcount
self.stdout.write(f" {trees_active} arbres mis à jour avec prochaine intervention active.")
self.stdout.write(f" {trees_theo} arbres recalculés selon cycle théorique (diag + périodicité).")
self.stdout.write(self.style.SUCCESS("\nOpération terminée avec succès !"))

View file

@ -1,210 +0,0 @@
"""
Management command: create_structure_folders
===========================================
Crée les répertoires par défaut de gestion documentaire pour les structures (ouvrages d'art)
qui n'en possèdent pas encore (ex: stations et tunnels de métro récemment importés).
Pour chaque ouvrage d'art traité :
1. Crée le dossier racine rattaché à l'ouvrage via DocumentFolderAttachment (is_default=True) :
- Nom : "<code_ouvrage> - <nom_ouvrage>" (ex: "MS001 - Station Albert")
- Slug : "<code_minuscule>" (ex: "ms001")
- Dossier parent : "Structures" (slug: "assets-structures")
- Thématique : "structures"
- Visibilité : "internal"
2. Crée l'arborescence standard de sous-dossiers :
- Civil engineering
├── Inspection reports
└── Exceptional transport
- Electromechanics
└── Preventive maintenance
Usage :
python manage.py create_structure_folders [--dry-run] [--type TYPE] [--code CODE]
"""
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.text import slugify
from assets.models.structures import Structure
from common.models import Thematic
from documents.models import DocumentFolder, DocumentFolderAttachment, VisibilityScope
User = get_user_model()
def ensure_structure_folders(structure, creator_user=None, dry_run=False):
"""
Crée ou s'assure de l'existence du dossier racine et des sous-dossiers
standards pour un ouvrage d'art (Structure).
Idempotent : ne recrée pas ce qui existe déjà.
"""
structure_ct = ContentType.objects.get_for_model(Structure)
structures_thematic = Thematic.objects.filter(code='structures').first()
structures_parent_folder = DocumentFolder.objects.filter(slug='assets-structures').first()
root_slug = slugify(structure.code)
root_folder = DocumentFolder.objects.filter(slug=root_slug).first()
root_attachment = DocumentFolderAttachment.objects.filter(
content_type=structure_ct,
object_id=structure.pk,
is_default=True
).select_related('folder').first()
if not root_folder and root_attachment:
root_folder = root_attachment.folder
root_name = f"{structure.code} - {structure.get_name()}"
root_desc = f"Dossier pour la structure {structure.code} - {structure.get_name()}"
created_root = False
if not root_folder:
if not dry_run:
root_folder = DocumentFolder.objects.create(
name=root_name,
slug=root_slug,
description=root_desc,
visibility=VisibilityScope.INTERNAL,
is_predefined=True,
application_label='structures',
created_by=creator_user
)
if structures_thematic:
root_folder.thematics.add(structures_thematic)
if structures_parent_folder:
root_folder.parent_folders.add(structures_parent_folder)
created_root = True
if not dry_run and root_folder and not root_attachment:
DocumentFolderAttachment.objects.get_or_create(
content_type=structure_ct,
object_id=structure.pk,
folder=root_folder,
defaults={'is_default': True}
)
subfolders_created = 0
def get_or_create_subfolder(parent, name, sub_suffix):
nonlocal subfolders_created
base_slug = f"{root_slug}-{sub_suffix}"[:150]
sub = DocumentFolder.objects.filter(slug=base_slug).first()
if not sub and parent and getattr(parent, 'pk', None):
sub = parent.child_folders.filter(name=name).first()
if not sub:
if not dry_run:
sub = DocumentFolder.objects.create(
name=name,
slug=base_slug,
visibility=VisibilityScope.INTERNAL,
is_predefined=False,
application_label='structures',
created_by=creator_user
)
if structures_thematic:
sub.thematics.add(structures_thematic)
if parent and getattr(parent, 'pk', None):
sub.parent_folders.add(parent)
subfolders_created += 1
return sub, True
return sub, False
# 1. Civil engineering & sous-dossiers
civil_folder, _ = get_or_create_subfolder(root_folder, "Civil engineering", "civil-engineering")
get_or_create_subfolder(civil_folder, "Inspection reports", "civil-engineering-inspection-reports")
get_or_create_subfolder(civil_folder, "Exceptional transport", "civil-engineering-exceptional-transport")
# 2. Electromechanics & sous-dossiers
em_folder, _ = get_or_create_subfolder(root_folder, "Electromechanics", "electromechanics")
get_or_create_subfolder(em_folder, "Preventive maintenance", "electromechanics-preventive-maintenance")
return {
'root_folder': root_folder,
'created_root': created_root,
'subfolders_created': subfolders_created
}
class Command(BaseCommand):
help = "Crée les répertoires par défaut de gestion documentaire pour les structures."
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help="Simule l'exécution sans effectuer d'écritures en base de données."
)
parser.add_argument(
'--type',
dest='structure_type',
help="Filtre par type de structure (ex: metro_station,metro_tunnel)."
)
parser.add_argument(
'--code',
dest='structure_code',
help="Cible un ouvrage spécifique via son code (ex: MS001)."
)
parser.add_argument(
'--user',
dest='username',
help="Nom d'utilisateur créateur des dossiers (par défaut kdeterme ou superuser)."
)
def handle(self, *args, **options):
dry_run = options['dry_run']
structure_type = options.get('structure_type')
structure_code = options.get('structure_code')
username = options.get('username')
if dry_run:
self.stdout.write(self.style.WARNING("=== Mode simulation (--dry-run) activé. Aucune modification en base. ==="))
creator_user = None
if username:
creator_user = User.objects.filter(username=username).first()
if not creator_user:
creator_user = User.objects.filter(username="kdeterme").first() or User.objects.filter(is_superuser=True).first()
qs = Structure.objects.all().order_by('code')
if structure_code:
qs = qs.filter(code__iexact=structure_code)
elif structure_type:
types = [t.strip() for t in structure_type.split(',')]
qs = qs.filter(structure_type__in=types)
structure_ct = ContentType.objects.get_for_model(Structure)
existing_attached_ids = set(
DocumentFolderAttachment.objects.filter(
content_type=structure_ct,
is_default=True
).values_list('object_id', flat=True)
)
missing_structures = [s for s in qs if s.pk not in existing_attached_ids]
self.stdout.write(f"Structures analysées : {qs.count()} | Sans répertoire par défaut : {len(missing_structures)}")
total_roots = 0
total_subfolders = 0
target_structures = missing_structures if not structure_code else list(qs)
for structure in target_structures:
res = ensure_structure_folders(structure, creator_user=creator_user, dry_run=dry_run)
if res['created_root']:
total_roots += 1
total_subfolders += res['subfolders_created']
self.stdout.write(f" ✓ [{structure.code}] {structure.get_name()} -> Dossier racine et arborescence configurés")
status_label = "[Simulation] Répertoires qui seraient créés" if dry_run else "Répertoires créés avec succès"
self.stdout.write(self.style.SUCCESS(
f"\n=== {status_label} ===\n"
f" - Dossiers racines créés : {total_roots}\n"
f" - Sous-dossiers créés : {total_subfolders}\n"
f" - Total dossiers : {total_roots + total_subfolders}"
))

View file

@ -1,5 +0,0 @@
from assets.management.commands.import_fulcrum_trees import Command as BaseFulcrumCommand
class Command(BaseFulcrumCommand):
help = "Importe l'inventaire Fulcrum des arbres régionaux (arbres, commandes/gestion, interventions)."

File diff suppressed because it is too large Load diff

View file

@ -1,18 +0,0 @@
# Generated by Django 6.0.7 on 2026-09-21 11:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0026_add_naturetree_specific_fields'),
]
operations = [
migrations.AlterField(
model_name='structure',
name='structure_type',
field=models.CharField(blank=True, choices=[('tunnel', 'Tunnel'), ('bridge', 'Pont'), ('viaduct', 'Viaduc'), ('overpass', 'Passerelle'), ('underpass', 'Passage souterrain'), ('gallery', 'Galerie technique'), ('atypical', 'Ouvrage atypique'), ('metro_station', 'Station de métro'), ('metro_tunnel', 'Tunnel de métro')], max_length=200, null=True),
),
]

View file

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

View file

@ -1,24 +0,0 @@
# 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

@ -1,102 +0,0 @@
# Generated by Django 6.0.7 on 2026-10-05 13:06
import common.storages
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0029_publicbuildingdocument_floor_location_and_more'),
('common', '0017_territory'),
('contenttypes', '0002_remove_content_type_name'),
('documents', '0004_alter_documentfolder_visibility_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ManagementZone',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=100, unique=True, verbose_name='Code')),
('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')),
('name_nl', models.CharField(blank=True, max_length=255, null=True, verbose_name='Nom (NL)')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('instructions', models.TextField(blank=True, help_text="Consignes d'exploitation, points de vigilance, règles de sécurité...", null=True, verbose_name="Consignes et points d'attention")),
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Périmètre géographique')),
('color', models.CharField(blank=True, help_text='Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone.', max_length=20, null=True, verbose_name="Couleur d'affichage")),
('is_active', models.BooleanField(default=True, verbose_name='Active')),
('target_frequency_days', models.IntegerField(blank=True, help_text='Intervalle indicatif entre interventions en jours.', null=True, verbose_name='Fréquence cible (jours)')),
('seasonality_notes', models.CharField(blank=True, help_text="Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps.", max_length=255, null=True, verbose_name='Saisonnalité / Calendrier')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Date de création')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Date de mise à jour')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_management_zones', to=settings.AUTH_USER_MODEL, verbose_name='Créé par')),
('thematic', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='management_zones', to='common.thematic', verbose_name='Thématique')),
],
options={
'verbose_name': 'Zone de gestion',
'verbose_name_plural': 'Zones de gestion',
'ordering': ['thematic', 'code'],
},
),
migrations.CreateModel(
name='ManagementZoneDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.TextField(blank=True, null=True, verbose_name='Description du document')),
('upload_date', models.DateTimeField(auto_now=True, verbose_name='Date de téléversement')),
('status', models.CharField(choices=[('active', 'Actif'), ('archived', 'Archivé')], default='active')),
('file', models.FileField(max_length=255, storage=common.storages.PrivateMediaStorage(), upload_to='assets_documents/management_zones/', verbose_name='Fichier')),
('thumbnail', models.ImageField(blank=True, max_length=255, null=True, storage=common.storages.PrivateMediaStorage(), upload_to='assets_documents/management_zones/')),
('managed_document', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_sources', to='documents.manageddocument')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par')),
('zone', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.managementzone', verbose_name='Zone de gestion')),
],
options={
'verbose_name': 'Document de zone de gestion',
'verbose_name_plural': 'Documents de zones de gestion',
},
),
migrations.CreateModel(
name='ManagementZoneType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=100, unique=True, verbose_name='Code')),
('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')),
('name_nl', models.CharField(blank=True, max_length=255, null=True, verbose_name='Nom (NL)')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('default_color', models.CharField(default='#198754', max_length=20, verbose_name='Couleur par défaut')),
('thematic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='management_zone_types', to='common.thematic', verbose_name='Thématique')),
],
options={
'verbose_name': 'Type de zone de gestion',
'verbose_name_plural': 'Types de zones de gestion',
'ordering': ['thematic', 'name_fr'],
},
),
migrations.AddField(
model_name='managementzone',
name='zone_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='management_zones', to='assets.managementzonetype', verbose_name='Type de zone de gestion'),
),
migrations.CreateModel(
name='ManagementZoneAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField(verbose_name="ID de l'asset")),
('is_excluded', models.BooleanField(default=False, help_text='Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre.', verbose_name='Exclu')),
('notes', models.CharField(blank=True, max_length=255, null=True, verbose_name='Notes')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name="Type d'asset")),
('management_zone', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='explicit_assets', to='assets.managementzone', verbose_name='Zone de gestion')),
],
options={
'verbose_name': 'Asset de zone de gestion',
'verbose_name_plural': 'Assets de zones de gestion',
'unique_together': {('management_zone', 'content_type', 'object_id')},
},
),
]

View file

@ -1,160 +0,0 @@
# Generated by Django 6.0.7 on 2026-10-08 10:19
import assets.models.core
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0030_managementzone_managementzonedocument_and_more'),
('contracts', '0014_contract_color_contract_geom'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='itsgeoasset',
name='extra_data',
field=models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques'),
),
migrations.CreateModel(
name='ITSBollardSystem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('external_reference', models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe')),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('inspection_status', models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection")),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('funding_program', models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.LineStringField(blank=True, null=True, srid=3812, verbose_name='Géométrie (Ligne)')),
('width', models.FloatField(blank=True, null=True, verbose_name='Largeur de passage (m)')),
('direction', models.CharField(blank=True, choices=[('unidirectional', 'Sens unique'), ('bidirectional', 'Double sens')], default='bidirectional', max_length=50, null=True, verbose_name='Sens de circulation')),
('extra_data', models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bollard_systems', to='assets.itslocation', verbose_name='Localisation ITS')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='assets.itsassetmodel', verbose_name='Modèle de système')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.itsbollardsystem', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
],
options={
'verbose_name': 'Système de bornes',
'verbose_name_plural': 'Systèmes de bornes',
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
migrations.CreateModel(
name='ITSBollardSystemAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('camera_entry', 'Caméra entrée (LAPI)'), ('camera_exit', 'Caméra sortie (LAPI)'), ('camera_cctv', 'Caméra de contexte / surveillance'), ('cabinet', 'Armoire électrique / automate'), ('totem', 'Totem de commande'), ('traffic_light', 'Feu de franchissement'), ('loop', 'Boucle de détection'), ('other', 'Autre')], default='other', max_length=50, verbose_name="Rôle de l'équipement")),
('comment', models.CharField(blank=True, max_length=255, null=True, verbose_name='Commentaire')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name="Date d'association")),
('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_system_links', to='assets.itsgeoasset', verbose_name='Équipement ITS')),
('bollard_system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='system_assets', to='assets.itsbollardsystem', verbose_name='Système de bornes')),
],
options={
'verbose_name': 'Équipement associé au système de bornes',
'verbose_name_plural': 'Équipements associés au système de bornes',
'unique_together': {('bollard_system', 'asset')},
},
),
migrations.AddField(
model_name='itsbollardsystem',
name='associated_assets',
field=models.ManyToManyField(blank=True, related_name='bollard_systems', through='assets.ITSBollardSystemAsset', to='assets.itsgeoasset', verbose_name='Équipements associés'),
),
migrations.CreateModel(
name='ITSBollardSystemContract',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('active', 'Actif'), ('inactive', 'Inactif'), ('archived', 'Archivé')], default='active', max_length=50)),
('maintenance_type', models.CharField(choices=[('equipments', 'Equipements'), ('office', 'Bureautique'), ('system', 'Système'), ('other', 'Autre')], default='other', max_length=50)),
('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_systems_contracts', to='contracts.contract')),
('system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_systems_contracts', to='assets.itsbollardsystem')),
],
options={
'verbose_name': 'Contrat de système de bornes',
'verbose_name_plural': 'Contrats de systèmes de bornes',
},
),
migrations.CreateModel(
name='ITSBollardPlot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('external_reference', models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe')),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('inspection_status', models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection")),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('funding_program', models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=3812, verbose_name='Géométrie (Point)')),
('position', models.PositiveSmallIntegerField(default=1, help_text='Ordre de gauche à droite (1, 2, 3...)', verbose_name='Position sur la traverse')),
('bollard_type', models.CharField(choices=[('automatic', 'Automatique / Escamotable'), ('semi_automatic', 'Semi-automatique'), ('fixed', 'Fixe'), ('removable', 'Amovible')], default='automatic', max_length=50, verbose_name='Type de plot')),
('extra_data', models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bollard_plots', to='assets.itslocation', verbose_name='Localisation ITS')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='assets.itsassetmodel', verbose_name='Modèle de plot')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.itsbollardplot', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
('system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plots', to='assets.itsbollardsystem', verbose_name='Système de bornes')),
],
options={
'verbose_name': 'Plot de borne',
'verbose_name_plural': 'Plots de bornes',
'ordering': ['system', 'position'],
'indexes': [models.Index(fields=['code'], name='assets_itsb_code_de6000_idx')],
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
migrations.AddIndex(
model_name='itsbollardsystem',
index=models.Index(fields=['code'], name='assets_itsb_code_a4c148_idx'),
),
migrations.AlterUniqueTogether(
name='itsbollardsystemcontract',
unique_together={('system', 'contract')},
),
]

View file

@ -1,108 +0,0 @@
# Generated by Django 6.0.7 on 2026-10-07 15:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0030_managementzone_managementzonedocument_and_more'),
]
operations = [
migrations.AddField(
model_name='naturetree',
name='amenity_value',
field=models.FloatField(blank=True, null=True, verbose_name="Valeur d'aménité (€)"),
),
migrations.AddField(
model_name='naturetree',
name='biodiversity_index',
field=models.FloatField(blank=True, null=True, verbose_name='Indice de biodiversité'),
),
migrations.AddField(
model_name='naturetree',
name='carbon_stock',
field=models.FloatField(blank=True, null=True, verbose_name='Stock de carbone (t CO2)'),
),
migrations.AddField(
model_name='naturetree',
name='cooling_energy_indicator',
field=models.FloatField(blank=True, null=True, verbose_name='Énergie de refroidissement (kWh)'),
),
migrations.AddField(
model_name='naturetree',
name='cooling_indicator',
field=models.FloatField(blank=True, null=True, verbose_name='Indice de rafraîchissement'),
),
migrations.AddField(
model_name='naturetree',
name='cultivar',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Cultivar / Variété'),
),
migrations.AddField(
model_name='naturetree',
name='death_date',
field=models.DateField(blank=True, null=True, verbose_name='Date de dépérissement / mort'),
),
migrations.AddField(
model_name='naturetree',
name='genus',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Genre botanique'),
),
migrations.AddField(
model_name='naturetree',
name='is_chronoxyle',
field=models.BooleanField(default=False, verbose_name='Arbre sénescent / Chronoxyle (bois mort sur pied)'),
),
migrations.AddField(
model_name='naturetree',
name='is_dead',
field=models.BooleanField(default=False, verbose_name='Arbre mort'),
),
migrations.AddField(
model_name='naturetree',
name='is_stump',
field=models.BooleanField(default=False, verbose_name='Souche'),
),
migrations.AddField(
model_name='naturetree',
name='management_type',
field=models.CharField(blank=True, choices=[('municipal', 'Communal (Régie communale)'), ('regional', 'Régional (Bruxelles Mobilité / Région)'), ('community', 'Intercommunal / Autre pouvoir public'), ('private', 'Privé / Tiers')], default='municipal', max_length=50, null=True, verbose_name='Type de gestionnaire'),
),
migrations.AddField(
model_name='naturetree',
name='managing_authority',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Autorité gestionnaire'),
),
migrations.AddField(
model_name='naturetree',
name='number_of_trunks',
field=models.IntegerField(blank=True, default=1, null=True, verbose_name='Nombre de troncs / Cépée'),
),
migrations.AddField(
model_name='naturetree',
name='plantation_coefficient',
field=models.FloatField(blank=True, null=True, verbose_name='Coefficient de plantation'),
),
migrations.AddField(
model_name='naturetree',
name='planting_date',
field=models.DateField(blank=True, null=True, verbose_name='Date de plantation réelle'),
),
migrations.AddField(
model_name='naturetree',
name='scientific_name',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Nom scientifique / latin'),
),
migrations.AddField(
model_name='naturetree',
name='situation_coefficient',
field=models.FloatField(blank=True, null=True, verbose_name='Coefficient de situation'),
),
migrations.AddField(
model_name='naturetree',
name='vernacular_name',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Nom vernaculaire / Essence'),
),
]

View file

@ -1,14 +0,0 @@
# Generated by Django 6.0.7 on 2026-10-09 11:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('assets', '0031_itsgeoasset_extra_data_itsbollardsystem_and_more'),
('assets', '0031_naturetree_amenity_value_and_more'),
]
operations = [
]

View file

@ -8,10 +8,9 @@ from .structures import Structure, StructureLocation, StructureGeoAsset, Structu
from .controlcenters import ControlCenter, ControlCenterLocation, ControlCenterGeoAsset, ControlCenterAssetModel, ControlCenterDocument, ControlCenterContract, ControlCenterGeoAssetContract, ControlCenterAssetDocument from .controlcenters import ControlCenter, ControlCenterLocation, ControlCenterGeoAsset, ControlCenterAssetModel, ControlCenterDocument, ControlCenterContract, ControlCenterGeoAssetContract, ControlCenterAssetDocument
from .sign import SignStreet, SignStreetDocument, SignPole, SignPoleModel, SignPanel, SignPanelModel, SignPanelType, ComplementaryPoliceRegulation, SignParkingSpot from .sign import SignStreet, SignStreetDocument, SignPole, SignPoleModel, SignPanel, SignPanelModel, SignPanelType, ComplementaryPoliceRegulation, SignParkingSpot
from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSidewalk, RoadSidewalkModel, RoadFurniture, RoadFurnitureModel, RoadwayFunction, RoadConcreteBlockSet, RoadDrainModel, RoadDrain from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSidewalk, RoadSidewalkModel, RoadFurniture, RoadFurnitureModel, RoadwayFunction, RoadConcreteBlockSet, RoadDrainModel, RoadDrain
from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract, ITSBollardSystem, ITSBollardPlot, ITSBollardSystemAsset, ITSBollardSystemContract from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract
from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract
from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument
from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
from .parking import ParkingLocation, ParkingSpot from .parking import ParkingLocation, ParkingSpot
from inspections.models import Inspection as AssetInspection, ParkingSpotInspection from inspections.models import Inspection as AssetInspection, ParkingSpotInspection
from .management_zones import ManagementZone, ManagementZoneType, ManagementZoneAsset, ManagementZoneDocument

View file

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

View file

@ -5,7 +5,7 @@
from django.contrib.gis.db import models from django.contrib.gis.db import models
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import get_language, gettext_lazy as _ from django.utils.translation import get_language
import os import os
@ -91,7 +91,6 @@ class ITSGeoAsset(AbstractGeoAsset):
location = models.ForeignKey(ITSLocation, related_name="assets", on_delete=models.SET_NULL, null=True, blank=True) location = models.ForeignKey(ITSLocation, related_name="assets", on_delete=models.SET_NULL, null=True, blank=True)
trafficlights_pole = models.ForeignKey("assets.TrafficLightPole", on_delete=models.SET_NULL, null=True, blank=True, related_name="its_assets", verbose_name="Traffic Light Pole") trafficlights_pole = models.ForeignKey("assets.TrafficLightPole", on_delete=models.SET_NULL, null=True, blank=True, related_name="its_assets", verbose_name="Traffic Light Pole")
lighting_pole = models.ForeignKey("assets.PublicLightingPole", on_delete=models.SET_NULL, null=True, blank=True, related_name="its_assets", verbose_name="Lighting Pole") lighting_pole = models.ForeignKey("assets.PublicLightingPole", on_delete=models.SET_NULL, null=True, blank=True, related_name="its_assets", verbose_name="Lighting Pole")
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
def clean(self): def clean(self):
super().clean() super().clean()
@ -118,155 +117,6 @@ class ITSGeoAsset(AbstractGeoAsset):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsgeoasset', 'asset_id':self.id}) return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsgeoasset', 'asset_id':self.id})
BOLLARD_DIRECTION_CHOICES = (
('unidirectional', _('Sens unique')),
('bidirectional', _('Double sens')),
)
class ITSBollardSystem(AbstractGeoAsset):
"""
Système / Traverse de bornes escamotables (représentation linéaire).
Contrôle l'accès à une voie ou zone piétonne.
"""
geom = models.LineStringField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie (Ligne)"))
model = models.ForeignKey(ITSAssetModel, on_delete=models.PROTECT, null=True, blank=True, verbose_name=_("Modèle de système"))
location = models.ForeignKey(ITSLocation, related_name="bollard_systems", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Localisation ITS"))
width = models.FloatField(null=True, blank=True, verbose_name=_("Largeur de passage (m)"))
direction = models.CharField(max_length=50, choices=BOLLARD_DIRECTION_CHOICES, default='bidirectional', null=True, blank=True, verbose_name=_("Sens de circulation"))
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
associated_assets = models.ManyToManyField(
'assets.ITSGeoAsset',
through='assets.ITSBollardSystemAsset',
related_name="bollard_systems",
blank=True,
verbose_name=_("Équipements associés")
)
class Meta:
verbose_name = _("Système de bornes")
verbose_name_plural = _("Systèmes de bornes")
indexes = [
models.Index(fields=["code"]),
]
def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsbollardsystem', 'asset_id': self.id})
@property
def plots_count(self):
return self.plots.count()
@property
def name(self):
return self.get_name()
BOLLARD_TYPE_CHOICES = (
('automatic', _('Automatique / Escamotable')),
('semi_automatic', _('Semi-automatique')),
('fixed', _('Fixe')),
('removable', _('Amovible')),
)
class ITSBollardPlot(AbstractGeoAsset):
"""
Plot / borne physique individuel sur un système de bornes.
"""
geom = models.PointField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie (Point)"))
system = models.ForeignKey(ITSBollardSystem, on_delete=models.CASCADE, related_name="plots", verbose_name=_("Système de bornes"))
model = models.ForeignKey(ITSAssetModel, on_delete=models.PROTECT, null=True, blank=True, verbose_name=_("Modèle de plot"))
position = models.PositiveSmallIntegerField(default=1, verbose_name=_("Position sur la traverse"), help_text=_("Ordre de gauche à droite (1, 2, 3...)"))
bollard_type = models.CharField(max_length=50, choices=BOLLARD_TYPE_CHOICES, default='automatic', verbose_name=_("Type de plot"))
location = models.ForeignKey(ITSLocation, related_name="bollard_plots", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Localisation ITS"))
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
@property
def name(self):
return self.get_name()
def clean(self):
super().clean()
if not self.location_id and self.system and self.system.location_id:
self.location = self.system.location
def save(self, *args, **kwargs):
if not self.location_id and self.system and self.system.location_id:
self.location = self.system.location
super().save(*args, **kwargs)
class Meta:
verbose_name = _("Plot de borne")
verbose_name_plural = _("Plots de bornes")
ordering = ["system", "position"]
indexes = [
models.Index(fields=["code"]),
]
def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsbollardplot', 'asset_id': self.id})
class ITSBollardSystemAsset(models.Model):
"""
Table de liaison ManyToMany entre un ITSBollardSystem et des ITSGeoAsset
(caméras LAPI, totems, armoires électriques, feux bicolores, boucles...).
"""
class AssetRole(models.TextChoices):
CAMERA_ENTRY = "camera_entry", _("Caméra entrée (LAPI)")
CAMERA_EXIT = "camera_exit", _("Caméra sortie (LAPI)")
CAMERA_CCTV = "camera_cctv", _("Caméra de contexte / surveillance")
CABINET = "cabinet", _("Armoire électrique / automate")
TOTEM = "totem", _("Totem de commande")
TRAFFIC_LIGHT = "traffic_light", _("Feu de franchissement")
LOOP = "loop", _("Boucle de détection")
OTHER = "other", _("Autre")
bollard_system = models.ForeignKey(
ITSBollardSystem,
on_delete=models.CASCADE,
related_name="system_assets",
verbose_name=_("Système de bornes")
)
asset = models.ForeignKey(
ITSGeoAsset,
on_delete=models.CASCADE,
related_name="bollard_system_links",
verbose_name=_("Équipement ITS")
)
role = models.CharField(
max_length=50,
choices=AssetRole.choices,
default=AssetRole.OTHER,
verbose_name=_("Rôle de l'équipement")
)
comment = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Commentaire"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Date d'association"))
class Meta:
verbose_name = _("Équipement associé au système de bornes")
verbose_name_plural = _("Équipements associés au système de bornes")
unique_together = ('bollard_system', 'asset')
def __str__(self):
return f"{self.bollard_system.code} - {self.asset.code} ({self.get_role_display()})"
class ITSBollardSystemContract(models.Model):
system = models.ForeignKey(ITSBollardSystem, on_delete=models.CASCADE, related_name="bollard_systems_contracts")
contract = models.ForeignKey("contracts.Contract", on_delete=models.CASCADE, related_name="bollard_systems_contracts")
status = models.CharField(max_length=50, choices=[('active', 'Actif'), ('inactive', 'Inactif'), ('archived', 'Archivé')], default='active')
maintenance_type = models.CharField(max_length=50, choices=[('equipments', 'Equipements'), ('office', 'Bureautique'), ('system', 'Système'), ('other', 'Autre')], default='other')
def __str__(self):
return f"{self.system.code} - {self.contract.contract_number}"
class Meta:
verbose_name = _("Contrat de système de bornes")
verbose_name_plural = _("Contrats de systèmes de bornes")
unique_together = ('system', 'contract')

View file

@ -1,214 +0,0 @@
from django.contrib.gis.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django.conf import settings
from django.utils.translation import gettext_lazy as _, get_language
import json
from common.models import Thematic, Document
from common.storages import PrivateMediaStorage
private_storage = PrivateMediaStorage()
class ManagementZoneType(models.Model):
"""
Typologie de zone de gestion spécifique à une thématique
(ex: Fauche tardive / refuge biodiversité, Tonte d'agrément, Onde verte coordonnée, etc.).
"""
code = models.CharField(max_length=100, unique=True, verbose_name=_("Code"))
name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)"))
name_nl = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Nom (NL)"))
thematic = models.ForeignKey(
Thematic,
on_delete=models.CASCADE,
related_name="management_zone_types",
verbose_name=_("Thématique")
)
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
default_color = models.CharField(max_length=20, default="#198754", verbose_name=_("Couleur par défaut"))
def get_name(self):
lang = get_language()
if lang and lang.startswith("nl") and self.name_nl:
return self.name_nl
return self.name_fr
@property
def name(self):
return self.get_name()
def __str__(self):
return f"{self.get_name()} ({self.thematic.name})"
class Meta:
ordering = ["thematic", "name_fr"]
verbose_name = _("Type de zone de gestion")
verbose_name_plural = _("Types de zones de gestion")
class ManagementZone(models.Model):
"""
Zone de gestion générique avec géométrie polygonale.
Regroupe des équipements/assets selon une logique métier, d'exploitation ou de gestion écologique.
"""
code = models.CharField(max_length=100, unique=True, verbose_name=_("Code"))
name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)"))
name_nl = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Nom (NL)"))
thematic = models.ForeignKey(
Thematic,
on_delete=models.PROTECT,
related_name="management_zones",
verbose_name=_("Thématique")
)
zone_type = models.ForeignKey(
ManagementZoneType,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="management_zones",
verbose_name=_("Type de zone de gestion")
)
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
instructions = models.TextField(
blank=True,
null=True,
verbose_name=_("Consignes et points d'attention"),
help_text=_("Consignes d'exploitation, points de vigilance, règles de sécurité...")
)
geom = models.MultiPolygonField(
srid=3812,
null=True,
blank=True,
verbose_name=_("Périmètre géographique")
)
color = models.CharField(
max_length=20,
blank=True,
null=True,
verbose_name=_("Couleur d'affichage"),
help_text=_("Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone.")
)
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
target_frequency_days = models.IntegerField(
null=True,
blank=True,
verbose_name=_("Fréquence cible (jours)"),
help_text=_("Intervalle indicatif entre interventions en jours.")
)
seasonality_notes = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_("Saisonnalité / Calendrier"),
help_text=_("Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps.")
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Date de création"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Date de mise à jour"))
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="created_management_zones",
verbose_name=_("Créé par")
)
def get_color(self):
if self.color:
return self.color
if self.zone_type and self.zone_type.default_color:
return self.zone_type.default_color
return "#198754"
@property
def geojson_geometry(self):
"""
Retourne la géométrie GeoJSON transformée en WGS84 (EPSG:4326)
pour utilisation directe dans MapLibre / Leaflet.
"""
if not self.geom:
return None
try:
geom_4326 = self.geom.transform(4326, clone=True)
return json.loads(geom_4326.geojson)
except Exception:
return None
def get_name(self):
lang = get_language()
if lang and lang.startswith("nl") and self.name_nl:
return self.name_nl
return self.name_fr
@property
def name(self):
return self.get_name()
def __str__(self):
return f"{self.code} - {self.get_name()}"
class Meta:
ordering = ["thematic", "code"]
verbose_name = _("Zone de gestion")
verbose_name_plural = _("Zones de gestion")
class ManagementZoneAsset(models.Model):
"""
Liaison explicite (inclusion ou exclusion) d'un asset à une zone de gestion.
Permet d'affiner le rattachement au-delà de la stricte inclusion spatiale.
"""
management_zone = models.ForeignKey(
ManagementZone,
on_delete=models.CASCADE,
related_name="explicit_assets",
verbose_name=_("Zone de gestion")
)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, verbose_name=_("Type d'asset"))
object_id = models.PositiveIntegerField(verbose_name=_("ID de l'asset"))
asset = GenericForeignKey('content_type', 'object_id')
is_excluded = models.BooleanField(
default=False,
verbose_name=_("Exclu"),
help_text=_("Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre.")
)
notes = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Notes"))
def __str__(self):
status = "Exclu" if self.is_excluded else "Inclus"
return f"{self.management_zone.code} -> {self.content_type.model} #{self.object_id} ({status})"
class Meta:
unique_together = ('management_zone', 'content_type', 'object_id')
verbose_name = _("Asset de zone de gestion")
verbose_name_plural = _("Assets de zones de gestion")
class ManagementZoneDocument(Document):
"""
Documents, fiches techniques et arrêtés associés à une zone de gestion.
"""
zone = models.ForeignKey(
ManagementZone,
on_delete=models.CASCADE,
related_name="documents",
verbose_name=_("Zone de gestion")
)
file = models.FileField(
upload_to="assets_documents/management_zones/",
storage=private_storage,
verbose_name=_("Fichier"),
max_length=255
)
thumbnail = models.ImageField(
upload_to="assets_documents/management_zones/",
storage=private_storage,
null=True,
blank=True,
max_length=255
)
class Meta:
verbose_name = _("Document de zone de gestion")
verbose_name_plural = _("Documents de zones de gestion")

View file

@ -146,13 +146,6 @@ TREE_PRUNING_CHOICES = [
('none', _("Non taillé / Entretien minimal")), ('none', _("Non taillé / Entretien minimal")),
] ]
TREE_MANAGEMENT_TYPE_CHOICES = [
('municipal', _("Communal (Régie communale)")),
('regional', _("Régional (Bruxelles Mobilité / Région)")),
('community', _("Intercommunal / Autre pouvoir public")),
('private', _("Privé / Tiers")),
]
BULB_TYPE_CHOICES = [ BULB_TYPE_CHOICES = [
('tulipes', _('Tulipes')), ('tulipes', _('Tulipes')),
@ -231,37 +224,6 @@ class NatureTree(AbstractGeoAsset):
pruning_frequency_years = models.IntegerField(null=True, blank=True, verbose_name=_("Périodicité de taille (années)")) pruning_frequency_years = models.IntegerField(null=True, blank=True, verbose_name=_("Périodicité de taille (années)"))
last_pruning_date = models.DateField(null=True, blank=True, verbose_name=_("Dernière taille / élagage")) last_pruning_date = models.DateField(null=True, blank=True, verbose_name=_("Dernière taille / élagage"))
# Gestionnaire & Compétence
managing_authority = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Autorité gestionnaire"))
management_type = models.CharField(max_length=50, choices=TREE_MANAGEMENT_TYPE_CHOICES, default='municipal', null=True, blank=True, verbose_name=_("Type de gestionnaire"))
# Taxonomie & Botanique
vernacular_name = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Nom vernaculaire / Essence"))
scientific_name = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Nom scientifique / latin"))
genus = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Genre botanique"))
cultivar = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Cultivar / Variété"))
# Dendrométrie & Morphologie complémentaire
number_of_trunks = models.IntegerField(null=True, blank=True, default=1, verbose_name=_("Nombre de troncs / Cépée"))
# Éco-services & Valeur patrimoniale (Indicateurs écosystémiques)
amenity_value = models.FloatField(null=True, blank=True, verbose_name=_("Valeur d'aménité (€)"))
carbon_stock = models.FloatField(null=True, blank=True, verbose_name=_("Stock de carbone (t CO2)"))
cooling_indicator = models.FloatField(null=True, blank=True, verbose_name=_("Indice de rafraîchissement"))
cooling_energy_indicator = models.FloatField(null=True, blank=True, verbose_name=_("Énergie de refroidissement (kWh)"))
biodiversity_index = models.FloatField(null=True, blank=True, verbose_name=_("Indice de biodiversité"))
# Évaluation patrimoniale & Barème
situation_coefficient = models.FloatField(null=True, blank=True, verbose_name=_("Coefficient de situation"))
plantation_coefficient = models.FloatField(null=True, blank=True, verbose_name=_("Coefficient de plantation"))
# Statut biologique particulier
is_dead = models.BooleanField(default=False, verbose_name=_("Arbre mort"))
is_stump = models.BooleanField(default=False, verbose_name=_("Souche"))
is_chronoxyle = models.BooleanField(default=False, verbose_name=_("Arbre sénescent / Chronoxyle (bois mort sur pied)"))
death_date = models.DateField(null=True, blank=True, verbose_name=_("Date de dépérissement / mort"))
planting_date = models.DateField(null=True, blank=True, verbose_name=_("Date de plantation réelle"))
# Références des choix pour utilisation simple dans les templates # Références des choix pour utilisation simple dans les templates
DEVELOPMENT_STAGE_CHOICES = TREE_DEVELOPMENT_STAGE_CHOICES DEVELOPMENT_STAGE_CHOICES = TREE_DEVELOPMENT_STAGE_CHOICES
VITALITY_CHOICES = TREE_VITALITY_CHOICES VITALITY_CHOICES = TREE_VITALITY_CHOICES
@ -271,7 +233,6 @@ class NatureTree(AbstractGeoAsset):
PIT_TYPE_CHOICES = TREE_PIT_TYPE_CHOICES PIT_TYPE_CHOICES = TREE_PIT_TYPE_CHOICES
PROTECTION_CHOICES = TREE_PROTECTION_CHOICES PROTECTION_CHOICES = TREE_PROTECTION_CHOICES
PRUNING_CHOICES = TREE_PRUNING_CHOICES PRUNING_CHOICES = TREE_PRUNING_CHOICES
MANAGEMENT_TYPE_CHOICES = TREE_MANAGEMENT_TYPE_CHOICES
indexes = [ indexes = [
models.Index(fields=["code"]), models.Index(fields=["code"]),
@ -281,11 +242,6 @@ class NatureTree(AbstractGeoAsset):
verbose_name = _("Arbre") verbose_name = _("Arbre")
verbose_name_plural = _("Arbres") verbose_name_plural = _("Arbres")
def save(self, *args, **kwargs):
from assets.utils.tree_ecoservices import compute_tree_ecoservices
compute_tree_ecoservices(self, overwrite_existing=False)
super().save(*args, **kwargs)
def get_absolute_url(self): def get_absolute_url(self):
return reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturetree', 'asset_id':self.id}) return reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturetree', 'asset_id':self.id})

View file

@ -17,15 +17,13 @@ private_storage = PrivateMediaStorage()
STRUCTURE_TYPE_CHOICES = ( STRUCTURE_TYPE_CHOICES = (
('tunnel', _('Tunnel')), ('tunnel', 'Tunnnel'),
('bridge', _('Pont')), ('bridge', 'Pont'),
('viaduct', _('Viaduc')), ('viaduct', 'Viaduc'),
('overpass', _('Passerelle')), ('overpass', 'Passerelle'),
('underpass', _('Passage souterrain')), ('underpass', 'Passage souterrain'),
('gallery', _('Galerie technique')), ('gallery', 'Galerie technique'),
('atypical', _('Ouvrage atypique')), ('atypical', 'Ouvrage atypique'),
('metro_station', _('Station de métro')),
('metro_tunnel', _('Tunnel de métro')),
) )

View file

@ -16,32 +16,32 @@ ASSET_EDITABLE_FIELDS_BY_ROLE = {
'admin': [ 'admin': [
'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category', 'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'note', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',
], ],
'manager': [ 'manager': [
'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category', 'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'note', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',
], ],
'controller': [ 'controller': [
'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category', 'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'note', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',
], ],
'external_manager': [ 'external_manager': [
'name_fr', 'name_nl', 'external_reference', 'category', 'brand', 'serial_number', 'funding_program', 'note', 'name_fr', 'name_nl', 'external_reference', 'category', 'brand', 'serial_number', 'funding_program'
], ],
'technician': ['name_fr', 'name_nl', 'external_reference', 'category', 'brand', 'serial_number', 'funding_program', 'note'], 'technician': ['name_fr', 'name_nl', 'external_reference', 'category', 'brand', 'serial_number', 'funding_program'],
'operator': [], 'operator': [],
'observer': [], 'observer': [],
'viewer': [], 'viewer': [],
'editor': [ 'editor': [
'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category', 'code', 'external_reference', 'name_fr', 'name_nl', 'status', 'model', 'category',
'serial_number', 'installation_date', 'brand', 'warranty_duration', 'serial_number', 'installation_date', 'brand', 'warranty_duration',
'last_inspection_date', 'funding_program', 'note', 'geom', 'lon', 'lat', 'last_inspection_date', 'funding_program', 'geom', 'lon', 'lat',
'location_id', 'intersection_id', 'location_id', 'intersection_id',
], ],
} }
@ -258,9 +258,9 @@ def can_view_asset(user, asset):
if not can_view: if not can_view:
return False return False
# Vérifier la restriction par contrat et commune pour les utilisateurs externes ou limités aux contrats # Vérifier la restriction par contrat pour les utilisateurs externes
is_external = (user_config is None) or (not user_config.is_intern) is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts) must_limit = is_external and (not user_config or user_config.limit_assets_to_contracts)
if must_limit and user_config: if must_limit and user_config:
asset_cids = get_asset_contract_ids(asset) asset_cids = get_asset_contract_ids(asset)
if asset_cids is not None: if asset_cids is not None:
@ -271,35 +271,8 @@ def can_view_asset(user, asset):
can_view_assets=True can_view_assets=True
).values_list('contract_id', flat=True) ).values_list('contract_id', flat=True)
) )
if user_config.default_contract_id:
accessible_cids.add(user_config.default_contract_id)
if not (asset_cids & accessible_cids): if not (asset_cids & accessible_cids):
return False return False
else:
# Vérifier la restriction par commune pour les assets sans contrat direct
accessible_munis = get_user_accessible_municipalities(user)
accessible_muni_ids = set(accessible_munis.values_list('id', flat=True))
if accessible_muni_ids:
muni_id = getattr(asset, 'municipality_id', None)
if not muni_id and hasattr(asset, 'location') and asset.location:
muni_id = getattr(asset.location, 'municipality_id', None)
if not muni_id and hasattr(asset, 'street') and asset.street:
muni_id = getattr(asset.street, 'municipality_id', None)
if not muni_id and hasattr(asset, 'pole') and asset.pole:
muni_id = getattr(asset.pole, 'municipality_id', None)
if not muni_id and hasattr(asset.pole, 'street') and asset.pole.street:
muni_id = getattr(asset.pole.street, 'municipality_id', None)
if not muni_id and hasattr(asset, 'intersection') and asset.intersection:
muni_id = getattr(asset.intersection, 'municipality_id', None)
if not muni_id and hasattr(asset, 'building') and asset.building:
muni_id = getattr(asset.building, 'municipality_id', None)
if muni_id is not None:
if muni_id not in accessible_muni_ids:
return False
elif getattr(asset, 'geom', None):
from common.models import Municipality
if not Municipality.objects.filter(id__in=accessible_muni_ids, geom__intersects=asset.geom).exists():
return False
return True return True
return _check_instance_permission(user, asset, 'can_view', _fallback) return _check_instance_permission(user, asset, 'can_view', _fallback)
@ -588,10 +561,6 @@ def get_allowed_update_fields_for_asset(user, asset):
role_fields = ASSET_EDITABLE_FIELDS_BY_ROLE.get(role.name, []) role_fields = ASSET_EDITABLE_FIELDS_BY_ROLE.get(role.name, [])
allowed_fields.update(role_fields) allowed_fields.update(role_fields)
# Superuser fallback if no explicit roles configured
if getattr(user, 'is_superuser', False) and not allowed_fields:
allowed_fields.update(ASSET_EDITABLE_FIELDS_BY_ROLE.get('admin', []))
# Check validation permission # Check validation permission
if can_validate_asset(user, asset): if can_validate_asset(user, asset):
allowed_fields.add('validation_status') allowed_fields.add('validation_status')
@ -916,124 +885,3 @@ def get_user_accessible_municipalities(user):
lang = get_language() or 'fr' lang = get_language() or 'fr'
order_field = 'name_nl' if lang.startswith('nl') else 'name_fr' order_field = 'name_nl' if lang.startswith('nl') else 'name_fr'
return Municipality.objects.filter(id__in=municipality_ids).order_by(order_field) return Municipality.objects.filter(id__in=municipality_ids).order_by(order_field)
# ─── Management Zones Permissions ─────────────────────────────────────────────
def can_view_management_zones(user, thematic=None):
"""
Vérifie si l'utilisateur a le droit de visualiser les zones de gestion.
Si thematic est fourni (instance Thematic ou code str), vérifie pour cette thématique.
"""
if not user or not user.is_authenticated:
return False
if getattr(user, 'is_superuser', False) or user.has_perm('assets.view_managementzone'):
return True
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name__in=['admin', 'operator', 'manager']).exists():
return True
if thematic:
thematic_obj = thematic
if isinstance(thematic, str):
from common.models import Thematic
thematic_obj = Thematic.objects.filter(code=thematic).first()
if not thematic_obj:
return False
try:
user_thematic = UserThematics.objects.get(user_config=user_config, thematic=thematic_obj)
return user_thematic.can_view_assets
except UserThematics.DoesNotExist:
return False
return UserThematics.objects.filter(user_config=user_config, can_view_assets=True).exists()
def can_edit_management_zones(user, thematic=None):
"""
Vérifie si l'utilisateur a le droit de créer ou modifier les zones de gestion.
Autorisé si:
- Superutilisateur ou permission Django explicite (assets.change_managementzone / assets.add_managementzone)
- Rôle 'admin' ou 'manager'
- Ou UserThematics.can_edit_management_zones == True (ou fallback can_edit_assets)
"""
from django.db import models as db_models
if not user or not user.is_authenticated:
return False
if getattr(user, 'is_superuser', False):
return True
if user.has_perm('assets.change_managementzone') or user.has_perm('assets.add_managementzone'):
return True
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return False
if user_config.roles.filter(name__in=['admin', 'manager']).exists():
return True
if thematic:
thematic_obj = thematic
if isinstance(thematic, str):
from common.models import Thematic
thematic_obj = Thematic.objects.filter(code=thematic).first()
if not thematic_obj:
return False
try:
user_thematic = UserThematics.objects.get(user_config=user_config, thematic=thematic_obj)
return bool(
getattr(user_thematic, 'can_edit_management_zones', False) or
user_thematic.can_edit_assets
)
except UserThematics.DoesNotExist:
return False
return UserThematics.objects.filter(
user_config=user_config
).filter(
db_models.Q(can_edit_management_zones=True) | db_models.Q(can_edit_assets=True)
).exists()
def can_delete_management_zone(user, zone):
"""Vérifie si l'utilisateur a le droit de supprimer une zone de gestion."""
if not user or not user.is_authenticated:
return False
if getattr(user, 'is_superuser', False) or user.has_perm('assets.delete_managementzone'):
return True
return can_edit_management_zones(user, thematic=zone.thematic)
def get_editable_thematics_for_management_zones(user):
"""
Retourne le queryset des thématiques pour lesquelles l'utilisateur peut créer/éditer des zones de gestion.
"""
from common.models import Thematic
from django.db import models as db_models
if not user or not user.is_authenticated:
return Thematic.objects.none()
if getattr(user, 'is_superuser', False) or user.has_perm('assets.change_managementzone'):
return Thematic.objects.all()
try:
user_config = UserConfig.objects.get(user=user)
except UserConfig.DoesNotExist:
return Thematic.objects.none()
if user_config.roles.filter(name__in=['admin', 'manager']).exists():
return Thematic.objects.all()
return Thematic.objects.filter(
userthematics__user_config=user_config
).filter(
db_models.Q(userthematics__can_edit_management_zones=True) |
db_models.Q(userthematics__can_edit_assets=True)
).distinct()

View file

@ -94,9 +94,6 @@
addIntersectionAssetsLayer(container); addIntersectionAssetsLayer(container);
addConnectionLinesLayer(); addConnectionLinesLayer();
} }
// Ensure asset layers are stacked above basemaps
bringAssetLayersToTop();
}); });
} }
@ -227,8 +224,6 @@
addLineLayer(); addLineLayer();
addPointLayer(); addPointLayer();
} }
bringAssetLayersToTop();
} }
function addPointLayer() { function addPointLayer() {
@ -656,29 +651,9 @@
'current-asset-icon', 'current-asset-icon',
]; ];
function getBasemapBeforeLayer(m) {
if (!m || !m.getStyle) return undefined;
const styleLayers = (m.getStyle() && m.getStyle().layers) || [];
const drawLayer = styleLayers.find(l => l.id && (l.id.startsWith('gl-draw-') || l.id.startsWith('custom-draw-')));
if (drawLayer) return drawLayer.id;
// Return first existing asset layer so basemap is placed below it
for (const id of ASSET_LAYER_ORDER) {
if (m.getLayer(id)) return id;
}
return undefined;
}
function bringAssetLayersToTop() { function bringAssetLayersToTop() {
if (!map || typeof map.isStyleLoaded !== 'function' || !map.isStyleLoaded()) return;
ASSET_LAYER_ORDER.forEach(id => { ASSET_LAYER_ORDER.forEach(id => {
if (map.getLayer(id)) { if (map.getLayer(id)) map.moveLayer(id);
try {
map.moveLayer(id);
} catch (e) {
// ignore if layer cannot be moved
}
}
}); });
} }
@ -686,25 +661,16 @@
if (typeof LayerSwitcherControl === 'undefined') return; if (typeof LayerSwitcherControl === 'undefined') return;
const layerSwitcher = new LayerSwitcherControl({ const layerSwitcher = new LayerSwitcherControl({
onBasemapChange: function() {
bringAssetLayersToTop();
},
layers: [ layers: [
{ {
id: 'ortho', id: 'ortho',
name: 'Orthophotos', name: 'Orthophotos',
icon: 'satellite', icon: 'satellite',
group: 'ortho', group: 'ortho',
beforeLayer: getBasemapBeforeLayer,
onLayerAdded: () => bringAssetLayersToTop(),
onVisibilityChange: (layerId, isVisible) => {
if (isVisible) bringAssetLayersToTop();
},
layer: { layer: {
type: 'raster', type: 'raster',
paint: { 'raster-opacity': 1 } paint: { 'raster-opacity': 1 }
}, },
basemaps: (window.MAP_BACKGROUNDS && window.MAP_BACKGROUNDS.length > 0) ? window.MAP_BACKGROUNDS : undefined,
sublayers: [ sublayers: [
{ {
id: 'urbis', id: 'urbis',
@ -734,12 +700,6 @@
map.addControl(layerSwitcher, 'top-left'); map.addControl(layerSwitcher, 'top-left');
// Listen for layer switcher events on map to keep asset layers on top
map.on('layerswitcher:layerAdded', bringAssetLayersToTop);
map.on('layerswitcher:visibilityChange', (e) => {
if (e && e.visible) bringAssetLayersToTop();
});
// The LayerSwitcherControl appends sublayer selectors to document.body with // The LayerSwitcherControl appends sublayer selectors to document.body with
// position:fixed and hardcoded coordinates designed for fullscreen maps. // position:fixed and hardcoded coordinates designed for fullscreen maps.
// We move each selector into #asset-map (position:relative) and switch to // We move each selector into #asset-map (position:relative) and switch to
@ -763,9 +723,8 @@
// After any ortho interaction, put asset layers back on top // After any ortho interaction, put asset layers back on top
map.getContainer().addEventListener('click', (e) => { map.getContainer().addEventListener('click', (e) => {
if (e.target.closest('.layer-switcher-btn') || e.target.closest('.layer-switcher-sublayer-btn') || e.target.closest('.layer-switcher-item')) { if (e.target.closest('.layer-switcher-btn') || e.target.closest('.layer-switcher-sublayer-btn')) {
setTimeout(bringAssetLayersToTop, 50); setTimeout(bringAssetLayersToTop, 150);
setTimeout(bringAssetLayersToTop, 200);
} }
}, true); }, true);
} }
@ -802,10 +761,6 @@
}); });
} }
} }
bringAssetLayersToTop();
}; };
window.bringAssetLayersToTop = bringAssetLayersToTop;
})(); })();

View file

@ -278,10 +278,6 @@
} }
}); });
if (typeof window.bringAssetLayersToTop === 'function') {
window.bringAssetLayersToTop();
}
// Remove draw control and listeners // Remove draw control and listeners
if (drawInstance && drawControlAdded) { if (drawInstance && drawControlAdded) {
mapInstance.off('draw.create', handleDrawCreate); mapInstance.off('draw.create', handleDrawCreate);

View file

@ -261,37 +261,10 @@
glyphCodepoint: 'f30b', glyphCodepoint: 'f30b',
mapImage: 'icon-naturerwiasset', mapImage: 'icon-naturerwiasset',
}, },
naturetree_regional: {
thematic: 'nature',
color: '#1d4ed8',
iconUrl: '/static/assets/nature_tree_icon_regional.svg?v=20261009_round',
iconSize: 56,
iconClass: 'bi-tree',
glyphCodepoint: 'f5e1',
mapImage: 'icon-nature-regional',
},
naturetree_remarkable: {
thematic: 'nature',
color: '#d97706',
iconUrl: '/static/assets/nature_tree_icon_remarkable.svg?v=20261009_round',
iconSize: 56,
iconClass: 'bi-tree',
glyphCodepoint: 'f5e1',
mapImage: 'icon-nature-remarkable',
},
naturetree_dead: {
thematic: 'nature',
color: '#6b7280',
iconUrl: '/static/assets/nature_tree_icon_dead.svg?v=20261009_round',
iconSize: 56,
iconClass: 'bi-tree',
glyphCodepoint: 'f5e1',
mapImage: 'icon-nature-dead',
},
naturetree: { naturetree: {
thematic: 'nature', thematic: 'nature',
color: '#2e7d32', color: '#2e7d32',
iconUrl: '/static/assets/nature_tree_icon.svg?v=20261009_round', iconUrl: '/static/assets/nature_tree_icon.svg?v=20260521a',
iconSize: 56, iconSize: 56,
iconClass: 'bi-tree-fill', iconClass: 'bi-tree-fill',
glyphCodepoint: 'f5e1', glyphCodepoint: 'f5e1',
@ -300,11 +273,11 @@
naturegreensurface: { naturegreensurface: {
thematic: 'nature', thematic: 'nature',
color: '#2e7d32', color: '#2e7d32',
iconUrl: '/static/assets/nature_greensurface_icon.svg?v=20261010a', iconUrl: '/static/assets/nature_tree_icon.svg?v=20260521a',
iconSize: 52, iconSize: 56,
iconClass: 'bi-grid-fill', iconClass: 'bi-tree-fill',
glyphCodepoint: 'f3fb', glyphCodepoint: 'f5e1',
mapImage: 'icon-naturegreensurface', mapImage: 'icon-nature',
}, },
natureshrub: { natureshrub: {
thematic: 'nature', thematic: 'nature',
@ -454,33 +427,6 @@
glyphCodepoint: 'f82b', glyphCodepoint: 'f82b',
mapImage: 'icon-sign', mapImage: 'icon-sign',
}, },
// ITS
itsgeoasset: {
thematic: 'its',
color: '#0288d1',
iconUrl: null,
iconClass: 'bi-broadcast-pin',
glyphCodepoint: 'f1d5',
mapImage: 'icon-its',
},
itsbollardsystem: {
thematic: 'its',
color: '#0288d1',
iconUrl: null,
iconClass: 'bi-border-width',
glyphCodepoint: 'f1ca',
mapImage: 'icon-itsbollardsystem',
},
itsbollardplot: {
thematic: 'its',
color: '#0288d1',
iconUrl: '/static/assets/its_bollard_plot_icon.svg?v=20261008',
iconSize: 42,
iconClass: 'bi-cone-striped',
glyphCodepoint: 'f2aa',
mapImage: 'icon-itsbollardplot',
},
}; };
// ─── 3. Specific Parking Spot Overrides by spot_type ───────────────────────── // ─── 3. Specific Parking Spot Overrides by spot_type ─────────────────────────
@ -605,26 +551,6 @@
} }
// 3. Specific asset_type override // 3. Specific asset_type override
if (assetType === 'naturetree' || (thematic === 'nature' && (assetType === 'naturetree' || !assetType))) {
const isDead = item.is_dead === true || item.is_dead === 'true' || item.is_dead === 1 ||
item.vitality === 'dead' || item.status === 'dead' || item.status === 'archived';
if (isDead && ASSET_TYPE_OVERRIDES.naturetree_dead) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree_dead);
}
const isRemarkable = item.is_remarkable === true || item.is_remarkable === 'true' || item.is_remarkable === 1;
const isRegional = item.management_type === 'regional' ||
(typeof item.managing_authority === 'string' && (item.managing_authority.toLowerCase().includes('région') || item.managing_authority.toLowerCase().includes('mobilité'))) ||
(typeof item.code === 'string' && item.code.toUpperCase().startsWith('BM-ARB'));
if (isRemarkable && ASSET_TYPE_OVERRIDES.naturetree_remarkable) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree_remarkable);
}
if (isRegional && ASSET_TYPE_OVERRIDES.naturetree_regional) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree_regional);
}
if (ASSET_TYPE_OVERRIDES.naturetree) {
return Object.assign({}, ASSET_TYPE_OVERRIDES.naturetree);
}
}
if (assetType === 'naturebench') { if (assetType === 'naturebench') {
const isPlanter = (item.asset_sub_type === 'planter') || const isPlanter = (item.asset_sub_type === 'planter') ||
['Bac à fleurs / Jardinière', 'Bloembak', 'Jardinière', 'Bac à fleurs', 'Bac à plantes'].includes(item.model) || ['Bac à fleurs / Jardinière', 'Bloembak', 'Jardinière', 'Bac à fleurs', 'Bac à plantes'].includes(item.model) ||
@ -672,17 +598,12 @@
const customStyle = options.style ? ` style="${options.style}"` : ''; const customStyle = options.style ? ` style="${options.style}"` : '';
const classList = ['asset-symbology-icon-img'];
if (options.imgClass) classList.push(options.imgClass);
const imgClass = classList.join(' ');
const defaultImgStyle = 'width: 20px; height: 20px; max-width: 20px; max-height: 20px; object-fit: contain; vertical-align: middle; display: inline-block;';
const finalStyle = options.style ? ` style="${defaultImgStyle} ${options.style}"` : ` style="${defaultImgStyle}"`;
if (symbology.iconUrl) { if (symbology.iconUrl) {
const imgClass = options.imgClass || 'asset-symbology-icon-img';
const fallbackUrl = symbology.fallbackUrl || (symbology.iconUrl.includes('.svg') ? symbology.iconUrl.replace(/\.svg(\?.*)?$/, '.png$1') : ''); const fallbackUrl = symbology.fallbackUrl || (symbology.iconUrl.includes('.svg') ? symbology.iconUrl.replace(/\.svg(\?.*)?$/, '.png$1') : '');
const fallbackClass = options.iconClass || symbology.iconClass || 'bi-sign-stop-fill'; const fallbackClass = options.iconClass || symbology.iconClass || 'bi-sign-stop-fill';
const onerrorAttr = ` onerror="this.onerror=null; ${fallbackUrl ? `if(this.src && this.src.indexOf('.svg') !== -1){ this.src='${fallbackUrl}'; } else {` : ''} this.style.display='none'; if(this.nextElementSibling){ this.nextElementSibling.style.display=''; } ${fallbackUrl ? '}' : ''}"`; const onerrorAttr = ` onerror="this.onerror=null; ${fallbackUrl ? `if(this.src && this.src.indexOf('.svg') !== -1){ this.src='${fallbackUrl}'; } else {` : ''} this.style.display='none'; if(this.nextElementSibling){ this.nextElementSibling.style.display=''; } ${fallbackUrl ? '}' : ''}"`;
return `<img src="${symbology.iconUrl}" alt="" class="${imgClass}"${finalStyle}${onerrorAttr}><i class="bi ${fallbackClass}" style="display:none;${options.style || ''}"></i>`; return `<img src="${symbology.iconUrl}" alt="" class="${imgClass}"${customStyle}${onerrorAttr}><i class="bi ${fallbackClass}" style="display:none;${options.style || ''}"></i>`;
} }
const iconClass = options.iconClass || symbology.iconClass || 'bi-pin-map'; const iconClass = options.iconClass || symbology.iconClass || 'bi-pin-map';
@ -705,10 +626,6 @@
publiclighting: injected.publiclighting || THEMATIC_CONFIG.publiclighting.iconUrl, publiclighting: injected.publiclighting || THEMATIC_CONFIG.publiclighting.iconUrl,
publiclightingelectricalcabinet: injected.publiclightingelectricalcabinet || ASSET_TYPE_OVERRIDES.publiclightingelectricalcabinet.iconUrl, publiclightingelectricalcabinet: injected.publiclightingelectricalcabinet || ASSET_TYPE_OVERRIDES.publiclightingelectricalcabinet.iconUrl,
nature: injected.nature || THEMATIC_CONFIG.nature.iconUrl, nature: injected.nature || THEMATIC_CONFIG.nature.iconUrl,
natureregional: injected.natureregional || (ASSET_TYPE_OVERRIDES.naturetree_regional && ASSET_TYPE_OVERRIDES.naturetree_regional.iconUrl),
natureremarkable: injected.natureremarkable || (ASSET_TYPE_OVERRIDES.naturetree_remarkable && ASSET_TYPE_OVERRIDES.naturetree_remarkable.iconUrl),
naturedead: injected.naturedead || (ASSET_TYPE_OVERRIDES.naturetree_dead && ASSET_TYPE_OVERRIDES.naturetree_dead.iconUrl),
naturegreensurface: injected.naturegreensurface || (ASSET_TYPE_OVERRIDES.naturegreensurface && ASSET_TYPE_OVERRIDES.naturegreensurface.iconUrl),
structures: injected.structures || THEMATIC_CONFIG.structures.iconUrl, structures: injected.structures || THEMATIC_CONFIG.structures.iconUrl,
roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl, roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl,
roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl, roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl,
@ -716,7 +633,6 @@
natureplanter: injected.natureplanter || (ASSET_TYPE_OVERRIDES.natureplanter && ASSET_TYPE_OVERRIDES.natureplanter.iconUrl), natureplanter: injected.natureplanter || (ASSET_TYPE_OVERRIDES.natureplanter && ASSET_TYPE_OVERRIDES.natureplanter.iconUrl),
artworkfountain: injected.artworkfountain || ASSET_TYPE_OVERRIDES.artworkfountain.iconUrl, artworkfountain: injected.artworkfountain || ASSET_TYPE_OVERRIDES.artworkfountain.iconUrl,
artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl, artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl,
itsbollardplot: injected.itsbollardplot || (ASSET_TYPE_OVERRIDES.itsbollardplot && ASSET_TYPE_OVERRIDES.itsbollardplot.iconUrl),
}; };
return urls; return urls;
} }
@ -734,16 +650,12 @@
{ name: 'icon-trafficlightradarpole', codepoint: 'f4e0', color: '#e53935' }, { name: 'icon-trafficlightradarpole', codepoint: 'f4e0', color: '#e53935' },
{ name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' }, { name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' },
{ name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' }, { name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' },
{ name: 'icon-naturegreensurface', codepoint: 'f3fb', color: '#2e7d32' },
{ name: 'icon-naturebench', codepoint: 'f5e1', color: '#2e7d32' }, { name: 'icon-naturebench', codepoint: 'f5e1', color: '#2e7d32' },
{ name: 'icon-natureplanter', codepoint: 'f3ce', color: '#d97706' }, { name: 'icon-natureplanter', codepoint: 'f3ce', color: '#d97706' },
{ name: 'icon-naturerwiasset', codepoint: 'f30b', color: '#0277bd' }, { name: 'icon-naturerwiasset', codepoint: 'f30b', color: '#0277bd' },
{ name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' }, { name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' },
{ name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' }, { name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' },
{ name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' }, { name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' },
{ name: 'icon-itsgeoasset', codepoint: 'f1d5', color: '#0288d1' },
{ name: 'icon-itsbollardsystem', codepoint: 'f1ca', color: '#0288d1' },
{ name: 'icon-itsbollardplot', codepoint: 'f2aa', color: '#0288d1' },
{ name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' }, { name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' },
{ name: 'icon-cleanglasscontainer', codepoint: 'f78a', color: '#16a34a' }, { name: 'icon-cleanglasscontainer', codepoint: 'f78a', color: '#16a34a' },
{ name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' }, { name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' },
@ -830,27 +742,6 @@
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'], ['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
], 'icon-natureplanter', ], 'icon-natureplanter',
['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench', ['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench',
['all',
['any', ['==', ['get', 'asset_type'], 'naturetree'], ['==', ['get', 'thematic'], 'nature']],
['any',
['==', ['get', 'is_dead'], true],
['==', ['get', 'is_dead'], 'true'],
['==', ['get', 'is_dead'], 1],
['==', ['get', 'status'], 'dead'],
['==', ['get', 'status'], 'archived'],
['==', ['get', 'vitality'], 'dead']
]
], 'icon-nature-dead',
['all',
['any', ['==', ['get', 'asset_type'], 'naturetree'], ['==', ['get', 'thematic'], 'nature']],
['any', ['==', ['get', 'is_remarkable'], true], ['==', ['get', 'is_remarkable'], 'true'], ['==', ['get', 'is_remarkable'], 1]]
], 'icon-nature-remarkable',
['all',
['any', ['==', ['get', 'asset_type'], 'naturetree'], ['==', ['get', 'thematic'], 'nature']],
['any', ['==', ['get', 'management_type'], 'regional'], ['==', ['get', 'management_type'], 'Régional']]
], 'icon-nature-regional',
['==', ['get', 'asset_type'], 'naturetree'], 'icon-nature',
['==', ['get', 'asset_type'], 'naturegreensurface'], 'icon-naturegreensurface',
['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain', ['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain',
['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain', ['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain',
['==', ['get', 'category'], 'Ralentisseur'], 'icon-speedbump', ['==', ['get', 'category'], 'Ralentisseur'], 'icon-speedbump',
@ -858,9 +749,6 @@
['==', ['get', 'asset_type'], 'roadfurniture'], 'icon-speedbump', ['==', ['get', 'asset_type'], 'roadfurniture'], 'icon-speedbump',
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset', ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset',
['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset', ['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset',
['==', ['get', 'asset_type'], 'itsbollardplot'], 'icon-itsbollardplot',
['==', ['get', 'asset_type'], 'itsbollardsystem'], 'icon-itsbollardsystem',
['==', ['get', 'asset_type'], 'itsgeoasset'], 'icon-its',
['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone', ['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone',
['==', ['get', 'spot_type'], 'pmr'], 'icon-parkingspot-pmr', ['==', ['get', 'spot_type'], 'pmr'], 'icon-parkingspot-pmr',
['==', ['get', 'spot_type'], 'ev_charging'], 'icon-parkingspot-ev_charging', ['==', ['get', 'spot_type'], 'ev_charging'], 'icon-parkingspot-ev_charging',
@ -1036,20 +924,14 @@
'#0288d1' '#0288d1'
], ],
['==', ['get', 'thematic'], 'nature'], '#2e7d32', ['==', ['get', 'thematic'], 'nature'], '#2e7d32',
['==', ['get', 'asset_type'], 'itsbollardsystem'], '#0288d1',
['match', ['get', 'asset_type'], 'roadway', '#334155', 'sidewalk', '#64748b', getThematicColorExpr()] ['match', ['get', 'asset_type'], 'roadway', '#334155', 'sidewalk', '#64748b', getThematicColorExpr()]
], ],
'line-width': [ 'line-width': [
'case', 'case',
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 6, ['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 6,
['==', ['get', 'asset_type'], 'itsbollardsystem'], 5,
3.5 3.5
], ],
'line-opacity': [ 'line-opacity': 0.88,
'case',
['==', ['get', 'asset_type'], 'itsbollardsystem'], 0.95,
0.88
],
}, },
minzoom: 8, minzoom: 8,
}; };

View file

@ -79,21 +79,6 @@ const draw = new MapboxDraw({
map.on('load', () => { map.on('load', () => {
map.addControl(draw); map.addControl(draw);
// Contrôle sélecteur de couches (LayerSwitcherControl)
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'basemap',
name: (typeof gettext === 'function') ? gettext('Fonds de plan & Orthophotos') : 'Fonds de plan & Orthophotos',
icon: 'satellite',
beforeLayer: 'locations',
basemaps: window.MAP_BACKGROUNDS || [],
}],
});
map.addControl(layerSwitcher, 'top-right');
window.layerSwitcher = layerSwitcher;
}
map.boxZoom.enable(); map.boxZoom.enable();
// Initial state // Initial state

View file

@ -0,0 +1,44 @@
const map = new maplibregl.Map({
style: window.MAP_STYLE_LIGHT,
center: [feature_lon, feature_lat],
zoom: 17,
pitch: 45,
bearing: -17.6,
container: 'map',
antialias: true
});
map.on('load', () => {
const featureData = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: JSON.parse(feature_geojson),
properties: {
name: '...'
}
}
]
};
// Add the circle as a GeoJSON source
map.addSource('green', {
type: 'geojson',
data: featureData
});
map.addLayer({
'id': 'green',
'type': 'fill',
'source': 'green',
'paint': {
'fill-color': '#0094ff',
'fill-opacity': 0.8
}
});
});

View file

@ -89,11 +89,9 @@
if (typeof AddressSearchControl !== 'undefined') { if (typeof AddressSearchControl !== 'undefined') {
const autocompleteUrl = window.AUTOCOMPLETE_STREET_URL || '/common/autocomplete-street/'; const autocompleteUrl = window.AUTOCOMPLETE_STREET_URL || '/common/autocomplete-street/';
const i18n = window.INSPECTION_MAP_I18N || {};
const placeholder = i18n.searchAddress || (typeof gettext !== 'undefined' ? gettext('Rechercher une adresse…') : 'Rechercher une adresse…');
const searchCtrl = new AddressSearchControl({ const searchCtrl = new AddressSearchControl({
autocompleteUrl: autocompleteUrl, autocompleteUrl: autocompleteUrl,
placeholder: placeholder placeholder: 'Rechercher une adresse...'
}); });
map.addControl(searchCtrl, 'top-left'); map.addControl(searchCtrl, 'top-left');
} }
@ -290,7 +288,7 @@
'match', ['coalesce', ['get', 'structure_type'], ''], 'match', ['coalesce', ['get', 'structure_type'], ''],
['massif drainant', 'tranchee drainante', 'drain', 'collecteur en beton poreux', 'reseau egout separatif', 'oed'], '#8d6e63', ['massif drainant', 'tranchee drainante', 'drain', 'collecteur en beton poreux', 'reseau egout separatif', 'oed'], '#8d6e63',
['noue', 'jardin de pluie', 'bande filtrante', 'evc'], '#009688', ['noue', 'jardin de pluie', 'bande filtrante', 'evc'], '#009688',
['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein', 'ta'], '#455a64', ['filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'avaloir de rail', 'trop plein', 'ta'], '#0277bd',
'#0288d1' '#0288d1'
], ],
'line-width': 2.5 'line-width': 2.5
@ -343,13 +341,11 @@
['==', ['coalesce', ['get', 'status'], ''], 'inactive'], '#343a40', ['==', ['coalesce', ['get', 'status'], ''], 'inactive'], '#343a40',
'#198754' '#198754'
], ],
['match', ['coalesce', ['get', 'model_name'], ''], ['roaddrain', 'drain'], true, false], '#37474f',
[ [
'match', ['coalesce', ['get', 'structure_type'], ''], 'match', ['coalesce', ['get', 'structure_type'], ''],
['massif drainant', 'tranchee drainante', 'oed'], '#8d6e63', ['massif drainant', 'tranchee drainante', 'oed'], '#8d6e63',
['jardin de pluie', 'noue', 'evc'], '#009688', ['jardin de pluie', 'noue', 'evc'], '#009688',
['bassin orage', 'citerne', 'ta'], '#0277bd', ['bassin orage', 'citerne', 'ta'], '#0277bd',
['avaloir', 'avaloir de rail', 'bouche injection', 'filet eau', 'caniveau infiltrant', 'gargouille de trottoir', 'puits infiltration', 'drain', 'regard', 'vanne regulation', 'vanne isolation'], '#37474f',
'#0288d1' '#0288d1'
] ]
], ],
@ -733,99 +729,50 @@
}, 400); }, 400);
} }
let gpsWatchId = null;
let isFirstGpsFix = true;
function setupGeolocation() { function setupGeolocation() {
const gpsBtn = document.getElementById('btn-gps-locate'); const gpsBtn = document.getElementById('btn-gps-locate');
if (gpsBtn) { if (gpsBtn) {
gpsBtn.addEventListener('click', function() { gpsBtn.addEventListener('click', locateUser);
if (window.currentGpsPosition && map) {
map.flyTo({
center: [window.currentGpsPosition.lng, window.currentGpsPosition.lat],
zoom: 18,
duration: 800
});
} else {
startGpsTracking(true);
}
});
} }
const hasPreselect = (window.PRESELECT_LON !== null && window.PRESELECT_LAT !== null && typeof window.PRESELECT_LON !== 'undefined' && typeof window.PRESELECT_LAT !== 'undefined'); const hasPreselect = (window.PRESELECT_LON !== null && window.PRESELECT_LAT !== null && typeof window.PRESELECT_LON !== 'undefined' && typeof window.PRESELECT_LAT !== 'undefined');
startGpsTracking(!hasPreselect); if (!hasPreselect) {
locateUser();
window.addEventListener('beforeunload', stopGpsTracking);
}
function stopGpsTracking() {
if (gpsWatchId !== null && navigator.geolocation) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
} }
} }
function locateUser() { function locateUser() {
startGpsTracking(true);
}
function startGpsTracking(shouldFlyTo = false) {
if (!navigator.geolocation) return; if (!navigator.geolocation) return;
const i18n = window.INSPECTION_MAP_I18N || {};
const statusText = document.getElementById('gps-status-text'); const statusText = document.getElementById('gps-status-text');
if (statusText) statusText.textContent = i18n.geolocation || (typeof gettext !== 'undefined' ? gettext('Géolocalisation...') : 'Géolocalisation...'); if (statusText) statusText.textContent = 'Géolocalisation...';
if (gpsWatchId !== null) { navigator.geolocation.getCurrentPosition(
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
gpsWatchId = navigator.geolocation.watchPosition(
function(position) { function(position) {
const lng = position.coords.longitude; const lng = position.coords.longitude;
const lat = position.coords.latitude; const lat = position.coords.latitude;
const accuracy = position.coords.accuracy;
window.currentGpsPosition = { lng, lat, accuracy, timestamp: Date.now() };
try {
window.dispatchEvent(new CustomEvent('loko:gps-update', {
detail: window.currentGpsPosition
}));
} catch(e) {}
if (typeof maplibregl !== 'undefined' && maplibregl.Marker && map) { if (typeof maplibregl !== 'undefined' && maplibregl.Marker && map) {
if (userMarker) { if (userMarker) {
userMarker.setLngLat([lng, lat]); userMarker.setLngLat([lng, lat]);
} else { } else {
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'user-gps-dot-container'; el.className = 'user-gps-dot';
el.innerHTML = '<div class="user-gps-dot-pulse"></div><div class="user-gps-dot"></div>';
userMarker = new maplibregl.Marker({ element: el, anchor: 'center' }) userMarker = new maplibregl.Marker({ element: el })
.setLngLat([lng, lat]) .setLngLat([lng, lat])
.addTo(map); .addTo(map);
} }
if (shouldFlyTo || isFirstGpsFix) { map.flyTo({ center: [lng, lat], zoom: 17 });
map.flyTo({ center: [lng, lat], zoom: 18, duration: 1000 });
isFirstGpsFix = false;
shouldFlyTo = false;
}
}
if (statusText) {
statusText.textContent = i18n.positionUpdated || (typeof gettext !== 'undefined' ? gettext('Position actualisée') : 'Position actualisée');
} }
if (statusText) statusText.textContent = 'Position actualisée';
}, },
function(err) { function(err) {
console.warn('GPS watchPosition error:', err.message); console.warn('GPS error:', err.message);
if (statusText) { if (statusText) statusText.textContent = 'GPS indisponible';
statusText.textContent = i18n.gpsUnavailable || (typeof gettext !== 'undefined' ? gettext('GPS indisponible') : 'GPS indisponible');
}
}, },
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 2000 } { enableHighAccuracy: true, timeout: 10000, maximumAge: 5000 }
); );
} }
@ -984,17 +931,13 @@
const STORAGE_HISTORY_PERIOD_KEY = 'loko_insp_history_period'; const STORAGE_HISTORY_PERIOD_KEY = 'loko_insp_history_period';
function getHistoryPeriodLabels() { function getHistoryPeriodLabels() {
const i18n = window.INSPECTION_MAP_I18N || {};
if (i18n.periodLabels) {
return i18n.periodLabels;
}
return { return {
'disabled': (typeof gettext !== 'undefined' ? gettext('Inspections (Désactivé)') : 'Inspections (Désactivé)'), 'disabled': 'Inspections (Désactivé)',
'1m': (typeof gettext !== 'undefined' ? gettext('Inspections (1 mois)') : 'Inspections (1 mois)'), '1m': 'Inspections (1 mois)',
'3m': (typeof gettext !== 'undefined' ? gettext('Inspections (3 mois)') : 'Inspections (3 mois)'), '3m': 'Inspections (3 mois)',
'6m': (typeof gettext !== 'undefined' ? gettext('Inspections (6 mois)') : 'Inspections (6 mois)'), '6m': 'Inspections (6 mois)',
'1y': (typeof gettext !== 'undefined' ? gettext('Inspections (1 an)') : 'Inspections (1 an)'), '1y': 'Inspections (1 an)',
'all': (typeof gettext !== 'undefined' ? gettext('Inspections (Tout)') : 'Inspections (Tout)') 'all': 'Inspections (Tout)'
}; };
} }
@ -1016,7 +959,7 @@
} }
}); });
if (labelEl) labelEl.textContent = labels[period] || labels['3m'] || (typeof gettext !== 'undefined' ? gettext('Inspections (3 mois)') : 'Inspections (3 mois)'); if (labelEl) labelEl.textContent = labels[period] || 'Inspections (3 mois)';
if (iconEl) { if (iconEl) {
if (period === 'disabled') { if (period === 'disabled') {
iconEl.className = 'bi bi-eye-slash text-muted me-1'; iconEl.className = 'bi bi-eye-slash text-muted me-1';
@ -1318,7 +1261,7 @@
document.getElementById('map-insp-summary').textContent = insp.details_summary || 'Aucun constat spécifique.'; document.getElementById('map-insp-summary').textContent = insp.details_summary || 'Aucun constat spécifique.';
const notesEl = document.getElementById('map-insp-notes'); const notesEl = document.getElementById('map-insp-notes');
if (notesEl) { if (notesEl) {
if (insp.notes && !insp.is_giep) { if (insp.notes) {
notesEl.textContent = insp.notes; notesEl.textContent = insp.notes;
notesEl.classList.remove('d-none'); notesEl.classList.remove('d-none');
} else { } else {
@ -1472,23 +1415,6 @@
window.currentGiepLocationId = props.location_id || (props.is_location ? objId : null) || objId; window.currentGiepLocationId = props.location_id || (props.is_location ? objId : null) || objId;
} }
const isGiep = (modelName === 'naturerwiz' || isZoneRwiz);
const quickNotesSection = document.getElementById('quickInspNotesSection');
const quickResultSection = document.getElementById('quickInspResultSection');
const quickPhotosSection = document.getElementById('quickInspPhotosSection');
if (quickNotesSection) {
if (isGiep) quickNotesSection.classList.add('d-none');
else quickNotesSection.classList.remove('d-none');
}
if (quickResultSection) {
if (isGiep) quickResultSection.classList.add('d-none');
else quickResultSection.classList.remove('d-none');
}
if (quickPhotosSection) {
if (isGiep) quickPhotosSection.classList.add('d-none');
else quickPhotosSection.classList.remove('d-none');
}
document.getElementById('insp_target_type').textContent = props.element_type || props.asset_type_display || props.model_name || 'Asset'; document.getElementById('insp_target_type').textContent = props.element_type || props.asset_type_display || props.model_name || 'Asset';
document.getElementById('insp_target_code').textContent = props.code || props.target_display || (objId ? ('#' + objId) : '-'); document.getElementById('insp_target_code').textContent = props.code || props.target_display || (objId ? ('#' + objId) : '-');
document.getElementById('insp_target_name').textContent = props.name || props.name_fr || props.target_display || '-'; document.getElementById('insp_target_name').textContent = props.name || props.name_fr || props.target_display || '-';
@ -2054,9 +1980,9 @@
row.innerHTML = ` row.innerHTML = `
<div class="d-flex align-items-center justify-content-between mb-2"> <div class="d-flex align-items-center justify-content-between mb-2">
<span class="badge bg-primary rounded-pill small">#${order} (${labelFr || (window.gettext ? window.gettext('Panneau') : 'Panneau')})</span> <span class="badge bg-primary rounded-pill small">#${order} (${labelFr || 'Panneau'})</span>
${confidence ? `<span class="badge bg-success-subtle text-success border border-success-subtle extra-small">IA: ${Math.round(confidence * 100)}%</span>` : ''} ${confidence ? `<span class="badge bg-success-subtle text-success border border-success-subtle extra-small">IA: ${Math.round(confidence * 100)}%</span>` : ''}
<button type="button" class="btn btn-outline-danger btn-xs py-0 px-1.5 btn-remove-panel-row" style="font-size: 0.75rem;" title="${window.gettext ? window.gettext('Supprimer ce panneau') : 'Supprimer ce panneau'}"> <button type="button" class="btn btn-outline-danger btn-xs py-0 px-1.5 btn-remove-panel-row" style="font-size: 0.75rem;" title="Supprimer ce panneau">
<i class="bi bi-trash"></i> <i class="bi bi-trash"></i>
</button> </button>
</div> </div>
@ -2068,23 +1994,23 @@
</div> </div>
<div class="col"> <div class="col">
<div class="input-group input-group-sm mb-1"> <div class="input-group input-group-sm mb-1">
<span class="input-group-text fw-bold">${window.gettext ? window.gettext('Code') : 'Code'}</span> <span class="input-group-text fw-bold">Code</span>
<input type="text" class="form-control form-control-sm text-uppercase panel-row-code" value="${upperCode}" placeholder="Ex: C1, D7, B1, M2..."> <input type="text" class="form-control form-control-sm text-uppercase panel-row-code" value="${upperCode}" placeholder="Ex: C1, D7, B1, M2...">
</div> </div>
<input type="text" class="form-control form-control-sm panel-row-text" value="${text || ''}" placeholder="${window.gettext ? window.gettext('Mention (ex: Sauf riverains)') : 'Mention (ex: Sauf riverains)'}"> <input type="text" class="form-control form-control-sm panel-row-text" value="${text || ''}" placeholder="Mention (ex: Sauf riverains)">
</div> </div>
</div> </div>
<div class="mt-2 pt-1 border-top d-flex align-items-center justify-content-between"> <div class="mt-2 pt-1 border-top d-flex align-items-center justify-content-between">
<small class="text-muted extra-small">${window.gettext ? window.gettext('Propreté :') : 'Propreté :'}</small> <small class="text-muted extra-small">Propreté :</small>
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_clean_${order}_${Date.now()}" value="clean" ${cleanliness === 'clean' ? 'checked' : ''}> <input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_clean_${order}_${Date.now()}" value="clean" ${cleanliness === 'clean' ? 'checked' : ''}>
<label class="btn btn-outline-success btn-xs py-0 px-2" for="rc_clean_${order}_${Date.now()}">${window.gettext ? window.gettext('Propre') : 'Propre'}</label> <label class="btn btn-outline-success btn-xs py-0 px-2" for="rc_clean_${order}_${Date.now()}">Propre</label>
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_dirty_${order}_${Date.now()}" value="dirty" ${cleanliness === 'dirty' ? 'checked' : ''}> <input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_dirty_${order}_${Date.now()}" value="dirty" ${cleanliness === 'dirty' ? 'checked' : ''}>
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_dirty_${order}_${Date.now()}">${window.gettext ? window.gettext('Sale') : 'Sale'}</label> <label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_dirty_${order}_${Date.now()}">Sale</label>
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_veg_${order}_${Date.now()}" value="vegetation" ${cleanliness === 'vegetation' ? 'checked' : ''}> <input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_veg_${order}_${Date.now()}" value="vegetation" ${cleanliness === 'vegetation' ? 'checked' : ''}>
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_veg_${order}_${Date.now()}">${window.gettext ? window.gettext('Végétation') : 'Végétation'}</label> <label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_veg_${order}_${Date.now()}">Végétation</label>
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_vand_${order}_${Date.now()}" value="vandalized" ${cleanliness === 'vandalized' ? 'checked' : ''}> <input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_vand_${order}_${Date.now()}" value="vandalized" ${cleanliness === 'vandalized' ? 'checked' : ''}>
<label class="btn btn-outline-danger btn-xs py-0 px-2" for="rc_vand_${order}_${Date.now()}">${window.gettext ? window.gettext('Dégradé') : 'Dégradé'}</label> <label class="btn btn-outline-danger btn-xs py-0 px-2" for="rc_vand_${order}_${Date.now()}">Dégradé</label>
</div> </div>
</div> </div>
`; `;
@ -2117,13 +2043,13 @@
const lon = document.getElementById('new_sign_lon')?.value; const lon = document.getElementById('new_sign_lon')?.value;
if (!lat || !lon) { if (!lat || !lon) {
alert(window.gettext ? window.gettext('Coordonnées GPS manquantes.') : 'Coordonnées GPS manquantes.'); alert('Coordonnées GPS manquantes.');
return; return;
} }
const rows = document.querySelectorAll('.new-sign-panel-row'); const rows = document.querySelectorAll('.new-sign-panel-row');
if (!rows.length) { if (!rows.length) {
alert(window.gettext ? window.gettext('Veuillez ajouter au moins un panneau.') : 'Veuillez ajouter au moins un panneau.'); alert('Veuillez ajouter au moins un panneau.');
return; return;
} }
@ -2148,8 +2074,7 @@
newSignPhotos.forEach(p => formData.append('photos', p)); newSignPhotos.forEach(p => formData.append('photos', p));
btnSubmit.disabled = true; btnSubmit.disabled = true;
const creatingMsg = window.gettext ? window.gettext('Création en cours...') : 'Création en cours...'; btnSubmit.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Création en cours...';
btnSubmit.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span> ${creatingMsg}`;
try { try {
const csrfToken = getCsrfToken(); const csrfToken = getCsrfToken();
@ -2170,7 +2095,7 @@
if (data.success) { if (data.success) {
modal.hide(); modal.hide();
alert(data.message || (window.gettext ? window.gettext('Panneaux créés avec succès !') : 'Panneaux créés avec succès !')); alert(data.message || 'Panneaux créés avec succès !');
// Basculer automatiquement sur la thématique Signalisation // Basculer automatiquement sur la thématique Signalisation
localStorage.setItem(STORAGE_THEMATIC_KEY, 'sign'); localStorage.setItem(STORAGE_THEMATIC_KEY, 'sign');
@ -2188,17 +2113,13 @@
loadAssets(); loadAssets();
loadInspectionsHistory(); loadInspectionsHistory();
} else { } else {
const errPrefix = window.gettext ? window.gettext('Erreur : ') : 'Erreur : '; alert('Erreur : ' + (data.message || 'Impossible de créer les panneaux.'));
const defaultErrMsg = window.gettext ? window.gettext('Impossible de créer les panneaux.') : 'Impossible de créer les panneaux.';
alert(errPrefix + (data.message || defaultErrMsg));
} }
} catch(err) { } catch(err) {
const errCreationMsg = window.gettext ? window.gettext('Erreur lors de la création : ') : 'Erreur lors de la création : '; alert('Erreur lors de la création : ' + err.message);
alert(errCreationMsg + err.message);
} finally { } finally {
btnSubmit.disabled = false; btnSubmit.disabled = false;
const createAndSaveMsg = window.gettext ? window.gettext('Créer et Enregistrer') : 'Créer et Enregistrer'; btnSubmit.innerHTML = '<i class="bi bi-check2-circle fs-6"></i> <span>Créer et Enregistrer</span>';
btnSubmit.innerHTML = `<i class="bi bi-check2-circle fs-6"></i> <span>${createAndSaveMsg}</span>`;
} }
}); });
} }
@ -2255,22 +2176,19 @@
const modal = bootstrap.Modal.getInstance(modalEl); const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide(); if (modal) modal.hide();
let msg = data.message || (window.gettext ? window.gettext('Inspection enregistrée !') : 'Inspection enregistrée !'); let msg = data.message || 'Inspection enregistrée !';
if (data.created_intervention_code) { if (data.created_intervention_code) {
const intervLabel = window.gettext ? window.gettext('Intervention') : 'Intervention'; msg += ' Intervention ' + data.created_intervention_code + ' créée.';
const createdLabel = window.gettext ? window.gettext('créée.') : 'créée.';
msg += ` ${intervLabel} ${data.created_intervention_code} ${createdLabel}`;
} }
alert(msg); alert(msg);
loadAssets(); loadAssets();
} else { } else {
const defaultValError = window.gettext ? window.gettext('Erreur lors de la validation.') : 'Erreur lors de la validation.'; alertDiv.textContent = data.error || 'Erreur lors de la validation.';
alertDiv.textContent = data.error || defaultValError;
alertDiv.classList.remove('d-none'); alertDiv.classList.remove('d-none');
} }
}) })
.catch(err => { .catch(err => {
alertDiv.textContent = window.gettext ? window.gettext('Erreur réseau.') : 'Erreur réseau.'; alertDiv.textContent = 'Erreur réseau.';
alertDiv.classList.remove('d-none'); alertDiv.classList.remove('d-none');
}) })
.finally(() => { .finally(() => {

View file

@ -1,36 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<ellipse cx="32" cy="54" rx="22" ry="5" fill="#1e293b" opacity="0.3"/>
<ellipse cx="32" cy="52" rx="18" ry="4" fill="#334155"/>
<ellipse cx="32" cy="51" rx="16" ry="3.5" fill="#64748b"/>
<ellipse cx="32" cy="50.5" rx="13" ry="2.8" fill="#1e293b"/>
<defs>
<linearGradient id="bollardGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#0284c7"/>
<stop offset="25%" stop-color="#38bdf8"/>
<stop offset="60%" stop-color="#0284c7"/>
<stop offset="100%" stop-color="#0369a1"/>
</linearGradient>
<linearGradient id="capGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#7dd3fc"/>
<stop offset="100%" stop-color="#0284c7"/>
</linearGradient>
<linearGradient id="bandGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f59e0b"/>
<stop offset="35%" stop-color="#fef08a"/>
<stop offset="70%" stop-color="#f59e0b"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
</defs>
<path d="M 21,24 L 21,50 C 21,53 43,53 43,50 L 43,24 Z" fill="url(#bollardGrad)"/>
<path d="M 21,21 L 21,27 C 21,29.5 43,29.5 43,27 L 43,21 C 43,23.5 21,23.5 21,21 Z" fill="url(#bandGrad)"/>
<path d="M 21,15 L 21,21 C 21,23.5 43,23.5 43,21 L 43,15 Z" fill="url(#bollardGrad)"/>
<ellipse cx="32" cy="15" rx="11" ry="3.5" fill="url(#capGrad)" stroke="#bae6fd" stroke-width="0.8"/>
<ellipse cx="32" cy="14.2" rx="7" ry="2" fill="#ffffff" opacity="0.6"/>
<circle cx="26" cy="24.5" r="1" fill="#ffffff"/>
<circle cx="32" cy="25.2" r="1.1" fill="#ffffff"/>
<circle cx="38" cy="24.5" r="1" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,405 +0,0 @@
/**
* Management Zones Map Overlay & Layer Switcher Integration
*
* Permet d'afficher en surimpression les zones de gestion (gestion écologique,
* ondes vertes, secteurs prioritaires, etc.) sur la carte MapLibre
* et ajoute une section dédiée "Zones de gestion" dans le LayerSwitcherControl.
*/
(function (window) {
'use strict';
/**
* Calcule la bounding box [[minX, minY], [maxX, maxY]] d'une géométrie GeoJSON.
*/
function getGeometryBBox(geometry) {
if (!geometry || !geometry.coordinates) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
function traverse(coords) {
if (typeof coords[0] === 'number') {
const [x, y] = coords;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
} else if (Array.isArray(coords)) {
for (let i = 0; i < coords.length; i++) {
traverse(coords[i]);
}
}
}
traverse(geometry.coordinates);
if (minX === Infinity || minY === Infinity) return null;
return [[minX, minY], [maxX, maxY]];
}
/**
* Initialise l'overlay des zones de gestion sur la carte MapLibre.
*/
async function initManagementZonesMapOverlay(options = {}) {
const map = options.map;
if (!map) {
console.warn('[ManagementZonesMapOverlay] Map instance missing.');
return;
}
if (!map.loaded()) {
map.once('load', () => initManagementZonesMapOverlay(options));
return;
}
const thematic = options.thematic || 'nature';
const geojsonUrl = options.geojsonUrl || '/assets/api/management-zones/geojson/';
const layerSwitcher = options.layerSwitcher || window.layerSwitcher;
const beforeLayerId = options.beforeLayerId || null;
const _t = (key, fallback) => (typeof gettext === 'function' ? gettext(key) : (fallback || key));
try {
const url = new URL(geojsonUrl, window.location.origin);
url.searchParams.set('thematic', thematic);
const response = await fetch(url.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!response.ok) {
console.warn('[ManagementZonesMapOverlay] Failed to fetch geojson:', response.status);
return;
}
const geojson = await response.json();
if (!geojson || geojson.type !== 'FeatureCollection') {
console.warn('[ManagementZonesMapOverlay] Invalid GeoJSON received.');
return;
}
const features = geojson.features || [];
// 1. Ajouter la source et les couches à MapLibre
const sourceId = 'management-zones-source';
const fillLayerId = 'management-zones-fill';
const lineLayerId = 'management-zones-line';
if (map.getSource(sourceId)) {
map.getSource(sourceId).setData(geojson);
} else {
map.addSource(sourceId, {
type: 'geojson',
data: geojson
});
}
// Couche de remplissage translucide
if (!map.getLayer(fillLayerId)) {
const fillLayerDef = {
id: fillLayerId,
type: 'fill',
source: sourceId,
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#198754'],
'fill-opacity': 0.20
}
};
if (beforeLayerId && map.getLayer(beforeLayerId)) {
map.addLayer(fillLayerDef, beforeLayerId);
} else {
map.addLayer(fillLayerDef);
}
}
// Couche de contour avec tirets pour marquer une zone de gestion
if (!map.getLayer(lineLayerId)) {
const lineLayerDef = {
id: lineLayerId,
type: 'line',
source: sourceId,
paint: {
'line-color': ['coalesce', ['get', 'color'], '#198754'],
'line-width': 2.5,
'line-opacity': 0.85,
'line-dasharray': [3, 1.5]
}
};
if (beforeLayerId && map.getLayer(beforeLayerId)) {
map.addLayer(lineLayerDef, beforeLayerId);
} else {
map.addLayer(lineLayerDef);
}
}
// 2. Gestion des popups et du curseur sur clic
const zonePopup = new maplibregl.Popup({
closeButton: true,
closeOnClick: true,
maxWidth: '340px'
});
map.on('click', fillLayerId, (e) => {
const assetLayerIds = (window.assets_layers_defs || []).map(l => l.id).filter(id => map.getLayer(id));
if (assetLayerIds.length > 0) {
const rendered = map.queryRenderedFeatures(e.point, { layers: assetLayerIds });
if (rendered && rendered.length > 0) {
return;
}
}
if (!e.features || !e.features[0]) return;
const props = e.features[0].properties || {};
let typeHtml = '';
if (props.zone_type_name) {
typeHtml = `<span class="badge me-1" style="background-color: ${props.color || '#198754'}; color: #fff; font-size: 10px;">
${props.zone_type_name}
</span>`;
}
let freqHtml = '';
if (props.target_frequency_days) {
freqHtml = `<div style="color: #64748b; font-size: 11px; margin-top: 3px;">
<i class="bi bi-arrow-repeat me-1"></i>${_t('Fréquence cible :', 'Fréquence cible :')} <strong>${props.target_frequency_days} ${_t('jours', 'jours')}</strong>
</div>`;
}
let seasonHtml = '';
if (props.seasonality_notes) {
seasonHtml = `<div style="color: #64748b; font-size: 11px; margin-top: 3px;">
<i class="bi bi-calendar3 me-1"></i>${props.seasonality_notes}
</div>`;
}
let instructionsHtml = '';
if (props.instructions) {
instructionsHtml = `<div style="background-color: #f8fafc; border-left: 3px solid ${props.color || '#198754'}; padding: 6px; margin-top: 6px; font-size: 11px; color: #334155; border-radius: 2px; max-height: 100px; overflow-y: auto;">
<div style="font-weight: 600; margin-bottom: 2px;"><i class="bi bi-exclamation-triangle me-1"></i>${_t('Consignes & points d\'attention', 'Consignes & points d\'attention')}</div>
${props.instructions}
</div>`;
}
let descHtml = '';
if (props.description) {
descHtml = `<div style="color: #475569; font-size: 11px; margin-top: 4px;">
${props.description}
</div>`;
}
const html = `
<div style="font-family: inherit; font-size: 12px; padding: 2px;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 4px;">
<div style="display: flex; align-items: center; gap: 6px;">
<span style="display: inline-block; width: 12px; height: 12px; border-radius: 3px; background-color: ${props.color || '#198754'}; border: 1px solid rgba(0,0,0,0.2); flex-shrink: 0;"></span>
<strong style="font-size: 13px; color: #1e293b;">${props.code}</strong>
</div>
${typeHtml}
</div>
<div style="font-weight: 600; color: #1e293b; margin-bottom: 4px;">${props.name}</div>
${descHtml}
${freqHtml}
${seasonHtml}
${instructionsHtml}
<div style="margin-top: 8px; text-align: right;">
<a href="/assets/management-zones/${props.id}/" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 11px;">
<i class="bi bi-eye me-1"></i>${_t('Détails & gestion', 'Détails & gestion')} &rarr;
</a>
</div>
</div>
`;
zonePopup.setLngLat(e.lngLat).setHTML(html).addTo(map);
});
map.on('mousemove', fillLayerId, (e) => {
const assetLayerIds = (window.assets_layers_defs || []).map(l => l.id).filter(id => map.getLayer(id));
if (assetLayerIds.length > 0) {
const rendered = map.queryRenderedFeatures(e.point, { layers: assetLayerIds });
if (rendered && rendered.length > 0) {
return;
}
}
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', fillLayerId, () => {
map.getCanvas().style.cursor = '';
});
// 3. Intégration dans le LayerSwitcherControl
if (layerSwitcher && typeof layerSwitcher.addCustomSection === 'function') {
layerSwitcher.addCustomSection({
id: 'management-zones',
title: `<span class="d-flex align-items-center gap-1"><i class="bi bi-bounding-box-circles me-1"></i>${_t('Zones de gestion', 'Zones de gestion')}</span>`,
render: (container, switcher) => {
renderManagementZonesSection(container, switcher, features, map, fillLayerId, lineLayerId, zonePopup, _t, thematic);
}
});
if (features.length > 0) {
layerSwitcher.setOverlayActive(true);
}
}
} catch (err) {
console.error('[ManagementZonesMapOverlay] Error initializing overlay:', err);
}
}
/**
* Rendu HTML et interactions de la section "Zones de gestion" dans le LayerSwitcherControl.
*/
function renderManagementZonesSection(container, switcher, features, map, fillLayerId, lineLayerId, zonePopup, _t, thematic) {
if (!features || features.length === 0) {
const manageUrl = `/assets/management-zones/${thematic ? '?thematic=' + thematic : ''}`;
container.innerHTML = `
<div class="text-muted small px-2 py-1 fst-italic d-flex justify-content-between align-items-center">
<span>${_t('Aucune zone de gestion définie pour cette thématique.', 'Aucune zone de gestion définie pour cette thématique.')}</span>
<a href="${manageUrl}" class="btn btn-link btn-sm p-0 text-muted ms-1" title="${_t('Gérer les zones de gestion', 'Gérer les zones de gestion')}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
`;
return;
}
const countText = `${features.length} ${_t('zones', 'zones')}`;
const manageUrl = `/assets/management-zones/${thematic ? '?thematic=' + thematic : ''}`;
// En-tête avec toggle général
const headerEl = document.createElement('div');
headerEl.className = 'd-flex justify-content-between align-items-center px-1 mb-2';
headerEl.innerHTML = `
<div class="d-flex align-items-center gap-1">
<span class="text-muted small fw-semibold">${countText}</span>
<a href="${manageUrl}" class="btn btn-link btn-sm p-0 text-muted" style="font-size: 11px;" title="${_t('Gérer les zones de gestion', 'Gérer les zones de gestion')}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input management-zones-master-toggle" type="checkbox" role="switch" checked title="${_t('Afficher / masquer toutes les zones', 'Afficher / masquer toutes les zones')}">
</div>
`;
container.appendChild(headerEl);
const masterToggle = headerEl.querySelector('.management-zones-master-toggle');
// Curseur d'opacité
const opacityRow = document.createElement('div');
opacityRow.className = 'px-1 mb-2';
opacityRow.innerHTML = `
<div class="d-flex justify-content-between align-items-center small text-muted mb-1">
<span>${_t('Opacité', 'Opacité')}</span>
<span class="management-zones-opacity-val">20%</span>
</div>
<input type="range" class="form-range management-zones-opacity-slider" min="5" max="80" value="20" style="height: 4px;">
`;
container.appendChild(opacityRow);
const opacitySlider = opacityRow.querySelector('.management-zones-opacity-slider');
const opacityValLabel = opacityRow.querySelector('.management-zones-opacity-val');
opacitySlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10);
opacityValLabel.textContent = `${val}%`;
if (map.getLayer(fillLayerId)) {
map.setPaintProperty(fillLayerId, 'fill-opacity', val / 100);
}
});
// Liste des zones
const listEl = document.createElement('div');
listEl.className = 'management-zones-list d-flex flex-column gap-1';
listEl.style.maxHeight = '240px';
listEl.style.overflowY = 'auto';
const activeZoneIds = new Set(features.map(f => f.id));
function updateLayersVisibility() {
if (!masterToggle.checked || activeZoneIds.size === 0) {
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none');
if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'none');
} else {
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'visible');
const filter = ['in', ['id'], ['literal', Array.from(activeZoneIds)]];
if (map.getLayer(fillLayerId)) map.setFilter(fillLayerId, filter);
if (map.getLayer(lineLayerId)) map.setFilter(lineLayerId, filter);
}
}
masterToggle.addEventListener('change', () => {
const checked = masterToggle.checked;
listEl.querySelectorAll('.zone-item-toggle').forEach(chk => {
chk.checked = checked;
});
if (checked) {
features.forEach(f => activeZoneIds.add(f.id));
} else {
activeZoneIds.clear();
}
updateLayersVisibility();
});
features.forEach(feat => {
const props = feat.properties || {};
const color = props.color || '#198754';
const itemEl = document.createElement('div');
itemEl.className = 'd-flex align-items-center justify-content-between p-1 rounded hover-bg small';
itemEl.style.cursor = 'pointer';
itemEl.innerHTML = `
<div class="d-flex align-items-center gap-2 text-truncate me-2 zone-click-target" style="flex: 1;">
<span style="display:inline-block; width: 12px; height: 12px; border-radius: 50%; background-color: ${color}; border: 1px solid rgba(0,0,0,0.25); flex-shrink: 0;"></span>
<div class="text-truncate">
<div class="fw-semibold text-truncate text-dark" style="font-size: 11px;">${props.code}</div>
<div class="text-muted text-truncate" style="font-size: 10px;">${props.name} ${props.zone_type_name ? `(${props.zone_type_name})` : ''}</div>
</div>
</div>
<div class="d-flex align-items-center gap-1 flex-shrink-0">
<button type="button" class="btn btn-link btn-sm p-0 text-muted zoom-btn" title="${_t('Centrer sur la zone', 'Centrer sur la zone')}">
<i class="bi bi-geo-alt" style="font-size: 12px;"></i>
</button>
<div class="form-check form-switch mb-0 ms-1">
<input class="form-check-input zone-item-toggle" type="checkbox" role="switch" checked data-zone-id="${feat.id}">
</div>
</div>
`;
const itemToggle = itemEl.querySelector('.zone-item-toggle');
itemToggle.addEventListener('change', (e) => {
e.stopPropagation();
if (itemToggle.checked) {
activeZoneIds.add(feat.id);
} else {
activeZoneIds.delete(feat.id);
}
masterToggle.checked = (activeZoneIds.size > 0);
updateLayersVisibility();
});
function zoomToZone() {
const bbox = getGeometryBBox(feat.geometry);
if (bbox) {
map.fitBounds(bbox, { padding: 40, maxZoom: 16 });
}
}
itemEl.querySelector('.zoom-btn').addEventListener('click', (e) => {
e.stopPropagation();
zoomToZone();
});
itemEl.querySelector('.zone-click-target').addEventListener('click', () => {
zoomToZone();
});
listEl.appendChild(itemEl);
});
container.appendChild(listEl);
}
window.initManagementZonesMapOverlay = initManagementZonesMapOverlay;
})(window);

View file

@ -1,69 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<!-- Background grass blades gradient (deep rich green) -->
<linearGradient id="grass-dark" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2e7d32"/>
<stop offset="100%" stop-color="#1b5e20"/>
</linearGradient>
<!-- Mid grass blades gradient (fresh vibrant green) -->
<linearGradient id="grass-mid" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#66bb6a"/>
<stop offset="100%" stop-color="#2e7d32"/>
</linearGradient>
<!-- Side arching grass blades gradient -->
<linearGradient id="grass-light" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#81c784"/>
<stop offset="100%" stop-color="#388e3c"/>
</linearGradient>
<!-- Ground turf base gradient -->
<linearGradient id="turf-base" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#43a047"/>
<stop offset="100%" stop-color="#1b5e20"/>
</linearGradient>
<!-- Front turf lip gradient -->
<linearGradient id="turf-front" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#4caf50"/>
<stop offset="100%" stop-color="#2e7d32"/>
</linearGradient>
<!-- Soil ground base gradient -->
<linearGradient id="soil-base" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#6d4c41"/>
<stop offset="100%" stop-color="#4e342e"/>
</linearGradient>
</defs>
<!-- Ground / Soil base slab -->
<rect x="9" y="52" width="46" height="6" rx="3" fill="url(#soil-base)"/>
<!-- Back turf mound -->
<ellipse cx="32" cy="49" rx="25" ry="7" fill="url(#turf-base)"/>
<!-- Background grass blades -->
<!-- Mid-left tall blade -->
<path d="M 21 48 C 17 32, 16 19, 20 11 C 24 15, 27 29, 28 48 Z" fill="url(#grass-dark)"/>
<!-- Mid-right tall blade -->
<path d="M 36 48 C 37 29, 40 15, 44 11 C 48 19, 47 32, 43 48 Z" fill="url(#grass-dark)"/>
<!-- Main foreground grass blades -->
<!-- Left arching blade -->
<path d="M 14 49 C 10 38, 7 28, 8 19 C 14 21, 21 34, 25 49 Z" fill="url(#grass-light)"/>
<!-- Right arching blade -->
<path d="M 39 49 C 43 34, 50 21, 56 19 C 57 28, 54 38, 50 49 Z" fill="url(#grass-light)"/>
<!-- Center tall proud blade -->
<path d="M 26 49 C 27 30, 28 16, 32 6 C 36 16, 37 30, 38 49 Z" fill="url(#grass-mid)"/>
<!-- Center blade subtle highlight -->
<path d="M 31 48 C 31.5 32, 31.8 18, 32 9 C 32.2 18, 32.8 32, 32.5 48 Z" fill="#a5d6a7" opacity="0.6"/>
<!-- Foreground turf rim -->
<ellipse cx="32" cy="51" rx="23" ry="5" fill="url(#turf-front)"/>
<!-- Small front sprouting blades -->
<path d="M 21 51 C 20 44, 18 36, 17 30 C 20 34, 23 42, 24 51 Z" fill="url(#grass-light)"/>
<path d="M 40 51 C 41 42, 44 34, 47 30 C 46 36, 44 44, 43 51 Z" fill="url(#grass-light)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -1,393 +0,0 @@
/**
* Visualisation cartographique détaillée d'une localisation Nature (StreetUp / Loko)
*/
document.addEventListener('DOMContentLoaded', function () {
const mapContainer = document.getElementById('map');
if (!mapContainer) return;
const lon = typeof feature_lon !== 'undefined' && feature_lon !== null ? feature_lon : 4.3517;
const lat = typeof feature_lat !== 'undefined' && feature_lat !== null ? feature_lat : 50.8503;
const map = new maplibregl.Map({
container: 'map',
style: window.MAP_STYLE_LIGHT || 'https://api.maptiler.com/maps/streets-v2-light/style.json',
center: [lon, lat],
zoom: 16,
pitch: 35,
bearing: -10,
antialias: true
});
// ── Helper to place raster basemaps below nature layers ──
function getBasemapBeforeLayer(m) {
if (!m || !m.getStyle) return undefined;
if (m.getLayer('nature-location-fill')) return 'nature-location-fill';
if (m.getLayer('nature-location-outline')) return 'nature-location-outline';
if (m.getLayer('layer-surfaces-fill')) return 'layer-surfaces-fill';
if (m.getLayer('layer-surfaces-outline')) return 'layer-surfaces-outline';
if (m.getLayer('layer-shrubs')) return 'layer-shrubs';
if (m.getLayer('layer-benches')) return 'layer-benches';
if (m.getLayer('layer-trees')) return 'layer-trees';
return undefined;
}
// ── Ensure strict layer stacking order on top of basemaps ──
function reorderNatureLayers() {
const layerOrder = [
'nature-location-fill',
'nature-location-outline',
'layer-surfaces-fill',
'layer-surfaces-outline',
'layer-shrubs',
'layer-benches',
'layer-trees',
];
layerOrder.forEach(function (layerId) {
if (map.getLayer(layerId)) {
try {
map.moveLayer(layerId);
} catch (e) {
// ignore
}
}
});
}
// Add layer switcher control for basemaps / orthophotos
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'basemap',
name: 'Fonds de plan & Orthophotos',
icon: 'satellite',
group: 'basemap',
beforeLayer: getBasemapBeforeLayer,
onLayerAdded: () => reorderNatureLayers(),
onVisibilityChange: () => reorderNatureLayers(),
layer: {
type: 'raster',
paint: { 'raster-opacity': 1 }
},
basemaps: (window.MAP_BACKGROUNDS && window.MAP_BACKGROUNDS.length > 0) ? window.MAP_BACKGROUNDS : undefined,
sublayers: [
{
id: 'urbis',
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
default: true,
source: {
type: 'raster',
tiles: [
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
],
tileSize: 256,
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '© Brussels UrbIS'
}
},
{
id: 'vlaanderen',
name: 'Vlaanderen 2023',
source: {
type: 'raster',
tiles: [
'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'
],
tileSize: 256,
attribution: '© Digitaal Vlaanderen - Orthofoto 2023'
}
}
]
}]
});
map.addControl(layerSwitcher, 'top-right');
}
// Add navigation and fullscreen controls
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right');
map.addControl(new maplibregl.FullscreenControl(), 'top-right');
map.on('load', function () {
let combinedBbox = null;
// 1. Location geometry (polygon / boundary)
if (typeof section_geojson !== 'undefined' && section_geojson) {
const locGeom = typeof section_geojson === 'string' ? JSON.parse(section_geojson) : section_geojson;
const locationFeature = {
type: 'Feature',
geometry: locGeom,
properties: {
name: 'Location'
}
};
map.addSource('nature-location', {
type: 'geojson',
data: locationFeature
});
// Fill layer for the section
map.addLayer({
id: 'nature-location-fill',
type: 'fill',
source: 'nature-location',
paint: {
'fill-color': '#16a34a',
'fill-opacity': 0.15
}
});
// Outline layer for the section
map.addLayer({
id: 'nature-location-outline',
type: 'line',
source: 'nature-location',
paint: {
'line-color': '#15803d',
'line-width': 2.5
}
});
if (typeof turf !== 'undefined') {
try {
combinedBbox = turf.bbox(locationFeature);
} catch (e) {
console.warn('Could not compute location bbox:', e);
}
}
}
// 2. Assets (green surfaces, trees, shrubs, benches)
if (typeof assets_geojson !== 'undefined' && assets_geojson) {
const assetsData = typeof assets_geojson === 'string' ? JSON.parse(assets_geojson) : assets_geojson;
map.addSource('nature-assets', {
type: 'geojson',
data: assetsData
});
// Green surfaces fill
map.addLayer({
id: 'layer-surfaces-fill',
type: 'fill',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturegreensurface'],
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#22c55e'],
'fill-opacity': 0.65
}
});
// Green surfaces outline
map.addLayer({
id: 'layer-surfaces-outline',
type: 'line',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturegreensurface'],
paint: {
'line-color': '#166534',
'line-width': 1.5
}
});
// Trees (circles)
map.addLayer({
id: 'layer-trees',
type: 'circle',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturetree'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
13, 4,
16, 7,
19, 12
],
'circle-color': '#15803d',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
});
// Shrubs (circles)
map.addLayer({
id: 'layer-shrubs',
type: 'circle',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'natureshrub'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
13, 3,
16, 5,
19, 9
],
'circle-color': '#84cc16',
'circle-stroke-width': 1.5,
'circle-stroke-color': '#ffffff'
}
});
// Benches (circles)
map.addLayer({
id: 'layer-benches',
type: 'circle',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturebench'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
13, 3,
16, 5,
19, 9
],
'circle-color': '#b45309',
'circle-stroke-width': 1.5,
'circle-stroke-color': '#ffffff'
}
});
// Fit bounds with Turf if features exist
if (typeof turf !== 'undefined' && assetsData.features && assetsData.features.length > 0) {
try {
const assetsBbox = turf.bbox(assetsData);
if (!combinedBbox) {
combinedBbox = assetsBbox;
} else {
combinedBbox = [
Math.min(combinedBbox[0], assetsBbox[0]),
Math.min(combinedBbox[1], assetsBbox[1]),
Math.max(combinedBbox[2], assetsBbox[2]),
Math.max(combinedBbox[3], assetsBbox[3])
];
}
} catch (e) {
console.warn('Could not compute assets bbox:', e);
}
}
}
// Apply fit bounds
if (combinedBbox && combinedBbox[0] !== Infinity) {
map.fitBounds(combinedBbox, {
padding: { top: 40, bottom: 40, left: 40, right: 40 },
maxZoom: 18,
duration: 800
});
}
reorderNatureLayers();
// 3. Interactive Popups
const interactiveLayers = [
'layer-surfaces-fill',
'layer-trees',
'layer-shrubs',
'layer-benches'
];
interactiveLayers.forEach(function (layerId) {
map.on('mouseenter', layerId, function () {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', layerId, function () {
map.getCanvas().style.cursor = '';
});
map.on('click', layerId, function (e) {
if (!e.features || !e.features.length) return;
const f = e.features[0];
const props = f.properties;
let iconClass = 'bi-tree-fill text-success';
let typeBadge = '<span class="badge bg-success">Surface</span>';
if (props.asset_type === 'naturetree') {
iconClass = 'bi-tree-fill text-success';
typeBadge = '<span class="badge bg-success">Arbre</span>';
} else if (props.asset_type === 'natureshrub') {
iconClass = 'bi-flower3 text-success';
typeBadge = '<span class="badge bg-info">Arbuste</span>';
} else if (props.asset_type === 'naturebench') {
iconClass = 'bi-inbox text-warning';
typeBadge = '<span class="badge bg-warning text-dark">Banc / Mobilier</span>';
} else if (props.asset_type === 'naturegreensurface') {
iconClass = 'bi-grid-fill text-success';
typeBadge = '<span class="badge bg-primary">Surface verte</span>';
}
let detailsHtml = '';
if (props.area) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-rulers me-1"></i>Superficie : <strong>${props.area}</strong></div>`;
}
if (props.vitality) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-heart-pulse me-1"></i>Vitalité : <strong>${props.vitality}</strong></div>`;
}
if (props.stage) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-clock-history me-1"></i>Stade : <strong>${props.stage}</strong></div>`;
}
if (props.height) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-arrows-vertical me-1"></i>Hauteur : <strong>${props.height}</strong></div>`;
}
if (props.is_remarkable === 'true' || props.is_remarkable === true) {
detailsHtml += `<div class="badge bg-warning text-dark my-1"><i class="bi bi-star-fill me-1"></i>Arbre remarquable</div>`;
}
const popupHtml = `
<div class="nature-map-popup" style="min-width: 200px; padding: 4px;">
<div class="d-flex justify-content-between align-items-center mb-1 pb-1 border-bottom">
<span class="fw-bold"><i class="bi ${iconClass} me-1"></i>${props.code || ''}</span>
${typeBadge}
</div>
<div class="mb-2">
${props.model ? `<div class="fw-semibold text-dark">${props.model}</div>` : ''}
${props.scientific_name ? `<div class="fst-italic text-muted small">${props.scientific_name}</div>` : ''}
${props.status ? `<div class="small"><span class="badge bg-light text-secondary border mt-1">${props.status}</span></div>` : ''}
${detailsHtml}
</div>
${props.detail_url ? `
<a href="${props.detail_url}" class="btn btn-xs btn-primary w-100 text-center py-1" style="font-size: 0.8rem;">
<i class="bi bi-arrow-right-circle me-1"></i>Consulter la fiche
</a>
` : ''}
</div>
`;
new maplibregl.Popup({ closeButton: true, closeOnClick: true, maxWidth: '280px' })
.setLngLat(e.lngLat)
.setHTML(popupHtml)
.addTo(map);
});
});
// 4. Layer Toggle Handlers
window.toggleNatureLayer = function (layerGroup, isChecked) {
const layerMapping = {
'surfaces': ['layer-surfaces-fill', 'layer-surfaces-outline'],
'trees': ['layer-trees'],
'shrubs': ['layer-shrubs'],
'benches': ['layer-benches']
};
const layers = layerMapping[layerGroup];
if (layers) {
const visibility = isChecked ? 'visible' : 'none';
layers.forEach(function (id) {
if (map.getLayer(id)) {
map.setLayoutProperty(id, 'visibility', visibility);
}
});
}
};
// 5. Global Zoom to Asset helper
window.zoomToAsset = function (assetLon, assetLat, assetCode) {
if (assetLon && assetLat) {
map.flyTo({
center: [assetLon, assetLat],
zoom: 18.5,
pitch: 45,
duration: 1000
});
}
};
});
});

View file

@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-dead" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#9ca3af"/>
<stop offset="100%" stop-color="#4b5563"/>
</linearGradient>
<linearGradient id="trunk-dead" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#78716c"/>
<stop offset="100%" stop-color="#57534e"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-dead)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-dead)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-dead)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-dead)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk-dead)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#44403c"/>
</svg>

Before

Width:  |  Height:  |  Size: 928 B

View file

@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-reg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
<linearGradient id="trunk" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8d6e63"/>
<stop offset="100%" stop-color="#5d4037"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-reg)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-reg)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-reg)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-reg)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#4e342e"/>
</svg>

Before

Width:  |  Height:  |  Size: 913 B

View file

@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-rem" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fbbf24"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
<linearGradient id="trunk" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8d6e63"/>
<stop offset="100%" stop-color="#5d4037"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-rem)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-rem)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-rem)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-rem)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#4e342e"/>
</svg>

Before

Width:  |  Height:  |  Size: 913 B

View file

@ -19,15 +19,7 @@ var activeCategories = new Set(SELECTED_CATEGORIES || []);
var activeModels = new Set(); var activeModels = new Set();
var modelFilterCollapsed = true; var modelFilterCollapsed = true;
var activeConditionalFilters = {}; var activeConditionalFilters = {};
var currentTypeFilter = 'all'; // legacy fallback var currentTypeFilter = 'all'; // 'all' | 'locations' | 'assets'
function getActiveTypeFilters() {
const types = [];
if (document.getElementById('type-cb-assets')?.checked) types.push('assets');
if (document.getElementById('type-cb-locations')?.checked) types.push('locations');
if (document.getElementById('type-cb-management-zones')?.checked) types.push('management_zones');
return types;
}
var searchTerms = []; var searchTerms = [];
var searchMode = 'and'; var searchMode = 'and';
var shouldFitSearch = false; var shouldFitSearch = false;
@ -53,7 +45,6 @@ var assetsLoadModes = {}; // thematic_code → null | 'always' | 'filter' |
// MapLibre sources // MapLibre sources
var mergedLocationsGeojson = { type: 'FeatureCollection', features: [] }; var mergedLocationsGeojson = { type: 'FeatureCollection', features: [] };
var mergedManagementZonesGeojson = { type: 'FeatureCollection', features: [] };
var mergedAssetsGeojson = { type: 'FeatureCollection', features: [] }; var mergedAssetsGeojson = { type: 'FeatureCollection', features: [] };
var currentFetchAbortController = null; var currentFetchAbortController = null;
var assetClickPopup = null; var assetClickPopup = null;
@ -535,7 +526,7 @@ function buildColumns() {
} }
}, },
{ {
field: 'element_type', title: gettext('Type'), sortable: true, width: 145, widthUnit: 'px', valign: 'middle', field: 'element_type', title: gettext('Type'), sortable: true, width: 130, widthUnit: 'px', valign: 'middle',
formatter: function (v, row) { formatter: function (v, row) {
const table = $('#unified-table'); const table = $('#unified-table');
let isCatVisible = false; let isCatVisible = false;
@ -543,21 +534,10 @@ function buildColumns() {
isCatVisible = table.length && typeof table.bootstrapTable === 'function' && isCatVisible = table.length && typeof table.bootstrapTable === 'function' &&
table.bootstrapTable('getVisibleColumns').some(c => c.field === 'category'); table.bootstrapTable('getVisibleColumns').some(c => c.field === 'category');
} catch (e) {} } catch (e) {}
let iconPrefix = '';
if (typeof AssetSymbology !== 'undefined' && row && !row.is_location && !row.is_management_zone) {
const symb = AssetSymbology.getAssetSymbology(row);
const colorStyle = symb.color ? `color:${symb.color};` : '';
iconPrefix = AssetSymbology.renderIconHtml(symb, {
style: `${colorStyle} width: 20px; height: 20px; vertical-align: middle; margin-right: 6px;`,
imgClass: 'me-1'
});
}
if (!isCatVisible && row && row.is_location && row.category) { if (!isCatVisible && row && row.is_location && row.category) {
return `${iconPrefix}${escapeHtml(v || '-')} <span class="badge bg-light text-secondary border fw-normal ms-1" style="font-size:0.75rem;">${escapeHtml(row.category)}</span>`; return `${escapeHtml(v || '-')} <span class="badge bg-light text-secondary border fw-normal ms-1" style="font-size:0.75rem;">${escapeHtml(row.category)}</span>`;
} }
return `${iconPrefix}${escapeHtml(v || '-')}`; return escapeHtml(v || '-');
} }
}, },
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true, width: 140, widthUnit: 'px', valign: 'middle' }, { field: 'thematic_name', title: gettext('Thématique'), sortable: true, width: 140, widthUnit: 'px', valign: 'middle' },
@ -678,31 +658,9 @@ function applyInitialPreferences() {
// Type filter // Type filter
if (prefs.type_filter) { if (prefs.type_filter) {
if (Array.isArray(prefs.type_filter)) { currentTypeFilter = prefs.type_filter;
const cbA = document.getElementById('type-cb-assets'); const rb = document.querySelector(`input[name="unified-type-filter"][value="${prefs.type_filter}"]`);
const cbL = document.getElementById('type-cb-locations'); if (rb) rb.checked = true;
const cbZ = document.getElementById('type-cb-management-zones');
if (cbA) cbA.checked = prefs.type_filter.includes('assets');
if (cbL) cbL.checked = prefs.type_filter.includes('locations');
if (cbZ) cbZ.checked = prefs.type_filter.includes('management_zones');
} else if (typeof prefs.type_filter === 'string') {
const cbA = document.getElementById('type-cb-assets');
const cbL = document.getElementById('type-cb-locations');
const cbZ = document.getElementById('type-cb-management-zones');
if (prefs.type_filter === 'all') {
if (cbA) cbA.checked = true;
if (cbL) cbL.checked = true;
if (cbZ) cbZ.checked = true;
} else if (prefs.type_filter === 'locations') {
if (cbA) cbA.checked = false;
if (cbL) cbL.checked = true;
if (cbZ) cbZ.checked = false;
} else if (prefs.type_filter === 'assets') {
if (cbA) cbA.checked = true;
if (cbL) cbL.checked = false;
if (cbZ) cbZ.checked = false;
}
}
} }
// Include no geometry filter // Include no geometry filter
@ -748,14 +706,6 @@ function applyInitialPreferences() {
window._restoredMapCenter = prefs.map_center; window._restoredMapCenter = prefs.map_center;
window._restoredMapZoom = prefs.map_zoom; window._restoredMapZoom = prefs.map_zoom;
} }
// Basemap preference
if (prefs.basemap) {
window._restoredBasemap = prefs.basemap;
try {
localStorage.setItem('loko_active_basemap', prefs.basemap);
} catch (e) {}
}
} else if (DEFAULT_THEMATIC_CODE && DEFAULT_THEMATIC_CODE in THEMATIC_CONFIG) { } else if (DEFAULT_THEMATIC_CODE && DEFAULT_THEMATIC_CODE in THEMATIC_CONFIG) {
// First visit: pre-select the user's default thematic // First visit: pre-select the user's default thematic
activeThematics = new Set([DEFAULT_THEMATIC_CODE]); activeThematics = new Set([DEFAULT_THEMATIC_CODE]);
@ -783,22 +733,18 @@ function savePreferences() {
).map(cb => cb.value); ).map(cb => cb.value);
const includeNoGeomCheckbox = document.getElementById('include-no-geom'); const includeNoGeomCheckbox = document.getElementById('include-no-geom');
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true; const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
const activeBasemap = (window.unifiedLayerSwitcher && window.unifiedLayerSwitcher.activeBasemapId)
|| localStorage.getItem('loko_active_basemap')
|| 'default';
const prefs = { const prefs = {
thematics: Array.from(activeThematics), thematics: Array.from(activeThematics),
categories: Array.from(activeCategories), categories: Array.from(activeCategories),
models: Array.from(activeModels), models: Array.from(activeModels),
conditional_filters: activeConditionalFilters, conditional_filters: activeConditionalFilters,
visible_columns: getVisibleColumnsPreference(), visible_columns: getVisibleColumnsPreference(),
type_filter: getActiveTypeFilters(), type_filter: currentTypeFilter,
status_filters: statusFilters, status_filters: statusFilters,
validation_status_filters: validationStatusFilters, validation_status_filters: validationStatusFilters,
include_no_geom: includeNoGeom, include_no_geom: includeNoGeom,
map_center: (typeof map !== 'undefined' && map.getCenter) ? [map.getCenter().lng, map.getCenter().lat] : null, map_center: (typeof map !== 'undefined' && map.getCenter) ? [map.getCenter().lng, map.getCenter().lat] : null,
map_zoom: (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : null, map_zoom: (typeof map !== 'undefined' && map.getZoom) ? map.getZoom() : null,
basemap: activeBasemap,
}; };
fetch(SAVE_ASSETS_PREFS_URL, { fetch(SAVE_ASSETS_PREFS_URL, {
method: 'POST', method: 'POST',
@ -824,12 +770,6 @@ $(document).ready(function () {
$('#unified-table').bootstrapTable({ $('#unified-table').bootstrapTable({
columns: buildColumns(), columns: buildColumns(),
rowStyle: function (row) {
if (row && (row.is_dead || row.vitality === 'dead' || row.status === 'dead' || row.status === 'archived')) {
return { classes: 'text-muted opacity-75' };
}
return {};
},
height: calcHeight(), height: calcHeight(),
search: false, search: false,
pagination: true, pagination: true,
@ -844,9 +784,9 @@ $(document).ready(function () {
onUncheckAll: function (rows) { rows.forEach(r => { r.state = false; selectedIds.delete(r.id); }); updateSelectionStatus(); }, onUncheckAll: function (rows) { rows.forEach(r => { r.state = false; selectedIds.delete(r.id); }); updateSelectionStatus(); },
onClickRow: function (row) { onClickRow: function (row) {
if (!row.lon || !row.lat) return; if (!row.lon || !row.lat) return;
const source = row.is_management_zone ? 'management_zones' : (row.is_location ? 'locations' : 'assets'); const source = row.is_location ? 'locations' : 'assets';
if (typeof highlightMapFeature === 'function') { if (typeof highlightMapFeature === 'function') {
highlightMapFeature(map, source, row.id, row.is_management_zone ? managementZonesHighlightState : (row.is_location ? locationshighlightState : assetshighlightState)); highlightMapFeature(map, source, row.id, row.is_location ? locationshighlightState : assetshighlightState);
} }
}, },
}); });
@ -925,13 +865,11 @@ $(document).ready(function () {
renderModelFilters(); renderModelFilters();
}); });
// ─── Type checkboxes ──────────────────────────────────────────────────── // ─── Type radio ────────────────────────────────────────────────────────
document.querySelectorAll('input[name="filter-element-type"]').forEach(cb => { document.querySelectorAll('input[name="unified-type-filter"]').forEach(rb => {
cb.addEventListener('change', function () { rb.addEventListener('change', function () {
applyFilters(); if (this.checked) currentTypeFilter = this.value;
updateFilterBadge();
schedulePreferencesSave();
}); });
}); });
@ -946,45 +884,26 @@ $(document).ready(function () {
} }
// init from locked element type // init from locked element type
if (PREVENTIVE_ELEMENT_TYPE === 'assets') { if (PREVENTIVE_ELEMENT_TYPE === 'assets') currentTypeFilter = 'assets';
const cbA = document.getElementById('type-cb-assets'); else if (PREVENTIVE_ELEMENT_TYPE === 'locations') currentTypeFilter = 'locations';
const cbL = document.getElementById('type-cb-locations');
const cbZ = document.getElementById('type-cb-management-zones');
if (cbA) cbA.checked = true;
if (cbL) cbL.checked = false;
if (cbZ) cbZ.checked = false;
} else if (PREVENTIVE_ELEMENT_TYPE === 'locations') {
const cbA = document.getElementById('type-cb-assets');
const cbL = document.getElementById('type-cb-locations');
const cbZ = document.getElementById('type-cb-management-zones');
if (cbA) cbA.checked = false;
if (cbL) cbL.checked = true;
if (cbZ) cbZ.checked = false;
}
// ─── Add actions ───────────────────────────────────────────────────── // ─── Add actions ─────────────────────────────────────────────────────
const addConfig = getUnifiedAddModalConfig(); const addConfig = getUnifiedAddModalConfig();
const assetThematics = Array.isArray(addConfig.asset_thematics) ? addConfig.asset_thematics : []; const assetThematics = Array.isArray(addConfig.asset_thematics) ? addConfig.asset_thematics : [];
const locationThematics = Array.isArray(addConfig.location_thematics) ? addConfig.location_thematics : []; const locationThematics = Array.isArray(addConfig.location_thematics) ? addConfig.location_thematics : [];
const managementZoneThematics = Array.isArray(addConfig.management_zone_thematics) ? addConfig.management_zone_thematics : [];
const addButton = document.getElementById('btn-add-asset'); const addButton = document.getElementById('btn-add-asset');
const assetThematicSelect = document.getElementById('unified-add-asset-thematic'); const assetThematicSelect = document.getElementById('unified-add-asset-thematic');
const locationThematicSelect = document.getElementById('unified-add-location-thematic'); const locationThematicSelect = document.getElementById('unified-add-location-thematic');
const managementZoneThematicSelect = document.getElementById('unified-add-management-zone-thematic');
const chooserEmpty = document.getElementById('unified-add-chooser-empty'); const chooserEmpty = document.getElementById('unified-add-chooser-empty');
fillSelectWithOptions(assetThematicSelect, assetThematics, assetThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible')); fillSelectWithOptions(assetThematicSelect, assetThematics, assetThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible'));
fillSelectWithOptions(locationThematicSelect, locationThematics, locationThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible')); fillSelectWithOptions(locationThematicSelect, locationThematics, locationThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible'));
fillSelectWithOptions(managementZoneThematicSelect, managementZoneThematics, managementZoneThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible'));
if (assetThematicSelect) assetThematicSelect.disabled = !assetThematics.length; if (assetThematicSelect) assetThematicSelect.disabled = !assetThematics.length;
if (locationThematicSelect) locationThematicSelect.disabled = !locationThematics.length; if (locationThematicSelect) locationThematicSelect.disabled = !locationThematics.length;
if (managementZoneThematicSelect) managementZoneThematicSelect.disabled = !managementZoneThematics.length; if (chooserEmpty) chooserEmpty.classList.toggle('d-none', assetThematics.length > 0 || locationThematics.length > 0);
if (addButton && !assetThematics.length && !locationThematics.length) {
const hasAnyAddRights = assetThematics.length > 0 || locationThematics.length > 0 || managementZoneThematics.length > 0;
if (chooserEmpty) chooserEmpty.classList.toggle('d-none', hasAnyAddRights);
if (addButton && !hasAnyAddRights) {
addButton.disabled = true; addButton.disabled = true;
addButton.title = gettext('Vous n\'avez pas de droits de création'); addButton.title = gettext('Vous n\'avez pas de droits de création');
} }
@ -1043,17 +962,6 @@ $(document).ready(function () {
} }
}); });
document.getElementById('unified-add-management-zone-open')?.addEventListener('click', function () {
const thematicCode = managementZoneThematicSelect ? managementZoneThematicSelect.value : '';
if (!thematicCode) {
setModalAlert('unified-add-chooser-alert', gettext('Sélectionnez une thématique pour créer une zone de gestion.'), 'warning');
return;
}
setModalAlert('unified-add-chooser-alert', '', 'info');
const baseUrl = addConfig.management_zone_create_url || '/assets/management-zones/add/';
window.location.href = `${baseUrl}?thematic=${encodeURIComponent(thematicCode)}`;
});
document.getElementById('unifiedAddAssetCategory')?.addEventListener('change', function () { document.getElementById('unifiedAddAssetCategory')?.addEventListener('change', function () {
const thematicCode = document.getElementById('unified-add-asset-thematic-code')?.value || ''; const thematicCode = document.getElementById('unified-add-asset-thematic-code')?.value || '';
loadUnifiedAssetModels(thematicCode, this.value); loadUnifiedAssetModels(thematicCode, this.value);
@ -1267,12 +1175,8 @@ $(document).ready(function () {
activeModels.clear(); activeModels.clear();
modelFilterCollapsed = true; modelFilterCollapsed = true;
activeConditionalFilters = {}; activeConditionalFilters = {};
const cbA = document.getElementById('type-cb-assets'); const allRadio = document.getElementById('type-all');
const cbL = document.getElementById('type-cb-locations'); if (allRadio) { allRadio.checked = true; currentTypeFilter = 'all'; }
const cbZ = document.getElementById('type-cb-management-zones');
if (cbA) cbA.checked = true;
if (cbL) cbL.checked = true;
if (cbZ) cbZ.checked = true;
document.querySelectorAll('[name="filter-status"]').forEach(cb => { cb.checked = false; }); document.querySelectorAll('[name="filter-status"]').forEach(cb => { cb.checked = false; });
document.querySelectorAll('[name="filter-validation-status"]').forEach(cb => { cb.checked = false; }); document.querySelectorAll('[name="filter-validation-status"]').forEach(cb => { cb.checked = false; });
const includeNoGeomCheckbox = document.getElementById('include-no-geom'); const includeNoGeomCheckbox = document.getElementById('include-no-geom');
@ -1611,11 +1515,6 @@ function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) {
} }
} }
if (thematicCode === 'nature') { if (thematicCode === 'nature') {
if (Array.isArray(thematicFilters.management_type) && thematicFilters.management_type.length) {
payload.management_type = thematicFilters.management_type;
} else if (thematicFilters.management_type) {
payload.management_type = [thematicFilters.management_type];
}
if (thematicFilters.location_type) { if (thematicFilters.location_type) {
payload.location_type = thematicFilters.location_type; payload.location_type = thematicFilters.location_type;
} }
@ -1961,13 +1860,11 @@ function findUnifiedRowIndex(rowRef) {
const rows = $table.bootstrapTable('getData') || []; const rows = $table.bootstrapTable('getData') || [];
const isLocation = !!rowRef.is_location; const isLocation = !!rowRef.is_location;
const isManagementZone = !!rowRef.is_management_zone;
const matches = (row) => { const matches = (row) => {
if (!!row.is_location !== isLocation) return false; if (!!row.is_location !== isLocation) return false;
if (!!row.is_management_zone !== isManagementZone) return false;
if (String(row.id) !== String(rowRef.id)) return false; if (String(row.id) !== String(rowRef.id)) return false;
if (!isLocation && !isManagementZone) { if (!isLocation) {
if (String(row.thematic || '') !== String(rowRef.thematic || '')) return false; if (String(row.thematic || '') !== String(rowRef.thematic || '')) return false;
if (String(row.asset_type || '') !== String(rowRef.asset_type || '')) return false; if (String(row.asset_type || '') !== String(rowRef.asset_type || '')) return false;
} else { } else {
@ -1981,7 +1878,7 @@ function findUnifiedRowIndex(rowRef) {
if (idx !== -1) return idx; if (idx !== -1) return idx;
// Fallback in case some rows do not expose all discriminators. // Fallback in case some rows do not expose all discriminators.
idx = rows.findIndex(row => !!row.is_location === isLocation && !!row.is_management_zone === isManagementZone && String(row.id) === String(rowRef.id)); idx = rows.findIndex(row => !!row.is_location === isLocation && String(row.id) === String(rowRef.id));
return idx; return idx;
} }
@ -2050,41 +1947,6 @@ function collectClickedAssets(clickEvent) {
element_type: p.element_type || '', element_type: p.element_type || '',
detail_url: p.detail_url || null, detail_url: p.detail_url || null,
feature_id: id, feature_id: id,
extra_info: p.plots_count != null ? `${p.plots_count} plot(s)` : (p.bollard_type || ''),
management_type: p.management_type || '',
managing_authority: p.managing_authority || '',
is_remarkable: p.is_remarkable || false,
});
});
return Array.from(unique.values());
}
function collectClickedManagementZones(clickEvent) {
const layers = ['management-zones-fill', 'management-zones-line'].filter(layerId => map.getLayer(layerId));
if (!layers.length) return [];
const rendered = map.queryRenderedFeatures(clickEvent.point, { layers });
const unique = new Map();
rendered.forEach(f => {
const p = f.properties || {};
const id = f.id ?? p.id;
if (id == null) return;
const key = `zone|${p.thematic || ''}|${id}`;
if (unique.has(key)) return;
unique.set(key, {
id: id,
code: p.code || '',
name: p.name || '',
thematic: p.thematic || '',
thematic_name: f._thematic_name || p.thematic_name || p.thematic || '',
element_type: p.element_type || gettext('Zone de gestion'),
category: p.category || p.zone_type_name || '',
detail_url: p.detail_url || null,
color: p.color || '',
feature_id: id,
is_management_zone: true,
}); });
}); });
@ -2154,29 +2016,12 @@ function bindAssetPopupActions(popupNode) {
} }
}); });
}); });
popupNode.querySelectorAll('.zone-popup-focus-btn').forEach(btn => {
btn.addEventListener('click', function (e) {
e.preventDefault();
const rowRef = {
id: this.dataset.zoneId,
thematic: this.dataset.thematic,
is_management_zone: true,
};
focusRowInUnifiedTable(rowRef);
if (typeof highlightMapFeature === 'function') {
highlightMapFeature(map, 'management_zones', rowRef.id, managementZonesHighlightState);
}
});
});
} }
function showAssetClickPopup(clickEvent) { function showAssetClickPopup(clickEvent) {
const assets = collectClickedAssets(clickEvent); const assets = collectClickedAssets(clickEvent);
const locations = collectClickedLocations(clickEvent); const locations = collectClickedLocations(clickEvent);
const managementZones = collectClickedManagementZones(clickEvent); if (!assets.length && !locations.length) return;
if (!assets.length && !locations.length && !managementZones.length) return;
const maxItems = 8; const maxItems = 8;
const visibleAssets = assets.slice(0, maxItems); const visibleAssets = assets.slice(0, maxItems);
@ -2187,26 +2032,15 @@ function showAssetClickPopup(clickEvent) {
const type = escapeHtml(a.element_type || a.asset_type || ''); const type = escapeHtml(a.element_type || a.asset_type || '');
const thematic = escapeHtml(a.thematic_name || a.thematic || ''); const thematic = escapeHtml(a.thematic_name || a.thematic || '');
const name = escapeHtml(a.name || ''); const name = escapeHtml(a.name || '');
const extra = a.extra_info ? ` &bull; <span class="badge bg-light text-primary border" style="font-size:0.7rem;">${escapeHtml(a.extra_info)}</span>` : '';
const detailAnchor = a.detail_url const detailAnchor = a.detail_url
? `<a href="${escapeHtml(a.detail_url)}">${code}</a>` ? `<a href="${escapeHtml(a.detail_url)}">${code}</a>`
: `<span>${code}</span>`; : `<span>${code}</span>`;
let iconPrefix = '';
if (typeof AssetSymbology !== 'undefined') {
const symb = AssetSymbology.getAssetSymbology(a);
const colorStyle = symb.color ? `color:${symb.color};` : '';
iconPrefix = AssetSymbology.renderIconHtml(symb, {
style: `${colorStyle} font-size: 1rem; vertical-align: -1px; margin-right: 5px;`,
imgClass: 'me-1'
});
}
return ` return `
<div class="asset-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;"> <div class="asset-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
<div style="min-width:0;"> <div style="min-width:0;">
<div style="font-weight:600; line-height:1.1;">${iconPrefix}${detailAnchor}</div> <div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}${extra}</div> <div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}</div>
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''} ${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
</div> </div>
<button type="button" <button type="button"
@ -2279,56 +2113,10 @@ function showAssetClickPopup(clickEvent) {
` `
: ''; : '';
const visibleZones = managementZones.slice(0, maxItems);
const hiddenZonesCount = Math.max(0, managementZones.length - visibleZones.length);
const zoneItemsHtml = visibleZones.map(z => {
const code = escapeHtml(z.code || '-');
const type = escapeHtml(z.element_type || gettext('Zone de gestion'));
const thematic = escapeHtml(z.thematic_name || z.thematic || '');
const cat = z.category ? ` &bull; ${escapeHtml(z.category)}` : '';
const dotHtml = z.color ? `<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background-color:${escapeHtml(z.color)}; margin-right:4px; vertical-align:middle; box-shadow:0 0 0 1px rgba(0,0,0,0.15);"></span>` : '';
const name = escapeHtml(z.name || '');
const detailAnchor = z.detail_url
? `<a href="${escapeHtml(z.detail_url)}">${code}</a>`
: `<span>${code}</span>`;
return `
<div class="zone-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
<div style="min-width:0;">
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${dotHtml}${type}${cat}${thematic ? ' - ' + thematic : ''}</div>
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
</div>
<button type="button"
class="btn btn-sm btn-light zone-popup-focus-btn"
data-zone-id="${escapeHtml(z.id)}"
data-thematic="${escapeHtml(z.thematic)}"
title="${escapeHtml(gettext('Voir dans le tableau'))}"
style="transform:translateX(-5px);">
<i class="bi bi-table"></i>
</button>
</div>
`;
}).join('');
const moreZonesHtml = hiddenZonesCount > 0
? `<div style="margin-top:0.35rem; font-size:0.75rem; color:#6c757d;">+${hiddenZonesCount} ${escapeHtml(gettext('autre(s) zone(s)'))}</div>`
: '';
const managementZonesSectionHtml = managementZones.length > 0
? `
<div style="font-weight:700; margin:${(assets.length || locations.length) ? '0.6rem' : '0'} 0 0.35rem 0;">${escapeHtml(gettext('Zones de gestion trouvées'))}: ${managementZones.length}</div>
<div>${zoneItemsHtml}</div>
${moreZonesHtml}
`
: '';
const popupHtml = ` const popupHtml = `
<div style="min-width:260px; max-width:320px;"> <div style="min-width:260px; max-width:320px;">
${assetsSectionHtml} ${assetsSectionHtml}
${locationsSectionHtml} ${locationsSectionHtml}
${managementZonesSectionHtml}
</div> </div>
`; `;
@ -2519,23 +2307,6 @@ function refreshData() {
); );
} }
// ── Management zones: loaded per active thematic
if (cfg.management_zones_geojson_url) {
if (!('management_zones' in geojsonCache[code])) {
const url = cfg.management_zones_geojson_url + '?thematic=' + encodeURIComponent(code);
fetches.push(
fetchGeojsonFeatures(url, { signal }, signal)
.then(data => {
geojsonCache[code].management_zones = data.features || [];
})
.catch(err => {
if (err.name === 'AbortError') throw err;
geojsonCache[code].management_zones = [];
})
);
}
}
// ── Assets: conditional loading // ── Assets: conditional loading
const alwaysLoad = ALWAYS_LOAD_ASSETS_THEMATICS.has(code); const alwaysLoad = ALWAYS_LOAD_ASSETS_THEMATICS.has(code);
const prevMode = assetsLoadModes[code] || null; const prevMode = assetsLoadModes[code] || null;
@ -2611,10 +2382,6 @@ function refreshData() {
f._thematic_name = thematicName; f._thematic_name = thematicName;
allFeatures.push(f); allFeatures.push(f);
}); });
(cache.management_zones || []).forEach(f => {
f._thematic_name = thematicName;
allFeatures.push(f);
});
}); });
renderModelFilters(); renderModelFilters();
applyFilters(); applyFilters();
@ -2639,27 +2406,19 @@ function applyFilters() {
let features = allFeatures.slice(); let features = allFeatures.slice();
// Type filter // Type filter
const cbAssets = document.getElementById('type-cb-assets'); if (currentTypeFilter === 'locations') {
const cbLocations = document.getElementById('type-cb-locations'); features = features.filter(f => f.properties && f.properties.is_location);
const cbZones = document.getElementById('type-cb-management-zones'); } else if (currentTypeFilter === 'assets') {
const showAssets = cbAssets ? cbAssets.checked : true; features = features.filter(f => f.properties && !f.properties.is_location);
const showLocations = cbLocations ? cbLocations.checked : true; }
const showManagementZones = cbZones ? cbZones.checked : true;
features = features.filter(f => {
if (!f.properties) return false;
if (f.properties.is_management_zone) return showManagementZones;
if (f.properties.is_location) return showLocations;
return showAssets;
});
// Include no geom filter // Include no geom filter
const includeNoGeomCheckbox = document.getElementById('include-no-geom'); const includeNoGeomCheckbox = document.getElementById('include-no-geom');
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true; const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
if (!includeNoGeom) { if (!includeNoGeom) {
features = features.filter(f => { features = features.filter(f => {
// Locations and management zones are never filtered by geometry checkbox (only assets) // Locations are never filtered by geometry checkbox (only assets)
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true; if (f.properties && f.properties.is_location) return true;
return f.geometry && (f.geometry.coordinates || f.geometry.type); return f.geometry && (f.geometry.coordinates || f.geometry.type);
}); });
} }
@ -2676,7 +2435,7 @@ function applyFilters() {
const selectedValidationStatuses = Array.from(document.querySelectorAll('[name="filter-validation-status"]:checked')).map(cb => cb.value); const selectedValidationStatuses = Array.from(document.querySelectorAll('[name="filter-validation-status"]:checked')).map(cb => cb.value);
if (selectedValidationStatuses.length) { if (selectedValidationStatuses.length) {
features = features.filter(f => { features = features.filter(f => {
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true; if (f.properties && f.properties.is_location) return true;
return selectedValidationStatuses.includes(f.properties && f.properties.validation_status); return selectedValidationStatuses.includes(f.properties && f.properties.validation_status);
}); });
} }
@ -2684,7 +2443,7 @@ function applyFilters() {
// Category filter // Category filter
if (activeCategories.size) { if (activeCategories.size) {
features = features.filter(f => { features = features.filter(f => {
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true; if (f.properties && f.properties.is_location) return true;
return activeCategories.has(getFeatureCategoryValue(f)); return activeCategories.has(getFeatureCategoryValue(f));
}); });
} }
@ -2692,7 +2451,7 @@ function applyFilters() {
// Model filter // Model filter
if (activeModels.size) { if (activeModels.size) {
features = features.filter(f => { features = features.filter(f => {
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true; if (f.properties && f.properties.is_location) return true;
return activeModels.has(getFeatureModelValue(f)); return activeModels.has(getFeatureModelValue(f));
}); });
} }
@ -2755,19 +2514,11 @@ function loadTableData(features) {
model: p.model || '', model: p.model || '',
status: p.status || '', status: p.status || '',
detail_url: p.detail_url || null, detail_url: p.detail_url || null,
management_type: p.management_type || '',
managing_authority: p.managing_authority || '',
is_remarkable: p.is_remarkable || false,
scientific_name: p.scientific_name || '',
vernacular_name: p.vernacular_name || '',
is_dead: p.is_dead || false,
vitality: p.vitality || '',
lon: p.lon, lon: p.lon,
lat: p.lat, lat: p.lat,
length: Number.isFinite(length) ? length : 0, length: Number.isFinite(length) ? length : 0,
area: Number.isFinite(area) ? area : 0, area: Number.isFinite(area) ? area : 0,
is_location: p.is_location, is_location: p.is_location,
is_management_zone: p.is_management_zone || false,
}; };
}); });
@ -2788,7 +2539,6 @@ function loadTableData(features) {
var locationshighlightState = { previousId: null }; var locationshighlightState = { previousId: null };
var assetshighlightState = { previousId: null }; var assetshighlightState = { previousId: null };
var managementZonesHighlightState = { previousId: null };
function isPanelTextSpriteId(missingId) { function isPanelTextSpriteId(missingId) {
@ -2861,20 +2611,15 @@ function rebuildMapSources() {
if (typeof map === 'undefined') return; if (typeof map === 'undefined') return;
const locSrc = map.getSource('locations'); const locSrc = map.getSource('locations');
const asstSrc = map.getSource('assets'); const asstSrc = map.getSource('assets');
const zoneSrc = map.getSource('management_zones'); if (!locSrc || !asstSrc) return; // sources not yet added (before mapLoaded)
if (!locSrc || !asstSrc || !zoneSrc) return; // sources not yet added (before mapLoaded)
mergedLocationsGeojson = { mergedLocationsGeojson = {
type: 'FeatureCollection', type: 'FeatureCollection',
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && f.properties.is_location), features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && f.properties.is_location),
}; };
mergedManagementZonesGeojson = {
type: 'FeatureCollection',
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && f.properties.is_management_zone),
};
mergedAssetsGeojson = { mergedAssetsGeojson = {
type: 'FeatureCollection', type: 'FeatureCollection',
features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && !f.properties.is_location && !f.properties.is_management_zone), features: filteredFeatures.filter(f => hasValidCoordinates(f) && f.properties && !f.properties.is_location),
}; };
applyUnifiedSignPanelOffsets(mergedAssetsGeojson); applyUnifiedSignPanelOffsets(mergedAssetsGeojson);
@ -2882,7 +2627,6 @@ function rebuildMapSources() {
locSrc.setData(mergedLocationsGeojson); locSrc.setData(mergedLocationsGeojson);
asstSrc.setData(mergedAssetsGeojson); asstSrc.setData(mergedAssetsGeojson);
if (zoneSrc) zoneSrc.setData(mergedManagementZonesGeojson);
} }
@ -2898,7 +2642,6 @@ maploader.addEventListener('mapLoaded', () => {
data: window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/' data: window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/'
}); });
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addSource('management_zones', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
// Add layers in render order: fills → lines → circles/symbols (see UNIFIED_LAYERS) // Add layers in render order: fills → lines → circles/symbols (see UNIFIED_LAYERS)
@ -2949,12 +2692,7 @@ maploader.addEventListener('mapLoaded', () => {
// Layer switcher — Urbis & Vlaanderen orthos (top-right, topmost) // Layer switcher — Urbis & Vlaanderen orthos (top-right, topmost)
if (typeof LayerSwitcherControl !== 'undefined') { if (typeof LayerSwitcherControl !== 'undefined') {
const initialBasemap = window._restoredBasemap || localStorage.getItem('loko_active_basemap') || 'default';
const layerSwitcher = new LayerSwitcherControl({ const layerSwitcher = new LayerSwitcherControl({
initialBasemapId: initialBasemap,
onBasemapChange: function (basemapId) {
schedulePreferencesSave();
},
layers: [{ layers: [{
id: 'basemap', id: 'basemap',
name: 'Fonds de plan & Orthophotos', name: 'Fonds de plan & Orthophotos',
@ -2962,10 +2700,7 @@ maploader.addEventListener('mapLoaded', () => {
beforeLayer: (map && map.getLayer('territory-mask') ? 'territory-mask' : 'locations'), beforeLayer: (map && map.getLayer('territory-mask') ? 'territory-mask' : 'locations'),
basemaps: window.MAP_BACKGROUNDS || [], basemaps: window.MAP_BACKGROUNDS || [],
}], }],
}); });// Reset view — fly back to initial Brussels Region view
window.unifiedLayerSwitcher = layerSwitcher;
// Reset view — fly back to initial Brussels Region view
map.addControl({ map.addControl({
onAdd(m) { onAdd(m) {
this._map = m; this._map = m;
@ -3047,10 +2782,10 @@ maploader.addEventListener('mapLoaded', () => {
}); });
// Click interaction on map elements: show popup when assets are found at click position. // Click interaction on map elements: show popup when assets are found at click position.
['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline', 'management-zones-fill', 'management-zones-line'].forEach(layerId => { ['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].forEach(layerId => {
if (!map.getLayer(layerId)) return; if (!map.getLayer(layerId)) return;
map.on('click', layerId, showAssetClickPopup); map.on('click', layerId, showAssetClickPopup);
if (['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'management-zones-fill'].includes(layerId)) { if (['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical'].includes(layerId)) {
map.on('mouseenter', layerId, function () { map.getCanvas().style.cursor = 'pointer'; }); map.on('mouseenter', layerId, function () { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', layerId, function () { map.getCanvas().style.cursor = ''; }); map.on('mouseleave', layerId, function () { map.getCanvas().style.cursor = ''; });
} }
@ -3060,7 +2795,7 @@ maploader.addEventListener('mapLoaded', () => {
map.on('click', function (e) { map.on('click', function (e) {
if (!assetClickPopup || !assetClickPopup.isOpen()) return; if (!assetClickPopup || !assetClickPopup.isOpen()) return;
const interactiveLayers = ['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline', 'management-zones-fill', 'management-zones-line'] const interactiveLayers = ['assets', 'assets-fill', 'assets-line', 'assets-point', 'sign-panels-circles', 'sign-panels-symbols', 'sign-poles-orientation', 'sign-poles', 'sign-poles-vertical', 'locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline']
.filter(layerId => map.getLayer(layerId)); .filter(layerId => map.getLayer(layerId));
if (!interactiveLayers.length) { if (!interactiveLayers.length) {
assetClickPopup.remove(); assetClickPopup.remove();
@ -3121,7 +2856,7 @@ function updateFilterBadge() {
}, 0); }, 0);
n += thematicBlocksCount; n += thematicBlocksCount;
if (getActiveTypeFilters().length < 3) n += 1; if (currentTypeFilter !== 'all') n += 1;
if (document.querySelectorAll('[name="filter-status"]:checked').length > 0) n += 1; if (document.querySelectorAll('[name="filter-status"]:checked').length > 0) n += 1;
if (document.querySelectorAll('[name="filter-validation-status"]:checked').length > 0) n += 1; if (document.querySelectorAll('[name="filter-validation-status"]:checked').length > 0) n += 1;

View file

@ -90,10 +90,7 @@ function loadUnifiedMapIcons(map) {
// ── File-based icons (full colour, non-SDF) ───────────────────────────── // ── File-based icons (full colour, non-SDF) ─────────────────────────────
// UNIFIED_ICON_URLS is set in the inline <script> of unified_index.html // UNIFIED_ICON_URLS is set in the inline <script> of unified_index.html
// using {% static %} so the URL is resolved server-side. // using {% static %} so the URL is resolved server-side.
const injected = (typeof UNIFIED_ICON_URLS !== 'undefined') ? UNIFIED_ICON_URLS : {}; const iconUrls = (typeof UNIFIED_ICON_URLS !== 'undefined') ? UNIFIED_ICON_URLS : {};
const iconUrls = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getIconUrls)
? Object.assign({}, AssetSymbology.getIconUrls(), injected)
: injected;
// trafficlights: traffic_light_icon.svg (56 px) // trafficlights: traffic_light_icon.svg (56 px)
if (iconUrls.trafficlights && !map.hasImage('icon-trafficlights')) { if (iconUrls.trafficlights && !map.hasImage('icon-trafficlights')) {
@ -143,27 +140,6 @@ function loadUnifiedMapIcons(map) {
.then(data => { if (!map.hasImage('icon-nature')) map.addImage('icon-nature', data); }) .then(data => { if (!map.hasImage('icon-nature')) map.addImage('icon-nature', data); })
.catch(e => console.warn('unified_map_layers: could not load nature icon', e)); .catch(e => console.warn('unified_map_layers: could not load nature icon', e));
} }
if (iconUrls.natureregional && !map.hasImage('icon-nature-regional')) {
loadNormalisedIcon(iconUrls.natureregional, 56)
.then(data => { if (!map.hasImage('icon-nature-regional')) map.addImage('icon-nature-regional', data); })
.catch(e => console.warn('unified_map_layers: could not load nature regional icon', e));
}
if (iconUrls.natureremarkable && !map.hasImage('icon-nature-remarkable')) {
loadNormalisedIcon(iconUrls.natureremarkable, 56)
.then(data => { if (!map.hasImage('icon-nature-remarkable')) map.addImage('icon-nature-remarkable', data); })
.catch(e => console.warn('unified_map_layers: could not load nature remarkable icon', e));
}
if (iconUrls.naturedead && !map.hasImage('icon-nature-dead')) {
loadNormalisedIcon(iconUrls.naturedead, 56)
.then(data => { if (!map.hasImage('icon-nature-dead')) map.addImage('icon-nature-dead', data); })
.catch(e => console.warn('unified_map_layers: could not load nature dead icon', e));
}
// naturegreensurface: dedicated green surface / lawn SVG (52 px)
if (iconUrls.naturegreensurface && !map.hasImage('icon-naturegreensurface')) {
loadNormalisedIcon(iconUrls.naturegreensurface, 52)
.then(data => { if (!map.hasImage('icon-naturegreensurface')) map.addImage('icon-naturegreensurface', data); })
.catch(e => console.warn('unified_map_layers: could not load naturegreensurface icon', e));
}
// structures: dedicated bridge/tunnel SVG (52 px) // structures: dedicated bridge/tunnel SVG (52 px)
if (iconUrls.structures && !map.hasImage('icon-structures')) { if (iconUrls.structures && !map.hasImage('icon-structures')) {
@ -225,22 +201,11 @@ function loadUnifiedMapIcons(map) {
.then(data => { if (!map.hasImage('icon-artworkmonument')) map.addImage('icon-artworkmonument', data); }) .then(data => { if (!map.hasImage('icon-artworkmonument')) map.addImage('icon-artworkmonument', data); })
.catch(e => console.warn('unified_map_layers: could not load artworkmonument icon', e)); .catch(e => console.warn('unified_map_layers: could not load artworkmonument icon', e));
} }
// itsbollardplot: dedicated automatic bollard / plot SVG (42 px)
if (iconUrls.itsbollardplot && !map.hasImage('icon-itsbollardplot')) {
loadNormalisedIcon(iconUrls.itsbollardplot, 42)
.then(data => { if (!map.hasImage('icon-itsbollardplot')) map.addImage('icon-itsbollardplot', data); })
.catch(e => console.warn('unified_map_layers: could not load itsbollardplot icon', e));
}
// If the SVG above is unavailable, publiclighting falls back to Bootstrap glyph. // If the SVG above is unavailable, publiclighting falls back to Bootstrap glyph.
// ── Bootstrap Icons glyph SDF icons (recoloured by icon-color) ────────── // ── Bootstrap Icons glyph SDF icons (recoloured by icon-color) ──────────
// codepoints and colors derived from AssetSymbology (single source of truth) // codepoints and colors derived from AssetSymbology (single source of truth)
const FILE_ICON_NAMES = new Set([ const FILE_ICON_NAMES = new Set(Object.keys(iconUrls).map(k => 'icon-' + k));
...Object.keys(iconUrls).map(k => 'icon-' + k),
'icon-nature-regional',
'icon-nature-remarkable',
'icon-nature-dead',
]);
const GLYPH_ICONS = (typeof AssetSymbology !== 'undefined') const GLYPH_ICONS = (typeof AssetSymbology !== 'undefined')
? AssetSymbology.getGlyphIcons() ? AssetSymbology.getGlyphIcons()
@ -419,34 +384,6 @@ const _unified_locations_fill = {
}, },
}; };
// ─── Layer 1b — management zones fill ─────────────────────────────────────────
const _unified_management_zones_fill = {
id: 'management-zones-fill',
type: 'fill',
source: 'management_zones',
filter: ['any',
['==', ['geometry-type'], 'Polygon'],
['==', ['geometry-type'], 'MultiPolygon']
],
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#10b981'],
'fill-opacity': 0.25,
'fill-antialias': false,
},
};
// ─── Layer 3b — management zones line ─────────────────────────────────────────
const _unified_management_zones_line = {
id: 'management-zones-line',
type: 'line',
source: 'management_zones',
paint: {
'line-color': ['coalesce', ['get', 'color'], '#10b981'],
'line-width': 2,
'line-dasharray': [3, 2],
},
};
// ─── Layer 2 — asset fill (polygon assets: road surfaces, nature surfaces) ──── // ─── Layer 2 — asset fill (polygon assets: road surfaces, nature surfaces) ────
const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsFillLayer) const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsFillLayer)
? AssetSymbology.getAssetsFillLayer('assets') ? AssetSymbology.getAssetsFillLayer('assets')
@ -669,7 +606,6 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], ['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'], ['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'], ['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'], ['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'], ['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'], ['==', ['get', 'category'], 'Corbeille'],
@ -705,7 +641,6 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], ['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'], ['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'], ['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'], ['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'], ['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'], ['==', ['get', 'category'], 'Corbeille'],
@ -741,7 +676,6 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], ['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'], ['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'], ['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'], ['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'], ['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'], ['==', ['get', 'category'], 'Corbeille'],
@ -777,7 +711,6 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], ['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'], ['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'], ['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'], ['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'], ['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'], ['==', ['get', 'category'], 'Corbeille'],
@ -813,7 +746,6 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], ['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'], ['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'], ['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'], ['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'], ['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'], ['==', ['get', 'category'], 'Corbeille'],
@ -849,7 +781,6 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], ['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'], ['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'], ['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'], ['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'], ['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'], ['==', ['get', 'category'], 'Corbeille'],
@ -885,7 +816,6 @@ const _unified_assets_symbol = {
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], ['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'], ['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
['==', ['get', 'asset_type'], 'naturerwiasset'], ['==', ['get', 'asset_type'], 'naturerwiasset'],
['==', ['get', 'asset_type'], 'itsbollardplot'],
['==', ['get', 'category'], 'Avaloir'], ['==', ['get', 'category'], 'Avaloir'],
['==', ['get', 'category'], 'Corbeille de rue'], ['==', ['get', 'category'], 'Corbeille de rue'],
['==', ['get', 'category'], 'Corbeille'], ['==', ['get', 'category'], 'Corbeille'],
@ -918,7 +848,6 @@ const _unified_assets_symbol = {
['==', ['get', 'thematic'], 'publiclighting'], 'bottom', ['==', ['get', 'thematic'], 'publiclighting'], 'bottom',
['==', ['get', 'thematic'], 'trafficlights'], 'bottom', ['==', ['get', 'thematic'], 'trafficlights'], 'bottom',
['==', ['get', 'thematic'], 'nature'], 'bottom', ['==', ['get', 'thematic'], 'nature'], 'bottom',
['==', ['get', 'asset_type'], 'itsbollardplot'], 'bottom',
'center' 'center'
], ],
'icon-offset': [ 'icon-offset': [
@ -955,8 +884,6 @@ const _unified_assets_symbol = {
['all', ['==', ['get', 'thematic'], 'roads'], ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false]], 0, ['all', ['==', ['get', 'thematic'], 'roads'], ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false]], 0,
['all', ['==', ['get', 'asset_type'], 'naturerwiasset'], ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false]], 0, ['all', ['==', ['get', 'asset_type'], 'naturerwiasset'], ['match', ['geometry-type'], ['Polygon', 'MultiPolygon', 'LineString', 'MultiLineString'], true, false]], 0,
['==', ['get', 'asset_type'], 'naturegreensurface'], 0, ['==', ['get', 'asset_type'], 'naturegreensurface'], 0,
['==', ['get', 'asset_type'], 'itsbollardsystem'], 0,
['match', ['geometry-type'], ['LineString', 'MultiLineString'], true, false], 0,
['boolean', ['feature-state', 'highlighted'], false], 1, ['boolean', ['feature-state', 'highlighted'], false], 1,
0.85 0.85
], ],
@ -1129,20 +1056,14 @@ const _unified_assets_line = (typeof AssetSymbology !== 'undefined' && AssetSymb
'line-color': [ 'line-color': [
'case', 'case',
['==', ['get', 'thematic'], 'nature'], '#2e7d32', ['==', ['get', 'thematic'], 'nature'], '#2e7d32',
['==', ['get', 'asset_type'], 'itsbollardsystem'], '#0288d1',
THEMATIC_COLOR_EXPR THEMATIC_COLOR_EXPR
], ],
'line-width': [ 'line-width': [
'case', 'case',
['boolean', ['feature-state', 'highlighted'], false], 6, ['boolean', ['feature-state', 'highlighted'], false], 5,
['==', ['get', 'asset_type'], 'itsbollardsystem'], 5,
3 3
], ],
'line-opacity': [ 'line-opacity': 0.8,
'case',
['==', ['get', 'asset_type'], 'itsbollardsystem'], 0.95,
0.8
],
}, },
minzoom: 8, minzoom: 8,
}; };
@ -1224,12 +1145,10 @@ const UNIFIED_LAYERS = [
_unified_territory_mask, // 0. territory mask (outside commune) _unified_territory_mask, // 0. territory mask (outside commune)
_unified_territory_outline, // 0b. territory boundary line _unified_territory_outline, // 0b. territory boundary line
_unified_locations_fill, // 1. polygon fills (bottom) _unified_locations_fill, // 1. polygon fills (bottom)
_unified_management_zones_fill, // 1b. management zones fill
_unified_assets_fill, // 2. polygon fills _unified_assets_fill, // 2. polygon fills
_unified_locations_outline, // 3. lines _unified_locations_outline, // 3. lines
_unified_management_zones_line, // 3b. management zones line _unified_assets_line, // 3b. asset lines
_unified_assets_line, // 3c. asset lines _unified_assets_polygon_outline, // 3c. polygon asset outlines
_unified_assets_polygon_outline, // 3d. polygon asset outlines
_unified_locations_circle, // 4. circles _unified_locations_circle, // 4. circles
_unified_locations_radar_outline, _unified_locations_radar_outline,
_unified_locations_radar, _unified_locations_radar,

View file

@ -364,9 +364,6 @@
<script src="{% url 'javascript-catalog' %}"></script> <script src="{% url 'javascript-catalog' %}"></script>
<script src="{% static 'common/base_script.js' %}"></script> <script src="{% static 'common/base_script.js' %}"></script>
<script src="{% static 'common/base_map.js' %}"></script> <script src="{% static 'common/base_map.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'contracts/js/contracts_map_overlay.js' %}"></script>
<script src="{% static 'assets/js/management_zones_map_overlay.js' %}"></script>
<script src="{% static 'assets/assets_index.js' %}"></script> <script src="{% static 'assets/assets_index.js' %}"></script>
{% block extra_script %} {% block extra_script %}
{% endblock extra_script %} {% endblock extra_script %}

View file

@ -1,48 +1,35 @@
{% load i18n %} {% load i18n %}
<ul class="list-group list-group-flush bg-transparent border-0"> <ul class="list-group list-group-flush bg-transparent">
{% for asset_node in asset_nodes %} {% for asset_node in asset_nodes %}
<li class="list-group-item bg-transparent border-0 py-1 px-0"> <li class="list-group-item bg-transparent border-0 py-1 px-0">
<!-- Row Header --> <!-- Row Header -->
<div class="d-flex align-items-center justify-content-between py-1 px-3"> <div class="d-flex align-items-center justify-content-between py-1 px-2">
<!-- Contenu gauche (indenté selon la profondeur) --> <!-- Contenu gauche (indenté selon la profondeur) -->
<div class="d-flex align-items-center gap-2 flex-wrap" style="padding-left: calc(({{ depth|default:1 }} - 1) * 1.25rem);"> <div class="d-flex align-items-center gap-2 flex-wrap" style="padding-left: calc(({{ depth|default:1 }} - 1) * 1.25rem);">
{% if asset_node.children %} {% if asset_node.children %}
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn" <button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn collapsed"
type="button" type="button"
data-bs-toggle="collapse" data-bs-toggle="collapse"
data-bs-target="#tree-asset-{{ asset_node.asset.id }}" data-bs-target="#tree-asset-{{ asset_node.asset.id }}"
aria-expanded="true"> aria-expanded="false">
<i class="bi bi-chevron-down small"></i> <i class="bi bi-chevron-right small"></i>
</button> </button>
{% else %} {% else %}
<span class="d-inline-block text-center text-muted" style="width: 1.35rem;"><i class="bi bi-dash small"></i></span> <span class="px-1"></span>
{% endif %} {% endif %}
<i class="bi bi-gear-fill text-info small"></i>
<!-- Icône colorée selon la famille d'asset -->
<i class="bi {{ asset_node.asset.get_icon_class|default:'bi-tools' }}" style="font-size: 1rem; color: {{ asset_node.asset.theme_icon_color }};"></i>
<!-- Code cliquable avec couleur thématique --> <!-- Code en bleu pour indiquer clairement un lien cliquable -->
<a href="{{ asset_node.asset.get_absolute_url }}" class="fw-bold text-decoration-underline small" style="color: {{ asset_node.asset.theme_color }};"> <a href="{{ asset_node.asset.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline small">
{{ asset_node.asset.code }} {{ asset_node.asset.code }}
</a> </a>
<span class="small text-dark">{{ asset_node.asset.get_name }}</span> <span class="small">{{ asset_node.asset.get_name }}</span>
<span class="badge bg-info-subtle text-info border small ms-1">{{ asset_node.asset.get_asset_type_display }}</span>
<!-- Badge Macro-lot sobre -->
<span class="badge bg-light text-muted border small">{{ asset_node.asset.get_macro_lot }}</span>
<!-- Badge Typologie avec couleur thématique différenciée -->
<span class="badge {{ asset_node.asset.theme_badge_class }} small" style="{{ asset_node.asset.theme_badge_style }}">
{{ asset_node.asset.get_asset_type_display }}
</span>
{% if asset_node.asset.status == 'inactive' %}
<span class="badge bg-danger-subtle text-danger border small">{% translate "Inactif" %}</span>
{% endif %}
</div> </div>
</div> </div>
{% if asset_node.children %} {% if asset_node.children %}
<div id="tree-asset-{{ asset_node.asset.id }}" class="collapse show tree-collapse-node"> <div id="tree-asset-{{ asset_node.asset.id }}" class="collapse mt-1">
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=asset_node.children depth=depth|default:1|add:1 only %} {% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=asset_node.children depth=depth|default:1|add:1 only %}
</div> </div>
{% endif %} {% endif %}

View file

@ -133,40 +133,6 @@
<th class="text-muted">{% translate "Surface Utile / Brut" %} :</th> <th class="text-muted">{% translate "Surface Utile / Brut" %} :</th>
<td>{{ building.usable_surface_m2|default:"-" }} m² / {{ building.total_surface_m2|default:"-" }} m²</td> <td>{{ building.usable_surface_m2|default:"-" }} m² / {{ building.total_surface_m2|default:"-" }} m²</td>
</tr> </tr>
{% if building.room_number %}
<tr>
<th class="text-muted">{% translate "Numéro de local / porte" %} :</th>
<td><span class="badge bg-secondary-subtle text-secondary border font-monospace"><i class="bi bi-tag me-1"></i>{{ building.room_number }}</span></td>
</tr>
{% endif %}
{% if building.surface_m2 %}
<tr>
<th class="text-muted">{% translate "Surface spécifique" %} :</th>
<td>{{ building.surface_m2 }} m²</td>
</tr>
{% endif %}
{% if building.ceiling_height_m %}
<tr>
<th class="text-muted">{% translate "Hauteur sous plafond" %} :</th>
<td>{{ building.ceiling_height_m }} m</td>
</tr>
{% endif %}
<tr>
<th class="text-muted">{% translate "Chauffage du local" %} :</th>
<td>{% if building.is_heated %}<span class="text-success"><i class="bi bi-fire me-1"></i>{% translate "Chauffé" %}</span>{% else %}<span class="text-muted"><i class="bi bi-slash-circle me-1"></i>{% translate "Non chauffé" %}</span>{% endif %}</td>
</tr>
{% if building.floor_type %}
<tr>
<th class="text-muted">{% translate "Revêtement de sol" %} :</th>
<td>{{ building.get_floor_type_display }}</td>
</tr>
{% endif %}
{% if building.fire_compartment %}
<tr>
<th class="text-muted">{% translate "Compartiment coupe-feu" %} :</th>
<td>{{ building.fire_compartment }}</td>
</tr>
{% endif %}
<tr> <tr>
<th class="text-muted">{{ regional_terms.public_access_category }} :</th> <th class="text-muted">{{ regional_terms.public_access_category }} :</th>
<td>{{ building.erp_category|default:"-" }}</td> <td>{{ building.erp_category|default:"-" }}</td>
@ -180,20 +146,10 @@
</div> </div>
<!-- Card 3: Arborescence Unifiée des Localisations & Assets --> <!-- Card 3: Arborescence Unifiée des Localisations & Assets -->
<div class="card shadow-sm mb-4" id="card-hierarchy-tree"> <div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center">
<span><i class="bi bi-diagram-3 me-2"></i>{% translate "Arborescence Hiérarchique" %}</span> <span><i class="bi bi-diagram-3 me-2"></i>{% translate "Arborescence Hiérarchique" %}</span>
<div class="d-flex align-items-center gap-2"> <span class="badge bg-primary-subtle text-primary border">{{ location_tree.children|length }} {% translate "sous-localisation(s)" %}</span>
<div class="btn-group btn-group-sm" role="group" id="tree-expand-collapse-group">
<button type="button" class="btn btn-outline-secondary py-0 px-2 small" id="btn-tree-collapse-all" title="{% translate 'Tout replier' %}">
<i class="bi bi-arrows-collapse me-1"></i>{% translate "Tout replier" %}
</button>
<button type="button" class="btn btn-outline-secondary py-0 px-2 small" id="btn-tree-expand-all" title="{% translate 'Tout déplier' %}">
<i class="bi bi-arrows-expand me-1"></i>{% translate "Tout déplier" %}
</button>
</div>
<span class="badge bg-primary-subtle text-primary border">{{ location_tree.children|length }} {% translate "sous-localisation(s)" %}</span>
</div>
</div> </div>
<div class="card-body p-3"> <div class="card-body p-3">
@ -221,42 +177,37 @@
<!-- Localisation Courante (Racine de l'affichage) --> <!-- Localisation Courante (Racine de l'affichage) -->
<div class="p-2 border rounded bg-white mb-2"> <div class="p-2 border rounded bg-white mb-2">
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="d-flex align-items-center gap-2">
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn" <i class="bi bi-building text-primary fs-5"></i>
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-root-content"
aria-expanded="true">
<i class="bi bi-chevron-down small"></i>
</button>
<i class="bi {{ building.get_icon_class }} text-primary fs-5"></i>
<span class="fw-bold fs-6 text-primary">{{ building.code }}</span> <span class="fw-bold fs-6 text-primary">{{ building.code }}</span>
{% if building.room_number %}
<span class="badge bg-secondary-subtle text-secondary border ms-1 font-monospace"><i class="bi bi-tag me-1"></i>{{ building.room_number }}</span>
{% endif %}
<span class="fw-bold fs-6">- {{ building.get_name }}</span> <span class="fw-bold fs-6">- {{ building.get_name }}</span>
<span class="badge bg-primary text-white ms-1">{{ building.get_location_type_display }}</span> <span class="badge bg-primary text-white ms-1">{{ building.get_location_type_display }}</span>
{% if location_tree.assets %} {% if location_tree.assets %}
<span class="badge bg-info-subtle text-info border small ms-1" title="{% translate 'Assets rattachés directement au bâtiment' %}"> <button class="btn btn-sm btn-outline-info py-0 px-2 small ms-2 text-nowrap"
<i class="bi bi-tools me-1"></i>{{ location_tree.assets|length }} {% translate "asset(s)" %} type="button"
</span> data-bs-toggle="collapse"
data-bs-target="#tree-assets-root"
aria-expanded="false">
<i class="bi bi-tools me-1"></i>Assets ({{ location_tree.assets|length }})
</button>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<div id="tree-root-content" class="collapse show mt-2"> <!-- Collapsible Assets de la localisation courante (NON VISIBLES PAR DÉFAUT) -->
<!-- Assets rattachés directement au bâtiment racine --> {% if location_tree.assets %}
{% if location_tree.assets %} <div id="tree-assets-root" class="collapse mt-2 ms-4">
<div class="tree-assets-group"> <div class="card card-body bg-light border-info-subtle p-2 mb-2">
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=location_tree.assets depth=2 only %} <div class="fw-bold small text-info mb-1"><i class="bi bi-tools me-1"></i>{% translate "Assets rattachés à" %} {{ building.code }} :</div>
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=location_tree.assets only %}
</div> </div>
{% endif %} </div>
{% endif %}
<!-- Sous-localisations & leurs assets --> <!-- Sous-localisations & leurs assets -->
<div class="tree-locations-group"> <div class="mt-2">
{% include "assets/buildings/publicbuilding_location_tree.html" with node=location_tree depth=1 only %} {% include "assets/buildings/publicbuilding_location_tree.html" with node=location_tree depth=1 only %}
</div>
</div> </div>
</div> </div>
@ -265,167 +216,37 @@
</div> </div>
<!-- Right Column: Localisation & Plans d'étage / Carte interactive --> <!-- Right Column: Localisation & Documents (sous la carte) -->
<div class="col-md-5"> <div class="col-md-5">
<!-- Card Localisation, Carte interactive & Plans d'étage --> <!-- Card Localisation & Carte interactive -->
<div class="card shadow-sm mb-4" id="card-location-plans"> <div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center flex-wrap gap-2 py-2"> <div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center"> <span><i class="bi bi-geo-alt me-2"></i>{% translate "Localisation & Carte" %}</span>
{% if level_specific_mode %} {% if center_lat and center_lon %}
<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> <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="
{% else %} target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-2">
<span class="fw-bold text-dark"><i class="bi bi-geo-alt text-primary me-2"></i>{% translate "Localisation & Plans" %}</span> <img src="{% static 'common/google-street-view-logo.png' %}" alt="Street View" height="14" class="me-1">
{% endif %} Street View
</div> </a>
{% 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">
<!-- 1. VUE PLANS D'ÉTAGE --> <div id="building-map" style="height: 340px; width: 100%; border-radius: 0 0 4px 4px;"></div>
<div id="section-plans" class="card-body p-0"> </div>
{% if not level_specific_mode and floors_data %} <div class="card-footer bg-white p-3">
<!-- Sélecteur d'étage horizontal --> <p class="mb-2 small">
<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;"> <strong>{% translate "Adresse" %} :</strong> {{ building.address|default:_("Non renseignée") }}
<span class="text-muted small fw-semibold me-2"><i class="bi bi-filter me-1"></i>{% translate "Niveaux" %} :</span> </p>
{% for f in floors_data %} {% if center_lat and center_lon %}
<button type="button" <div class="small text-muted font-monospace">
class="btn btn-sm floor-nav-btn {% if f.id == active_floor.id %}btn-primary text-white shadow-sm{% else %}btn-outline-secondary{% endif %}" WGS84: {{ center_lat|floatformat:6 }}, {{ center_lon|floatformat:6 }}
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 les plans et la carte) --> <!-- Card Documents associés (placée sous la localisation) -->
<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>
@ -437,30 +258,15 @@
<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 p-2 text-center position-relative"> <div class="card h-100 border text-center p-2">
{% if doc.document_type == 'floor_plan' %} {% if doc.document.file %}
<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>
{% endif %} <div class="small text-truncate w-100 fw-bold" title="{{ doc.document.file.name }}">{{ doc.document.file.name }}</div>
<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>
@ -493,13 +299,11 @@
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th>{% translate "Code / Nom" %}</th> <th>{% translate "Code / Nom" %}</th>
<th>{% translate "Lot / Type d'asset" %}</th> <th>{% translate "Type d'asset" %}</th>
<th>{% translate "Caractéristiques & Spécifications" %}</th>
<th>{% translate "Localisation spécifique" %}</th> <th>{% translate "Localisation spécifique" %}</th>
<th>{% translate "Asset Parent" %}</th> <th>{% translate "Asset Parent" %}</th>
<th>{% translate "Statut" %}</th> <th>{% translate "Statut" %}</th>
<th>{% translate "Prochain contrôle" %}</th> <th>{% translate "Prochain contrôle" %}</th>
<th>{% translate "Maintenance & Prestataire" %}</th>
<th class="text-end pe-3">{% translate "Actions" %}</th> <th class="text-end pe-3">{% translate "Actions" %}</th>
</tr> </tr>
</thead> </thead>
@ -507,39 +311,18 @@
{% for eq in equipments %} {% for eq in equipments %}
<tr> <tr>
<td> <td>
<a href="{{ eq.get_absolute_url }}" class="fw-bold text-decoration-none" style="color: {{ eq.theme_color }};"> <a href="{{ eq.get_absolute_url }}" class="fw-bold text-decoration-none">{{ eq.code }}</a>
<i class="bi {{ eq.get_icon_class }} me-1" style="color: {{ eq.theme_icon_color }};"></i>{{ eq.code }}
</a>
<div class="small text-muted">{{ eq.get_name }}</div> <div class="small text-muted">{{ eq.get_name }}</div>
</td> </td>
<td> <td>
<span class="badge bg-light text-muted border me-1">{{ eq.get_macro_lot }}</span> <span class="badge bg-info-subtle text-info border">
<span class="badge {{ eq.theme_badge_class }} d-block mt-1" style="{{ eq.theme_badge_style }}">
{{ eq.get_asset_type_display }} {{ eq.get_asset_type_display }}
</span> </span>
</td> </td>
<td>
{% if eq.dimensions or eq.surface_m2 %}
<div class="small fw-semibold">{% if eq.dimensions %}{{ eq.dimensions }}{% endif %}{% if eq.surface_m2 %} ({{ eq.surface_m2 }} m²){% endif %}</div>
{% endif %}
{% if eq.power_kw or eq.energy_source %}
<div class="small text-muted">
{% if eq.power_kw %}<i class="bi bi-lightning-charge me-1"></i>{{ eq.power_kw }} kW{% endif %}
{% if eq.energy_source %}<span class="badge bg-warning-subtle text-dark border ms-1">{{ eq.get_energy_source_display }}</span>{% endif %}
</div>
{% endif %}
{% if eq.material or eq.fire_rating or eq.glazing_type %}
<div class="small text-muted">
{% if eq.material %}{{ eq.material }}{% endif %}
{% if eq.glazing_type %} - {{ eq.get_glazing_type_display }}{% endif %}
{% if eq.fire_rating %}<span class="badge bg-danger-subtle text-danger border ms-1">{{ eq.fire_rating }}</span>{% endif %}
</div>
{% endif %}
</td>
<td> <td>
{% if eq.location %} {% if eq.location %}
<a href="{{ eq.location.get_absolute_url }}" class="small text-dark text-decoration-none"> <a href="{{ eq.location.get_absolute_url }}" class="small text-dark text-decoration-none">
<i class="bi {{ eq.location.get_icon_class }} me-1"></i>{{ eq.location.code }} - {{ eq.location.get_name }} <i class="bi bi-geo-alt me-1"></i>{{ eq.location.code }} - {{ eq.location.get_name }}
</a> </a>
{% else %} {% else %}
<span class="small text-muted">{{ eq.building.code }}</span> <span class="small text-muted">{{ eq.building.code }}</span>
@ -548,7 +331,7 @@
<td> <td>
{% if eq.parent_asset %} {% if eq.parent_asset %}
<a href="{{ eq.parent_asset.get_absolute_url }}" class="small text-decoration-none"> <a href="{{ eq.parent_asset.get_absolute_url }}" class="small text-decoration-none">
<i class="bi {{ eq.parent_asset.get_icon_class }} me-1"></i>{{ eq.parent_asset.code }} <i class="bi bi-diagram-2 me-1"></i>{{ eq.parent_asset.code }}
</a> </a>
{% else %} {% else %}
<span class="text-muted small">-</span> <span class="text-muted small">-</span>
@ -560,16 +343,6 @@
</span> </span>
</td> </td>
<td>{{ eq.next_control_date|date:"d/m/Y"|default:"-" }}</td> <td>{{ eq.next_control_date|date:"d/m/Y"|default:"-" }}</td>
<td>
{% if eq.contractor_name %}
<div class="small fw-semibold"><i class="bi bi-building me-1"></i>{{ eq.contractor_name }}</div>
{% endif %}
{% if eq.maintenance_contract_ref %}
<div class="small text-muted font-monospace">Réf: {{ eq.maintenance_contract_ref }}</div>
{% elif not eq.contractor_name %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td class="text-end pe-3"> <td class="text-end pe-3">
<a href="{{ eq.get_absolute_url }}" class="btn btn-sm btn-outline-info py-0 px-2 small text-nowrap" title="{% translate 'Détail de l’asset' %}"> <a href="{{ eq.get_absolute_url }}" class="btn btn-sm btn-outline-info py-0 px-2 small text-nowrap" title="{% translate 'Détail de l’asset' %}">
<i class="bi bi-eye me-1"></i>{% translate "Détail" %} <i class="bi bi-eye me-1"></i>{% translate "Détail" %}
@ -578,7 +351,7 @@
</tr> </tr>
{% empty %} {% empty %}
<tr> <tr>
<td colspan="9" class="text-center text-muted py-4">{% translate "Aucun asset ou équipement technique enregistré." %}</td> <td colspan="7" class="text-center text-muted py-4">{% translate "Aucun asset ou équipement technique enregistré." %}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@ -641,351 +414,87 @@
{% 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_json|safe }} || 4.3572; const centerLon = {{ center_lon|default:4.3572 }};
const centerLat = {{ center_lat_json|safe }} || 50.8477; const centerLat = {{ center_lat|default: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 || map) return; if (!container) 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 initMap();
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) { const overviewTab = document.getElementById('overview-tab');
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');
} }
}); });
} }
}); });
// Gestion du repliage / dépliage global de l'arborescence
const treeCard = document.getElementById("card-hierarchy-tree");
if (treeCard) {
const collapseAllBtn = document.getElementById("btn-tree-collapse-all");
const expandAllBtn = document.getElementById("btn-tree-expand-all");
if (collapseAllBtn) {
collapseAllBtn.addEventListener("click", function() {
// Replier tous les nœuds enfants de l'arborescence (localisations et assets)
treeCard.querySelectorAll(".tree-collapse-node").forEach(function(el) {
if (window.bootstrap && bootstrap.Collapse) {
const bsCollapse = bootstrap.Collapse.getOrCreateInstance(el, { toggle: false });
bsCollapse.hide();
} else {
el.classList.remove("show");
}
});
// Mettre à jour l'état visuel de tous les boutons chevrons
treeCard.querySelectorAll(".tree-toggle-btn").forEach(function(btn) {
if (btn.getAttribute("data-bs-target") !== "#tree-root-content") {
btn.classList.add("collapsed");
btn.setAttribute("aria-expanded", "false");
const icon = btn.querySelector("i");
if (icon) {
icon.classList.remove("bi-chevron-down");
icon.classList.add("bi-chevron-right");
}
}
});
});
}
if (expandAllBtn) {
expandAllBtn.addEventListener("click", function() {
// S'assurer que le conteneur racine est bien déplié
const rootContent = document.getElementById("tree-root-content");
if (rootContent) {
if (window.bootstrap && bootstrap.Collapse) {
bootstrap.Collapse.getOrCreateInstance(rootContent, { toggle: false }).show();
} else {
rootContent.classList.add("show");
}
}
// Déplier tous les nœuds enfants (localisations et assets)
treeCard.querySelectorAll(".tree-collapse-node").forEach(function(el) {
if (window.bootstrap && bootstrap.Collapse) {
const bsCollapse = bootstrap.Collapse.getOrCreateInstance(el, { toggle: false });
bsCollapse.show();
} else {
el.classList.add("show");
}
});
// Mettre à jour l'état visuel de tous les boutons chevrons
treeCard.querySelectorAll(".tree-toggle-btn").forEach(function(btn) {
btn.classList.remove("collapsed");
btn.setAttribute("aria-expanded", "true");
const icon = btn.querySelector("i");
if (icon) {
icon.classList.remove("bi-chevron-right");
icon.classList.add("bi-chevron-down");
}
});
});
}
}
}); });
</script> </script>
{% endblock script %} {% endblock script %}

View file

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

View file

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

View file

@ -15,41 +15,52 @@
<i class="bi bi-chevron-down small"></i> <i class="bi bi-chevron-down small"></i>
</button> </button>
{% else %} {% else %}
<span class="d-inline-block" style="width: 1.35rem;"></span> <span class="px-2"></span>
{% endif %} {% endif %}
<i class="bi {{ child_node.loc.get_icon_class }} text-primary"></i> {% if child_node.loc.location_type == 'building' or child_node.loc.location_type == 'site' %}
<i class="bi bi-building text-primary"></i>
{% elif child_node.loc.location_type == 'level' %}
<i class="bi bi-layers text-secondary"></i>
{% elif child_node.loc.location_type == 'room' %}
<i class="bi bi-door-open text-info"></i>
{% else %}
<i class="bi bi-geo-alt text-dark"></i>
{% endif %}
<!-- Code en bleu pour indiquer clairement un lien cliquable --> <!-- Code en bleu pour indiquer clairement un lien cliquable -->
<a href="{{ child_node.loc.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline small"> <a href="{{ child_node.loc.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline small">
{{ child_node.loc.code }} {{ child_node.loc.code }}
</a> </a>
{% if child_node.loc.room_number %}
<span class="badge bg-secondary-subtle text-secondary border small font-monospace"><i class="bi bi-tag me-1"></i>{{ child_node.loc.room_number }}</span>
{% endif %}
<span class="small text-muted">- {{ child_node.loc.get_name }}</span> <span class="small text-muted">- {{ child_node.loc.get_name }}</span>
<span class="badge bg-primary-subtle text-primary border small">{{ child_node.loc.get_location_type_display }}</span> <span class="badge bg-primary-subtle text-primary border small">{{ child_node.loc.get_location_type_display }}</span>
{% if child_node.assets %} {% if child_node.assets %}
<span class="badge bg-info-subtle text-info border small" title="{% translate 'Assets physiques rattachés' %}"> <button class="btn btn-sm btn-outline-info py-0 px-2 small text-nowrap"
<i class="bi bi-tools me-1"></i>{{ child_node.assets|length }} type="button"
</span> data-bs-toggle="collapse"
data-bs-target="#tree-assets-loc-{{ child_node.loc.id }}"
aria-expanded="false">
<i class="bi bi-tools me-1"></i>Assets ({{ child_node.assets|length }})
</button>
{% endif %} {% endif %}
</div> </div>
</div> </div>
<!-- Collapsible Content (Direct Assets & Child Locations) --> <!-- Collapsible Assets for this location (NON VISIBLES PAR DÉFAUT) -->
{% if child_node.children or child_node.assets %} {% if child_node.assets %}
<div id="tree-loc-{{ child_node.loc.id }}" class="collapse show tree-collapse-node"> <div id="tree-assets-loc-{{ child_node.loc.id }}" class="collapse mt-2" style="padding-left: calc({{ depth|default:1 }} * 1.25rem);">
<!-- 1. Assets directement rattachés à cette localisation --> <div class="card card-body bg-light border-info-subtle p-2 mb-2 me-3">
{% if child_node.assets %} <div class="fw-bold small text-info mb-1"><i class="bi bi-tools me-1"></i>{% translate "Assets rattachés à" %} {{ child_node.loc.code }} :</div>
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=child_node.assets depth=depth|default:1|add:1 only %} {% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=child_node.assets depth=depth|default:1|add:1 only %}
{% endif %} </div>
</div>
{% endif %}
<!-- 2. Sous-localisations --> <!-- Collapsible Child Locations -->
{% if child_node.children %} {% if child_node.children %}
{% include "assets/buildings/publicbuilding_location_tree.html" with node=child_node depth=depth|default:1|add:1 only %} <div id="tree-loc-{{ child_node.loc.id }}" class="collapse show">
{% endif %} {% include "assets/buildings/publicbuilding_location_tree.html" with node=child_node depth=depth|default:1|add:1 only %}
</div> </div>
{% endif %} {% endif %}
</li> </li>

View file

@ -153,7 +153,7 @@
{% endif %} {% endif %}
</ul> </ul>
{% if insp.notes and not insp.is_giep %} {% if insp.notes %}
<div class="mb-3"> <div class="mb-3">
<label class="form-label text-muted small fw-semibold"><i class="bi bi-journal-text me-1"></i>{% translate "Remarques / Observations" %}:</label> <label class="form-label text-muted small fw-semibold"><i class="bi bi-journal-text me-1"></i>{% translate "Remarques / Observations" %}:</label>
<div class="p-2 bg-light rounded border text-secondary small"> <div class="p-2 bg-light rounded border text-secondary small">
@ -162,7 +162,7 @@
</div> </div>
{% endif %} {% endif %}
{% if insp.documents.all and not insp.is_giep %} {% if insp.documents.all %}
<div> <div>
<label class="form-label text-muted small fw-semibold mb-2"><i class="bi bi-camera me-1"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }}):</label> <label class="form-label text-muted small fw-semibold mb-2"><i class="bi bi-camera me-1"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }}):</label>
<div class="d-flex flex-wrap gap-2 align-items-center"> <div class="d-flex flex-wrap gap-2 align-items-center">

View file

@ -85,9 +85,7 @@
{% endif %} {% endif %}
</td> </td>
<td class="pe-3"> <td class="pe-3">
<small class="text-muted" title="{% if intervention.end_time %}{% translate 'Date de réalisation' %}{% elif intervention.expected_end_time %}{% translate 'Échéance prévisionnelle' %}{% elif intervention.expected_begin_time %}{% translate 'Date de début prévisionnelle' %}{% else %}{% translate 'Date de création' %}{% endif %}"> <small class="text-muted">{{ intervention.creation_time|date:"d/m/Y" }}</small>
{{ intervention.end_time|default:intervention.expected_end_time|default:intervention.expected_begin_time|default:intervention.creation_time|date:"d/m/Y" }}
</small>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -168,7 +166,7 @@
<div class="mb-3"> <div class="mb-3">
<label class="form-label fw-semibold">{% translate "Description / Remarques" %}</label> <label class="form-label fw-semibold">{% translate "Description / Remarques" %}</label>
<textarea name="init_description" class="form-control" rows="3" placeholder="{% translate 'Détails ou remarques globales...' %}"></textarea> <textarea name="init_description" class="form-control" rows="3" placeholder="{% translate 'Détails ou remarques initiales...' %}"></textarea>
</div> </div>
</div> </div>
<div class="modal-footer bg-light"> <div class="modal-footer bg-light">

View file

@ -37,7 +37,7 @@
<div id="quickInspectionAlert" class="alert alert-danger d-none mb-3"></div> <div id="quickInspectionAlert" class="alert alert-danger d-none mb-3"></div>
<!-- Résultat de contrôle avec boutons colorés --> <!-- Résultat de contrôle avec boutons colorés -->
<div class="mb-3" id="quickInspResultSection"> <div class="mb-3">
<label class="form-label fw-semibold d-block">{% translate "Résultat du contrôle" %} <span class="text-danger">*</span></label> <label class="form-label fw-semibold d-block">{% translate "Résultat du contrôle" %} <span class="text-danger">*</span></label>
<div class="d-flex flex-column gap-2" role="group" aria-label="{% translate 'Résultat' %}"> <div class="d-flex flex-column gap-2" role="group" aria-label="{% translate 'Résultat' %}">
<div class="row g-2"> <div class="row g-2">
@ -83,7 +83,7 @@
</div> </div>
<!-- Section Photos (Mode continu & Fichiers) --> <!-- Section Photos (Mode continu & Fichiers) -->
<div class="card border border-success-subtle mb-3" style="background-color: #e8f6f3;" id="quickInspPhotosSection"> <div class="card border border-success-subtle mb-3" style="background-color: #e8f6f3;">
<div class="card-header bg-transparent border-0 pb-0 pt-2 px-3"> <div class="card-header bg-transparent border-0 pb-0 pt-2 px-3">
<span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers" %}</span> <span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers" %}</span>
</div> </div>
@ -124,7 +124,7 @@
</div> </div>
<!-- Remarques --> <!-- Remarques -->
<div class="mb-3" id="quickInspNotesSection"> <div class="mb-3">
<label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label> <label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label>
<textarea name="notes" id="insp_notes" class="form-control" rows="2" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea> <textarea name="notes" id="insp_notes" class="form-control" rows="2" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea>
</div> </div>

View file

@ -534,19 +534,6 @@
</div> </div>
</td> </td>
</tr> </tr>
<!-- Note générale -->
<tr class="asset-editable-field" data-field="note" data-can-edit="{% if 'note' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Note générale" %}</th>
<td>
<div class="field-display">
<span class="field-value text-muted">{{ asset.note|default:"-"|linebreaksbr }}</span>
</div>
<div class="field-edit d-none">
<textarea class="form-control form-control-sm" rows="3">{{ asset.note|default:'' }}</textarea>
</div>
</td>
</tr>
<!-- Champs spécifiques au type d'asset --> <!-- Champs spécifiques au type d'asset -->
{% get_asset_specific_fields asset as asset_specific_fields %} {% get_asset_specific_fields asset as asset_specific_fields %}
{% if asset_specific_fields %} {% if asset_specific_fields %}
@ -1852,11 +1839,11 @@
{% block script %} {% block script %}
<!-- MapLibre GL 5.7.0 --> <!-- MapLibre GL 5.7.0 -->
<script src="{% static 'common/maplibre-gl.js' %}"></script> <script src="{% static 'common/maplibre-gl.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}?v=20261010_order"></script> <script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'assets/asset_cms_edit_mode.js' %}"></script> <script src="{% static 'assets/asset_cms_edit_mode.js' %}"></script>
<script src="{% static 'assets/asset_location_edit.js' %}"></script> <script src="{% static 'assets/asset_location_edit.js' %}"></script>
<!-- MapLibre map initialization for asset detail --> <!-- MapLibre map initialization for asset detail -->
<script src="{% static 'assets/asset_generic_map.js' %}?v=20261010_order"></script> <script src="{% static 'assets/asset_generic_map.js' %}"></script>
<!-- MapboxDraw for geometry editing (Point, LineString, Polygon) --> <!-- MapboxDraw for geometry editing (Point, LineString, Polygon) -->
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script> <script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script> <script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script>
@ -1865,11 +1852,11 @@
{% block extra_scripts %} {% block extra_scripts %}
<!-- MapLibre GL 5.7.0 --> <!-- MapLibre GL 5.7.0 -->
<script src="{% static 'common/maplibre-gl.js' %}"></script> <script src="{% static 'common/maplibre-gl.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}?v=20261010_order"></script> <script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'assets/asset_cms_edit_mode.js' %}"></script> <script src="{% static 'assets/asset_cms_edit_mode.js' %}"></script>
<script src="{% static 'assets/asset_location_edit.js' %}"></script> <script src="{% static 'assets/asset_location_edit.js' %}"></script>
<!-- MapLibre map initialization for asset detail --> <!-- MapLibre map initialization for asset detail -->
<script src="{% static 'assets/asset_generic_map.js' %}?v=20261010_order"></script> <script src="{% static 'assets/asset_generic_map.js' %}"></script>
<!-- MapboxDraw for geometry editing (Point, LineString, Polygon) --> <!-- MapboxDraw for geometry editing (Point, LineString, Polygon) -->
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script> <script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script> <script src="{% static 'assets/asset_geometry_edit.js' %}?v=1.1"></script>

View file

@ -111,11 +111,6 @@
id: 'assets', id: 'assets',
type: 'circle', type: 'circle',
source: 'assets', source: 'assets',
filter: [
'any',
['==', ['geometry-type'], 'Point'],
['==', ['geometry-type'], 'MultiPoint']
],
paint: { paint: {
'circle-radius': [ 'circle-radius': [
'interpolate', 'interpolate',
@ -140,32 +135,7 @@
minzoom: 8 minzoom: 8
}; };
const assets_lines_layer_def = { const assets_layers_defs = [assets_layer_def];
id: 'assets-lines',
type: 'line',
source: 'assets',
filter: [
'any',
['==', ['geometry-type'], 'LineString'],
['==', ['geometry-type'], 'MultiLineString']
],
paint: {
'line-color': [
'case',
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
'#0022ff'
],
'line-width': [
'case',
['boolean', ['feature-state', 'highlighted'], false], 6,
4
],
'line-opacity': 0.85
},
minzoom: 8
};
const assets_layers_defs = [assets_lines_layer_def, assets_layer_def];
// Filtrer les modèles par catégorie dans le formulaire des assets // Filtrer les modèles par catégorie dans le formulaire des assets

View file

@ -1,38 +0,0 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% translate "Supprimer la zone de gestion" %} {{ zone.code }} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container py-5" style="max-width: 600px;">
<div class="card shadow border-danger">
<div class="card-header bg-danger text-white py-3">
<h5 class="card-title mb-0">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% translate "Confirmer la suppression" %}
</h5>
</div>
<div class="card-body p-4">
<p>{% translate "Êtes-vous certain de vouloir supprimer définitivement la zone de gestion suivante ?" %}</p>
<div class="alert alert-light border">
<h6 class="fw-bold mb-1">{{ zone.code }} - {{ zone.get_name }}</h6>
<div class="small text-muted">{{ zone.thematic.name }} {% if zone.zone_type %}| {{ zone.zone_type.get_name }}{% endif %}</div>
</div>
<p class="text-danger small">
<i class="bi bi-info-circle me-1"></i>{% translate "Cette action est irréversible. Les documents et associations liés à cette zone seront supprimés." %}
</p>
<form method="post" action="{% url 'assets:management_zone_delete' zone.pk %}">
{% csrf_token %}
<div class="d-flex justify-content-end gap-2 mt-4">
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-secondary">
{% translate "Annuler" %}
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer définitivement" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}

View file

@ -1,434 +0,0 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{{ zone.code }} - {{ zone.get_name }} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="/">{% translate "Accueil" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}">{% translate "Assets" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:management_zones_index' %}?thematic={{ zone.thematic.code }}">{% translate "Zones de gestion" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ zone.code }}</li>
</ol>
</nav>
<div class="d-flex gap-2">
<a href="{% url 'assets:management_zones_index' %}?thematic={{ zone.thematic.code }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la liste" %}
</a>
{% if can_edit %}
<a href="{% url 'assets:management_zone_edit' zone.pk %}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier" %}
</a>
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteZoneModal">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer" %}
</button>
{% endif %}
</div>
</div>
<!-- Zone Banner Card -->
<div class="card shadow-sm mb-4 border-start border-4" style="border-left-color: {{ zone.get_color }} !important;">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<div class="d-flex align-items-center gap-2 mb-2">
<span style="display:inline-block; width: 16px; height: 16px; border-radius: 4px; background-color: {{ zone.get_color }}; border: 1px solid rgba(0,0,0,0.25);"></span>
<h1 class="h4 mb-0 fw-bold">{{ zone.code }} - {{ zone.get_name }}</h1>
</div>
{% if zone.name_nl and zone.name_nl != zone.name_fr %}
<div class="text-muted small mb-2 fst-italic">{{ zone.name_nl }}</div>
{% endif %}
<div class="d-flex gap-2 flex-wrap">
<span class="badge bg-secondary-subtle text-secondary border">
<i class="bi bi-tag me-1"></i>{{ zone.thematic.name }}
</span>
{% if zone.zone_type %}
<span class="badge" style="background-color: {{ zone.zone_type.default_color }}; color: #fff;">
{{ zone.zone_type.get_name }}
</span>
{% endif %}
{% if zone.is_active %}
<span class="badge bg-success-subtle text-success border">
<i class="bi bi-check-circle me-1"></i>{% translate "Active" %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border">
<i class="bi bi-dash-circle me-1"></i>{% translate "Inactive" %}
</span>
{% endif %}
{% if area_ha %}
<span class="badge bg-light text-dark border">
<i class="bi bi-rulers me-1"></i>{{ area_ha }} ha ({{ area_m2|floatformat:0 }} m²)
</span>
{% endif %}
</div>
</div>
<div class="col-md-4 text-md-end mt-3 mt-md-0">
{% if zone.target_frequency_days %}
<div class="d-inline-block text-center me-3 p-2 bg-light rounded border">
<span class="d-block text-muted small">{% translate "Fréquence cible" %}</span>
<span class="fs-5 fw-bold text-dark">{{ zone.target_frequency_days }} {% translate "j" %}</span>
</div>
{% endif %}
{% if zone.seasonality_notes %}
<div class="d-inline-block text-start p-2 bg-light rounded border">
<span class="d-block text-muted small">{% translate "Période" %}</span>
<span class="small fw-semibold text-dark">{{ zone.seasonality_notes }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Consignes et Points d'attention (Callout Banner) -->
{% if zone.instructions %}
<div class="card shadow-sm mb-4 border-warning border-start border-4">
<div class="card-body bg-warning bg-opacity-10">
<div class="d-flex align-items-start gap-2">
<i class="bi bi-exclamation-triangle-fill text-warning fs-4 mt-1"></i>
<div>
<h5 class="h6 fw-bold text-dark mb-1">{% translate "Consignes d'exploitation et points de vigilance" %}</h5>
<div class="text-dark" style="white-space: pre-line;">{{ zone.instructions }}</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Main Grid: Informations & Carte -->
<div class="row g-4 mb-4">
<!-- Informations & Modalités -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold">
<i class="bi bi-info-circle me-2 text-success"></i>{% translate "Détails & Caractéristiques" %}
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-muted">{% translate "Description" %}</dt>
<dd class="col-sm-8">{{ zone.description|default:_("Aucune description renseignée")|linebreaks }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Thématique" %}</dt>
<dd class="col-sm-8">{{ zone.thematic.name }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Type de zone" %}</dt>
<dd class="col-sm-8">{{ zone.zone_type.get_name|default:"-" }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Couleur de repérage" %}</dt>
<dd class="col-sm-8">
<div class="d-flex align-items-center gap-2">
<span style="display:inline-block; width:18px; height:18px; border-radius:3px; background-color: {{ zone.get_color }}; border: 1px solid rgba(0,0,0,0.25);"></span>
<code>{{ zone.get_color }}</code>
</div>
</dd>
<dt class="col-sm-4 text-muted">{% translate "Superficie estimée" %}</dt>
<dd class="col-sm-8">
{% if area_m2 %}
<strong>{{ area_ha }} hectares</strong> ({{ area_m2|floatformat:0 }} m²)
{% else %}
<span class="text-muted">{% translate "Périmètre non défini" %}</span>
{% endif %}
</dd>
<dt class="col-sm-4 text-muted">{% translate "Créé par" %}</dt>
<dd class="col-sm-8">{% if zone.created_by %}{{ zone.created_by.get_full_name|default:zone.created_by.username }}{% else %}-{% endif %}</dd>
<dt class="col-sm-4 text-muted">{% translate "Date de création" %}</dt>
<dd class="col-sm-8">{{ zone.created_at|date:"d/m/Y H:i" }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Dernière mise à jour" %}</dt>
<dd class="col-sm-8">{{ zone.updated_at|date:"d/m/Y H:i" }}</dd>
</dl>
</div>
</div>
</div>
<!-- Carte du périmètre géographique -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold">
<i class="bi bi-map me-2 text-success"></i>{% translate "Périmètre géographique" %}
</span>
{% if zone.geom %}
<span class="badge bg-success">{% translate "Géométrie définie" %}</span>
{% else %}
<span class="badge bg-secondary">{% translate "Aucun périmètre" %}</span>
{% endif %}
</div>
<div class="card-body p-0 position-relative">
<div id="detailZoneMap" style="height: 380px; width: 100%;"></div>
</div>
</div>
</div>
</div>
<!-- Documents & Arrêtés -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold">
<i class="bi bi-files me-2 text-success"></i>{% translate "Documents & Fiches techniques" %} ({{ documents.count }})
</span>
{% if can_edit %}
<button type="button" class="btn btn-sm btn-outline-success" data-bs-toggle="modal" data-bs-target="#uploadDocModal">
<i class="bi bi-upload me-1"></i>{% translate "Ajouter des documents" %}
</button>
{% endif %}
</div>
<div class="card-body p-0">
{% if documents %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase text-muted">
<tr>
<th>{% translate "Fichier" %}</th>
<th>{% translate "Date d'ajout" %}</th>
<th>{% translate "Ajouté par" %}</th>
<th class="text-end" style="width: 140px;">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<i class="bi bi-file-earmark-text text-primary fs-5"></i>
<div>
<a href="{{ doc.file.url }}" target="_blank" class="fw-semibold text-decoration-none">
{{ doc.file.name|slice:"25:"|default:doc.file.name }}
</a>
</div>
</div>
</td>
<td>{{ doc.upload_date|date:"d/m/Y H:i" }}</td>
<td>{% if doc.uploaded_by %}{{ doc.uploaded_by.get_full_name|default:doc.uploaded_by.username }}{% else %}-{% endif %}</td>
<td class="text-end">
<a href="{{ doc.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="{% translate 'Télécharger' %}">
<i class="bi bi-download"></i>
</a>
{% if can_edit %}
<form method="post" action="{% url 'assets:management_zone_delete_document' zone.pk doc.pk %}" class="d-inline" onsubmit="return confirm('{% translate 'Supprimer ce document ?' %}');">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted small">
<i class="bi bi-folder2-open fs-2 d-block mb-2 text-muted"></i>
{% translate "Aucun document n'a été rattaché à cette zone de gestion." %}
</div>
{% endif %}
</div>
</div>
<!-- Équipements rattachés (Assets explicites) -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<span class="fw-bold">
<i class="bi bi-layers me-2 text-success"></i>{% translate "Équipements rattachés explicitement" %} ({{ explicit_assets.count }})
</span>
</div>
<div class="card-body p-0">
{% if explicit_assets %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase text-muted">
<tr>
<th>{% translate "Équipement" %}</th>
<th>{% translate "Type" %}</th>
<th>{% translate "Statut de rattachement" %}</th>
<th>{% translate "Notes" %}</th>
</tr>
</thead>
<tbody>
{% for ea in explicit_assets %}
<tr>
<td>
<strong>{{ ea.asset|default:ea.object_id }}</strong>
</td>
<td>{{ ea.content_type.name }}</td>
<td>
{% if ea.is_excluded %}
<span class="badge bg-danger-subtle text-danger border">
<i class="bi bi-slash-circle me-1"></i>{% translate "Exclu de la zone" %}
</span>
{% else %}
<span class="badge bg-success-subtle text-success border">
<i class="bi bi-check-circle me-1"></i>{% translate "Inclus dans la zone" %}
</span>
{% endif %}
</td>
<td class="text-muted small">{{ ea.notes|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted small">
<p class="mb-0">{% translate "Tous les équipements situés dans le périmètre géographique de la zone y sont automatiquement associés par intersection spatiale." %}</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Modal: Upload Documents -->
{% if can_edit and doc_form %}
<div class="modal fade" id="uploadDocModal" tabindex="-1" aria-labelledby="uploadDocModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'assets:management_zone_add_document' zone.pk %}" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="uploadDocModalLabel">
<i class="bi bi-upload me-2 text-success"></i>{% translate "Ajouter des documents" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="{{ doc_form.documents.id_for_label }}" class="form-label fw-semibold">
{% translate "Sélectionner des fichiers" %}
</label>
{{ doc_form.documents }}
<div class="form-text small">
{% translate "Formats acceptés : PDF, Word, Excel, images, etc. Vous pouvez sélectionner plusieurs fichiers simultanément." %}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-success">
<i class="bi bi-check-lg me-1"></i>{% translate "Téléverser" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Modal: Delete Zone Confirmation -->
{% if can_edit %}
<div class="modal fade" id="deleteZoneModal" tabindex="-1" aria-labelledby="deleteZoneModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'assets:management_zone_delete' zone.pk %}">
{% csrf_token %}
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteZoneModalLabel">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% translate "Confirmer la suppression" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{% translate "Êtes-vous certain de vouloir supprimer la zone de gestion suivante ?" %}</p>
<div class="alert alert-light border">
<strong>{{ zone.code }}</strong> - {{ zone.get_name }}<br>
<small class="text-muted">{{ zone.thematic.name }} | {{ zone.zone_type.get_name|default:"-" }}</small>
</div>
<p class="text-danger small mb-0">
<i class="bi bi-info-circle me-1"></i>{% translate "Cette action est irréversible. Les documents et historiques liés seront supprimés." %}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer définitivement" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock content %}
{% block script %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const mapDiv = document.getElementById("detailZoneMap");
if (!mapDiv || typeof maplibregl === "undefined") return;
const zoneColor = "{{ zone.get_color }}";
const geojsonGeometry = {% if zone.geojson_geometry %}{{ zone.geojson_geometry|safe }}{% else %}null{% endif %};
const map = new maplibregl.Map({
container: mapDiv,
style: window.MAP_STYLE_LIGHT,
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
zoom: 12
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.on('load', function () {
if (!geojsonGeometry) {
return;
}
const feature = {
type: "Feature",
geometry: geojsonGeometry
};
map.addSource("detail-zone-source", {
type: "geojson",
data: feature
});
map.addLayer({
id: "detail-zone-fill",
type: "fill",
source: "detail-zone-source",
paint: {
"fill-color": zoneColor,
"fill-opacity": 0.30
}
});
map.addLayer({
id: "detail-zone-line",
type: "line",
source: "detail-zone-source",
paint: {
"line-color": zoneColor,
"line-width": 3,
"line-dasharray": [3, 1.5]
}
});
// Fit bounds to polygon
const bounds = new maplibregl.LngLatBounds();
const coords = geojsonGeometry.coordinates;
const addCoords = (arr) => {
if (typeof arr[0] === 'number') {
bounds.extend(arr);
} else {
arr.forEach(addCoords);
}
};
addCoords(coords);
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 45, maxZoom: 16 });
}
});
});
</script>
{% endblock script %}

View file

@ -1,568 +0,0 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{{ title }} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="/">{% translate "Accueil" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}">{% translate "Assets" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:management_zones_index' %}">{% translate "Zones de gestion" %}</a></li>
{% if not is_create and zone %}
<li class="breadcrumb-item"><a href="{% url 'assets:management_zone_detail' zone.pk %}">{{ zone.code }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% translate "Modifier" %}</li>
{% else %}
<li class="breadcrumb-item active" aria-current="page">{% translate "Nouvelle zone" %}</li>
{% endif %}
</ol>
</nav>
<div>
{% if not is_create and zone %}
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Annuler" %}
</a>
{% else %}
<a href="{% url 'assets:management_zones_index' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Annuler" %}
</a>
{% endif %}
</div>
</div>
<!-- Title -->
<div class="mb-4">
<h1 class="h3 mb-1">
{% if is_create %}
<i class="bi bi-plus-circle-fill text-success me-2"></i>{% translate "Créer une zone de gestion" %}
{% else %}
<i class="bi bi-pencil-square text-success me-2"></i>{% translate "Modifier la zone de gestion" %} {{ zone.code }}
{% endif %}
</h1>
<p class="text-muted mb-0">
{% translate "Définissez les paramètres d'entretien, les consignes et le périmètre géographique de la zone." %}
</p>
</div>
{% if form.errors %}
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>{% translate "Certains champs contiennent des erreurs." %}</strong>
{% translate "Veuillez vérifier les informations ci-dessous." %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<form method="post" action="" class="needs-validation" id="managementZoneForm" novalidate>
{% csrf_token %}
{{ form.geom_geojson }}
<div class="row g-4">
<!-- Left Column: Form Fields -->
<div class="col-lg-6">
<!-- Informations générales -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-info-circle me-2 text-success"></i>{% translate "Informations générales" %}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-5">
<label for="{{ form.code.id_for_label }}" class="form-label fw-semibold">
{% translate "Code de la zone" %} <span class="text-danger">*</span>
</label>
{{ form.code }}
{% if form.code.errors %}<div class="text-danger small mt-1">{{ form.code.errors.0 }}</div>{% endif %}
<div class="form-text small">{% translate "Identifiant unique (ex: ZG-NAT-001)" %}</div>
</div>
<div class="col-md-7">
<label for="{{ form.thematic.id_for_label }}" class="form-label fw-semibold">
{% translate "Thématique" %} <span class="text-danger">*</span>
</label>
{{ form.thematic }}
{% if form.thematic.errors %}<div class="text-danger small mt-1">{{ form.thematic.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.name_fr.id_for_label }}" class="form-label fw-semibold">
{% translate "Nom (FR)" %} <span class="text-danger">*</span>
</label>
{{ form.name_fr }}
{% if form.name_fr.errors %}<div class="text-danger small mt-1">{{ form.name_fr.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.name_nl.id_for_label }}" class="form-label fw-semibold">
{% translate "Nom (NL)" %}
</label>
{{ form.name_nl }}
{% if form.name_nl.errors %}<div class="text-danger small mt-1">{{ form.name_nl.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-8">
<label for="{{ form.zone_type.id_for_label }}" class="form-label fw-semibold">
{% translate "Type de zone de gestion" %}
</label>
{{ form.zone_type }}
{% if form.zone_type.errors %}<div class="text-danger small mt-1">{{ form.zone_type.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.color.id_for_label }}" class="form-label fw-semibold">
{% translate "Couleur" %}
</label>
<div class="d-flex align-items-center gap-2">
{{ form.color }}
<span class="small text-muted" id="colorHexLabel"></span>
</div>
{% if form.color.errors %}<div class="text-danger small mt-1">{{ form.color.errors.0 }}</div>{% endif %}
</div>
<div class="col-12">
<div class="form-check form-switch mt-2">
{{ form.is_active }}
<label class="form-check-label fw-semibold ms-2" for="{{ form.is_active.id_for_label }}">
{% translate "Zone active (affichée sur les cartes d'exploitation)" %}
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Entretien, Calendrier & Consignes -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-calendar-check me-2 text-success"></i>{% translate "Modalités d'entretien & Consignes" %}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-5">
<label for="{{ form.target_frequency_days.id_for_label }}" class="form-label fw-semibold">
{% translate "Fréquence cible (jours)" %}
</label>
<div class="input-group">
{{ form.target_frequency_days }}
<span class="input-group-text">{% translate "jours" %}</span>
</div>
<div class="form-text small">{% translate "Intervalle indicatif entre passages" %}</div>
{% if form.target_frequency_days.errors %}<div class="text-danger small mt-1">{{ form.target_frequency_days.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-7">
<label for="{{ form.seasonality_notes.id_for_label }}" class="form-label fw-semibold">
{% translate "Saisonnalité / Calendrier" %}
</label>
{{ form.seasonality_notes }}
<div class="form-text small">{% translate "Ex: Fauche en sept; interdiction taille mars-juillet" %}</div>
{% if form.seasonality_notes.errors %}<div class="text-danger small mt-1">{{ form.seasonality_notes.errors.0 }}</div>{% endif %}
</div>
<div class="col-12">
<label for="{{ form.instructions.id_for_label }}" class="form-label fw-semibold text-warning-emphasis">
<i class="bi bi-exclamation-triangle me-1 text-warning"></i>{% translate "Consignes et points d'attention" %}
</label>
{{ form.instructions }}
<div class="form-text small">{% translate "Consignes cruciales : sécurité tramway, respect des zones humides, hauteur de coupe, etc." %}</div>
{% if form.instructions.errors %}<div class="text-danger small mt-1">{{ form.instructions.errors.0 }}</div>{% endif %}
</div>
<div class="col-12">
<label for="{{ form.description.id_for_label }}" class="form-label fw-semibold">
{% translate "Description générale" %}
</label>
{{ form.description }}
{% if form.description.errors %}<div class="text-danger small mt-1">{{ form.description.errors.0 }}</div>{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Interactive Map Polygon Drawer -->
<div class="col-lg-6">
<div class="card shadow-sm mb-4 sticky-top" style="top: 20px;">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-bounding-box-circles me-2 text-success"></i>
<span class="fw-bold">{% translate "Périmètre géographique" %}</span>
<small class="text-muted fw-normal">({% translate "polygone" %})</small>
</div>
<div class="d-flex gap-1">
<button type="button" id="polyPickerDrawBtn" class="btn btn-sm btn-outline-success">
<i class="bi bi-pentagon me-1"></i>{% translate "Dessiner" %}
</button>
<button type="button" id="polyPickerFinishBtn" class="btn btn-sm btn-success d-none">
<i class="bi bi-check-circle me-1"></i>{% translate "Terminer le polygone" %}
</button>
<button type="button" id="polyPickerClearBtn" class="btn btn-sm btn-outline-danger" title="{% translate 'Effacer le polygone' %}">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
<div class="card-body p-0 position-relative">
<div id="zonePickerMap" style="height: 480px; width: 100%;"></div>
</div>
<div class="card-footer bg-white border-top small text-muted d-flex justify-content-between align-items-center">
<span id="polyPickerHint">
{% translate "Cliquez sur « Dessiner » pour tracer les limites de la zone sur la carte." %}
</span>
<span id="polyStatusBadge" class="badge bg-secondary">
{% translate "Aucun tracé" %}
</span>
</div>
</div>
<!-- Form Submit Actions -->
<div class="d-flex justify-content-end gap-2 mb-4">
{% if not is_create and zone %}
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-secondary">
{% translate "Annuler" %}
</a>
{% else %}
<a href="{% url 'assets:management_zones_index' %}" class="btn btn-outline-secondary">
{% translate "Annuler" %}
</a>
{% endif %}
<button type="submit" class="btn btn-success px-4">
<i class="bi bi-check-lg me-1"></i>
{% if is_create %}{% translate "Créer la zone de gestion" %}{% else %}{% translate "Enregistrer les modifications" %}{% endif %}
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock content %}
{% block script %}
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const mapDiv = document.getElementById("zonePickerMap");
const geomInput = document.getElementById("id_geom_geojson");
const thematicSelect = document.getElementById("id_thematic");
const zoneTypeSelect = document.getElementById("id_zone_type");
const colorInput = document.getElementById("id_color");
const colorHexLabel = document.getElementById("colorHexLabel");
if (colorInput && colorHexLabel) {
colorHexLabel.textContent = colorInput.value;
colorInput.addEventListener("input", () => {
colorHexLabel.textContent = colorInput.value;
if (map && map.getLayer("poly-preview-fill")) {
map.setPaintProperty("poly-preview-fill", "fill-color", colorInput.value);
map.setPaintProperty("poly-preview-line", "line-color", colorInput.value);
}
});
}
// Dynamic zone types when thematic changes
if (thematicSelect && zoneTypeSelect) {
thematicSelect.addEventListener("change", async function () {
const thematicId = thematicSelect.value;
if (!thematicId) return;
try {
const resp = await fetch("{% url 'assets:management_zone_types_ajax' %}?thematic_id=" + encodeURIComponent(thematicId));
if (resp.ok) {
const data = await resp.json();
const currentVal = zoneTypeSelect.value;
zoneTypeSelect.innerHTML = '<option value="">-- {% translate "Sélectionner un type" %} --</option>';
(data.types || []).forEach(t => {
const opt = document.createElement("option");
opt.value = t.id;
opt.textContent = t.name;
if (String(t.id) === String(currentVal)) opt.selected = true;
zoneTypeSelect.appendChild(opt);
});
}
} catch (e) {
console.error("Error updating zone types:", e);
}
});
}
// Interactive Map Polygon Drawer
if (!mapDiv || typeof maplibregl === "undefined") return;
let drawing = false;
let vertices = [];
let vertexMarkers = [];
let map = null;
const drawBtn = document.getElementById("polyPickerDrawBtn");
const finishBtn = document.getElementById("polyPickerFinishBtn");
const clearBtn = document.getElementById("polyPickerClearBtn");
const hintEl = document.getElementById("polyPickerHint");
const badgeEl = document.getElementById("polyStatusBadge");
map = new maplibregl.Map({
container: mapDiv,
style: window.MAP_STYLE_LIGHT,
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
zoom: 12
});
// 1. Fond de plan & Orthophotos (LayerSwitcherControl)
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'basemap',
name: "{% translate 'Fonds de plan & Orthophotos' %}",
icon: 'satellite',
group: 'basemap',
beforeLayer: function (mapInstance) {
if (mapInstance && mapInstance.getLayer('poly-preview-fill')) {
return 'poly-preview-fill';
}
return undefined;
},
layer: {
type: 'raster',
paint: { 'raster-opacity': 1 }
},
basemaps: (window.MAP_BACKGROUNDS && window.MAP_BACKGROUNDS.length > 0) ? window.MAP_BACKGROUNDS : undefined,
sublayers: [
{
id: 'urbis',
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
default: true,
source: {
type: 'raster',
tiles: [
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
],
tileSize: 256,
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '© Brussels UrbIS'
}
},
{
id: 'vlaanderen',
name: 'Vlaanderen 2023',
source: {
type: 'raster',
tiles: [
'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'
],
tileSize: 256,
attribution: '© Digitaal Vlaanderen - Orthofoto 2023'
}
}
]
}]
});
map.addControl(layerSwitcher, 'top-right');
}
// 2. Navigation Control (Zoom +/- & Pitch/Bearing)
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right');
// 3. Outil de recherche d'adresse
if (typeof AddressSearchControl !== 'undefined') {
const searchCtrl = new AddressSearchControl({
autocompleteUrl: "{% url 'common:autocomplete_street' %}",
placeholder: "{% translate 'Rechercher une adresse…' %}"
});
map.addControl(searchCtrl, 'top-left');
}
function getColor() {
return colorInput ? colorInput.value : "#198754";
}
function initPolyLayers() {
if (!map.getSource("poly-preview")) {
map.addSource("poly-preview", {
type: "geojson",
data: { type: "Feature", geometry: { type: "Polygon", coordinates: [] } }
});
map.addLayer({
id: "poly-preview-fill",
type: "fill",
source: "poly-preview",
paint: {
"fill-color": getColor(),
"fill-opacity": 0.25
}
});
map.addLayer({
id: "poly-preview-line",
type: "line",
source: "poly-preview",
paint: {
"line-color": getColor(),
"line-width": 2.5
}
});
}
}
function clearMarkers() {
vertexMarkers.forEach(m => m.remove());
vertexMarkers = [];
}
function addVertexMarker(lng, lat, index) {
const el = document.createElement("div");
el.style.width = "10px";
el.style.height = "10px";
el.style.borderRadius = "50%";
el.style.backgroundColor = index === 0 ? "#dc3545" : "#198754";
el.style.border = "2px solid #ffffff";
el.style.boxShadow = "0 0 3px rgba(0,0,0,0.5)";
el.style.cursor = index === 0 && drawing && vertices.length >= 3 ? "pointer" : "default";
if (index === 0) {
el.title = "{% translate 'Cliquez pour clore le polygone' %}";
el.addEventListener("click", (e) => {
e.stopPropagation();
if (drawing && vertices.length >= 3) {
finishPolygon();
}
});
}
const marker = new maplibregl.Marker({ element: el }).setLngLat([lng, lat]).addTo(map);
vertexMarkers.push(marker);
}
function renderPoly(coords) {
initPolyLayers();
let geom;
if (coords.length >= 3) {
geom = {
type: "Polygon",
coordinates: [coords.concat([coords[0]])]
};
} else if (coords.length === 2) {
geom = {
type: "LineString",
coordinates: coords
};
} else {
geom = { type: "Point", coordinates: coords[0] || [0, 0] };
}
map.getSource("poly-preview").setData({ type: "Feature", geometry: geom });
}
function finishPolygon() {
if (vertices.length < 3) {
alert("{% translate 'Un polygone doit comporter au moins 3 sommets.' %}");
return;
}
drawing = false;
const closed = vertices.concat([vertices[0]]);
const polyGeojson = {
type: "Polygon",
coordinates: [closed]
};
if (geomInput) {
geomInput.value = JSON.stringify(polyGeojson);
}
renderPoly(vertices);
updateUI(false);
hintEl.textContent = "{% translate 'Polygone enregistré. Cliquez sur « Dessiner » pour le refaire.' %}";
badgeEl.className = "badge bg-success";
badgeEl.textContent = `{% translate 'Polygone défini' %} (${vertices.length} {% translate 'sommets' %})`;
}
function updateUI(isDrawing) {
drawBtn.classList.toggle("d-none", isDrawing);
finishBtn.classList.toggle("d-none", !isDrawing);
map.getCanvas().style.cursor = isDrawing ? "crosshair" : "default";
}
function showExistingPolygon(geojsonStr) {
if (!geojsonStr) return;
try {
const geom = JSON.parse(geojsonStr);
let coords = null;
if (geom.type === "Polygon") {
coords = geom.coordinates[0];
} else if (geom.type === "MultiPolygon") {
coords = geom.coordinates[0][0];
}
if (!coords || coords.length === 0) return;
// Strip closing duplicate vertex for marker tracking
const rawVertices = (coords[0][0] === coords[coords.length - 1][0] && coords[0][1] === coords[coords.length - 1][1])
? coords.slice(0, coords.length - 1)
: coords;
vertices = rawVertices;
clearMarkers();
vertices.forEach((c, idx) => addVertexMarker(c[0], c[1], idx));
renderPoly(vertices);
// Fit bounds
const bounds = new maplibregl.LngLatBounds();
coords.forEach(c => bounds.extend(c));
map.fitBounds(bounds, { padding: 50, maxZoom: 16 });
badgeEl.className = "badge bg-success";
badgeEl.textContent = `{% translate 'Polygone existant' %} (${vertices.length} {% translate 'sommets' %})`;
hintEl.textContent = "{% translate 'Polygone actuel affiché. Cliquez sur « Dessiner » pour le modifier.' %}";
} catch (e) {
console.warn("Could not parse existing geometry:", e);
}
}
map.on("load", function () {
initPolyLayers();
if (geomInput && geomInput.value) {
showExistingPolygon(geomInput.value);
}
});
map.on("click", function (e) {
if (!drawing) return;
const { lng, lat } = e.lngLat;
vertices.push([lng, lat]);
addVertexMarker(lng, lat, vertices.length - 1);
renderPoly(vertices);
hintEl.textContent = `{% translate 'Sommets posés' %} : ${vertices.length}. {% translate 'Cliquez sur le premier sommet ou sur « Terminer » pour clore.' %}`;
badgeEl.className = "badge bg-warning text-dark";
badgeEl.textContent = `{% translate 'Tracé en cours' %} (${vertices.length})`;
});
drawBtn.addEventListener("click", function (e) {
e.preventDefault();
drawing = true;
vertices = [];
clearMarkers();
if (geomInput) geomInput.value = "";
initPolyLayers();
map.getSource("poly-preview").setData({ type: "Feature", geometry: { type: "Polygon", coordinates: [] } });
updateUI(true);
hintEl.textContent = "{% translate 'Cliquez sur la carte pour poser chaque sommet.' %}";
badgeEl.className = "badge bg-warning text-dark";
badgeEl.textContent = "{% translate 'Tracé en cours' %}";
});
finishBtn.addEventListener("click", function (e) {
e.preventDefault();
finishPolygon();
});
clearBtn.addEventListener("click", function (e) {
e.preventDefault();
drawing = false;
vertices = [];
clearMarkers();
if (geomInput) geomInput.value = "";
initPolyLayers();
map.getSource("poly-preview").setData({ type: "Feature", geometry: { type: "Polygon", coordinates: [] } });
updateUI(false);
hintEl.textContent = "{% translate 'Périmètre effacé. Cliquez sur « Dessiner » pour recommencer.' %}";
badgeEl.className = "badge bg-secondary";
badgeEl.textContent = "{% translate 'Aucun tracé' %}";
});
});
</script>
{% endblock script %}

View file

@ -1,340 +0,0 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% translate "Zones de gestion" %} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="/">{% translate "Accueil" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}">{% translate "Assets" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% translate "Zones de gestion" %}</li>
</ol>
</nav>
{% if can_create %}
<a href="{% url 'assets:management_zone_create' %}{% if selected_thematic_code %}?thematic={{ selected_thematic_code }}{% endif %}" class="btn btn-success">
<i class="bi bi-plus-lg me-1"></i>{% translate "Nouvelle zone de gestion" %}
</a>
{% endif %}
</div>
<!-- Title Bar -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-diagram-3-fill text-success me-2"></i>{% translate "Zones de gestion" %}
</h1>
<p class="text-muted mb-0">
{% translate "Regroupements géographiques et fonctionnels d'équipements pour l'entretien récurrent, les plans de fauche et l'exploitation." %}
</p>
</div>
<div class="text-end">
<span class="badge bg-light text-dark border fs-6 px-3 py-2">
<strong>{{ total_count }}</strong> {% translate "zone(s) répertoriée(s)" %}
</span>
</div>
</div>
<!-- Filter Card -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<form method="get" class="row g-3 align-items-end">
<!-- Search text -->
<div class="col-md-3">
<label class="form-label small text-muted">{% translate "Recherche" %}</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="q" class="form-control" placeholder="{% translate 'Code, nom, consignes...' %}" value="{{ search_query }}">
</div>
</div>
<!-- Thematic -->
<div class="col-md-3">
<label class="form-label small text-muted">{% translate "Thématique" %}</label>
<select name="thematic" id="filterThematic" class="form-select form-select-sm">
<option value="">-- {% translate "Toutes les thématiques" %} --</option>
{% for th in accessible_thematics %}
<option value="{{ th.code }}" {% if selected_thematic_code == th.code %}selected{% endif %}>
{{ th.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Zone Type -->
<div class="col-md-3">
<label class="form-label small text-muted">{% translate "Type de zone" %}</label>
<select name="zone_type" id="filterZoneType" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les types" %} --</option>
{% for zt in available_types %}
<option value="{{ zt.id }}" {% if selected_type_id == zt.id|stringformat:"s" %}selected{% endif %}>
{{ zt.get_name }} ({{ zt.thematic.name }})
</option>
{% endfor %}
</select>
</div>
<!-- Status -->
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Statut" %}</label>
<select name="status" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les statuts" %} --</option>
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% translate "Actives uniquement" %}</option>
<option value="inactive" {% if selected_status == 'inactive' %}selected{% endif %}>{% translate "Inactives" %}</option>
</select>
</div>
<!-- Submit / Reset buttons -->
<div class="col-md-1 d-flex gap-1">
<button type="submit" class="btn btn-sm btn-primary w-100" title="{% translate 'Filtrer' %}">
<i class="bi bi-funnel"></i>
</button>
<a href="{% url 'assets:management_zones_index' %}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Réinitialiser' %}">
<i class="bi bi-x-lg"></i>
</a>
</div>
</form>
</div>
</div>
<!-- Map Overview Card -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
<span class="fw-semibold small">
<i class="bi bi-map me-1 text-success"></i>{% translate "Aperçu cartographique des périmètres" %}
</span>
<small class="text-muted" id="mapLoadingStatus">{% translate "Chargement de la carte..." %}</small>
</div>
<div class="card-body p-0">
<div id="zonesOverviewMap" style="height: 320px; width: 100%;"></div>
</div>
</div>
<!-- Zones Table Card -->
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase text-muted">
<tr>
<th style="width: 140px;">{% translate "Code" %}</th>
<th>{% translate "Nom de la zone" %}</th>
<th>{% translate "Thématique" %}</th>
<th>{% translate "Type de zone" %}</th>
<th>{% translate "Fréquence cible" %}</th>
<th>{% translate "Saisonnalité" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end" style="width: 160px;">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for item in zones_list %}
{% with zone=item.zone can_edit=item.can_edit %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<span style="display:inline-block; width: 14px; height: 14px; border-radius: 3px; background-color: {{ zone.get_color }}; border: 1px solid rgba(0,0,0,0.2); flex-shrink: 0;"></span>
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="fw-bold text-decoration-none">
{{ zone.code }}
</a>
</div>
</td>
<td>
<div class="fw-semibold">{{ zone.get_name }}</div>
{% if zone.instructions %}
<div class="text-muted small text-truncate" style="max-width: 320px;" title="{{ zone.instructions }}">
<i class="bi bi-exclamation-triangle text-warning me-1"></i>{{ zone.instructions }}
</div>
{% endif %}
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border">
{{ zone.thematic.name }}
</span>
</td>
<td>
{% if zone.zone_type %}
<span class="badge" style="background-color: {{ zone.zone_type.default_color }}; color: #fff;">
{{ zone.zone_type.get_name }}
</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if zone.target_frequency_days %}
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-arrow-repeat me-1"></i>{{ zone.target_frequency_days }} {% translate "jours" %}
</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if zone.seasonality_notes %}
<span class="small text-muted"><i class="bi bi-calendar3 me-1"></i>{{ zone.seasonality_notes }}</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if zone.is_active %}
<span class="badge bg-success-subtle text-success border">{% translate "Active" %}</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border">{% translate "Inactive" %}</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-primary" title="{% translate 'Détails' %}">
<i class="bi bi-eye"></i>
</a>
{% if can_edit %}
<a href="{% url 'assets:management_zone_edit' zone.pk %}" class="btn btn-outline-secondary" title="{% translate 'Modifier' %}">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'assets:management_zone_delete' zone.pk %}" class="btn btn-outline-danger" title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endwith %}
{% empty %}
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<i class="bi bi-diagram-3 fs-1 d-block mb-2 text-muted"></i>
<p class="mb-2">{% translate "Aucune zone de gestion ne correspond aux critères sélectionnés." %}</p>
{% if can_create %}
<a href="{% url 'assets:management_zone_create' %}{% if selected_thematic_code %}?thematic={{ selected_thematic_code }}{% endif %}" class="btn btn-sm btn-success">
<i class="bi bi-plus-lg me-1"></i>{% translate "Créer une première zone de gestion" %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block script %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const mapDiv = document.getElementById("zonesOverviewMap");
if (!mapDiv || typeof maplibregl === "undefined") return;
const thematicParam = "{{ selected_thematic_code|default:'' }}";
const geojsonUrl = "{% url 'assets:management_zones_geojson' %}" + (thematicParam ? ("?thematic=" + encodeURIComponent(thematicParam)) : "");
const map = new maplibregl.Map({
container: mapDiv,
style: window.MAP_STYLE_LIGHT,
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
zoom: 11
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.on('load', async function () {
try {
const resp = await fetch(geojsonUrl, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!resp.ok) {
document.getElementById("mapLoadingStatus").textContent = "";
return;
}
const data = await resp.json();
const features = data.features || [];
document.getElementById("mapLoadingStatus").textContent = `${features.length} {% translate "périmètre(s) affiché(s)" %}`;
map.addSource('management-zones-overview', {
type: 'geojson',
data: data
});
map.addLayer({
id: 'management-zones-fill',
type: 'fill',
source: 'management-zones-overview',
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#198754'],
'fill-opacity': 0.25
}
});
map.addLayer({
id: 'management-zones-line',
type: 'line',
source: 'management-zones-overview',
paint: {
'line-color': ['coalesce', ['get', 'color'], '#198754'],
'line-width': 2,
'line-dasharray': [3, 1.5]
}
});
const popup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, maxWidth: '300px' });
map.on('click', 'management-zones-fill', function (e) {
if (!e.features || !e.features[0]) return;
const p = e.features[0].properties;
const detailUrl = "/assets/management-zones/" + p.id + "/";
const html = `
<div style="font-size: 12px; line-height: 1.4;">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:4px;">
<span style="display:inline-block; width:12px; height:12px; border-radius:3px; background-color:${p.color || '#198754'};"></span>
<strong>${p.code}</strong>
</div>
<div class="fw-semibold mb-1">${p.name}</div>
${p.zone_type_name ? `<span class="badge bg-secondary mb-1">${p.zone_type_name}</span>` : ''}
${p.target_frequency_days ? `<div class="text-muted small"><i class="bi bi-arrow-repeat me-1"></i>${p.target_frequency_days} {% translate "jours" %}</div>` : ''}
<div class="mt-2 text-end">
<a href="${detailUrl}" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 11px;">
{% translate "Consulter la fiche" %} &rarr;
</a>
</div>
</div>
`;
popup.setLngLat(e.lngLat).setHTML(html).addTo(map);
});
map.on('mouseenter', 'management-zones-fill', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'management-zones-fill', () => { map.getCanvas().style.cursor = ''; });
// Fit bounds to features
if (features.length > 0) {
const bounds = new maplibregl.LngLatBounds();
features.forEach(f => {
const coords = f.geometry?.coordinates;
if (!coords) return;
const addCoords = (arr) => {
if (typeof arr[0] === 'number') {
bounds.extend(arr);
} else {
arr.forEach(addCoords);
}
};
addCoords(coords);
});
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 40, maxZoom: 15 });
}
}
} catch (err) {
console.error("Error loading management zones overview map:", err);
document.getElementById("mapLoadingStatus").textContent = "";
}
});
});
</script>
{% endblock script %}

View file

@ -1,14 +1,5 @@
{% extends "assets/base_assets_index.html" %} {% extends "assets/base_assets_index.html" %}
{% load static i18n %} {% load static i18n %}
{% block tab_actions %}
<li class="nav-item ms-auto">
<a href="{% url 'assets:management_zones_index' %}?thematic=nature" class="btn btn-sm btn-outline-success d-inline-flex align-items-center gap-1 my-1 me-2" title="{% translate "Gérer les zones de gestion écologique et d'entretien" %}">
<i class="bi bi-diagram-3"></i>
<span>{% translate "Zones de gestion" %}</span>
</a>
</li>
{% endblock tab_actions %}
{% block locations_filter_form %} {% block locations_filter_form %}
@ -64,16 +55,6 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div class="col-12">
<label for="management_type" class="form-label">{% translate "Gestionnaire / Compétence" %}</label>
<select name="management_type" id="management_type" class="form-select" multiple>
{% for key, value in management_type_choices %}
<option value="{{ key }}" {% if key in selected_management_types %}selected{% endif %}>
{{ value }}
</option>
{% endfor %}
</select>
</div>
<div class="col-12 d-flex justify-content-between mt-4" style="width: 200px;"> <div class="col-12 d-flex justify-content-between mt-4" style="width: 200px;">
<button type="button" id="apply-assets-filters" class="btn btn-sm btn-primary">{% translate "Filtrer" %}</button> <button type="button" id="apply-assets-filters" class="btn btn-sm btn-primary">{% translate "Filtrer" %}</button>
<button type="button" id="reset-assets-filters" class="btn btn-sm btn-secondary">{% translate "Réinitialiser" %}</button> <button type="button" id="reset-assets-filters" class="btn btn-sm btn-secondary">{% translate "Réinitialiser" %}</button>
@ -96,15 +77,12 @@
{ field: 'highway', title: '{% translate "Ring/Autoroute" %}', align: 'center' } { field: 'highway', title: '{% translate "Ring/Autoroute" %}', align: 'center' }
]; ];
var assets_property_fields = ['code', 'name', 'managing_authority', 'model', 'scientific_name', 'area']; var assets_property_fields = ['category', 'model', 'area'];
var assets_columns = [ var assets_columns = [
{ field: 'code', title: '{% translate "Code" %}' }, { field: 'category', title: '{% translate "Catégorie" %}' },
{ field: 'name', title: '{% translate "Nom" %}' },
{ field: 'managing_authority', title: '{% translate "Gestionnaire" %}' },
{ field: 'model', title: '{% translate "Modèle" %}' }, { field: 'model', title: '{% translate "Modèle" %}' },
{ field: 'scientific_name', title: '{% translate "Botanique" %}' },
{ field: 'area', title: '{% translate "Superficie" %}' } { field: 'area', title: '{% translate "Superficie" %}' }
]; ]
const locations_layer_def = { const locations_layer_def = {
@ -174,25 +152,16 @@
16, 5, 16, 5,
20, 8 20, 8
], ],
'circle-color': [ 'circle-color': ['coalesce', ['get', 'color'], '#0277BD'],
'case',
['==', ['get', 'management_type'], 'regional'], '#1E3A8A',
['boolean', ['get', 'is_remarkable'], false], '#F59E0B',
['coalesce', ['get', 'color'], '#15803D']
],
'circle-stroke-width': [ 'circle-stroke-width': [
'case', 'case',
['boolean', ['feature-state', 'highlighted'], false], 2.5, ['boolean', ['feature-state', 'highlighted'], false], 2,
['==', ['get', 'management_type'], 'regional'], 1.5,
['boolean', ['get', 'is_remarkable'], false], 2,
1 1
], ],
'circle-stroke-color': [ 'circle-stroke-color': [
'case', 'case',
['boolean', ['feature-state', 'highlighted'], false], '#EF4444', ['boolean', ['feature-state', 'highlighted'], false], '#ff0000',
['==', ['get', 'management_type'], 'regional'], '#93C5FD', '#000000'
['boolean', ['get', 'is_remarkable'], false], '#FEF08A',
'#064E3B'
], ],
'circle-opacity': 0.8 'circle-opacity': 0.8
}, },
@ -252,36 +221,5 @@
{% endblock const_script %} {% endblock const_script %}
{% block extra_script %} {% block extra_script %}
<script>
document.addEventListener("DOMContentLoaded", function () {
function setupNatureOverlays() {
if (typeof initContractsMapOverlay === 'function' && window.map) {
initContractsMapOverlay({
map: window.map,
layerSwitcher: window.layerSwitcher,
thematic: 'nature',
geojsonUrl: "{% url 'contracts:contracts_geojson' %}",
});
}
if (typeof initManagementZonesMapOverlay === 'function' && window.map) {
initManagementZonesMapOverlay({
map: window.map,
layerSwitcher: window.layerSwitcher,
thematic: 'nature',
geojsonUrl: "{% url 'assets:management_zones_geojson' %}",
});
}
}
if (window.map && window.map.loaded && window.map.loaded()) {
setupNatureOverlays();
} else if (window.maploader) {
window.maploader.addEventListener('mapLoaded', () => {
setTimeout(setupNatureOverlays, 100);
});
} else if (window.map) {
window.map.on('load', setupNatureOverlays);
}
});
</script>
{% endblock extra_script %} {% endblock extra_script %}

View file

@ -2,704 +2,116 @@
{% load static %} {% load static %}
{% load i18n %} {% load i18n %}
{% block extra_head %}
<style>
.nature-kpi-card {
border-radius: 10px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
border: 1px solid #e2e8f0;
background: #ffffff;
}
.nature-kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
.nature-kpi-icon {
width: 44px;
height: 44px;
min-width: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
}
.nature-tab-content {
background: #ffffff;
border: 1px solid #dee2e6;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 1rem;
}
#map {
width: 100%;
height: calc(100vh - 230px);
min-height: 520px;
border-radius: 8px;
}
.map-card-wrapper {
position: sticky;
top: 1rem;
}
.map-layer-pill {
font-size: 0.78rem;
cursor: pointer;
user-select: none;
}
</style>
{% endblock extra_head %}
{% block content %} {% block content %}
<div class="container-fluid py-3"> <div class="row mt-2" style="height: calc(100vh - 200px);">
<div class="col-md-8" style="max-height: calc(100vh - 200px); overflow: auto;">
<!-- Breadcrumb & Navigation --> <h3 class="h3">
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2"> <i class="bi bi-suit-club"></i><span class="mx-2">{{ section.code}} - {{ section.name_fr}}</span>
<nav aria-label="breadcrumb"> </h3>
<ol class="breadcrumb mb-0"> <hr>
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}?thematic=nature" class="text-decoration-none">{% translate "Nature" %}</a></li> <div class="row mb-4">
<li class="breadcrumb-item active fw-semibold" aria-current="page">{{ section.code }}</li> <div class="col-md-6">
</ol> <label class="mx-2">{% translate "Zone:" %}</label><i class="bi bi-circle-fill status_{{ surface.status }}"></i> {{ section.zone|default:"" }}<br>
</nav> <label class="mx-2">{% translate "Autoroute:" %}</label>{{ section.is_highway|default:"" }}<br>
<div class="d-flex gap-2"> </div>
<a href="{% url 'assets:index' %}?thematic=nature" class="btn btn-sm btn-outline-secondary"> <div class="col-md-6">
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la carte" %} {% if section.lon %}
</a> <a href="https://www.google.com/maps?cbll={{ section.lat|floatformat:'6u'}},{{ section.lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid=" target="_blank" style="text-decoration: none; font-size: 16px; display: flex; align-items: center;">
{% if section.lon and section.lat %} <img src="{% static 'common/google-street-view-logo.png' %}" alt="{% translate 'Street View' %}" height="20" style="margin-right: 8px;">
<a href="https://www.google.com/maps?cbll={{ section.lat|floatformat:'6u'}},{{ section.lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid=" {% translate "View in Google Street View" %}
target="_blank" class="btn btn-sm btn-outline-primary" style="text-decoration: none;"> </a>
<img src="{% static 'common/google-street-view-logo.png' %}" alt="{% translate 'Street View' %}" height="18" class="me-1"> {% endif %}
{% translate "View in Google Street View" %} </div>
</a> </div>
{% endif %} <div class="row mb-4">
<div class="col-md-12">
<label class="mx-2">{% translate "Nombre d'arbres:" %}</label><br>
<label class="mx-2">{% translate "Superficie massif arboré:" %}</label><br>
<label class="mx-2">{% translate "Superficie pré fleuri:" %}</label><br>
</div>
</div> </div>
</div>
<!-- Header Card --> <!-- Documents -->
<div class="card shadow-sm mb-4 border-0" style="background: linear-gradient(to right, #f8fafc, #ffffff); border-left: 5px solid #16a34a !important;"> <div class="card mb-4">
<div class="card-body p-3 p-md-4"> <div class="card-header">
<div class="row align-items-center g-3"> <h4>{% translate "Documents" %}</h4>
<div class="col-lg-8"> </div>
<div class="d-flex align-items-center gap-3"> <div class="card-body">
<div class="rounded-circle bg-success-subtle p-2 d-flex align-items-center justify-content-center text-success" style="width: 52px; height: 52px; min-width: 52px;"> <div class="row">
<i class="bi bi-tree-fill" style="font-size: 1.8rem;"></i> {% for document in section.documents.all %}
</div> <div class="col-md-2 mb-3">
<div> <a href="{{ document.file.url }}" target="_blank">
<div class="d-flex align-items-center gap-2 flex-wrap"> <div class="img-thumbnail" style="width: 100%; height: 100px; overflow: hidden;">
<h1 class="h4 mb-0 fw-bold text-dark"> {% if document.thumbnail %}
{{ section.code }} <span class="text-muted fw-normal">&mdash; {{ section.get_name }}</span> <img src="{{ document.thumbnail.url }}" alt="{{ document.file.name }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
</h1> {% else %}
</div> <img src="{{ document.file.url }}" alt="{{ document.file.name }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
<div class="d-flex align-items-center gap-2 mt-2 flex-wrap text-muted small">
{% if section.municipality %}
<span><i class="bi bi-geo-alt-fill text-danger me-1"></i>{{ section.municipality }}</span>
<span>&bull;</span>
{% endif %}
<span>
<strong>{% translate "Zone:" %}</strong>
{% if section.zone %}
<span class="badge bg-secondary-subtle text-secondary border">Zone {{ section.zone }}</span>
{% else %}
<span class="text-muted">{% translate "Non définie" %}</span>
{% endif %}
</span>
<span>&bull;</span>
<span>
<strong>{% translate "Autoroute:" %}</strong>
{% if section.is_highway %}
<span class="badge bg-primary-subtle text-primary border">{% translate "Oui" %}</span>
{% else %}
<span class="badge bg-light text-secondary border">{% translate "Non" %}</span>
{% endif %}
</span>
{% if section.category %}
<span>&bull;</span>
<span><i class="bi bi-tag me-1"></i>{{ section.category.get_name }}</span>
{% endif %} {% endif %}
</div> </div>
</div> </a>
</div>
{% if section.parent_location or section.child_locations.exists %}
<div class="mt-2 pt-2 border-top d-flex gap-3 flex-wrap small text-muted">
{% if section.parent_location %}
<div>
<i class="bi bi-diagram-2 me-1"></i>{% translate "Section parente :" %}
<a href="{{ section.parent_location.get_absolute_url }}" class="fw-semibold text-decoration-none">
{{ section.parent_location.code }} - {{ section.parent_location.get_name }}
</a>
</div>
{% endif %}
{% if section.child_locations.exists %}
<div>
<i class="bi bi-diagram-3 me-1"></i>{% translate "Sous-sections :" %}
{% for child in section.child_locations.all %}
<a href="{{ child.get_absolute_url }}" class="badge bg-light text-dark border text-decoration-none me-1">
{{ child.code }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="col-lg-4 text-lg-end">
<div class="d-inline-flex gap-2 flex-wrap justify-content-lg-end">
<div class="badge bg-light text-dark border p-2 text-start">
<span class="d-block text-muted" style="font-size: 0.72rem;">{% translate "Total équipements" %}</span>
<span class="fw-bold fs-6">{{ total_assets_count }}</span>
</div>
<div class="badge bg-light text-dark border p-2 text-start">
<span class="d-block text-muted" style="font-size: 0.72rem;">{% translate "Interventions" %}</span>
<span class="fw-bold fs-6 text-primary">{{ interventions|length }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- KPI Metric Cards Grid -->
<div class="row g-3 mb-4">
<!-- 1. Nombre d'arbres -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-success-subtle text-success">
<i class="bi bi-tree-fill"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Nombre d'arbres:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ trees_count }}</div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ trees_active_count }} {% translate "actifs" %}
{% if trees_remarkable_count %}
&bull; <span class="text-warning fw-semibold"><i class="bi bi-star-fill text-warning"></i> {{ trees_remarkable_count }} {% translate "remarquables" %}</span>
{% endif %}
</div>
</div> </div>
{% endfor %}
</div> </div>
</div> </div>
</div> </div>
<!-- 2. Superficie totale surfaces vertes --> <!-- Interventions -->
<div class="col-sm-6 col-xl-3"> <div class="card mb-4">
<div class="card h-100 nature-kpi-card shadow-sm p-3"> <div class="card-header">
<div class="d-flex align-items-center gap-3"> <h4>{% translate "Interventions" %}</h4>
<div class="nature-kpi-icon bg-primary-subtle text-primary">
<i class="bi bi-grid-fill"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Surfaces végétales:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ total_green_surface|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ green_surfaces_count }} {% translate "surfaces au total" %}
</div>
</div>
</div>
</div> </div>
</div> <div class="card-body">
<table id="interventions-table" class="table"
<!-- 3. Superficie massif arboré --> data-height="550"
<div class="col-sm-6 col-xl-3"> data-pagination="true"
<div class="card h-100 nature-kpi-card shadow-sm p-3"> data-page-size="25"
<div class="d-flex align-items-center gap-3"> data-click-to-select="true" >
<div class="nature-kpi-icon bg-warning-subtle text-warning-emphasis"> <thead>
<i class="bi bi-flower1"></i> <tr>
</div> <th data-field="actions" data-width="100"></th>
<div> <th data-field="code">{% translate "Code" %}</th>
<div class="text-muted small fw-semibold">{% translate "Superficie massif arboré:" %}</div> <th data-field="title">{% translate "Titre" %}</th>
<div class="fs-4 fw-bold text-dark">{{ surface_massif_arbore|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div> <th data-field="begin_date">{% translate "Début" %}</th>
<div class="text-muted" style="font-size: 0.75rem;"> <th data-field="statut">{% translate "Statut" %}</th>
{{ massif_arbore_count }} {% translate "massifs arborés" %} </tr>
</div> <tbody>
</div> {% for interv in interventions %}
</div>
</div>
</div>
<!-- 4. Superficie pré fleuri -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-info-subtle text-info-emphasis">
<i class="bi bi-flower2"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Superficie pré fleuri:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ surface_pre_fleuri|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ pre_fleuri_count }} {% translate "prés fleuris" %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Typology breakdown (if available) -->
{% if surfaces_by_type %}
<div class="card shadow-sm mb-4 border-0">
<div class="card-body p-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="small fw-bold text-secondary text-uppercase"><i class="bi bi-pie-chart-fill me-1"></i>{% translate "Répartition des surfaces végétales" %}</span>
<span class="small text-muted">{{ total_green_surface|floatformat:1 }} m²</span>
</div>
<!-- Progress bar -->
<div class="progress mb-2" style="height: 10px; border-radius: 6px;">
{% for st in surfaces_by_type %}
<div class="progress-bar" role="progressbar"
style="width: {{ st.percent }}%; background-color: {{ st.color }};"
title="{{ st.name }}: {{ st.total_area|floatformat:1 }} m² ({{ st.percent }}%)"
aria-valuenow="{{ st.percent }}" aria-valuemin="0" aria-valuemax="100"></div>
{% endfor %}
</div>
<!-- Badges -->
<div class="d-flex flex-wrap gap-2 mt-2">
{% for st in surfaces_by_type %}
<span class="badge bg-light text-dark border d-inline-flex align-items-center gap-1 py-1 px-2" style="font-size: 0.8rem;">
<span class="d-inline-block rounded-circle" style="width: 8px; height: 8px; background-color: {{ st.color }};"></span>
<span class="fw-semibold">{{ st.name }}</span>:
<span class="text-muted">{{ st.total_area|floatformat:1 }} m² ({{ st.count }})</span>
</span>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Main Content Layout (Split View: Tabs/Tables on Left, Interactive Map on Right) -->
<div class="row g-4">
<!-- Left Column: Tabs & Tables -->
<div class="col-lg-7 col-xl-7">
<!-- Tabs Navigation -->
<ul class="nav nav-tabs" id="natureLocationTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active fw-semibold" id="surfaces-tab" data-bs-toggle="tab" data-bs-target="#surfaces-pane" type="button" role="tab">
<i class="bi bi-grid-fill text-success me-1"></i>{% translate "Surfaces vertes" %}
<span class="badge bg-success-subtle text-success ms-1">{{ green_surfaces_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="trees-tab" data-bs-toggle="tab" data-bs-target="#trees-pane" type="button" role="tab">
<i class="bi bi-tree-fill text-success me-1"></i>{% translate "Arbres" %}
<span class="badge bg-success-subtle text-success ms-1">{{ trees_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="equipments-tab" data-bs-toggle="tab" data-bs-target="#equipments-pane" type="button" role="tab">
<i class="bi bi-flower3 text-info me-1"></i>{% translate "Arbustes & Bancs" %}
<span class="badge bg-secondary-subtle text-secondary ms-1">{{ shrubs_count|add:benches_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="interventions-tab" data-bs-toggle="tab" data-bs-target="#interventions-pane" type="button" role="tab">
<i class="bi bi-tools text-primary me-1"></i>{% translate "Interventions" %}
<span class="badge bg-primary-subtle text-primary ms-1">{{ interventions|length }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="documents-tab" data-bs-toggle="tab" data-bs-target="#documents-pane" type="button" role="tab">
<i class="bi bi-files text-secondary me-1"></i>{% translate "Documents" %}
<span class="badge bg-secondary-subtle text-secondary ms-1">{{ documents|length }}</span>
</button>
</li>
</ul>
<!-- Tabs Content -->
<div class="tab-content nature-tab-content shadow-sm" id="natureLocationTabsContent">
<!-- TAB 1: Surfaces végétalisées -->
<div class="tab-pane fade show active" id="surfaces-pane" role="tabpanel" aria-labelledby="surfaces-tab">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr> <tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Typologie / Modèle" %}</th>
<th class="text-end">{% translate "Superficie" %}</th>
<th>{% translate "Pente / Tonte" %}</th>
<th>{% translate "Bulbes" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for gs in green_surfaces %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturegreensurface' gs.id %}" class="text-decoration-none">
{{ gs.code }}
</a>
</td>
<td> <td>
<span class="d-inline-block rounded-circle me-1" style="width: 8px; height: 8px; background-color: {{ gs.model.color|default:'#4caf50' }};"></span> <a href="{% url 'interventions:interventions_index' %}{{ interv.id }}" type="button" class="btn btn-sm btn-light detail-btn" data-id="${row.id}" data-bs-toggle="tooltip" title="{% translate 'Détail' %}">
{{ gs.model.get_name|default:"-" }}
</td>
<td class="text-end fw-semibold">
{% if gs.surface_area %}{{ gs.surface_area|floatformat:1 }} m²{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td>
<span class="small text-muted">
{% if gs.slope %}{{ gs.slope }}%{% else %}-{% endif %} /
{% if gs.mower_frenquency %}{{ gs.mower_frenquency }}x/an{% else %}-{% endif %}
</span>
</td>
<td>
{% if gs.bulb_types %}
<span class="badge bg-info-subtle text-info border">{{ gs.bulb_types|length }} bulbe(s)</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
<span class="badge {% if gs.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ gs.get_status_display }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if gs.lon and gs.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ gs.lon|stringformat:'f' }}, {{ gs.lat|stringformat:'f' }}, '{{ gs.code }}')" title="{% translate 'Centrer sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturegreensurface' gs.id %}" class="btn btn-outline-primary" title="{% translate 'Détail' %}">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="bi bi-grid mb-2 d-block fs-3 text-muted"></i>
{% translate "Aucune surface végétalisée enregistrée sur cette voirie." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 2: Arbres -->
<div class="tab-pane fade" id="trees-pane" role="tabpanel" aria-labelledby="trees-tab">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Essence / Modèle" %}</th>
<th>{% translate "Stade" %}</th>
<th>{% translate "Vitalité" %}</th>
<th>{% translate "Risque" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for tree in trees %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturetree' tree.id %}" class="text-decoration-none">
{{ tree.code }}
</a>
{% if tree.is_remarkable %}
<i class="bi bi-star-fill text-warning ms-1" title="{% translate 'Arbre remarquable' %}"></i>
{% endif %}
</td>
<td>
<div>{{ tree.model.get_name|default:"-" }}</div>
{% if tree.model.scientific_name %}
<div class="small text-muted fst-italic">{{ tree.model.scientific_name }}</div>
{% endif %}
</td>
<td>
<span class="small">{{ tree.get_development_stage_display|default:"-" }}</span>
</td>
<td>
{% if tree.vitality == 'strong' %}
<span class="badge bg-success-subtle text-success border">{% translate "Forte" %}</span>
{% elif tree.vitality == 'normal' %}
<span class="badge bg-info-subtle text-info border">{% translate "Normale" %}</span>
{% elif tree.vitality == 'weak' %}
<span class="badge bg-warning-subtle text-warning-emphasis border">{% translate "Faible" %}</span>
{% elif tree.vitality == 'declining' or tree.vitality == 'dead' %}
<span class="badge bg-danger">{{ tree.get_vitality_display }}</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if tree.risk_level == 'high' or tree.risk_level == 'imminent_danger' %}
<span class="badge bg-danger">{{ tree.get_risk_level_display }}</span>
{% elif tree.risk_level == 'moderate' %}
<span class="badge bg-warning text-dark">{{ tree.get_risk_level_display }}</span>
{% else %}
<span class="small text-muted">{{ tree.get_risk_level_display|default:"Faible" }}</span>
{% endif %}
</td>
<td>
<span class="badge {% if tree.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ tree.get_status_display }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if tree.lon and tree.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ tree.lon|stringformat:'f' }}, {{ tree.lat|stringformat:'f' }}, '{{ tree.code }}')" title="{% translate 'Centrer sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturetree' tree.id %}" class="btn btn-outline-primary" title="{% translate 'Détail' %}">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="bi bi-tree mb-2 d-block fs-3 text-muted"></i>
{% translate "Aucun arbre enregistré sur cette voirie." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 3: Arbustes & Bancs -->
<div class="tab-pane fade" id="equipments-pane" role="tabpanel" aria-labelledby="equipments-tab">
<!-- Arbustes -->
<h6 class="fw-bold mb-2 text-dark"><i class="bi bi-flower3 text-success me-1"></i>{% translate "Arbustes" %} ({{ shrubs_count }})</h6>
<div class="table-responsive mb-4">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "Hauteur" %}</th>
<th>{% translate "Taillé" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for shrub in shrubs %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'natureshrub' shrub.id %}" class="text-decoration-none">
{{ shrub.code }}
</a>
</td>
<td>{{ shrub.model.get_name|default:"-" }}</td>
<td>{% if shrub.height %}{{ shrub.height }} m{% else %}-{% endif %}</td>
<td>
{% if shrub.trimmed %}<span class="badge bg-success-subtle text-success">{% translate "Oui" %}</span>{% else %}<span class="badge bg-light text-secondary">{% translate "Non" %}</span>{% endif %}
</td>
<td><span class="badge bg-secondary">{{ shrub.get_status_display }}</span></td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if shrub.lon and shrub.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ shrub.lon|stringformat:'f' }}, {{ shrub.lat|stringformat:'f' }}, '{{ shrub.code }}')">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'natureshrub' shrub.id %}" class="btn btn-outline-primary">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-2 small">{% translate "Aucun arbuste répertorié." %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Bancs / Mobilier -->
<h6 class="fw-bold mb-2 text-dark"><i class="bi bi-inbox text-warning me-1"></i>{% translate "Mobilier & Bancs" %} ({{ benches_count }})</h6>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "Matériau" %}</th>
<th>{% translate "Dimensions" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for bench in benches %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturebench' bench.id %}" class="text-decoration-none">
{{ bench.code }}
</a>
</td>
<td>{{ bench.model.get_name|default:"-" }}</td>
<td>{{ bench.material|default:"-" }}</td>
<td>
{% if bench.length or bench.width or bench.height %}
{{ bench.length|default:"-" }} x {{ bench.width|default:"-" }} x {{ bench.height|default:"-" }} m
{% else %}
-
{% endif %}
</td>
<td><span class="badge bg-secondary">{{ bench.get_status_display }}</span></td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if bench.lon and bench.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ bench.lon|stringformat:'f' }}, {{ bench.lat|stringformat:'f' }}, '{{ bench.code }}')">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturebench' bench.id %}" class="btn btn-outline-primary">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-2 small">{% translate "Aucun banc ou mobilier répertorié." %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 4: Interventions -->
<div class="tab-pane fade" id="interventions-pane" role="tabpanel" aria-labelledby="interventions-tab">
<div class="table-responsive">
<table id="interventions-table" class="table table-hover align-middle mb-0"
data-pagination="true"
data-page-size="25">
<thead class="table-light">
<tr>
<th data-field="actions" class="text-center" style="width: 50px;"></th>
<th data-field="code">{% translate "Code" %}</th>
<th data-field="title">{% translate "Titre" %}</th>
<th data-field="begin_date">{% translate "Début" %}</th>
<th data-field="status">{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for interv in interventions %}
<tr>
<td class="text-center">
<a href="{% url 'interventions:interventions_index' %}{{ interv.id }}" class="btn btn-sm btn-light detail-btn" title="{% translate 'Détail' %}">
<i class="bi bi-info-circle"></i> <i class="bi bi-info-circle"></i>
</a> </a>
</td> </td>
<td class="fw-bold">{{ interv.code }}</td> <td>{{ interv.code }}</td>
<td>{{ interv.title }}</td> <td>{{ interv.title }}</td>
<td>{{ interv.begin_time|date:"d-m-Y H:i"|default:"-" }}</td> <td>{{ interv.begin_time|date:"d-m-Y H:i" }}</td>
<td> <td>{{ interv.get_status_display }}</td>
<span class="badge bg-secondary">{{ interv.get_status_display }}</span>
</td>
</tr> </tr>
{% empty %} {% endfor %}
<tr> </tbody>
<td colspan="5" class="text-center text-muted py-4"> </thead>
<i class="bi bi-tools mb-2 d-block fs-3 text-muted"></i> </table>
{% translate "Aucune intervention enregistrée sur cette localisation ou ses aménagements." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 5: Documents -->
<div class="tab-pane fade" id="documents-pane" role="tabpanel" aria-labelledby="documents-tab">
{% if documents %}
<div class="row g-3">
{% for document in documents %}
<div class="col-6 col-sm-4 col-md-3">
<div class="card h-100 shadow-sm border p-2 text-center">
<a href="{{ document.url }}" target="_blank" class="text-decoration-none text-dark">
<div class="img-thumbnail d-flex align-items-center justify-content-center mx-auto mb-2" style="width: 100%; height: 90px; overflow: hidden; background: #f8fafc;">
{% if document.thumbnail_url %}
<img src="{{ document.thumbnail_url }}" alt="{{ document.title }}" style="max-height: 100%; max-width: 100%; object-fit: contain;">
{% else %}
<i class="bi bi-file-earmark-text fs-2 text-secondary"></i>
{% endif %}
</div>
<div class="fw-semibold small text-truncate" title="{{ document.title }}">{{ document.title }}</div>
{% if document.tag %}
<span class="badge bg-primary-subtle text-primary border my-1" style="font-size: 0.7rem;">{{ document.tag }}</span>
{% endif %}
{% if document.date %}
<div class="text-muted" style="font-size: 0.7rem;">{{ document.date|date:"d/m/Y" }}</div>
{% endif %}
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-folder2-open mb-2 d-block fs-2 text-muted"></i>
{% translate "Aucun document associé à cette localisation." %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Right Column: Interactive Map -->
<div class="col-lg-5 col-xl-5">
<div class="card shadow-sm border-0 map-card-wrapper">
<div class="card-header bg-white py-2 px-3 d-flex align-items-center justify-content-between flex-wrap gap-2 border-bottom">
<span class="fw-bold text-dark d-flex align-items-center">
<i class="bi bi-geo-alt-fill text-success me-2"></i>{% translate "Carte & Aménagements" %}
</span>
<!-- Map Layer Controls / Legend -->
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-surfaces" checked onchange="toggleNatureLayer('surfaces', this.checked)">
<label class="form-check-label" for="toggle-surfaces"><span class="badge bg-success" style="width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Surfaces" %}</label>
</div>
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-trees" checked onchange="toggleNatureLayer('trees', this.checked)">
<label class="form-check-label" for="toggle-trees"><span class="badge bg-success" style="border-radius: 50%; width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Arbres" %}</label>
</div>
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-equipments" checked onchange="toggleNatureLayer('shrubs', this.checked); toggleNatureLayer('benches', this.checked)">
<label class="form-check-label" for="toggle-equipments"><span class="badge bg-warning" style="border-radius: 50%; width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Autres" %}</label>
</div>
</div>
</div>
<div class="card-body p-0 position-relative">
<div id="map"></div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-4">
<div id="map">
</div>
</div>
</div> </div>
{% endblock content %}
{% endblock content%}
{% block script %} {% block script %}
{{ feature_lon|json_script:"lon_value" }} {{ section.lon|json_script:"lon_value" }}
{{ feature_lat|json_script:"lat_value" }} {{ section.lat|json_script:"lat_value" }}
{{ section_geojson|json_script:"section_geojson_value" }} {{ section.geojson|json_script:"geojson_value" }}
{{ assets_geojson|json_script:"assets_geojson_value" }}
<script> <script>
const feature_geojson = JSON.parse(document.getElementById("geojson_value").textContent);
const feature_lon = JSON.parse(document.getElementById("lon_value").textContent); const feature_lon = JSON.parse(document.getElementById("lon_value").textContent);
const feature_lat = JSON.parse(document.getElementById("lat_value").textContent); const feature_lat = JSON.parse(document.getElementById("lat_value").textContent);
const section_geojson = JSON.parse(document.getElementById("section_geojson_value").textContent);
const assets_geojson = JSON.parse(document.getElementById("assets_geojson_value").textContent);
</script> </script>
<script src="{% static 'common/turf.min.js' %}"></script> <script src="{% static 'assets/green_detail.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'assets/nature_location_detail.js' %}"></script>
{% endblock script %} {% endblock script %}

View file

@ -0,0 +1,122 @@
{% extends "base.html" %}
{% load static %}
{% block content %}
<div class="row mt-2" style="height: calc(100vh - 200px);">
<div class="col-md-8" style="max-height: calc(100vh - 200px); overflow: auto;">
<h3 class="h3">
<i class="bi bi-suit-club"></i><span class="mx-2">{{ asset.code}}</span>
</h3>
<hr>
<div class="row mb-4">
<div class="col-md-6">
<label class="mx-2">Statut:</label><i class="bi bi-circle-fill status_{{ asset.status }}"></i> {{ asset.get_status_display }}<br>
<label class="mx-2">Type:</label>{{ asset.model.name_fr|default:"" }}<br>
</div>
<div class="col-md-6">
{% if asset.lon %}
<a href="https://www.google.com/maps?cbll={{ asset.lat|floatformat:'6u'}},{{ asset.lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid=" target="_blank" style="text-decoration: none; font-size: 16px; display: flex; align-items: center;">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="Street View" height="20" style="margin-right: 8px;">
View in Google Street View
</a>
{% endif %}
</div>
</div>
<div class="row mb-4">
<div class="col-md-12">
<label class="mx-2">Densité:</label>{{ asset.density }}<br>
<label class="mx-2">Pondération:</label>{{ asset.weighting }}<br>
<label class="mx-2">Fréquence de tonte:</label>{{ asset.weighting }}<br>
</div>
</div>
<div class="row mb-4">
<div class="col-md-6">
<label class="mx-2">Dernier entretien:</label>{{ asset.last_preventive_date|default:"" }}<br>
</div>
</div>
<!-- Documents -->
<div class="card mb-4">
<div class="card-header">
<h4>Documents</h4>
</div>
<div class="card-body">
<div class="row">
{% for document in asset.documents.all %}
<div class="col-md-2 mb-3">
<a href="{{ document.file.url }}" target="_blank">
<div class="img-thumbnail" style="width: 100%; height: 100px; overflow: hidden;">
{% if document.thumbnail %}
<img src="{{ document.thumbnail.url }}" alt="{{ document.file.name }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% else %}
<img src="{{ document.file.url }}" alt="{{ document.file.name }}" style="width: 100%; height: 100%; object-fit: cover; object-position: center;">
{% endif %}
</div>
</a>
</div>
{% endfor %}
</div>
</div>
</div>
<!-- Interventions -->
<div class="card mb-4">
<div class="card-header">
<h4>Interventions</h4>
</div>
<div class="card-body">
<table id="interventions-table" class="table"
data-height="550"
data-pagination="true"
data-page-size="25"
data-click-to-select="true" >
<thead>
<tr>
<th data-field="actions" data-width="100"></th>
<th data-field="code">Code</th>
<th data-field="title">Titre</th>
<th data-field="begin_date">Début</th>
<th data-field="statut">Statut</th>
</tr>
<tbody>
{% for interv in interventions %}
<tr>
<td>
<a href="{% url 'interventions:interventions_index' %}{{ interv.id }}" type="button" class="btn btn-sm btn-light detail-btn" data-id="${row.id}" data-bs-toggle="tooltip" title="Detail">
<i class="bi bi-info-circle"></i>
</a>
</td>
<td>{{ interv.code }}</td>
<td>{{ interv.title }}</td>
<td>{{ interv.begin_time|date:"d-m-Y H:i" }}</td>
<td>{{ interv.get_status_display }}</td>
</tr>
{% endfor %}
</tbody>
</thead>
</table>
</div>
</div>
</div>
<div class="col-md-4">
<div id="map">
</div>
</div>
</div>
{% endblock content%}
{% block script %}
{{ asset.lon|json_script:"lon_value" }}
{{ asset.lat|json_script:"lat_value" }}
{{ asset.geojson|json_script:"geojson_value" }}
<script>
const feature_geojson = JSON.parse(document.getElementById("geojson_value").textContent);
const feature_lon = JSON.parse(document.getElementById("lon_value").textContent);
const feature_lat = JSON.parse(document.getElementById("lat_value").textContent);
</script>
<script src="{% static 'assets/green_detail.js' %}"></script>
{% endblock script %}

View file

@ -88,24 +88,6 @@
{% if asset.is_remarkable %} {% if asset.is_remarkable %}
<span class="badge bg-warning text-dark ms-1 shadow-sm"><i class="bi bi-star-fill text-warning me-1"></i>{% translate "Arbre remarquable" %}</span> <span class="badge bg-warning text-dark ms-1 shadow-sm"><i class="bi bi-star-fill text-warning me-1"></i>{% translate "Arbre remarquable" %}</span>
{% endif %} {% endif %}
{# Badge Gestionnaire #}
{% if asset.managing_authority %}
<span class="badge {% if asset.management_type == 'regional' %}bg-primary-subtle text-primary border border-primary{% else %}bg-success-subtle text-success border border-success{% endif %} ms-1">
<i class="bi bi-buildings me-1"></i>{{ asset.managing_authority }}
</span>
{% elif asset.management_type %}
<span class="badge bg-secondary-subtle text-secondary border ms-1">{{ asset.get_management_type_display }}</span>
{% endif %}
{# Badges Biologiques Spéciaux #}
{% if asset.is_dead %}
<span class="badge bg-dark text-white ms-1"><i class="bi bi-x-circle me-1"></i>{% translate "Mort" %}</span>
{% elif asset.is_stump %}
<span class="badge bg-secondary text-white ms-1"><i class="bi bi-dash-circle me-1"></i>{% translate "Souche" %}</span>
{% elif asset.is_chronoxyle %}
<span class="badge bg-warning-subtle text-dark border border-warning ms-1"><i class="bi bi-recycle me-1"></i>{% translate "Chronoxyle" %}</span>
{% endif %}
</div> </div>
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.8rem; color: #6c757d;"> <div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.8rem; color: #6c757d;">
@ -177,24 +159,6 @@
{% if asset.is_remarkable %} {% if asset.is_remarkable %}
<span class="badge bg-warning text-dark ms-1 shadow-sm"><i class="bi bi-star-fill text-warning me-1"></i>{% translate "Arbre remarquable" %}</span> <span class="badge bg-warning text-dark ms-1 shadow-sm"><i class="bi bi-star-fill text-warning me-1"></i>{% translate "Arbre remarquable" %}</span>
{% endif %} {% endif %}
{# Badge Gestionnaire #}
{% if asset.managing_authority %}
<span class="badge {% if asset.management_type == 'regional' %}bg-primary-subtle text-primary border border-primary{% else %}bg-success-subtle text-success border border-success{% endif %} ms-1">
<i class="bi bi-buildings me-1"></i>{{ asset.managing_authority }}
</span>
{% elif asset.management_type %}
<span class="badge bg-secondary-subtle text-secondary border ms-1">{{ asset.get_management_type_display }}</span>
{% endif %}
{# Badges Biologiques Spéciaux #}
{% if asset.is_dead %}
<span class="badge bg-dark text-white ms-1"><i class="bi bi-x-circle me-1"></i>{% translate "Mort" %}</span>
{% elif asset.is_stump %}
<span class="badge bg-secondary text-white ms-1"><i class="bi bi-dash-circle me-1"></i>{% translate "Souche" %}</span>
{% elif asset.is_chronoxyle %}
<span class="badge bg-warning-subtle text-dark border border-warning ms-1"><i class="bi bi-recycle me-1"></i>{% translate "Chronoxyle" %}</span>
{% endif %}
</div> </div>
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.85rem; color: #6c757d;"> <div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.85rem; color: #6c757d;">
@ -257,89 +221,6 @@
</td> </td>
</tr> </tr>
<!-- Autorité gestionnaire -->
<tr class="asset-editable-field" data-field="managing_authority" data-can-edit="{% if 'managing_authority' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Autorité gestionnaire" %}</th>
<td>
<div class="field-display">
<span class="field-value fw-semibold text-primary">
{% if asset.managing_authority %}
<i class="bi bi-bank me-1"></i>{{ asset.managing_authority }}
{% if asset.management_type %}
<small class="text-muted fw-normal">({{ asset.get_management_type_display }})</small>
{% endif %}
{% else %}
{{ asset.get_management_type_display|default:"-" }}
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.managing_authority|default:'' }}" placeholder="{% translate 'Ex: Commune de Watermael-Boitsfort' %}">
</div>
</td>
</tr>
<!-- Type de gestionnaire -->
<tr class="asset-editable-field" data-field="management_type" data-can-edit="{% if 'management_type' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Type de gestionnaire" %}</th>
<td>
<div class="field-display"><span class="field-value">{{ asset.get_management_type_display|default:"-" }}</span></div>
<div class="field-edit d-none">
<select class="form-select form-select-sm">
{% for val, lbl in asset.TREE_MANAGEMENT_TYPE_CHOICES %}
<option value="{{ val }}" {% if asset.management_type == val %}selected{% endif %}>{{ lbl }}</option>
{% endfor %}
</select>
</div>
</td>
</tr>
<!-- Nom scientifique / Latin -->
<tr class="asset-editable-field" data-field="scientific_name" data-can-edit="{% if 'scientific_name' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Nom scientifique / Latin" %}</th>
<td>
<div class="field-display">
<span class="field-value fst-italic fw-medium text-success">
{{ asset.scientific_name|default:"-" }}
</span>
</div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.scientific_name|default:'' }}" placeholder="{% translate 'Ex: Prunus serrulata' %}">
</div>
</td>
</tr>
<!-- Nom vernaculaire / Essence -->
<tr class="asset-editable-field" data-field="vernacular_name" data-can-edit="{% if 'vernacular_name' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Nom vernaculaire / Essence" %}</th>
<td>
<div class="field-display"><span class="field-value">{{ asset.vernacular_name|default:"-" }}</span></div>
<div class="field-edit d-none">
<input type="text" class="form-control form-control-sm" value="{{ asset.vernacular_name|default:'' }}" placeholder="{% translate 'Ex: Cerisier du Japon' %}">
</div>
</td>
</tr>
<!-- Genre & Cultivar -->
{% if asset.genus or asset.cultivar or 'genus' in allowed_update_fields %}
<tr class="asset-editable-field" data-field="genus" data-can-edit="{% if 'genus' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Genre & Cultivar" %}</th>
<td>
<div class="field-display">
<span class="field-value">
{{ asset.genus|default:"-" }}{% if asset.cultivar %} '{{ asset.cultivar }}'{% endif %}
</span>
</div>
<div class="field-edit d-none">
<div class="row g-1">
<div class="col-6"><input type="text" class="form-control form-control-sm" placeholder="Genre" value="{{ asset.genus|default:'' }}"></div>
<div class="col-6"><input type="text" class="form-control form-control-sm" placeholder="Cultivar" value="{{ asset.cultivar|default:'' }}"></div>
</div>
</div>
</td>
</tr>
{% endif %}
<!-- Nom commun FR --> <!-- Nom commun FR -->
<tr class="asset-editable-field" data-field="name_fr" data-can-edit="{% if 'name_fr' in allowed_update_fields %}true{% else %}false{% endif %}"> <tr class="asset-editable-field" data-field="name_fr" data-can-edit="{% if 'name_fr' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Nom commun (FR)" %}</th> <th>{% translate "Nom commun (FR)" %}</th>
@ -490,7 +371,7 @@
<span class="field-value text-muted">{{ asset.note|default:"-"|linebreaksbr }}</span> <span class="field-value text-muted">{{ asset.note|default:"-"|linebreaksbr }}</span>
</div> </div>
<div class="field-edit d-none"> <div class="field-edit d-none">
<textarea class="form-control form-control-sm" rows="3">{{ asset.note|default:'' }}</textarea> <textarea class="form-control form-control-sm" rows="2">{{ asset.note|default:'' }}</textarea>
</div> </div>
</td> </td>
</tr> </tr>
@ -538,25 +419,6 @@
<table class="table table-striped mb-0 align-middle"> <table class="table table-striped mb-0 align-middle">
<tbody> <tbody>
<!-- Nombre de troncs / Cépée -->
<tr class="asset-editable-field" data-field="number_of_trunks" data-can-edit="{% if 'number_of_trunks' in allowed_update_fields %}true{% else %}false{% endif %}">
<th style="width: 35%;">{% translate "Nombre de troncs (cépée)" %}</th>
<td>
<div class="field-display">
<span class="field-value">
{% if asset.number_of_trunks and asset.number_of_trunks > 1 %}
<span class="badge bg-secondary-subtle text-secondary border"><i class="bi bi-diagram-3 me-1"></i>{{ asset.number_of_trunks }} {% translate "troncs" %}</span>
{% else %}
1 {% translate "(tronc unique)" %}
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<input type="number" step="1" min="1" class="form-control form-control-sm" value="{{ asset.number_of_trunks|default:1 }}">
</div>
</td>
</tr>
<!-- Hauteur totale --> <!-- Hauteur totale -->
<tr class="asset-editable-field" data-field="height" data-can-edit="{% if 'height' in allowed_update_fields %}true{% else %}false{% endif %}"> <tr class="asset-editable-field" data-field="height" data-can-edit="{% if 'height' in allowed_update_fields %}true{% else %}false{% endif %}">
<th style="width: 35%;">{% translate "Hauteur totale (m)" %}</th> <th style="width: 35%;">{% translate "Hauteur totale (m)" %}</th>
@ -656,173 +518,6 @@
</div> </div>
{# ── CARTE ÉCO-SERVICES & VALEUR PATRIMONIALE (GRALITY / BARÈME DE L'ARBRE) ── #}
<div class="card mb-3 shadow-sm border-success-subtle">
<div class="card-header bg-success-subtle d-flex align-items-center justify-content-between">
<h5 class="mb-0 fs-6 fw-bold text-success-emphasis">
<i class="bi bi-feather me-2"></i>{% translate "Éco-services & Valeur patrimoniale" %}
</h5>
<span class="badge bg-success text-white">
<i class="bi bi-shield-check me-1"></i>{% translate "Indicateurs écologiques" %}
</span>
</div>
<div class="card-body">
{# 4 mini-widgets écologiques #}
<div class="row g-2 mb-3">
<!-- Valeur d'aménité -->
<div class="col-6 col-md-3">
<div class="p-2 border border-success-subtle rounded text-center bg-white shadow-sm tree-metric-widget">
<div class="small text-muted mb-1 metric-title text-truncate" title="{% translate "Valeur d'aménité calculée" %}">
<i class="bi bi-currency-euro text-success me-1"></i>{% translate "Aménité" %}
</div>
<div class="fs-5 fw-bold text-success metric-value">
{% if asset.amenity_value %}
{{ asset.amenity_value|floatformat:0 }} <small class="text-muted fw-normal" style="font-size: 0.75rem;">€</small>
{% else %}
<span class="text-muted fs-6">&mdash;</span>
{% endif %}
</div>
</div>
</div>
<!-- Stock carbone -->
<div class="col-6 col-md-3">
<div class="p-2 border border-primary-subtle rounded text-center bg-white shadow-sm tree-metric-widget">
<div class="small text-muted mb-1 metric-title text-truncate" title="{% translate 'Stock de carbone séquestré' %}">
<i class="bi bi-cloud-check text-primary me-1"></i>{% translate "Carbone" %}
</div>
<div class="fs-5 fw-bold text-primary metric-value">
{% if asset.carbon_stock %}
{{ asset.carbon_stock|floatformat:2 }} <small class="text-muted fw-normal" style="font-size: 0.75rem;">t CO₂</small>
{% else %}
<span class="text-muted fs-6">&mdash;</span>
{% endif %}
</div>
</div>
</div>
<!-- Rafraîchissement urbain -->
<div class="col-6 col-md-3">
<div class="p-2 border border-info-subtle rounded text-center bg-white shadow-sm tree-metric-widget">
<div class="small text-muted mb-1 metric-title text-truncate" title="{% translate 'Potentiel de rafraîchissement urbain' %}">
<i class="bi bi-thermometer-snow text-info me-1"></i>{% translate "Fraîcheur" %}
</div>
<div class="fs-5 fw-bold text-info-emphasis metric-value">
{% if asset.cooling_indicator %}
{{ asset.cooling_indicator|floatformat:1 }} <small class="text-muted fw-normal" style="font-size: 0.75rem;">/ 10</small>
{% else %}
<span class="text-muted fs-6">&mdash;</span>
{% endif %}
</div>
</div>
</div>
<!-- Indice de biodiversité -->
<div class="col-6 col-md-3">
<div class="p-2 border border-warning-subtle rounded text-center bg-white shadow-sm tree-metric-widget">
<div class="small text-muted mb-1 metric-title text-truncate" title="{% translate "Indice d'accueil biodiversité" %}">
<i class="bi bi-bug text-warning me-1"></i>{% translate "Biodiversité" %}
</div>
<div class="fs-5 fw-bold text-warning-emphasis metric-value">
{% if asset.biodiversity_index %}
{{ asset.biodiversity_index|floatformat:2 }}
{% else %}
<span class="text-muted fs-6">&mdash;</span>
{% endif %}
</div>
</div>
</div>
</div>
{# Table détaillée des indicateurs écosystémiques #}
<table class="table table-striped mb-0 align-middle">
<tbody>
<!-- Énergie de refroidissement -->
<tr class="asset-editable-field" data-field="cooling_energy_indicator" data-can-edit="{% if 'cooling_energy_indicator' in allowed_update_fields %}true{% else %}false{% endif %}">
<th style="width: 35%;">{% translate "Énergie de refroidissement (kWh)" %}</th>
<td>
<div class="field-display">
<span class="field-value">
{% if asset.cooling_energy_indicator %}
{{ asset.cooling_energy_indicator|floatformat:1 }} kWh
{% else %}
-
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<input type="number" step="0.1" class="form-control form-control-sm" value="{{ asset.cooling_energy_indicator|default:'' }}">
</div>
</td>
</tr>
<!-- Coefficient de situation -->
<tr class="asset-editable-field" data-field="situation_coefficient" data-can-edit="{% if 'situation_coefficient' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Coefficient de situation (barème)" %}</th>
<td>
<div class="field-display"><span class="field-value">{{ asset.situation_coefficient|default:"-" }}</span></div>
<div class="field-edit d-none">
<input type="number" step="0.1" class="form-control form-control-sm" value="{{ asset.situation_coefficient|default:'' }}">
</div>
</td>
</tr>
<!-- Coefficient de plantation -->
<tr class="asset-editable-field" data-field="plantation_coefficient" data-can-edit="{% if 'plantation_coefficient' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Coefficient de plantation (barème)" %}</th>
<td>
<div class="field-display"><span class="field-value">{{ asset.plantation_coefficient|default:"-" }}</span></div>
<div class="field-edit d-none">
<input type="number" step="0.1" class="form-control form-control-sm" value="{{ asset.plantation_coefficient|default:'' }}">
</div>
</td>
</tr>
<!-- Date de plantation réelle -->
<tr class="asset-editable-field" data-field="planting_date" data-can-edit="{% if 'planting_date' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Date de plantation constatée" %}</th>
<td>
<div class="field-display">
<span class="field-value">
{% if asset.planting_date %}
<i class="bi bi-calendar3 me-1 text-muted"></i>{{ asset.planting_date|date:"d/m/Y" }}
{% else %}
-
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<input type="date" class="form-control form-control-sm" value="{{ asset.planting_date|date:'Y-m-d'|default:'' }}">
</div>
</td>
</tr>
<!-- Date de dépérissement / mort -->
{% if asset.death_date or asset.is_dead or 'death_date' in allowed_update_fields %}
<tr class="asset-editable-field" data-field="death_date" data-can-edit="{% if 'death_date' in allowed_update_fields %}true{% else %}false{% endif %}">
<th>{% translate "Date de mort / dépérissement" %}</th>
<td>
<div class="field-display">
<span class="field-value text-danger">
{% if asset.death_date %}
<i class="bi bi-calendar-x me-1"></i>{{ asset.death_date|date:"d/m/Y" }}
{% else %}
-
{% endif %}
</span>
</div>
<div class="field-edit d-none">
<input type="date" class="form-control form-control-sm" value="{{ asset.death_date|date:'Y-m-d'|default:'' }}">
</div>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{# ── CARTE 3 : DIAGNOSTIC SANITAIRE & SÉCURITÉ ── #} {# ── CARTE 3 : DIAGNOSTIC SANITAIRE & SÉCURITÉ ── #}
<div class="card mb-3 shadow-sm"> <div class="card mb-3 shadow-sm">
<div class="card-header bg-light d-flex align-items-center justify-content-between"> <div class="card-header bg-light d-flex align-items-center justify-content-between">

View file

@ -342,25 +342,20 @@
<select name="action_pole_db_{{ item.code }}" class="form-select form-select-sm rounded-2"> <select name="action_pole_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="keep">{% translate "Conserver en DB" %}</option> <option value="keep">{% translate "Conserver en DB" %}</option>
<option value="archive" selected>{% translate "Archiver" %}</option> <option value="archive" selected>{% translate "Archiver" %}</option>
<option value="delete">{% translate "Supprimer de la DB" %}</option>
</select> </select>
{% else %} {% else %}
<select name="action_pole_{{ item.code }}" class="form-select form-select-sm rounded-2"> <select name="action_pole_{{ item.code }}" class="form-select form-select-sm rounded-2">
{% if item.status == 'new' %} {% if item.status == 'new' %}
<option value="add" selected>{% translate "Créer l'asset" %}</option> <option value="add" selected>{% translate "Créer l'asset" %}</option>
<option value="keep">{% translate "Ne pas créer (Ignorer)" %}</option> <option value="keep">{% translate "Ignorer" %}</option>
{% elif item.status == 'modified' %} {% elif item.status == 'modified' %}
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option> <option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option> <option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="keep">{% translate "Conserver existant" %}</option> <option value="keep">{% translate "Conserver existant" %}</option>
<option value="archive">{% translate "Archiver l'existant (Ne pas recréer)" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% elif item.status == 'identical' %} {% elif item.status == 'identical' %}
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option> <option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
<option value="modify">{% translate "Mettre à jour l'asset" %}</option> <option value="modify">{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option> <option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="archive">{% translate "Archiver l'existant" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% endif %} {% endif %}
</select> </select>
{% endif %} {% endif %}
@ -443,25 +438,19 @@
<select name="action_lantern_db_{{ item.code }}" class="form-select form-select-sm rounded-2"> <select name="action_lantern_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="keep">{% translate "Conserver en DB" %}</option> <option value="keep">{% translate "Conserver en DB" %}</option>
<option value="archive" selected>{% translate "Archiver" %}</option> <option value="archive" selected>{% translate "Archiver" %}</option>
<option value="delete">{% translate "Supprimer de la DB" %}</option>
</select> </select>
{% else %} {% else %}
<select name="action_lantern_{{ item.code }}" class="form-select form-select-sm rounded-2"> <select name="action_lantern_{{ item.code }}" class="form-select form-select-sm rounded-2">
{% if item.status == 'new' %} {% if item.status == 'new' %}
<option value="add" selected>{% translate "Créer l'asset" %}</option> <option value="add" selected>{% translate "Créer l'asset" %}</option>
<option value="keep">{% translate "Ne pas créer (Ignorer)" %}</option> <option value="keep">{% translate "Ignorer" %}</option>
{% elif item.status == 'modified' %} {% elif item.status == 'modified' %}
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option> <option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option> <option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="keep">{% translate "Conserver existant" %}</option> <option value="keep">{% translate "Conserver existant" %}</option>
<option value="archive">{% translate "Archiver l'existant (Ne pas recréer)" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% elif item.status == 'identical' %} {% elif item.status == 'identical' %}
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option> <option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
<option value="modify">{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option> <option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="archive">{% translate "Archiver l'existant" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% endif %} {% endif %}
</select> </select>
{% endif %} {% endif %}
@ -514,11 +503,11 @@
</thead> </thead>
<tbody> <tbody>
{% for item in detectors %} {% for item in detectors %}
<tr class="{% if item.status == 'new' %}table-success-light{% elif item.status == 'modified' %}table-warning-light{% elif item.status == 'db_only' %}{% if item.action_default == 'keep' %}table-light{% else %}table-danger-light{% endif %}{% endif %}"> <tr class="{% if item.status == 'new' %}table-success-light{% elif item.status == 'modified' %}table-warning-light{% elif item.status == 'db_only' %}table-danger-light{% endif %}">
<td class="ps-4"><input type="checkbox" name="{% if item.status == 'db_only' %}selected_detector_db_{{ item.code }}{% else %}selected_detector_{{ item.code }}{% endif %}" class="form-check-input detector-checkbox" checked></td> <td class="ps-4"><input type="checkbox" name="{% if item.status == 'db_only' %}selected_detector_db_{{ item.code }}{% else %}selected_detector_{{ item.code }}{% endif %}" class="form-check-input detector-checkbox" checked></td>
<td class="font-weight-medium"><code>{{ item.code }}</code><input type="hidden" name="db_id_detector_{{ item.code }}" value="{{ item.db_id|default:'' }}"></td> <td class="font-weight-medium"><code>{{ item.code }}</code><input type="hidden" name="db_id_detector_{{ item.code }}" value="{{ item.db_id|default:'' }}"></td>
<td> <td>
<input type="text" name="name_detector_{{ item.code }}" class="form-control form-control-sm" value="{{ item.name }}"> <input type="text" name="name_detector_{{ item.code }}" class="form-control form-control-sm" value="{{ item.name }}">
</td> </td>
<td><code>{{ item.pole_code|default:"—" }}</code></td> <td><code>{{ item.pole_code|default:"—" }}</code></td>
<td> <td>
@ -529,11 +518,7 @@
{% elif item.status == 'identical' %} {% elif item.status == 'identical' %}
<span class="badge bg-light text-secondary border">{% translate "Identique" %}</span> <span class="badge bg-light text-secondary border">{% translate "Identique" %}</span>
{% elif item.status == 'db_only' %} {% elif item.status == 'db_only' %}
{% if item.action_default == 'keep' %} <span class="badge bg-danger">{% translate "DB uniquement" %}</span>
<span class="badge bg-secondary">{% translate "DB uniquement" %}</span>
{% else %}
<span class="badge bg-danger">{% translate "DB uniquement" %}</span>
{% endif %}
{% endif %} {% endif %}
{% for diff in item.details_diff %} {% for diff in item.details_diff %}
<div class="small text-muted mt-1" style="font-size: 0.75rem; line-height: 1.25;"> <div class="small text-muted mt-1" style="font-size: 0.75rem; line-height: 1.25;">
@ -546,27 +531,21 @@
<td> <td>
{% if item.status == 'db_only' %} {% if item.status == 'db_only' %}
<select name="action_detector_db_{{ item.code }}" class="form-select form-select-sm rounded-2"> <select name="action_detector_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="keep" {% if item.action_default == 'keep' %}selected{% endif %}>{% translate "Conserver en DB" %}</option> <option value="keep">{% translate "Conserver en DB" %}</option>
<option value="archive" {% if item.action_default != 'keep' %}selected{% endif %}>{% translate "Archiver" %}</option> <option value="archive" selected>{% translate "Archiver" %}</option>
<option value="delete">{% translate "Supprimer de la DB" %}</option>
</select> </select>
{% else %} {% else %}
<select name="action_detector_{{ item.code }}" class="form-select form-select-sm rounded-2"> <select name="action_detector_{{ item.code }}" class="form-select form-select-sm rounded-2">
{% if item.status == 'new' %} {% if item.status == 'new' %}
<option value="add" selected>{% translate "Créer l'asset" %}</option> <option value="add" selected>{% translate "Créer l'asset" %}</option>
<option value="keep">{% translate "Ne pas créer (Ignorer)" %}</option> <option value="keep">{% translate "Ignorer" %}</option>
{% elif item.status == 'modified' %} {% elif item.status == 'modified' %}
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option> <option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option> <option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="keep">{% translate "Conserver existant" %}</option> <option value="keep">{% translate "Conserver existant" %}</option>
<option value="archive">{% translate "Archiver l'existant (Ne pas recréer)" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% elif item.status == 'identical' %} {% elif item.status == 'identical' %}
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option> <option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
<option value="modify">{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option> <option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="archive">{% translate "Archiver l'existant" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% endif %} {% endif %}
</select> </select>
{% endif %} {% endif %}
@ -661,11 +640,6 @@
{% else %} {% else %}
<span class="badge bg-light text-muted border">{% translate "Non tracé" %}</span> <span class="badge bg-light text-muted border">{% translate "Non tracé" %}</span>
{% endif %} {% endif %}
{% if item.status != 'db_only' %}
<button type="button" class="btn btn-xs btn-outline-primary ms-1 show-cable-on-map-btn" data-code="{{ item.code }}" title="{% translate 'Voir et éditer sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
</td> </td>
<td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td> <td><span class="text-secondary small">{{ item.deduced_model_name|default:"—" }}</span></td>
<td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td> <td><span class="text-secondary small">{{ item.db_model_name|default:"—" }}</span></td>
@ -674,25 +648,20 @@
<select name="action_cable_db_{{ item.code }}" class="form-select form-select-sm rounded-2"> <select name="action_cable_db_{{ item.code }}" class="form-select form-select-sm rounded-2">
<option value="keep">{% translate "Conserver en DB" %}</option> <option value="keep">{% translate "Conserver en DB" %}</option>
<option value="archive" selected>{% translate "Archiver" %}</option> <option value="archive" selected>{% translate "Archiver" %}</option>
<option value="delete">{% translate "Supprimer de la DB" %}</option>
</select> </select>
{% else %} {% else %}
<select name="action_cable_{{ item.code }}" class="form-select form-select-sm rounded-2"> <select name="action_cable_{{ item.code }}" class="form-select form-select-sm rounded-2">
{% if item.status == 'new' %} {% if item.status == 'new' %}
<option value="add" selected>{% translate "Créer l'asset" %}</option> <option value="add" selected>{% translate "Créer l'asset" %}</option>
<option value="keep">{% translate "Ne pas créer (Ignorer)" %}</option> <option value="keep">{% translate "Ignorer" %}</option>
{% elif item.status == 'modified' %} {% elif item.status == 'modified' %}
<option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option> <option value="modify" selected>{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option> <option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="keep">{% translate "Conserver existant" %}</option> <option value="keep">{% translate "Conserver existant" %}</option>
<option value="archive">{% translate "Archiver l'existant (Ne pas recréer)" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% elif item.status == 'identical' %} {% elif item.status == 'identical' %}
<option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option> <option value="keep" selected>{% translate "Ne rien faire (Identique)" %}</option>
<option value="modify">{% translate "Mettre à jour l'asset" %}</option> <option value="modify">{% translate "Mettre à jour l'asset" %}</option>
<option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option> <option value="replace">{% translate "Remplacer (Archiver + Créer)" %}</option>
<option value="archive">{% translate "Archiver l'existant" %}</option>
<option value="delete">{% translate "Supprimer l'existant de la DB" %}</option>
{% endif %} {% endif %}
</select> </select>
{% endif %} {% endif %}
@ -780,7 +749,6 @@
<select name="action_other_{{ item.unique_key }}" class="form-select form-select-sm rounded-2"> <select name="action_other_{{ item.unique_key }}" class="form-select form-select-sm rounded-2">
<option value="keep" selected>{% translate "Conserver (Par défaut)" %}</option> <option value="keep" selected>{% translate "Conserver (Par défaut)" %}</option>
<option value="archive">{% translate "Archiver" %}</option> <option value="archive">{% translate "Archiver" %}</option>
<option value="delete">{% translate "Supprimer de la DB" %}</option>
</select> </select>
</td> </td>
</tr> </tr>
@ -879,19 +847,13 @@
<!-- Map Cables Sub-pane --> <!-- Map Cables Sub-pane -->
<div class="tab-pane fade h-100" id="map-cables-pane" role="tabpanel" aria-labelledby="map-cables-tab"> <div class="tab-pane fade h-100" id="map-cables-pane" role="tabpanel" aria-labelledby="map-cables-tab">
<div class="d-flex flex-column h-100 align-items-start justify-content-start"> <div class="d-flex flex-column h-100 align-items-start justify-content-start">
<div class="mb-2 p-2.5 bg-light rounded-3 border w-100 shadow-sm" style="font-size: 0.78rem;"> <div class="mb-2 p-2 bg-light rounded-3 border w-100">
<div class="d-flex align-items-center justify-content-between mb-1.5 pb-1 border-bottom"> <div class="font-weight-semibold text-success mb-1">
<span class="font-weight-semibold text-success d-flex align-items-center gap-1"> <i class="bi bi-bezier"></i> {% translate "Tracé des câbles" %}
<i class="bi bi-bezier"></i> {% translate "Aide au tracé des câbles" %}
</span>
</div> </div>
<ul class="list-unstyled mb-0 text-secondary d-flex flex-column gap-1" style="line-height: 1.35;"> <p class="text-secondary mb-0 small">
<li><i class="bi bi-plus-circle text-primary me-1"></i><strong>{% translate "Tracer" %} :</strong> {% translate "Double-clic sur la carte pour ajouter un point." %}</li> {% translate "Cliquez sur 'Tracer / Éditer' puis double-cliquez sur la carte pour tracer la ligne du câble." %}
<li><i class="bi bi-node-plus text-success me-1"></i><strong>{% translate "Ajouter un nœud" %} :</strong> {% translate "Double-clic sur un segment du câble." %}</li> </p>
<li><i class="bi bi-node-minus text-danger me-1"></i><strong>{% translate "Supprimer un nœud" %} :</strong> {% translate "Double-clic sur un nœud (poignée orange)." %}</li>
<li><i class="bi bi-arrows-move text-warning me-1"></i><strong>{% translate "Déplacer" %} :</strong> {% translate "Glisser-déposer les poignées pour ajuster." %}</li>
<li><i class="bi bi-files text-info me-1"></i><strong>{% translate "Copier tracé" %} :</strong> {% translate "Copiez le tracé d'un autre câble suivant le même trajet (ex: 48 et tel.)." %}</li>
</ul>
</div> </div>
<div class="list-group list-group-flush overflow-auto flex-grow-1 w-100 align-self-stretch"> <div class="list-group list-group-flush overflow-auto flex-grow-1 w-100 align-self-stretch">
@ -900,23 +862,12 @@
<div class="list-group-item py-2 px-1 border-bottom d-flex flex-column gap-1 cable-config-item align-self-stretch" data-code="{{ item.code }}"> <div class="list-group-item py-2 px-1 border-bottom d-flex flex-column gap-1 cable-config-item align-self-stretch" data-code="{{ item.code }}">
<div class="d-flex align-items-center justify-content-between w-100"> <div class="d-flex align-items-center justify-content-between w-100">
<strong class="text-dark font-monospace">{{ item.name }}</strong> <strong class="text-dark font-monospace">{{ item.name }}</strong>
<span class="badge cable-geom-status-badge font-monospace" id="badge-cable-status-{{ item.code }}" style="font-size: 0.72rem;">
<span class="text-muted">…</span>
</span>
</div> </div>
<div class="text-muted small font-monospace">{{ item.deduced_model_name }}</div> <div class="text-muted small font-monospace">{{ item.deduced_model_name }}</div>
<div class="mt-1 d-flex align-items-center gap-1 flex-wrap"> <div class="mt-1 align-self-start">
<button type="button" class="btn btn-xs btn-outline-success py-0.5 px-2 edit-cable-btn rounded-2" data-code="{{ item.code }}"> <button type="button" class="btn btn-xs btn-outline-success py-0.5 px-2 edit-cable-btn rounded-2" data-code="{{ item.code }}">
<i class="bi bi-pencil-square"></i> {% translate "Tracer / Éditer" %} <i class="bi bi-pencil-square"></i> {% translate "Tracer / Éditer" %}
</button> </button>
<div class="dropdown d-inline-block">
<button type="button" class="btn btn-xs btn-outline-secondary dropdown-toggle py-0.5 px-2 rounded-2 copy-cable-btn" data-bs-toggle="dropdown" aria-expanded="false" title="{% translate "Copier le tracé d'un autre câble" %}">
<i class="bi bi-files"></i> {% translate "Copier tracé…" %}
</button>
<ul class="dropdown-menu dropdown-menu-end shadow-sm copy-cable-menu py-1" data-target-code="{{ item.code }}" style="font-size: 0.8rem; min-width: 220px;">
<!-- Populated dynamically by JS -->
</ul>
</div>
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -936,17 +887,10 @@
<!-- Mode indicator centered at the top --> <!-- Mode indicator centered at the top -->
<div class="position-absolute top-0 start-50 translate-middle-x m-3 z-3 bg-white py-1 px-3 rounded-pill shadow border d-flex align-items-center gap-2" style="white-space: nowrap;"> <div class="position-absolute top-0 start-50 translate-middle-x m-3 z-3 bg-white py-1 px-3 rounded-pill shadow border d-flex align-items-center gap-2" style="white-space: nowrap;">
<span class="small text-muted font-monospace font-weight-semibold mb-0" id="map-mode-indicator">{% translate "Mode: Lecture" %}</span> <span class="small text-muted font-monospace font-weight-semibold mb-0" id="map-mode-indicator">{% translate "Mode: Lecture" %}</span>
<button type="button" id="btn-duplicate-trace" class="btn btn-xs btn-outline-primary rounded-pill py-0.5 px-2" style="display: none; font-size: 0.75rem;">
<i class="bi bi-copy"></i> {% translate "Appliquer aux autres câbles…" %}
</button>
<button type="button" id="btn-clear-trace" class="btn btn-xs btn-outline-danger rounded-pill py-0.5 px-2" style="display: none; font-size: 0.75rem;"> <button type="button" id="btn-clear-trace" class="btn btn-xs btn-outline-danger rounded-pill py-0.5 px-2" style="display: none; font-size: 0.75rem;">
<i class="bi bi-trash"></i> {% translate "Effacer" %} <i class="bi bi-trash"></i> {% translate "Effacer" %}
</button> </button>
</div> </div>
<div id="cable-edit-tip" class="position-absolute start-50 translate-middle-x z-2 px-3 py-1 bg-white bg-opacity-95 rounded-pill shadow-sm border small text-muted text-center" style="top: 56px; display: none; font-size: 0.75rem; white-space: nowrap;">
<i class="bi bi-info-circle text-primary me-1"></i>
<span>{% translate "Double-clic sur un segment pour ajouter un nœud • Double-clic sur un nœud pour le supprimer • Glisser pour déplacer" %}</span>
</div>
<div id="map" class="w-100 h-100"></div> <div id="map" class="w-100 h-100"></div>
</div> </div>
@ -955,46 +899,6 @@
</div> </div>
</form> </form>
<!-- Modal: Appliquer le tracé d'un câble à d'autres câbles -->
<div class="modal fade" id="duplicateCableModal" tabindex="-1" aria-labelledby="duplicateCableModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content rounded-4 border-0 shadow">
<div class="modal-header border-bottom py-2.5 px-3">
<h6 class="modal-title font-weight-semibold text-dark d-flex align-items-center gap-2 mb-0" id="duplicateCableModalLabel">
<i class="bi bi-copy text-primary"></i> {% translate "Appliquer le tracé à d'autres câbles" %}
</h6>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-3">
<p class="text-secondary small mb-3">
{% translate "Copier le tracé de" %} <strong id="modal-source-cable-name" class="text-dark"></strong> <span id="modal-source-cable-info" class="badge bg-light text-secondary border"></span> {% translate "vers les câbles sélectionnés ci-dessous :" %}
</p>
<div class="p-2.5 bg-light rounded-3 border mb-2">
<div class="form-check mb-0">
<input class="form-check-input" type="checkbox" id="modal-select-all-cables">
<label class="form-check-label font-weight-semibold small text-dark cursor-pointer" for="modal-select-all-cables">
{% translate "Tout sélectionner" %}
</label>
</div>
</div>
<div id="modal-target-cables-list" class="d-flex flex-column gap-1.5 overflow-auto pe-1" style="max-height: 240px;">
<!-- Dynamically generated list of cables -->
</div>
</div>
<div class="modal-footer border-top py-2 px-3 d-flex justify-content-between">
<button type="button" class="btn btn-sm btn-outline-secondary rounded-2" data-bs-dismiss="modal">
{% translate "Annuler" %}
</button>
<button type="button" id="btn-confirm-duplicate-cable" class="btn btn-sm btn-primary rounded-2 px-3 d-flex align-items-center gap-1.5">
<i class="bi bi-check-lg"></i> {% translate "Copier le tracé" %}
</button>
</div>
</div>
</div>
</div>
{% endif %} {% endif %}
</div> </div>
@ -1153,47 +1057,30 @@ document.addEventListener('DOMContentLoaded', () => {
const tableView = document.getElementById('table-view'); const tableView = document.getElementById('table-view');
const mapView = document.getElementById('map-view'); const mapView = document.getElementById('map-view');
function switchToMapView(targetTab, cableCodeToEdit) {
tableView.classList.remove('d-block');
tableView.classList.add('d-none');
mapView.classList.remove('d-none');
mapView.classList.add('d-block');
// Trigger MapLibre GL canvas resize recalculation
if (map) {
map.resize();
}
// Activate corresponding tab in map sidebar
if (targetTab === 'poles') {
const tabEl = document.getElementById('map-poles-tab');
if (tabEl) new bootstrap.Tab(tabEl).show();
} else if (targetTab === 'cables') {
const tabEl = document.getElementById('map-cables-tab');
if (tabEl) new bootstrap.Tab(tabEl).show();
if (cableCodeToEdit) {
activateCableEditingMode(cableCodeToEdit);
const item = document.querySelector(`.cable-config-item[data-code="${cableCodeToEdit}"]`);
if (item) {
item.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
}
}
document.querySelectorAll('.show-map-btn').forEach(btn => { document.querySelectorAll('.show-map-btn').forEach(btn => {
btn.addEventListener('click', function(e) { btn.addEventListener('click', function(e) {
e.preventDefault(); e.preventDefault();
switchToMapView(this.dataset.targetTab); const targetTab = this.dataset.targetTab;
});
}); tableView.classList.remove('d-block');
tableView.classList.add('d-none');
document.querySelectorAll('.show-cable-on-map-btn').forEach(btn => {
btn.addEventListener('click', function(e) { mapView.classList.remove('d-none');
e.preventDefault(); mapView.classList.add('d-block');
e.stopPropagation();
switchToMapView('cables', this.dataset.code); // Trigger MapLibre GL canvas resize recalculation
if (map) {
map.resize();
}
// Activate corresponding tab in map sidebar
if (targetTab === 'poles') {
const tabEl = document.getElementById('map-poles-tab');
if (tabEl) new bootstrap.Tab(tabEl).show();
} else if (targetTab === 'cables') {
const tabEl = document.getElementById('map-cables-tab');
if (tabEl) new bootstrap.Tab(tabEl).show();
}
}); });
}); });
@ -1264,11 +1151,7 @@ document.addEventListener('DOMContentLoaded', () => {
name: "{{ item.name|escapejs }}", name: "{{ item.name|escapejs }}",
status: "{{ item.status|escapejs }}", status: "{{ item.status|escapejs }}",
geom_modified: {{ item.geom_modified|yesno:"true,false" }}, geom_modified: {{ item.geom_modified|yesno:"true,false" }},
connected_poles: {{ item.connected_poles|safe|default:"[]" }}, connected_poles: {{ item.connected_poles|safe|default:"[]" }}
db_coords: {{ item.db_coords|safe|default:"[]" }},
has_db_geom: {{ item.has_db_geom|yesno:"true,false" }},
db_geom_length: {% if item.db_geom_length is not None %}{{ item.db_geom_length|unlocalize }}{% else %}null{% endif %},
deduced_model_name: "{{ item.deduced_model_name|default:''|escapejs }}"
}, },
{% endfor %} {% endfor %}
]; ];
@ -1303,87 +1186,6 @@ document.addEventListener('DOMContentLoaded', () => {
const placedPoles = {}; const placedPoles = {};
let layerSwitcher = null; let layerSwitcher = null;
function calculateLineLengthMeters(coords) {
if (!coords || coords.length < 2) return 0;
const R = 6371000;
let total = 0;
for (let i = 0; i < coords.length - 1; i++) {
const lon1 = coords[i][0] * Math.PI / 180;
const lat1 = coords[i][1] * Math.PI / 180;
const lon2 = coords[i+1][0] * Math.PI / 180;
const lat2 = coords[i+1][1] * Math.PI / 180;
const dLat = lat2 - lat1;
const dLon = lon2 - lon1;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
total += R * c;
}
return Math.round(total * 10) / 10;
}
function getCableCoords(code) {
if (cableGeometries[code] && cableGeometries[code].length >= 2) {
return cableGeometries[code];
}
const c = cablesList.find(item => item.code === code);
if (!c) return [];
if (c.db_coords && c.db_coords.length >= 2) {
return c.db_coords;
}
const coords = [];
const connected = c.connected_poles || [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
return coords;
}
function updateCableBadge(code) {
const badge = document.getElementById(`badge-cable-status-${code}`);
const coords = getCableCoords(code);
const c = cablesList.find(item => item.code === code);
if (!badge || !c) return;
if (coords && coords.length >= 2) {
const len = calculateLineLengthMeters(coords);
const isModified = cableGeometries[code] !== undefined || c.geom_modified;
if (isModified) {
badge.className = 'badge bg-warning text-dark border border-warning-subtle font-monospace cable-geom-status-badge';
badge.innerHTML = `<i class="bi bi-bezier2"></i> ${coords.length} pts (${len} m)`;
} else {
badge.className = 'badge bg-info-subtle text-info-emphasis border border-info-subtle font-monospace cable-geom-status-badge';
badge.innerHTML = `<i class="bi bi-bezier2"></i> ${coords.length} pts (${len} m)`;
}
} else {
badge.className = 'badge bg-light text-muted border font-monospace cable-geom-status-badge';
badge.textContent = `{% translate "Non tracé" %}`;
}
}
function updateTableCableGeomCell(code) {
const geomCell = document.getElementById(`geom_cell_cable_${code}`);
if (!geomCell) return;
const coords = getCableCoords(code);
if (coords && coords.length >= 2) {
const len = calculateLineLengthMeters(coords);
geomCell.innerHTML = `<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1"><i class="bi bi-arrows-move"></i> {% translate "Tracé modifié" %} (${len} m)</span> <button type="button" class="btn btn-xs btn-outline-primary ms-1 show-cable-on-map-btn" data-code="${code}" title="{% translate 'Voir et éditer sur la carte' %}"><i class="bi bi-geo-alt"></i></button>`;
} else {
geomCell.innerHTML = `<span class="badge bg-light text-muted border">{% translate "Non tracé" %}</span> <button type="button" class="btn btn-xs btn-outline-primary ms-1 show-cable-on-map-btn" data-code="${code}" title="{% translate 'Tracer sur la carte' %}"><i class="bi bi-geo-alt"></i></button>`;
}
const btn = geomCell.querySelector('.show-cable-on-map-btn');
if (btn) {
btn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
switchToMapView('cables', code);
});
}
}
function markCableAsModified(code) { function markCableAsModified(code) {
const selectAction = document.querySelector(`select[name="action_cable_${code}"]`); const selectAction = document.querySelector(`select[name="action_cable_${code}"]`);
if (selectAction) { if (selectAction) {
@ -1396,8 +1198,10 @@ document.addEventListener('DOMContentLoaded', () => {
const cb = document.querySelector(`input[name="selected_cable_${code}"]`); const cb = document.querySelector(`input[name="selected_cable_${code}"]`);
if (cb) cb.checked = true; if (cb) cb.checked = true;
updateTableCableGeomCell(code); const geomCell = document.getElementById(`geom_cell_cable_${code}`);
updateCableBadge(code); if (geomCell) {
geomCell.innerHTML = `<span class="badge bg-warning text-dark d-inline-flex align-items-center gap-1"><i class="bi bi-arrows-move"></i> {% translate "Tracé modifié" %}</span>`;
}
} }
const snapCandidates = initialDxfPoles.map(dp => [dp.lon, dp.lat, dp.x_3812, dp.y_3812]); const snapCandidates = initialDxfPoles.map(dp => [dp.lon, dp.lat, dp.x_3812, dp.y_3812]);
@ -1887,12 +1691,8 @@ document.addEventListener('DOMContentLoaded', () => {
deactivateCableEditingMode(); deactivateCableEditingMode();
activeEditCable = code; activeEditCable = code;
const cableInfo = cablesList.find(c => c.code === code); document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Tracé" %} ${cablesList.find(c => c.code === code).name}`;
const cableName = cableInfo ? cableInfo.name : code;
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Tracé" %} ${cableName}`;
document.getElementById('btn-clear-trace').style.display = 'block'; document.getElementById('btn-clear-trace').style.display = 'block';
document.getElementById('btn-duplicate-trace').style.display = 'block';
document.getElementById('cable-edit-tip').style.display = 'block';
const mapPane = document.getElementById('map-cables-pane'); const mapPane = document.getElementById('map-cables-pane');
mapPane.querySelectorAll('.cable-config-item').forEach(item => item.classList.remove('bg-light')); mapPane.querySelectorAll('.cable-config-item').forEach(item => item.classList.remove('bg-light'));
@ -1901,21 +1701,25 @@ document.addEventListener('DOMContentLoaded', () => {
let coords = cableGeometries[code]; let coords = cableGeometries[code];
if (!coords || coords.length === 0) { if (!coords || coords.length === 0) {
const defaultCoords = getCableCoords(code); const cableInfo = cablesList.find(c => c.code === code);
coords = defaultCoords && defaultCoords.length > 0 ? defaultCoords.map(pt => [pt[0], pt[1]]) : []; const connected = cableInfo.connected_poles || [];
coords = [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
cableGeometries[code] = coords; cableGeometries[code] = coords;
} }
drawCableHandles(code); drawCableHandles(code);
redrawCablesOnMap(); redrawCablesOnMap();
updateCableBadge(code);
} }
function deactivateCableEditingMode() { function deactivateCableEditingMode() {
activeEditCable = null; activeEditCable = null;
document.getElementById('btn-clear-trace').style.display = 'none'; document.getElementById('btn-clear-trace').style.display = 'none';
document.getElementById('btn-duplicate-trace').style.display = 'none';
document.getElementById('cable-edit-tip').style.display = 'none';
document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Lecture" %}`; document.getElementById('map-mode-indicator').textContent = `{% translate "Mode: Lecture" %}`;
cableHandles.forEach(h => h.remove()); cableHandles.forEach(h => h.remove());
@ -1964,7 +1768,6 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap(); redrawCablesOnMap();
saveCablesToForm(); saveCablesToForm();
markCableAsModified(code); markCableAsModified(code);
populateCopyCableMenus();
}); });
el.addEventListener('dblclick', function(e) { el.addEventListener('dblclick', function(e) {
@ -1974,7 +1777,6 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap(); redrawCablesOnMap();
saveCablesToForm(); saveCablesToForm();
markCableAsModified(code); markCableAsModified(code);
populateCopyCableMenus();
}); });
}); });
} }
@ -2032,7 +1834,6 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap(); redrawCablesOnMap();
saveCablesToForm(); saveCablesToForm();
markCableAsModified(activeEditCable); markCableAsModified(activeEditCable);
populateCopyCableMenus();
}); });
map.on('dblclick', function(e) { map.on('dblclick', function(e) {
@ -2056,7 +1857,6 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap(); redrawCablesOnMap();
saveCablesToForm(); saveCablesToForm();
markCableAsModified(activeEditCable); markCableAsModified(activeEditCable);
populateCopyCableMenus();
}); });
// Hover mouse styles for active cable line // Hover mouse styles for active cable line
@ -2080,192 +1880,20 @@ document.addEventListener('DOMContentLoaded', () => {
redrawCablesOnMap(); redrawCablesOnMap();
saveCablesToForm(); saveCablesToForm();
markCableAsModified(activeEditCable); markCableAsModified(activeEditCable);
populateCopyCableMenus();
} }
}); });
function copyCableGeometry(sourceCode, targetCode) {
const srcCoords = getCableCoords(sourceCode);
if (!srcCoords || srcCoords.length < 2) {
alert("{% translate "Le câble source ne possède aucun tracé valide à copier." %}");
return false;
}
cableGeometries[targetCode] = srcCoords.map(pt => [pt[0], pt[1]]);
saveCablesToForm();
markCableAsModified(targetCode);
populateCopyCableMenus();
if (activeEditCable === targetCode) {
drawCableHandles(targetCode);
redrawCablesOnMap();
}
return true;
}
function populateCopyCableMenus() {
document.querySelectorAll('.copy-cable-menu').forEach(menu => {
const targetCode = menu.dataset.targetCode;
menu.innerHTML = '';
let hasAvailable = false;
cablesList.forEach(other => {
if (other.code === targetCode || other.status === 'db_only') return;
const coords = getCableCoords(other.code);
if (coords && coords.length >= 2) {
hasAvailable = true;
const len = calculateLineLengthMeters(coords);
const li = document.createElement('li');
li.innerHTML = `
<button type="button" class="dropdown-item py-1.5 px-3 d-flex align-items-center justify-content-between copy-from-cable-btn" data-source="${other.code}" data-target="${targetCode}">
<span class="font-monospace text-truncate me-2">${other.name}</span>
<span class="badge bg-light text-secondary border font-monospace" style="font-size: 0.7rem;">${coords.length} pts (${len} m)</span>
</button>
`;
menu.appendChild(li);
}
});
if (!hasAvailable) {
const li = document.createElement('li');
li.className = 'dropdown-item text-muted small fst-italic py-2';
li.textContent = `{% translate "Aucun tracé disponible" %}`;
menu.appendChild(li);
}
});
// Rebind click listeners for dynamically populated dropdown items
document.querySelectorAll('.copy-from-cable-btn').forEach(btn => {
btn.onclick = function(e) {
e.preventDefault();
const source = this.dataset.source;
const target = this.dataset.target;
const success = copyCableGeometry(source, target);
if (success) {
const targetCable = cablesList.find(c => c.code === target);
const sourceCable = cablesList.find(c => c.code === source);
const targetName = targetCable ? targetCable.name : target;
const sourceName = sourceCable ? sourceCable.name : source;
const tipEl = document.getElementById('cable-edit-tip');
if (tipEl) {
tipEl.style.display = 'block';
const origContent = tipEl.innerHTML;
tipEl.innerHTML = `<i class="bi bi-check-circle-fill text-success me-1"></i><strong>{% translate "Tracé copié de" %} ${sourceName} {% translate "vers" %} ${targetName}</strong>`;
setTimeout(() => {
if (!activeEditCable) tipEl.style.display = 'none';
else tipEl.innerHTML = origContent;
}, 3000);
}
}
};
});
}
// Modal Duplicate Trace logic
const duplicateModalEl = document.getElementById('duplicateCableModal');
const duplicateModal = duplicateModalEl ? new bootstrap.Modal(duplicateModalEl) : null;
const btnDuplicateTrace = document.getElementById('btn-duplicate-trace');
if (btnDuplicateTrace) {
btnDuplicateTrace.addEventListener('click', function() {
if (!activeEditCable) return;
const currentCable = cablesList.find(c => c.code === activeEditCable);
const coords = getCableCoords(activeEditCable);
if (!coords || coords.length < 2) {
alert("{% translate "Veuillez d'abord tracer au moins deux points pour ce câble avant de le dupliquer." %}");
return;
}
const len = calculateLineLengthMeters(coords);
const srcNameEl = document.getElementById('modal-source-cable-name');
const srcInfoEl = document.getElementById('modal-source-cable-info');
if (srcNameEl) srcNameEl.textContent = currentCable ? currentCable.name : activeEditCable;
if (srcInfoEl) srcInfoEl.textContent = `${coords.length} pts • ${len} m`;
const listContainer = document.getElementById('modal-target-cables-list');
if (listContainer) {
listContainer.innerHTML = '';
const otherCables = cablesList.filter(c => c.code !== activeEditCable && c.status !== 'db_only');
if (otherCables.length === 0) {
listContainer.innerHTML = `<div class="text-center text-muted small py-3">{% translate "Aucun autre câble disponible dans ce carrefour." %}</div>`;
const confirmBtn = document.getElementById('btn-confirm-duplicate-cable');
if (confirmBtn) confirmBtn.disabled = true;
} else {
const confirmBtn = document.getElementById('btn-confirm-duplicate-cable');
if (confirmBtn) confirmBtn.disabled = false;
otherCables.forEach(c => {
const cCoords = getCableCoords(c.code);
const hasGeom = cCoords && cCoords.length >= 2;
const cLen = hasGeom ? calculateLineLengthMeters(cCoords) : null;
const geomBadge = hasGeom
? `<span class="badge bg-light text-secondary border font-monospace ms-auto" style="font-size: 0.7rem;">${cCoords.length} pts (${cLen} m)</span>`
: `<span class="badge bg-light text-muted border font-monospace ms-auto" style="font-size: 0.7rem;">{% translate "Non tracé" %}</span>`;
const itemDiv = document.createElement('div');
itemDiv.className = 'form-check d-flex align-items-center p-2 rounded border bg-white';
itemDiv.innerHTML = `
<input class="form-check-input modal-cable-target-cb me-2" type="checkbox" value="${c.code}" id="modal_cb_target_${c.code}">
<label class="form-check-label small text-dark d-flex align-items-center w-100 cursor-pointer mb-0" for="modal_cb_target_${c.code}">
<span class="font-monospace font-weight-semibold me-2">${c.name}</span>
<span class="text-muted small">${c.deduced_model_name ? '(' + c.deduced_model_name + ')' : ''}</span>
${geomBadge}
</label>
`;
listContainer.appendChild(itemDiv);
});
}
}
const selectAllCb = document.getElementById('modal-select-all-cables');
if (selectAllCb) selectAllCb.checked = false;
if (duplicateModal) duplicateModal.show();
});
}
const modalSelectAllCables = document.getElementById('modal-select-all-cables');
if (modalSelectAllCables) {
modalSelectAllCables.addEventListener('change', function() {
const checked = this.checked;
document.querySelectorAll('.modal-cable-target-cb').forEach(cb => {
cb.checked = checked;
});
});
}
const btnConfirmDuplicateCable = document.getElementById('btn-confirm-duplicate-cable');
if (btnConfirmDuplicateCable) {
btnConfirmDuplicateCable.addEventListener('click', function() {
if (!activeEditCable) return;
const selectedCbs = document.querySelectorAll('.modal-cable-target-cb:checked');
if (selectedCbs.length === 0) {
alert("{% translate "Veuillez sélectionner au moins un câble cible." %}");
return;
}
let appliedCount = 0;
selectedCbs.forEach(cb => {
const targetCode = cb.value;
copyCableGeometry(activeEditCable, targetCode);
appliedCount++;
});
if (duplicateModal) duplicateModal.hide();
const tipEl = document.getElementById('cable-edit-tip');
if (tipEl) {
const origContent = tipEl.innerHTML;
tipEl.innerHTML = `<i class="bi bi-check-circle-fill text-success me-1"></i><strong>${appliedCount} {% translate "câble(s) mis à jour avec ce tracé !" %}</strong>`;
setTimeout(() => {
if (activeEditCable) tipEl.innerHTML = origContent;
}, 3000);
}
});
}
function saveCablesToForm() { function saveCablesToForm() {
cablesList.forEach(c => { cablesList.forEach(c => {
let coords = cableGeometries[c.code]; let coords = cableGeometries[c.code];
if (!coords || coords.length === 0) { if (!coords || coords.length === 0) {
coords = getCableCoords(c.code); coords = [];
const connected = c.connected_poles || [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
} }
const input = document.getElementById(`geom_cable_${c.code}`); const input = document.getElementById(`geom_cable_${c.code}`);
if (input && coords && coords.length >= 2) { if (input && coords && coords.length >= 2) {
@ -2298,7 +1926,13 @@ document.addEventListener('DOMContentLoaded', () => {
let coords = cableGeometries[c.code]; let coords = cableGeometries[c.code];
if (!coords || coords.length === 0) { if (!coords || coords.length === 0) {
coords = getCableCoords(c.code); coords = [];
const connected = c.connected_poles || [];
connected.forEach(pCode => {
if (placedPoles[pCode]) {
coords.push([placedPoles[pCode].lon, placedPoles[pCode].lat]);
}
});
} }
if (coords && coords.length >= 2) { if (coords && coords.length >= 2) {
@ -2326,12 +1960,6 @@ document.addEventListener('DOMContentLoaded', () => {
map.triggerRepaint(); map.triggerRepaint();
} }
// Initial cable badges and copy menus update
cablesList.forEach(c => {
updateCableBadge(c.code);
});
populateCopyCableMenus();
// ── Georeferenced Plans & DXF Background Loader ── // ── Georeferenced Plans & DXF Background Loader ──
const georeferencedDocumentsDataEl = document.getElementById('georeferencedDocumentsData'); const georeferencedDocumentsDataEl = document.getElementById('georeferencedDocumentsData');
let georeferencedDocuments = []; let georeferencedDocuments = [];

View file

@ -480,7 +480,7 @@
text-transform: uppercase !important; text-transform: uppercase !important;
letter-spacing: 0.04em !important; letter-spacing: 0.04em !important;
border-bottom: 1.5px solid #e2e8f0 !important; border-bottom: 1.5px solid #e2e8f0 !important;
padding: 0.45rem 0.65rem !important; padding: 0.65rem 0.75rem !important;
vertical-align: middle !important; vertical-align: middle !important;
white-space: nowrap !important; white-space: nowrap !important;
} }
@ -489,28 +489,16 @@
padding: 0 !important; padding: 0 !important;
font-size: 0.74rem !important; font-size: 0.74rem !important;
font-weight: 600 !important; font-weight: 600 !important;
line-height: 1.3 !important; line-height: 1.4 !important;
} }
/* Rows & Cells */ /* Rows & Cells */
.bootstrap-table.bootstrap5 #unified-table td { .bootstrap-table.bootstrap5 #unified-table td {
padding: 0.32rem 0.65rem !important; padding: 0.65rem 0.75rem !important;
vertical-align: middle !important; vertical-align: middle !important;
font-size: 0.82rem !important; font-size: 0.83rem !important;
color: #334155; color: #334155;
border-bottom: 1px solid #f1f5f9 !important; border-bottom: 1px solid #f1f5f9 !important;
line-height: 1.3 !important;
}
.asset-symbology-icon-img {
width: 20px !important;
height: 20px !important;
max-width: 20px !important;
max-height: 20px !important;
object-fit: contain !important;
vertical-align: middle !important;
display: inline-block !important;
margin-right: 6px !important;
} }
.bootstrap-table.bootstrap5 #unified-table tbody tr { .bootstrap-table.bootstrap5 #unified-table tbody tr {
@ -682,24 +670,24 @@
<div class="filter-section-header"> <div class="filter-section-header">
<label>{% translate "Type d'élément" %}</label> <label>{% translate "Type d'élément" %}</label>
</div> </div>
<div class="filter-options d-flex gap-3 flex-wrap"> <div class="segmented-control">
<div class="filter-option"> <div class="filter-option segmented-option">
<input type="checkbox" name="filter-element-type" id="type-cb-assets" value="assets" <input type="radio" name="unified-type-filter" id="type-all" value="all"
{% if preventive_element_type != 'locations' %}checked{% endif %} {% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
{% if preventive_element_type == 'locations' %}disabled{% endif %}>
<label for="type-cb-assets"><i class="bi bi-layers me-1 text-primary"></i>{% translate "Assets" %}</label>
</div>
<div class="filter-option">
<input type="checkbox" name="filter-element-type" id="type-cb-locations" value="locations"
{% if preventive_element_type != 'assets' %}checked{% endif %}
{% if preventive_element_type == 'assets' %}disabled{% endif %}>
<label for="type-cb-locations"><i class="bi bi-geo-alt me-1 text-info"></i>{% translate "Localisations" %}</label>
</div>
<div class="filter-option">
<input type="checkbox" name="filter-element-type" id="type-cb-management-zones" value="management_zones"
{% if not preventive_element_type %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}> {% if preventive_element_type %}disabled{% endif %}>
<label for="type-cb-management-zones"><i class="bi bi-diagram-3 me-1 text-success"></i>{% translate "Zones de gestion" %}</label> <label for="type-all">{% translate "Assets & localisations" %}</label>
</div>
<div class="filter-option segmented-option">
<input type="radio" name="unified-type-filter" id="type-locations" value="locations"
{% if preventive_element_type == 'locations' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-locations">{% translate "Localisations uniquement" %}</label>
</div>
<div class="filter-option segmented-option">
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
{% if preventive_element_type == 'assets' %}checked{% endif %}
{% if preventive_element_type %}disabled{% endif %}>
<label for="type-assets">{% translate "Assets uniquement" %}</label>
</div> </div>
</div> </div>
</div> </div>
@ -849,7 +837,7 @@
{% translate "Choisissez d'abord le type d'élément, puis la thématique autorisée." %} {% translate "Choisissez d'abord le type d'élément, puis la thématique autorisée." %}
</div> </div>
<div class="row g-3"> <div class="row g-3">
<div class="col-md-4"> <div class="col-md-6">
<div class="unified-add-panel h-100"> <div class="unified-add-panel h-100">
<h6 class="d-flex align-items-center gap-2"><i class="bi bi-layers"></i>{% translate "Asset" %}</h6> <h6 class="d-flex align-items-center gap-2"><i class="bi bi-layers"></i>{% translate "Asset" %}</h6>
<label for="unified-add-asset-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label> <label for="unified-add-asset-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
@ -859,7 +847,7 @@
</button> </button>
</div> </div>
</div> </div>
<div class="col-md-4"> <div class="col-md-6">
<div class="unified-add-panel h-100"> <div class="unified-add-panel h-100">
<h6 class="d-flex align-items-center gap-2"><i class="bi bi-geo-alt"></i>{% translate "Localisation" %}</h6> <h6 class="d-flex align-items-center gap-2"><i class="bi bi-geo-alt"></i>{% translate "Localisation" %}</h6>
<label for="unified-add-location-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label> <label for="unified-add-location-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
@ -869,16 +857,6 @@
</button> </button>
</div> </div>
</div> </div>
<div class="col-md-4">
<div class="unified-add-panel h-100">
<h6 class="d-flex align-items-center gap-2"><i class="bi bi-diagram-3 text-success"></i>{% translate "Zone de gestion" %}</h6>
<label for="unified-add-management-zone-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
<select id="unified-add-management-zone-thematic" class="form-select mb-3"></select>
<button type="button" id="unified-add-management-zone-open" class="btn btn-outline-success w-100">
<i class="bi bi-plus-circle me-1"></i>{% translate "Créer une zone" %}
</button>
</div>
</div>
</div> </div>
<div id="unified-add-chooser-empty" class="text-muted small mt-3 d-none"> <div id="unified-add-chooser-empty" class="text-muted small mt-3 d-none">
{% translate "Aucune thématique éditable n'est disponible pour la création." %} {% translate "Aucune thématique éditable n'est disponible pour la création." %}
@ -1315,7 +1293,7 @@
{% block script %} {% block script %}
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script> <script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}?v=20261009_basemap"></script> <script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script> <script>
window.TERRITORY_MASK_URL = "{% url 'common:get_territory_mask_geojson' %}"; window.TERRITORY_MASK_URL = "{% url 'common:get_territory_mask_geojson' %}";
const preventive_draft_id = "{{ preventive_draft_id|default:'' }}"; const preventive_draft_id = "{{ preventive_draft_id|default:'' }}";
@ -1353,11 +1331,7 @@
trafficlightelectricalcabinet: "{% static 'assets/electrical-panel.png' %}", trafficlightelectricalcabinet: "{% static 'assets/electrical-panel.png' %}",
publiclighting: "{% static 'assets/public_lamp_post_icon.svg' %}?v=20260521c", publiclighting: "{% static 'assets/public_lamp_post_icon.svg' %}?v=20260521c",
publiclightingelectricalcabinet: "{% static 'assets/electrical-panel.png' %}", publiclightingelectricalcabinet: "{% static 'assets/electrical-panel.png' %}",
nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20261009_round", nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20260521a",
natureregional: "{% static 'assets/nature_tree_icon_regional.svg' %}?v=20261009_round",
natureremarkable: "{% static 'assets/nature_tree_icon_remarkable.svg' %}?v=20261009_round",
naturedead: "{% static 'assets/nature_tree_icon_dead.svg' %}?v=20261009_round",
naturegreensurface: "{% static 'assets/nature_greensurface_icon.svg' %}?v=20261010a",
structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b", structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b",
roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a", roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a",
speedbump: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920", speedbump: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920",
@ -1374,7 +1348,7 @@
<script src="{% static 'common/base_script.js' %}"></script> <script src="{% static 'common/base_script.js' %}"></script>
<script src="{% static 'common/base_map.js' %}"></script> <script src="{% static 'common/base_map.js' %}"></script>
<script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script> <script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
<script src="{% static 'assets/asset_symbology.js' %}?v=20261010_greensurface"></script> <script src="{% static 'assets/asset_symbology.js' %}?v=20260920_grid_options"></script>
<script src="{% static 'assets/unified_map_layers.js' %}?v=20261010_greensurface"></script> <script src="{% static 'assets/unified_map_layers.js' %}?v=20260920_grid_options"></script>
<script src="{% static 'assets/unified_index.js' %}?v=20261010_greensurface"></script> <script src="{% static 'assets/unified_index.js' %}?v=20260920_grid_options"></script>
{% endblock script %} {% endblock script %}

View file

@ -1,92 +0,0 @@
{% extends "assets/detail_generic.html" %}
{% load i18n %}
{% load static %}
{% block extra_blocks %}
<!-- Système de bornes parent -->
<div class="card mb-3" id="bollard-plot-system-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-diagram-3-fill text-primary me-2"></i>{% translate "Système de bornes de rattachement" %}
</h5>
{% if asset.system %}
<a href="{{ asset.system.get_absolute_url }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-arrow-right-circle me-1"></i>{% translate "Voir le système complet" %}
</a>
{% endif %}
</div>
<div class="card-body">
{% if asset.system %}
<div class="row align-items-center mb-3">
<div class="col-md-7">
<div class="d-flex align-items-center mb-2">
<span class="badge bg-dark rounded-pill fs-6 me-2">#{{ asset.position }}</span>
<h5 class="mb-0">
<a href="{{ asset.system.get_absolute_url }}" class="text-decoration-none">
{{ asset.system.code }} - {{ asset.system.get_name|default:asset.system.code }}
</a>
</h5>
</div>
<div class="text-muted small mb-2">
<i class="bi bi-geo-alt me-1"></i>{{ asset.system.location|default:"Localisation non définie" }}
</div>
<div class="d-flex flex-wrap gap-2">
{% if asset.system.width %}
<span class="badge bg-light text-dark border">
<i class="bi bi-arrows-expand me-1"></i>{% translate "Largeur" %} : {{ asset.system.width }} m
</span>
{% endif %}
{% if asset.system.direction %}
<span class="badge bg-light text-dark border">
<i class="bi bi-signpost-split me-1"></i>{{ asset.system.get_direction_display }}
</span>
{% endif %}
{% if asset.system.model %}
<span class="badge bg-light text-dark border">
<i class="bi bi-tag me-1"></i>{{ asset.system.model.get_name }}
</span>
{% endif %}
</div>
</div>
<div class="col-md-5 text-md-end mt-3 mt-md-0">
<div class="p-3 bg-light rounded border text-start">
<div class="small text-muted mb-1">{% translate "Traverse complète" %} ({{ asset.system.plots.count }} {% translate "plots" %}) :</div>
<div class="d-flex flex-wrap gap-1">
{% for sibling in asset.system.plots.all %}
<a href="{{ sibling.get_absolute_url }}"
class="btn btn-sm {% if sibling.id == asset.id %}btn-primary fw-bold{% else %}btn-outline-secondary{% endif %}"
title="{{ sibling.code }} - {{ sibling.get_bollard_type_display }}">
#{{ sibling.position }} ({{ sibling.code }})
</a>
{% endfor %}
</div>
</div>
</div>
</div>
{% else %}
<div class="text-muted text-center p-3">
<i class="bi bi-exclamation-triangle text-warning me-2"></i>{% translate "Ce plot n'est actuellement rattaché à aucun système de bornes." %}
</div>
{% endif %}
</div>
</div>
<!-- Métadonnées techniques (extra_data) -->
{% if asset.extra_data %}
<div class="card mb-3" id="bollard-plot-extra-data-card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-code-slash text-secondary me-2"></i>{% translate "Métadonnées techniques" %}
</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, val in asset.extra_data.items %}
<dt class="col-sm-4 text-truncate" title="{{ key }}">{{ key }}</dt>
<dd class="col-sm-8 mb-2"><code>{{ val }}</code></dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
{% endblock extra_blocks %}

View file

@ -1,218 +0,0 @@
{% extends "assets/detail_generic.html" %}
{% load i18n %}
{% load static %}
{% block extra_blocks %}
<!-- Plots de la traverse -->
<div class="card mb-3" id="bollard-system-plots-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-geo-alt-fill text-primary me-2"></i>{% translate "Plots de la traverse" %}
<span class="badge bg-primary ms-2">{{ asset.plots.count }}</span>
</h5>
</div>
<div class="card-body p-0">
{% if asset.plots.all %}
<div class="table-responsive">
<table class="table table-hover table-striped mb-0 align-middle">
<thead>
<tr>
<th style="width: 80px;" class="text-center">{% translate "Pos." %}</th>
<th>{% translate "Code" %}</th>
<th>{% translate "Type de borne" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "N° de série" %}</th>
<th>{% translate "Statut" %}</th>
<th style="width: 110px;" class="text-end">{% translate "Action" %}</th>
</tr>
</thead>
<tbody>
{% for plot in asset.plots.all %}
<tr>
<td class="text-center">
<span class="badge bg-dark rounded-pill">#{{ plot.position }}</span>
</td>
<td>
<a href="{{ plot.get_absolute_url }}" class="fw-bold text-decoration-none">
{{ plot.code }}
</a>
{% if plot.get_name %}
<div class="small text-muted">{{ plot.get_name }}</div>
{% endif %}
</td>
<td>
{% if plot.bollard_type == 'automatic' %}
<span class="badge bg-primary"><i class="bi bi-arrow-up-down me-1"></i>{{ plot.get_bollard_type_display }}</span>
{% elif plot.bollard_type == 'semi_automatic' %}
<span class="badge bg-info text-dark">{{ plot.get_bollard_type_display }}</span>
{% elif plot.bollard_type == 'removable' %}
<span class="badge bg-warning text-dark">{{ plot.get_bollard_type_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ plot.get_bollard_type_display }}</span>
{% endif %}
</td>
<td>{{ plot.model.get_name|default:"-" }}</td>
<td><code>{{ plot.serial_number|default:"-" }}</code></td>
<td>
{% if plot.status == 'active' %}
<span class="badge bg-success">{{ plot.get_status_display }}</span>
{% elif plot.status == 'damaged' %}
<span class="badge bg-warning text-dark">{{ plot.get_status_display }}</span>
{% elif plot.status == 'to_replace' %}
<span class="badge bg-danger">{{ plot.get_status_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ plot.get_status_display }}</span>
{% endif %}
</td>
<td class="text-end">
<a href="{{ plot.get_absolute_url }}" class="btn btn-sm btn-outline-primary" title="{% translate 'Voir le détail du plot' %}">
<i class="bi bi-eye me-1"></i>{% translate "Voir" %}
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-cone-striped display-6 d-block mb-2 text-secondary opacity-50"></i>
{% translate "Aucun plot physique n'est encore configuré sur ce système de bornes." %}
</div>
{% endif %}
</div>
</div>
<!-- Équipements associés au système (Caméras LAPI, totems, armoires...) -->
<div class="card mb-3" id="bollard-system-assets-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-hdd-network text-info me-2"></i>{% translate "Équipements associés" %}
<span class="badge bg-secondary ms-2">{{ asset.system_assets.count }}</span>
</h5>
</div>
<div class="card-body p-0">
{% if asset.system_assets.all %}
<div class="table-responsive">
<table class="table table-hover table-striped mb-0 align-middle">
<thead>
<tr>
<th>{% translate "Rôle" %}</th>
<th>{% translate "Équipement" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Commentaire" %}</th>
<th style="width: 110px;" class="text-end">{% translate "Action" %}</th>
</tr>
</thead>
<tbody>
{% for item in asset.system_assets.all %}
<tr>
<td>
{% if 'camera' in item.role %}
<span class="badge bg-info text-dark"><i class="bi bi-camera-video me-1"></i>{{ item.get_role_display }}</span>
{% elif item.role == 'cabinet' %}
<span class="badge bg-dark"><i class="bi bi-cpu me-1"></i>{{ item.get_role_display }}</span>
{% elif item.role == 'totem' %}
<span class="badge bg-primary"><i class="bi bi-tablet me-1"></i>{{ item.get_role_display }}</span>
{% elif item.role == 'traffic_light' %}
<span class="badge bg-warning text-dark"><i class="bi bi-traffic-light me-1"></i>{{ item.get_role_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ item.get_role_display }}</span>
{% endif %}
</td>
<td>
<a href="{{ item.asset.get_absolute_url }}" class="fw-bold text-decoration-none">
{{ item.asset.code }}
</a>
{% if item.asset.get_name %}
<div class="small text-muted">{{ item.asset.get_name }}</div>
{% endif %}
</td>
<td>{{ item.asset.model.get_name|default:"-" }}</td>
<td>
{% if item.asset.status == 'active' %}
<span class="badge bg-success">{{ item.asset.get_status_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ item.asset.get_status_display }}</span>
{% endif %}
</td>
<td>{{ item.comment|default:"-" }}</td>
<td class="text-end">
<a href="{{ item.asset.get_absolute_url }}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Voir équipement' %}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted">
<i class="bi bi-camera-video display-6 d-block mb-2 text-secondary opacity-50"></i>
{% translate "Aucun équipement périphérique (caméra, armoire, totem...) n'est actuellement associé." %}
</div>
{% endif %}
</div>
</div>
<!-- Contrats de maintenance liés -->
{% if asset.bollard_systems_contracts.exists %}
<div class="card mb-3" id="bollard-system-contracts-card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-file-earmark-text text-secondary me-2"></i>{% translate "Contrats d'entretien" %}
<span class="badge bg-secondary ms-2">{{ asset.bollard_systems_contracts.count }}</span>
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-striped mb-0 align-middle">
<thead>
<tr>
<th>{% translate "Numéro de contrat" %}</th>
<th>{% translate "Type de maintenance" %}</th>
<th>{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for sc in asset.bollard_systems_contracts.all %}
<tr>
<td class="fw-bold">{{ sc.contract.contract_number }} - {{ sc.contract.name }}</td>
<td>{{ sc.get_maintenance_type_display }}</td>
<td>
{% if sc.status == 'active' %}
<span class="badge bg-success">{% translate "Actif" %}</span>
{% else %}
<span class="badge bg-secondary">{{ sc.get_status_display }}</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}
<!-- Métadonnées techniques (extra_data) -->
{% if asset.extra_data %}
<div class="card mb-3" id="bollard-system-extra-data-card">
<div class="card-header bg-light">
<h5 class="mb-0">
<i class="bi bi-code-slash text-secondary me-2"></i>{% translate "Métadonnées techniques" %}
</h5>
</div>
<div class="card-body">
<dl class="row mb-0">
{% for key, val in asset.extra_data.items %}
<dt class="col-sm-4 text-truncate" title="{{ key }}">{{ key }}</dt>
<dd class="col-sm-8 mb-2"><code>{{ val }}</code></dd>
{% endfor %}
</dl>
</div>
</div>
{% endif %}
{% endblock extra_blocks %}

View file

@ -103,11 +103,11 @@ def get_asset_specific_fields(asset):
from django.contrib.gis.db.models import GeometryField from django.contrib.gis.db.models import GeometryField
from django.db.models import (ForeignKey, OneToOneField, ManyToManyField, AutoField, from django.db.models import (ForeignKey, OneToOneField, ManyToManyField, AutoField,
BooleanField, FloatField, DecimalField, IntegerField, BooleanField, FloatField, DecimalField, IntegerField,
DateField, DateTimeField, TextField, JSONField) DateField, DateTimeField, TextField)
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from assets.models import AbstractAsset, AbstractGeoAsset from assets.models import AbstractAsset, AbstractGeoAsset
excluded_types = (ForeignKey, OneToOneField, ManyToManyField, AutoField, GeometryField, JSONField) excluded_types = (ForeignKey, OneToOneField, ManyToManyField, AutoField, GeometryField)
base_field_names = {f.name for f in AbstractAsset._meta.fields} base_field_names = {f.name for f in AbstractAsset._meta.fields}
base_field_names |= {f.name for f in AbstractGeoAsset._meta.fields} base_field_names |= {f.name for f in AbstractGeoAsset._meta.fields}

File diff suppressed because it is too large Load diff

View file

@ -1,292 +0,0 @@
import json
import tempfile
import os
from datetime import date, datetime
from django.test import TestCase
from django.core.management import call_command
from django.contrib.gis.geos import Point, Polygon, MultiPolygon
from common.models import Thematic
from assets.models import NatureTree, AssetCategory
from contracts.models import Contract, ContractOrder, Company
from interventions.models import Intervention, InterventionAsset
class ImportFulcrumTreesCommandTest(TestCase):
def setUp(self):
self.thematic = Thematic.objects.create(name_fr="Nature", name_nl="Natuur")
self.category = AssetCategory.objects.create(id=60, name_fr="Arbres", name_nl="Bomen")
self.company = Company.objects.create(name="Krinkels")
self.contract = Contract.objects.create(
company=self.company,
contract_number="E19.051",
description="Marché E19.051",
start_date=date(2020, 1, 1),
end_date=date(2030, 12, 31)
)
self.contract.thematics.add(self.thematic)
def test_import_fulcrum_trees_and_interventions(self):
# 1. Préparer gestion.csv temporaire
gestion_content = (
"com_id,com_typ,com_nom,_status,com_dat,com_ech\n"
"BC-TEST-001,Marché E19.051 - Diagnostic,Élagage test,commande,2024-05-10,2024-06-15\n"
)
with tempfile.NamedTemporaryFile(mode='w', suffix='.csv', delete=False) as f_ges:
f_ges.write(gestion_content)
ges_path = f_ges.name
# 2. Préparer arbes.geojson temporaire
mock_tree_uuid = "11111111-2222-3333-4444-555555555555"
tree_geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [4.3517, 50.8503]
},
"properties": {
"_record_id": mock_tree_uuid,
"id_sit_pos": "099.01.001",
"id_visuel": "V-9901",
"sit_id": "099.01",
"pos_id": "001",
"sit_nom": "Boulevard du Test",
"comm_commune": "Bruxelles",
"arb_ess": "Platanus x hispanica",
"arb_vern": "Platane commun",
"arb_tax": "Platanus hispanica",
"arb_haut": 18.5,
"arb_circ": 140.0,
"arb_diam_tmp": 8.0,
"arb_st": "V",
"arb_vital": "Bo",
"arb_ont": "Ad",
"ges_conduite": "LI",
"cf_sit": 4.5,
"arb_anpla": "1995",
"maj_date": "2024-05-10T00:00:00.000Z"
}
}
]
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.geojson', delete=False) as f_arb:
json.dump(tree_geojson, f_arb)
arb_path = f_arb.name
# 3. Préparer interventions.geojson temporaire
mock_itv_uuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
itv_geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [4.3517, 50.8503]
},
"properties": {
"_record_id": mock_itv_uuid,
"_title": "099.01.001, Formation, Dans 1 an",
"rec_interv_com": "Taille branches basses",
"sit_nom": "Boulevard du Test",
"comm_commune": "Bruxelles",
"sit_id": "099.01",
"pos_id": "001",
"dia_comma": "BC-TEST-001",
"dia_date": "2024-05-10T00:00:00.000Z",
"in_deadline": "2025-05-10T00:00:00.000Z",
"rec_delai": "Pla-1",
"rec_interv": "FO",
"in_fin_yn": "no",
"mater_arbres_lk": [mock_tree_uuid]
}
}
]
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.geojson', delete=False) as f_itv:
json.dump(itv_geojson, f_itv)
itv_path = f_itv.name
try:
# Exécuter la commande
call_command(
'import_fulcrum_trees',
arbes_file=arb_path,
gestion_file=ges_path,
interventions_file=itv_path,
managing_authority="Test Authority",
management_type="regional",
code_prefix="TEST-ARB-"
)
# Vérifications gestion / bon de commande
order = ContractOrder.objects.filter(order_code="BC-TEST-001").first()
self.assertIsNotNone(order)
self.assertEqual(order.contract.contract_number, "E19.051")
self.assertEqual(order.order_status, "sent")
# Vérifications arbre
tree = NatureTree.objects.filter(external_reference=mock_tree_uuid).first()
self.assertIsNotNone(tree)
self.assertEqual(tree.code, "TEST-ARB-099.01.001")
self.assertEqual(tree.managing_authority, "Test Authority")
self.assertEqual(tree.management_type, "regional")
self.assertEqual(tree.circumference, 140.0)
self.assertEqual(tree.height, 18.5)
self.assertIsNotNone(tree.amenity_value)
self.assertGreater(tree.amenity_value, 0)
self.assertIsNotNone(tree.carbon_stock)
self.assertGreater(tree.carbon_stock, 0)
self.assertIsNotNone(tree.cooling_indicator)
# Vérifications intervention
itv = Intervention.objects.filter(source_ref=mock_itv_uuid).first()
self.assertIsNotNone(itv)
self.assertEqual(itv.order_id, order.id)
self.assertEqual(itv.priority, "3") # Pla-1 -> 3
self.assertEqual(itv.status, "to_be_planned")
self.assertIsNotNone(itv.expected_begin_time)
self.assertEqual(itv.expected_begin_time.date(), date(2024, 5, 10))
self.assertIsNotNone(itv.expected_end_time)
self.assertEqual(itv.expected_end_time.date(), date(2025, 5, 10))
# Vérifications dates de maintenance sur l'arbre
self.assertEqual(tree.last_phytosanitary_date, date(2024, 5, 10))
self.assertEqual(tree.next_inspection_date, date(2025, 5, 10))
self.assertEqual(tree.pruning_frequency_years, 1)
# Vérifications événements de planification (timeline)
self.assertEqual(itv.planned_events.count(), 2)
begin_event = itv.planned_events.filter(event_type='expected_begin').first()
self.assertIsNotNone(begin_event)
self.assertEqual(begin_event.event_time.date(), date(2024, 5, 10))
end_event = itv.planned_events.filter(event_type='expected_end').first()
self.assertIsNotNone(end_event)
self.assertEqual(end_event.event_time.date(), date(2025, 5, 10))
# Vérification liaison InterventionAsset
links = InterventionAsset.objects.filter(intervention=itv)
self.assertEqual(links.count(), 1)
self.assertEqual(links.first().object_id, tree.id)
# Test mise à jour sans duplication
tree_geojson['features'][0]['properties']['arb_circ'] = 145.0
with open(arb_path, 'w') as f_arb_update:
json.dump(tree_geojson, f_arb_update)
call_command(
'import_fulcrum_trees',
arbes_file=arb_path,
gestion_file=ges_path,
interventions_file=itv_path,
skip_gestion=True,
skip_interventions=True
)
# Le nombre d'arbres ne doit pas avoir augmenté
self.assertEqual(NatureTree.objects.filter(external_reference=mock_tree_uuid).count(), 1)
tree.refresh_from_db()
self.assertEqual(tree.circumference, 145.0)
finally:
if os.path.exists(ges_path): os.remove(ges_path)
if os.path.exists(arb_path): os.remove(arb_path)
if os.path.exists(itv_path): os.remove(itv_path)
def test_import_fulcrum_regional_trees_alias(self):
from io import StringIO
out = StringIO()
call_command('import_fulcrum_regional_trees', skip_trees=True, skip_gestion=True, skip_interventions=True, stdout=out)
self.assertIn("FIN DE L'IMPORTATION", out.getvalue())
def test_attach_trees_to_green_sections(self):
from assets.models import NatureLocation
from django.contrib.gis.geos import Polygon, MultiPolygon
# Créer une section verte polygonale
poly = Polygon(((648000, 670000), (648100, 670000), (648100, 670100), (648000, 670100), (648000, 670000)), srid=3812)
loc = NatureLocation.objects.create(
code="WAT_R22_008",
name_fr="Boulevard du Souverain",
geom=MultiPolygon(poly, srid=3812)
)
# Créer un arbre à l'intérieur du polygone (sans location)
pt = Point(648050, 670050, srid=3812)
pt_4326 = pt.clone().transform(4326, clone=True)
tree = NatureTree.objects.create(
code="TR-ATTACH-TEST",
name_fr="Arbre test Souverain",
category=self.category,
geom=pt,
lon=pt_4326.x,
lat=pt_4326.y,
location=None
)
self.assertEqual(loc.nature_trees.count(), 0)
# Exécuter le rattachement
call_command('attach_trees_to_green_sections', max_distance=25, skip_sync_munis=True)
tree.refresh_from_db()
self.assertEqual(tree.location_id, loc.id)
self.assertEqual(loc.nature_trees.count(), 1)
def test_cancel_obsolete_interventions_and_theoretical_inspection(self):
from django.contrib.contenttypes.models import ContentType
from interventions.models import Intervention, InterventionAsset, InterventionTimeLine
# Créer un arbre avec dernier diagnostic en 2025 et périodicité 1 an
tree = NatureTree.objects.create(
code="TR-OBSOLETE-TEST",
name_fr="Arbre test obsolète",
category=self.category,
last_phytosanitary_date=date(2025, 12, 4),
pruning_frequency_years=1,
next_inspection_date=date(2023, 7, 22),
geom=Point(648050, 670050, srid=3812),
lon=4.42,
lat=50.80
)
# Créer une intervention en attente échue en 2023 (antérieure au diagnostic de 2025)
itv = Intervention.objects.create(
code="I999999",
title="003.01.270.(1912).00, Entretien, Dans 1 an",
status="to_be_planned",
status_order=5,
expected_begin_time=datetime(2022, 7, 22, 0, 0),
expected_end_time=datetime(2023, 7, 22, 0, 0),
thematic=self.thematic,
geom=MultiPolygon(Polygon(((648000, 670000), (648010, 670000), (648010, 670010), (648000, 670010), (648000, 670000)), srid=3812))
)
ct_tree = ContentType.objects.get_for_model(NatureTree)
InterventionAsset.objects.create(
intervention=itv,
content_type=ct_tree,
object_id=tree.id
)
# Exécuter la synchronisation des dates de maintenance
call_command('import_fulcrum_trees', sync_tree_dates=True)
# Vérifier que l'intervention échue a été annulée
itv.refresh_from_db()
self.assertEqual(itv.status, "canceled")
self.assertEqual(itv.status_order, 200)
self.assertEqual(itv.cancellation_type, "non_relevant")
# Vérifier que l'événement d'annulation a été consigné dans la timeline
tl = InterventionTimeLine.objects.filter(intervention=itv, to_status="canceled").first()
self.assertIsNotNone(tl)
self.assertIn("caduque", tl.event_description)
self.assertIn("04/12/2025", tl.event_description)
# Vérifier que la prochaine inspection de l'arbre est calculée théoriquement : 2025-12-04 + 1 an = 2026-12-04
tree.refresh_from_db()
self.assertEqual(tree.next_inspection_date, date(2026, 12, 4))

View file

@ -1,597 +0,0 @@
from django.test import TestCase, Client
from django.contrib.auth import get_user_model
from django.urls import reverse
from django.contrib.gis.geos import Polygon, MultiPolygon, Point
from common.models import Thematic, Role, UserConfig, UserThematics
from assets.models import (
ManagementZoneType,
ManagementZone,
ManagementZoneAsset,
NatureGreenSurface,
NatureLocation,
)
User = get_user_model()
class ManagementZoneModelTests(TestCase):
def setUp(self):
self.thematic_nature = Thematic.objects.create(
code="nature",
name_fr="Nature",
name_nl="Natuur"
)
self.zone_type = ManagementZoneType.objects.create(
code="fauche_tardive",
name_fr="Fauche tardive",
name_nl="Laat maaien",
thematic=self.thematic_nature,
default_color="#28a745",
description="Zone de gestion écologique avec fauche annuelle en automne."
)
def test_management_zone_type_bilingual_and_str(self):
self.assertEqual(self.zone_type.name_fr, "Fauche tardive")
self.assertEqual(self.zone_type.name_nl, "Laat maaien")
self.assertIn("Fauche tardive", str(self.zone_type))
def test_management_zone_creation_geometry_and_color_fallback(self):
poly = Polygon(((0, 0), (100, 0), (100, 100), (0, 100), (0, 0)))
multi_poly = MultiPolygon([poly], srid=3812)
# 1. Sans couleur explicite -> prend la couleur par défaut du type
zone1 = ManagementZone.objects.create(
code="ZG-NAT-001",
name_fr="Marais de Jette - Prairie",
name_nl="Moeras van Jette - Weide",
thematic=self.thematic_nature,
zone_type=self.zone_type,
geom=multi_poly,
target_frequency_days=365,
seasonality_notes="Fauche en septembre; pas de taille au printemps",
instructions="Attention aux zones humides et aux ornières.",
)
self.assertEqual(zone1.get_color(), "#28a745")
self.assertEqual(zone1.geom.srid, 3812)
self.assertEqual(zone1.geom.geom_type, "MultiPolygon")
# GeoJSON property
geojson = zone1.geojson_geometry
self.assertIsNotNone(geojson)
self.assertEqual(geojson["type"], "MultiPolygon")
# Spatial intersection
pt_inside = Point(50, 50, srid=3812)
pt_outside = Point(200, 200, srid=3812)
self.assertTrue(ManagementZone.objects.filter(pk=zone1.pk, geom__intersects=pt_inside).exists())
self.assertFalse(ManagementZone.objects.filter(pk=zone1.pk, geom__intersects=pt_outside).exists())
# 2. Avec couleur personnalisée explicite
zone2 = ManagementZone.objects.create(
code="ZG-NAT-002",
name_fr="Zone spéciale",
thematic=self.thematic_nature,
color="#ff5722",
)
self.assertEqual(zone2.get_color(), "#ff5722")
class ManagementZoneAssetTests(TestCase):
def setUp(self):
self.thematic_nature = Thematic.objects.create(code="nature", name_fr="Nature", name_nl="Natuur")
self.zone = ManagementZone.objects.create(
code="ZG-NAT-TEST",
name_fr="Zone test",
thematic=self.thematic_nature,
)
self.location = NatureLocation.objects.create(code="LOC-01", name_fr="Parc de Jette")
self.green_surface = NatureGreenSurface.objects.create(
code="GS-01",
name_fr="Pelouse 1",
location=self.location,
)
def test_explicit_asset_association_and_exclusion(self):
# Association explicite d'inclusion
link = ManagementZoneAsset.objects.create(
management_zone=self.zone,
asset=self.green_surface,
is_excluded=False,
notes="Inclus dans le plan"
)
self.assertIn("Inclus", str(link))
self.assertEqual(self.zone.explicit_assets.count(), 1)
self.assertEqual(self.zone.explicit_assets.first().asset, self.green_surface)
# Bascule en exclusion
link.is_excluded = True
link.save()
self.assertIn("Exclu", str(link))
class ManagementZoneGeojsonEndpointTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="nature_manager", password="password")
self.role_manager = Role.objects.create(name="manager")
self.config = UserConfig.objects.create(user=self.user)
self.config.roles.add(self.role_manager)
self.thematic_nature = Thematic.objects.create(code="nature", name_fr="Nature", name_nl="Natuur")
self.thematic_roads = Thematic.objects.create(code="roads", name_fr="Voiries", name_nl="Wegen")
UserThematics.objects.create(
user_config=self.config,
thematic=self.thematic_nature,
can_view_assets=True,
can_edit_assets=True
)
self.zone_type = ManagementZoneType.objects.create(
code="fauche",
name_fr="Fauche tardive",
thematic=self.thematic_nature,
default_color="#198754"
)
poly = Polygon(((0, 0), (10, 0), (10, 10), (0, 10), (0, 0)))
self.multi_poly = MultiPolygon([poly], srid=3812)
self.zone_nature = ManagementZone.objects.create(
code="ZG-NAT-1",
name_fr="Prairie fleurie",
thematic=self.thematic_nature,
zone_type=self.zone_type,
geom=self.multi_poly,
instructions="Ne pas tondre au printemps",
target_frequency_days=180,
seasonality_notes="Septembre",
)
self.zone_roads = ManagementZone.objects.create(
code="ZG-ROAD-1",
name_fr="Itinéraire déneigement P1",
thematic=self.thematic_roads,
geom=self.multi_poly,
color="#0d6efd",
)
def test_anonymous_access_redirects(self):
url = reverse("assets:management_zones_geojson")
response = self.client.get(url)
self.assertEqual(response.status_code, 302)
def test_authenticated_user_access_nature_zones(self):
self.client.login(username="nature_manager", password="password")
url = reverse("assets:management_zones_geojson") + "?thematic=nature"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["type"], "FeatureCollection")
self.assertEqual(len(data["features"]), 1)
feat = data["features"][0]
self.assertEqual(feat["id"], self.zone_nature.id)
self.assertEqual(feat["properties"]["code"], "ZG-NAT-1")
self.assertEqual(feat["properties"]["name"], "Prairie fleurie")
self.assertEqual(feat["properties"]["thematic"], "nature")
self.assertEqual(feat["properties"]["zone_type_name"], "Fauche tardive")
self.assertEqual(feat["properties"]["color"], "#198754")
self.assertEqual(feat["properties"]["instructions"], "Ne pas tondre au printemps")
self.assertEqual(feat["properties"]["target_frequency_days"], 180)
self.assertEqual(feat["geometry"]["type"], "MultiPolygon")
def test_user_without_thematic_permission_denied(self):
self.client.login(username="nature_manager", password="password")
# nature_manager n'a pas accès à la thématique 'roads'
url = reverse("assets:management_zones_geojson") + "?thematic=roads"
response = self.client.get(url)
self.assertEqual(response.status_code, 403)
def test_superuser_access_all_thematics(self):
admin = User.objects.create_superuser(username="superadmin", password="password", email="admin@example.com")
self.client.login(username="superadmin", password="password")
# Sans paramètre thématique, superuser reçoit toutes les zones avec géométrie
url = reverse("assets:management_zones_geojson")
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(len(data["features"]), 2)
import json
from django.core.files.uploadedfile import SimpleUploadedFile
from assets.permissions import (
can_view_management_zones,
can_edit_management_zones,
can_delete_management_zone,
get_editable_thematics_for_management_zones,
)
from assets.forms import ManagementZoneForm
from assets.models import ManagementZoneDocument
class ManagementZonesPermissionAndCRUDTests(TestCase):
def setUp(self):
# Users
self.user_manager = User.objects.create_user(username="mgr", password="password")
self.user_viewer = User.objects.create_user(username="viewer", password="password")
self.user_editor = User.objects.create_user(username="editor", password="password")
self.user_no_access = User.objects.create_user(username="no_access", password="password")
# Thematics
self.thematic_nature = Thematic.objects.create(code="nature", name_fr="Nature", name_nl="Natuur")
self.thematic_roads = Thematic.objects.create(code="roads", name_fr="Voiries", name_nl="Wegen")
# Roles & Configs
self.role_manager = Role.objects.create(name="manager")
self.role_operator = Role.objects.create(name="operator")
self.config_mgr = UserConfig.objects.create(user=self.user_manager)
self.config_mgr.roles.add(self.role_manager)
self.config_viewer = UserConfig.objects.create(user=self.user_viewer)
UserThematics.objects.create(
user_config=self.config_viewer,
thematic=self.thematic_nature,
can_view_assets=True,
can_edit_assets=False,
can_edit_management_zones=False
)
self.config_editor = UserConfig.objects.create(user=self.user_editor)
UserThematics.objects.create(
user_config=self.config_editor,
thematic=self.thematic_nature,
can_view_assets=True,
can_edit_assets=False,
can_edit_management_zones=True # Specific permission
)
self.config_no_access = UserConfig.objects.create(user=self.user_no_access)
# Type de zone
self.zone_type = ManagementZoneType.objects.create(
code="fauche_eco",
name_fr="Fauche écologique",
thematic=self.thematic_nature,
default_color="#20c997"
)
poly = Polygon(((0, 0), (50, 0), (50, 50), (0, 50), (0, 0)))
self.zone = ManagementZone.objects.create(
code="ZG-TEST-01",
name_fr="Zone Test Marais",
name_nl="Testzone Moeras",
thematic=self.thematic_nature,
zone_type=self.zone_type,
geom=MultiPolygon([poly], srid=3812),
target_frequency_days=90,
seasonality_notes="Avril-Septembre",
instructions="Attention à la faune aquatique",
is_active=True
)
def test_permission_functions(self):
# Manager can view and edit any thematic
self.assertTrue(can_view_management_zones(self.user_manager))
self.assertTrue(can_edit_management_zones(self.user_manager))
self.assertTrue(can_edit_management_zones(self.user_manager, thematic=self.thematic_nature))
# Viewer can view nature, but cannot edit
self.assertTrue(can_view_management_zones(self.user_viewer, thematic=self.thematic_nature))
self.assertFalse(can_view_management_zones(self.user_viewer, thematic=self.thematic_roads))
self.assertFalse(can_edit_management_zones(self.user_viewer))
self.assertFalse(can_edit_management_zones(self.user_viewer, thematic=self.thematic_nature))
# Editor with can_edit_management_zones=True
self.assertTrue(can_view_management_zones(self.user_editor, thematic=self.thematic_nature))
self.assertTrue(can_edit_management_zones(self.user_editor))
self.assertTrue(can_edit_management_zones(self.user_editor, thematic=self.thematic_nature))
self.assertFalse(can_edit_management_zones(self.user_editor, thematic=self.thematic_roads))
# Editable thematics
editable = get_editable_thematics_for_management_zones(self.user_editor)
self.assertIn(self.thematic_nature, editable)
self.assertNotIn(self.thematic_roads, editable)
def test_management_zone_form_with_geom_geojson(self):
# Test initial geojson
form = ManagementZoneForm(instance=self.zone, user=self.user_editor)
self.assertIn('geom_geojson', form.initial)
geom_dict = json.loads(form.initial['geom_geojson'])
self.assertEqual(geom_dict['type'], 'MultiPolygon')
# Test saving with new GeoJSON polygon in WGS84
geojson_polygon = {
"type": "Polygon",
"coordinates": [[[4.35, 50.84], [4.36, 50.84], [4.36, 50.85], [4.35, 50.85], [4.35, 50.84]]]
}
data = {
'code': 'ZG-NEW-01',
'name_fr': 'Nouvelle zone',
'thematic': self.thematic_nature.pk,
'zone_type': self.zone_type.pk,
'color': '#ff9900',
'target_frequency_days': 30,
'geom_geojson': json.dumps(geojson_polygon),
'is_active': True,
}
form = ManagementZoneForm(data=data, user=self.user_editor)
self.assertTrue(form.is_valid(), form.errors)
new_zone = form.save()
self.assertEqual(new_zone.code, 'ZG-NEW-01')
self.assertIsNotNone(new_zone.geom)
self.assertEqual(new_zone.geom.srid, 3812)
self.assertEqual(new_zone.geom.geom_type, 'MultiPolygon')
def test_management_zones_index_view(self):
url = reverse("assets:management_zones_index")
# Anonymous -> 302
res = self.client.get(url)
self.assertEqual(res.status_code, 302)
# No access user -> 403
self.client.login(username="no_access", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 403)
# Viewer -> 200
self.client.login(username="viewer", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertContains(res, "ZG-TEST-01")
self.assertFalse(res.context['can_create'])
# Editor -> 200 and can_create is True
self.client.login(username="editor", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertTrue(res.context['can_create'])
def test_management_zone_create_view(self):
url = reverse("assets:management_zone_create")
# Viewer cannot create -> 403
self.client.login(username="viewer", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 403)
# Editor can access form
self.client.login(username="editor", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertContains(res, "layer-switcher-control.js")
self.assertContains(res, "AddressSearchControl")
# Post valid zone
geojson_polygon = {
"type": "Polygon",
"coordinates": [[[4.35, 50.84], [4.36, 50.84], [4.36, 50.85], [4.35, 50.85], [4.35, 50.84]]]
}
post_data = {
'code': 'ZG-CREATED-01',
'name_fr': 'Zone créée',
'thematic': self.thematic_nature.pk,
'zone_type': self.zone_type.pk,
'color': '#28a745',
'target_frequency_days': 15,
'geom_geojson': json.dumps(geojson_polygon),
'instructions': 'Passage le matin uniquement',
'is_active': True,
}
res = self.client.post(url, post_data)
self.assertEqual(res.status_code, 302)
created_zone = ManagementZone.objects.get(code='ZG-CREATED-01')
self.assertEqual(created_zone.created_by, self.user_editor)
self.assertEqual(created_zone.instructions, 'Passage le matin uniquement')
self.assertEqual(created_zone.geom.srid, 3812)
def test_management_zone_edit_view(self):
url = reverse("assets:management_zone_edit", kwargs={'pk': self.zone.pk})
# Viewer cannot edit -> 403
self.client.login(username="viewer", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 403)
# Editor can edit
self.client.login(username="editor", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertContains(res, "layer-switcher-control.js")
self.assertContains(res, "AddressSearchControl")
post_data = {
'code': self.zone.code,
'name_fr': 'Nom Modifié Marais',
'thematic': self.thematic_nature.pk,
'zone_type': self.zone_type.pk,
'color': '#ff5500',
'target_frequency_days': 120,
'geom_geojson': json.dumps({
"type": "Polygon",
"coordinates": [[[4.35, 50.84], [4.37, 50.84], [4.37, 50.86], [4.35, 50.86], [4.35, 50.84]]]
}),
'instructions': 'Consigne mise à jour',
'is_active': True,
}
res = self.client.post(url, post_data)
self.assertEqual(res.status_code, 302)
self.zone.refresh_from_db()
self.assertEqual(self.zone.name_fr, 'Nom Modifié Marais')
self.assertEqual(self.zone.color, '#ff5500')
self.assertEqual(self.zone.target_frequency_days, 120)
def test_management_zone_detail_view(self):
url = reverse("assets:management_zone_detail", kwargs={'pk': self.zone.pk})
# Viewer can view
self.client.login(username="viewer", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertContains(res, "ZG-TEST-01")
self.assertFalse(res.context['can_edit'])
# Editor can view with can_edit=True
self.client.login(username="editor", password="password")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertTrue(res.context['can_edit'])
def test_management_zone_delete_view(self):
zone_to_del = ManagementZone.objects.create(
code="ZG-TO-DELETE",
name_fr="A supprimer",
thematic=self.thematic_nature,
)
url = reverse("assets:management_zone_delete", kwargs={'pk': zone_to_del.pk})
# Viewer cannot delete -> 403
self.client.login(username="viewer", password="password")
res = self.client.post(url)
self.assertEqual(res.status_code, 403)
# Editor can delete
self.client.login(username="editor", password="password")
res = self.client.post(url)
self.assertEqual(res.status_code, 302)
self.assertFalse(ManagementZone.objects.filter(pk=zone_to_del.pk).exists())
def test_management_zone_document_upload_and_delete(self):
self.client.login(username="editor", password="password")
# Upload document
upload_url = reverse("assets:management_zone_add_document", kwargs={'pk': self.zone.pk})
fake_file = SimpleUploadedFile("rapport_tonte.pdf", b"Dummy PDF content", content_type="application/pdf")
res = self.client.post(upload_url, {'documents': [fake_file]})
self.assertEqual(res.status_code, 302)
self.assertEqual(self.zone.documents.count(), 1)
doc = self.zone.documents.first()
self.assertEqual(doc.uploaded_by, self.user_editor)
# Delete document
delete_url = reverse("assets:management_zone_delete_document", kwargs={'pk': self.zone.pk, 'document_id': doc.pk})
res = self.client.post(delete_url)
self.assertEqual(res.status_code, 302)
self.assertEqual(self.zone.documents.count(), 0)
def test_management_zone_types_ajax(self):
self.client.login(username="editor", password="password")
url = reverse("assets:management_zone_types_ajax") + f"?thematic_id={self.thematic_nature.pk}"
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
data = res.json()
self.assertEqual(len(data['types']), 1)
self.assertEqual(data['types'][0]['code'], 'fauche_eco')
class ManagementZoneUnifiedViewIntegrationTests(TestCase):
def setUp(self):
self.thematic_nature = Thematic.objects.create(name_fr="Nature", name_nl="Natuur", code="nature")
self.thematic_roads = Thematic.objects.create(name_fr="Voirie", name_nl="Wegen", code="roads")
self.user_editor = User.objects.create_user(username="editor_unified", password="password")
self.user_viewer = User.objects.create_user(username="viewer_unified", password="password")
self.config_editor = UserConfig.objects.create(user=self.user_editor)
self.config_viewer = UserConfig.objects.create(user=self.user_viewer)
UserThematics.objects.create(
user_config=self.config_editor,
thematic=self.thematic_nature,
can_view_assets=True,
can_edit_assets=True,
can_edit_management_zones=True,
)
UserThematics.objects.create(
user_config=self.config_viewer,
thematic=self.thematic_nature,
can_view_assets=True,
can_edit_assets=False,
can_edit_management_zones=False,
)
self.zone_type = ManagementZoneType.objects.create(
thematic=self.thematic_nature,
code="fauche_eco",
name_fr="Fauche tardive",
name_nl="Laat maaien",
default_color="#27ae60",
)
poly = Polygon(((149000, 170000), (149100, 170000), (149100, 170100), (149000, 170100), (149000, 170000)))
multi_poly = MultiPolygon([poly], srid=3812)
self.zone = ManagementZone.objects.create(
thematic=self.thematic_nature,
zone_type=self.zone_type,
code="ZN-TEST-001",
name_fr="Pelouse A",
name_nl="Grasveld A",
geom=multi_poly,
is_active=True,
created_by=self.user_editor,
)
def test_build_thematic_config_includes_management_zones_url(self):
from assets.views.core import build_thematic_config
config = build_thematic_config([self.thematic_nature, self.thematic_roads])
self.assertIn("nature", config)
self.assertIn("management_zones_geojson_url", config["nature"])
self.assertEqual(config["nature"]["management_zones_geojson_url"], reverse("assets:management_zones_geojson"))
def test_build_unified_add_modal_config_permissions(self):
from assets.views.core import build_unified_add_modal_config
# Editor can edit management zones in nature
editor_config = build_unified_add_modal_config([self.thematic_nature], user=self.user_editor)
self.assertIn("management_zone_thematics", editor_config)
editor_codes = [t["code"] for t in editor_config["management_zone_thematics"]]
self.assertIn("nature", editor_codes)
# Viewer cannot edit management zones
viewer_config = build_unified_add_modal_config([self.thematic_nature], user=self.user_viewer)
viewer_codes = [t["code"] for t in viewer_config["management_zone_thematics"]]
self.assertNotIn("nature", viewer_codes)
def test_management_zones_geojson_properties_for_unified_table(self):
self.client.login(username="editor_unified", password="password")
url = reverse("assets:management_zones_geojson") + f"?thematic={self.thematic_nature.code}"
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
data = res.json()
self.assertEqual(len(data["features"]), 1)
feat = data["features"][0]
props = feat["properties"]
self.assertTrue(props["is_management_zone"])
self.assertFalse(props["is_location"])
self.assertEqual(props["asset_type"], "managementzone")
self.assertIn(props["element_type"], ["Zone de gestion", "Management zone"])
self.assertEqual(props["category"], "Fauche tardive")
self.assertEqual(props["status"], "active")
self.assertIsNotNone(props["lon"])
self.assertIsNotNone(props["lat"])
self.assertIsNotNone(props["area"])
self.assertEqual(props["detail_url"], reverse("assets:management_zone_detail", kwargs={"pk": self.zone.pk}))
def test_unified_index_page_contains_management_zone_elements(self):
self.client.login(username="editor_unified", password="password")
url = reverse("assets:index")
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
# Check filter modal checkboxes
self.assertContains(res, 'id="type-cb-assets"')
self.assertContains(res, 'id="type-cb-locations"')
self.assertContains(res, 'id="type-cb-management-zones"')
# Check add modal card
self.assertContains(res, 'id="unified-add-management-zone-thematic"')
self.assertContains(res, 'id="unified-add-management-zone-open"')

View file

@ -1,19 +1,9 @@
from django.urls import path from django.urls import path
from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor, parking, management_zones from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor, parking
app_name = "assets" app_name = "assets"
urlpatterns = [ urlpatterns = [
path("", core.unified_assets_index, name="index"), path("", core.unified_assets_index, name="index"),
# Management Zones (Zones de gestion)
path("management-zones/", management_zones.management_zones_index, name="management_zones_index"),
path("management-zones/add/", management_zones.management_zone_create, name="management_zone_create"),
path("management-zones/types/ajax/", management_zones.management_zone_types_ajax, name="management_zone_types_ajax"),
path("management-zones/<int:pk>/", management_zones.management_zone_detail, name="management_zone_detail"),
path("management-zones/<int:pk>/edit/", management_zones.management_zone_edit, name="management_zone_edit"),
path("management-zones/<int:pk>/delete/", management_zones.management_zone_delete, name="management_zone_delete"),
path("management-zones/<int:pk>/add-document/", management_zones.management_zone_add_document, name="management_zone_add_document"),
path("management-zones/<int:pk>/documents/<int:document_id>/delete/", management_zones.management_zone_delete_document, name="management_zone_delete_document"),
path("api/management-zones/geojson/", management_zones.management_zones_geojson, name="management_zones_geojson"),
path("parking/", parking.parking_index, name="parking_index"), path("parking/", parking.parking_index, name="parking_index"),
path("parking/assets/", parking.parking_assets, name="parking_assets"), path("parking/assets/", parking.parking_assets, name="parking_assets"),
path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"), path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"),
@ -24,7 +14,6 @@ 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

@ -26,56 +26,6 @@ def is_hp_or_ls(text):
return True return True
return False return False
def is_wiring_or_non_equipment(text):
"""
Checks if the wire/direction/circuit text refers to auxiliary wiring, communication,
power supply, contact lines, or reserve wires that must NOT be treated as equipment
(neither lantern nor detector).
In cross plans, 'Com' / 'Comm' / 'Communication' represents communication wiring/bus
for detectors/cameras or radar power supplies, NOT an actual detector.
"""
if not text:
return False
t_str = str(text).strip()
t_lower = t_str.lower()
t_clean = re.sub(r'[\s/\\_\-\.:;]+', ' ', t_lower).strip()
t_upper = re.sub(r'\s+', '', t_str.upper())
# 1. Direct keyword matches (word boundary)
if re.search(r'\b(?:com|comm|communication|communicatie|alim|alimentation|voeding|tension|contact|reserve|reservee|réservé|réservée|réserve|libre|vrij|spaar|terre|masse|neutre)\b', t_clean, re.IGNORECASE):
return True
# 2. String starts with or equals COM, COMM, ALIM, etc.
if re.match(r'^(?:COM|COMM|ALIM|CONTACT|TENSION|RESERVE)\d*$', t_upper, re.IGNORECASE):
return True
if t_upper.startswith(("COM_", "COMM_", "ALIM_", "COM-", "COMM-", "ALIM-")):
return True
# 3. Radar power supply (not a detector code like RA, RB, RC, RD, RM, RP)
if t_upper == "RADAR" or t_upper.startswith("RADAR") or (bool(re.search(r'\bradar\b', t_str, re.IGNORECASE)) and not bool(re.match(r'^(?:RA|RB|RC|RD|RM|RP)\d*$', t_upper, re.IGNORECASE))):
return True
return False
def is_cyclopieton_phase(phase):
"""
Checks if a phase code corresponds to a combined pedestrian + cyclist signal (cyclo-piéton).
Standard notation: lowercase letter (pedestrian) + 'F' or 'f' (fiets/cyclist), e.g. 'bF', 'aF', 'cF', 'dF', 'bF1',
or vice versa ('Fa', 'Fb').
"""
if not phase:
return False
ph = str(phase).strip()
if re.match(r'^[a-z][Ff]\d*$', ph) or re.match(r'^[Ff][a-z]\d*$', ph):
return True
if re.match(r'^[a-z]\s*[\+/&]\s*[Ff]\d*$', ph) or re.match(r'^[Ff]\s*[\+/&]\s*[a-z]\d*$', ph):
return True
ph_lower = ph.lower()
if 'cyclo' in ph_lower and ('piet' in ph_lower or 'piéto' in ph_lower or 'ped' in ph_lower):
return True
return False
def parse_cross_plan(excel_path): def parse_cross_plan(excel_path):
""" """
Parses a crossroads cross plan (kruisjesplan) Excel file and returns Parses a crossroads cross plan (kruisjesplan) Excel file and returns
@ -315,11 +265,6 @@ def parse_cross_plan(excel_path):
pole_has_hp[p] = True pole_has_hp[p] = True
continue continue
# Check if wire refers to communication, power supply, contact, reserve, etc.
# It must NOT be treated as an equipment (neither lantern nor detector).
if is_wiring_or_non_equipment(richting) or is_wiring_or_non_equipment(kringen):
continue
# Detectors # Detectors
# Check if push button (e.g. Dka, Dkb, PBa1, BPf4...) # Check if push button (e.g. Dka, Dkb, PBa1, BPf4...)
if richting.lower().startswith(("dk", "pb", "bp")): if richting.lower().startswith(("dk", "pb", "bp")):
@ -420,7 +365,7 @@ def parse_cross_plan(excel_path):
# Richting codes are typically: A, B, C, T1, T2, F1, F2, a, b, c... # Richting codes are typically: A, B, C, T1, T2, F1, F2, a, b, c...
# Ignore wiring/cabling categories like ALIM, Radar, Com, contact, HP/LS, etc. # Ignore wiring/cabling categories like ALIM, Radar, Com, contact, HP/LS, etc.
normalized = re.sub(r'\s+', ' ', richting).strip() normalized = re.sub(r'\s+', ' ', richting).strip()
if is_hp_or_ls(richting) or is_hp_or_ls(kringen) or is_wiring_or_non_equipment(richting) or is_wiring_or_non_equipment(kringen) or re.search(r'(?:alim|com|contact|radar|tension|comm|\bhp\b|\bls\b|hp/ls|ls/hp)', normalized, re.IGNORECASE): if is_hp_or_ls(richting) or is_hp_or_ls(kringen) or re.search(r'(?:alim|com|contact|radar|tension|comm|\bhp\b|\bls\b|hp/ls|ls/hp)', normalized, re.IGNORECASE):
continue continue
if p not in pole_phases: if p not in pole_phases:
@ -430,13 +375,9 @@ def parse_cross_plan(excel_path):
# Now generate LANTERNS for each pole # Now generate LANTERNS for each pole
deduced_lanterns = [] deduced_lanterns = []
# helper to sort phases: vehicles first, then trams, then cycles, then cyclo-piétons / pedestrians # helper to sort phases: vehicles first, then trams, then cycles, then pedestrians
def phase_sort_key(ph): def phase_sort_key(ph):
if is_cyclopieton_phase(ph): if ph and ph[0].islower(): # Pedestrian
if ph and ph[0].islower():
return (3, ph)
return (2, ph)
elif ph and ph[0].islower(): # Pedestrian
return (3, ph) return (3, ph)
elif ph.startswith("T"): # Tram elif ph.startswith("T"): # Tram
return (1, ph) return (1, ph)
@ -463,10 +404,7 @@ def parse_cross_plan(excel_path):
if is_mast_arm and has_double_marker: if is_mast_arm and has_double_marker:
# Determine models for both lanterns # Determine models for both lanterns
if is_cyclopieton_phase(phase): if phase and phase[0].islower(): # Pedestrian
model_name_1 = "2V200 cyclo-piéto + HP" if has_hp else "2V200 cyclo-piéto"
model_name_2 = "2V200 cyclo-piéto + HP" if has_hp else "2V200 cyclo-piéto"
elif phase and phase[0].islower(): # Pedestrian
model_name_1 = "2V200 piéton+HP" if has_hp else "2V200" model_name_1 = "2V200 piéton+HP" if has_hp else "2V200"
model_name_2 = "2V200 piéton+HP" if has_hp else "2V200" model_name_2 = "2V200 piéton+HP" if has_hp else "2V200"
elif phase.startswith("T"): # Tram elif phase.startswith("T"): # Tram
@ -514,12 +452,7 @@ def parse_cross_plan(excel_path):
lantern_idx += 1 lantern_idx += 1
else: else:
# Deduce single model # Deduce single model
if is_cyclopieton_phase(phase): if phase and phase[0].islower(): # Pedestrian
if has_hp:
model_name = "2V200 cyclo-piéto + HP"
else:
model_name = "2V200 cyclo-piéto"
elif phase and phase[0].islower(): # Pedestrian
if has_hp: if has_hp:
model_name = "2V200 piéton+HP" model_name = "2V200 piéton+HP"
else: else:
@ -609,23 +542,9 @@ def classify_lantern(model_name="", phase="", code="", name=""):
) )
) )
# Check Cyclo-piéton:
is_cyclopieton = (
not is_tram and not is_bus and (
is_cyclopieton_phase(phase_clean)
or bool(re.search(r'[\b_]LAN\d+_[a-z][Ff]\d*', code_upper))
or "cyclo-piét" in combined_text
or "cyclo-piet" in combined_text
or "cyclopiet" in combined_text
or ("cyclo" in combined_text and any(w in combined_text for w in ["piét", "piet", "voetganger"]))
or (any(w in combined_text for w in ["cycliste", "velo", "vélo", "fiets"])
and any(w in combined_text for w in ["piéton", "pieton", "voetganger", "pedestrian"]))
)
)
# Check Cyclist: # Check Cyclist:
is_cyclist = ( is_cyclist = (
not is_tram and not is_bus and not is_cyclopieton and ( not is_tram and not is_bus and (
any(w in combined_text for w in ["cycliste", "cycl", "velo", "vélo", "fiets"]) any(w in combined_text for w in ["cycliste", "cycl", "velo", "vélo", "fiets"])
or phase_clean.upper().startswith("F") or phase_clean.upper().startswith("F")
or bool(re.search(r'\bF\d+\b', code_upper)) or bool(re.search(r'\bF\d+\b', code_upper))
@ -634,7 +553,7 @@ def classify_lantern(model_name="", phase="", code="", name=""):
# Check Pedestrian: # Check Pedestrian:
is_pedestrian = ( is_pedestrian = (
not is_tram and not is_bus and not is_cyclopieton and not is_cyclist and ( not is_tram and not is_bus and not is_cyclist and (
any(w in combined_text for w in ["piéton", "pieton", "voetganger", "pedestrian"]) any(w in combined_text for w in ["piéton", "pieton", "voetganger", "pedestrian"])
or (bool(phase_clean) and phase_clean[0].islower() and not phase_clean.startswith("fl")) or (bool(phase_clean) and phase_clean[0].islower() and not phase_clean.startswith("fl"))
or phase_clean.upper().startswith("P") or phase_clean.upper().startswith("P")
@ -646,8 +565,6 @@ def classify_lantern(model_name="", phase="", code="", name=""):
category = "tram" category = "tram"
elif is_bus: elif is_bus:
category = "bus" category = "bus"
elif is_cyclopieton:
category = "cyclopieton"
elif is_cyclist: elif is_cyclist:
category = "cyclist" category = "cyclist"
elif is_pedestrian: elif is_pedestrian:
@ -717,13 +634,7 @@ def calculate_lantern_match_score(dl, el, is_single_on_pole=False):
return 10000 return 10000
dl_base_code = dl_code.rsplit("_", 1)[0] if "_" in dl_code else dl_code dl_base_code = dl_code.rsplit("_", 1)[0] if "_" in dl_code else dl_code
el_base_code = el_code.rsplit("_", 1)[0] if "_" in el_code else el_code is_prefix_code_match = bool(dl_base_code and el_code and dl_base_code == el_code)
is_prefix_code_match = bool(
(dl_base_code and el_code and dl_base_code == el_code)
or (dl_code and el_base_code and dl_code == el_base_code)
or (dl_base_code and el_base_code and dl_base_code == el_base_code)
)
if is_single_on_pole and is_prefix_code_match: if is_single_on_pole and is_prefix_code_match:
return 1000 return 1000
@ -753,12 +664,10 @@ def calculate_lantern_match_score(dl, el, is_single_on_pole=False):
# 2. Category matching # 2. Category matching
if dl_cat == el_cat: if dl_cat == el_cat:
if dl_cat in ("tram", "bus", "pedestrian", "cyclist", "cyclopieton"): if dl_cat in ("tram", "bus", "pedestrian", "cyclist"):
score += 300 score += 300
else: else:
score += 200 score += 200
elif {dl_cat, el_cat} in ({"cyclopieton", "pedestrian"}, {"cyclopieton", "cyclist"}):
score += 50
else: else:
# Category mismatch penalty: e.g. tram vs car, pedestrian vs car # Category mismatch penalty: e.g. tram vs car, pedestrian vs car
score -= 600 score -= 600
@ -798,9 +707,9 @@ def calculate_lantern_match_score(dl, el, is_single_on_pole=False):
else: else:
score -= 60 score -= 60
# 8. Prefix match bonus (e.g. LAN03 vs LAN03_bF on the same pole) # 8. Prefix match bonus
if is_prefix_code_match: if is_prefix_code_match:
score += 250 score += 30
# 9. LAN index tie-breaker (e.g. LAN01 vs LAN01) # 9. LAN index tie-breaker (e.g. LAN01 vs LAN01)
m_dl = re.search(r'LAN(\d+)', dl_code, re.IGNORECASE) m_dl = re.search(r'LAN(\d+)', dl_code, re.IGNORECASE)

View file

@ -1,190 +0,0 @@
import math
from typing import Optional
# Coefficients allométriques carbone par genre botanique (t CO2 / (cm_circ * m_hauteur))
# Dérivés des tables de biomasse et densités ligneuses (i-Tree / Chave / Grality)
GENUS_CARBON_K = {
"aesculus": 0.02471, # Marronnier
"carpinus": 0.02370, # Charme
"quercus": 0.01952, # Chêne
"betula": 0.01952, # Bouleau
"fagus": 0.01882, # Hêtre
"prunus": 0.01813, # Cerisier / Prunier
"fraxinus": 0.01743, # Frêne
"acer": 0.01673, # Érable
"platanus": 0.01673, # Platane
"castanea": 0.01900, # Châtaignier
"robinia": 0.01850, # Robinier
"ulmus": 0.01750, # Orme
"cedrus": 0.01600, # Cèdre
"salix": 0.01550, # Saule
"populus": 0.01500, # Peuplier
"pinus": 0.01450, # Pin
"tilia": 0.01366, # Tilleul
"malus": 0.01800, # Pommier
"pyrus": 0.01800, # Poirier
}
# Indices écologiques de biodiversité par genre / essence
GENUS_BIODIVERSITY = {
"quercus": 0.92, # Chêne (très forte valeur d'accueil)
"fagus": 0.88, # Hêtre
"platanus": 0.85, # Platane
"fraxinus": 0.78, # Frêne
"tilia": 0.78, # Tilleul
"carpinus": 0.76, # Charme
"salix": 0.75, # Saule
"acer": 0.68, # Érable
"aesculus": 0.62, # Marronnier
"pinus": 0.62, # Pin
"populus": 0.60, # Peuplier
"ulmus": 0.60, # Orme
"malus": 0.53, # Pommier
"betula": 0.47, # Bouleau
"prunus": 0.44, # Cerisier / Prunus ornemental
}
# Constante d'énergie de refroidissement par an (kWh / m^2 de canopée projetée)
# Dérivée de l'évapotranspiration urbaine Grality : 296.357 * D^2
COOLING_ENERGY_PER_D2 = 296.357
# Valeur de base au cm² de section de tronc (Barème de l'Arbre / Plante & Cité)
BASE_AMENITY_VALUE_PER_CM2 = 3.20
def get_genus_carbon_k(botanical_str: Optional[str]) -> float:
"""Retourne le coefficient allométrique de carbone pour l'essence ou genre spécifié."""
if not botanical_str:
return 0.0180
b_lower = botanical_str.lower()
for genus, k in GENUS_CARBON_K.items():
if genus in b_lower:
return k
return 0.0180
def get_genus_biodiversity_index(botanical_str: Optional[str]) -> float:
"""Retourne l'indice de biodiversité pour l'essence ou genre spécifié."""
if not botanical_str:
return 0.60
b_lower = botanical_str.lower()
for genus, b in GENUS_BIODIVERSITY.items():
if genus in b_lower:
return b
return 0.60
def compute_tree_ecoservices(tree, overwrite_existing: bool = False) -> bool:
"""
Calcule et met à jour automatiquement les indicateurs écosystémiques et patrimoniaux
d'un arbre (NatureTree) :
1. Dimensions déduites si manquantes (circonférence et diamètre de houppier).
2. Valeur d'aménité (€) selon le Barème de l'Arbre (section du tronc et coefficients).
3. Stock de carbone (t CO2) selon l'allométrie dendrométrique et l'essence.
4. Énergie de refroidissement (kWh) et Indice de fraîcheur selon la canopée.
5. Indice de biodiversité selon l'essence.
Si overwrite_existing=False (par défaut), ne remplace pas les valeurs déjà renseignées
(ex: valeurs officielles Grality déjà enregistrées).
Retourne True si au moins un champ a été calculé/mis à jour.
"""
changed = False
# 1. Estimation / déduction des dimensions manquantes à partir de la hauteur ou circonférence
h = getattr(tree, 'height', None)
c = getattr(tree, 'circumference', None)
crown_d = getattr(tree, 'crown_diameter', None)
if (not c or c <= 0) and h and h > 0:
c = round(h * 7.5, 1)
tree.circumference = c
changed = True
if (not crown_d or crown_d <= 0):
if h and h > 0:
crown_d = round(max(2.0, min(18.0, h * 0.45)), 1)
tree.crown_diameter = crown_d
changed = True
elif c and c > 0:
estimated_h = c / 7.5
crown_d = round(max(2.0, min(18.0, estimated_h * 0.45)), 1)
tree.crown_diameter = crown_d
changed = True
if (not h or h <= 0) and c and c > 0:
h = round(c / 7.5, 1)
tree.height = h
changed = True
# 2. Coefficients de situation et plantation par défaut si absents
sit = getattr(tree, 'situation_coefficient', None)
if sit is None or overwrite_existing:
ctx = getattr(tree, 'implantation_context', 'street')
sit = 5.0 if ctx in ('park', 'square', 'isolated') else 4.0
tree.situation_coefficient = sit
changed = True
plant = getattr(tree, 'plantation_coefficient', None)
if plant is None or overwrite_existing:
plant = 5.0
tree.plantation_coefficient = plant
changed = True
# Statut sanitaire / vitalité
is_dead = bool(getattr(tree, 'is_dead', False))
vitality = getattr(tree, 'vitality', 'normal') or 'normal'
if is_dead or vitality == 'dead':
sa_coeff = 0.0
elif vitality == 'declining':
sa_coeff = 0.3
elif vitality == 'weak':
sa_coeff = 0.6
else:
sa_coeff = 1.0
# 3. Valeur d'aménité (€) — Barème de l'Arbre
if getattr(tree, 'amenity_value', None) is None or overwrite_existing:
if sa_coeff == 0.0:
tree.amenity_value = 0.0
changed = True
elif c and c > 0:
section_cm2 = (c ** 2) / (4.0 * math.pi)
tree.amenity_value = round(
section_cm2 * BASE_AMENITY_VALUE_PER_CM2 * (sit / 5.0) * (plant / 5.0) * sa_coeff,
2
)
changed = True
# 4. Énergie de refroidissement (kWh) et Indice de fraîcheur
if getattr(tree, 'cooling_energy_indicator', None) is None or overwrite_existing:
if is_dead or vitality == 'dead':
tree.cooling_energy_indicator = 0.0
changed = True
elif crown_d and crown_d > 0:
tree.cooling_energy_indicator = round(COOLING_ENERGY_PER_D2 * (crown_d ** 2), 2)
changed = True
if getattr(tree, 'cooling_indicator', None) is None or overwrite_existing:
if is_dead or vitality == 'dead':
tree.cooling_indicator = 0.0
changed = True
elif tree.cooling_energy_indicator is not None and tree.cooling_energy_indicator > 0:
tree.cooling_indicator = round(math.sqrt(tree.cooling_energy_indicator) / 6.75, 2)
changed = True
# 5. Stock de carbone (t CO2)
botanical = getattr(tree, 'scientific_name', '') or getattr(tree, 'genus', '') or getattr(tree, 'name_fr', '')
if getattr(tree, 'carbon_stock', None) is None or overwrite_existing:
if c and h and c > 0 and h > 0:
k = get_genus_carbon_k(botanical)
tree.carbon_stock = round(k * c * h, 3)
changed = True
# 6. Indice de biodiversité
if getattr(tree, 'biodiversity_index', None) is None or overwrite_existing:
tree.biodiversity_index = get_genus_biodiversity_index(botanical)
changed = True
return changed

View file

@ -1411,11 +1411,6 @@ CATEGORY_ASSET_MAPPING = {
'LI_poles': ('PublicLightingPole', 'PublicLightingPoleModel'), 'LI_poles': ('PublicLightingPole', 'PublicLightingPoleModel'),
'publiclighting_luminaire': ('PublicLightingLuminaire', 'PublicLightingLuminaireModel'), 'publiclighting_luminaire': ('PublicLightingLuminaire', 'PublicLightingLuminaireModel'),
'LI_lightfixtures': ('PublicLightingLightFixture', 'PublicLightingLightFixtureModel'), 'LI_lightfixtures': ('PublicLightingLightFixture', 'PublicLightingLightFixtureModel'),
# ITS categories
'its_bollard_system': ('ITSBollardSystem', 'ITSAssetModel'),
'bollard_system': ('ITSBollardSystem', 'ITSAssetModel'),
'its_bollard_plot': ('ITSBollardPlot', 'ITSAssetModel'),
'bollard_plot': ('ITSBollardPlot', 'ITSAssetModel'),
} }
@ -1861,18 +1856,6 @@ def create_asset_api(request):
except Exception: except Exception:
continue continue
# Handle bollard system (for bollard plots)
system_id = data.get('system_id') or data.get('bollard_system_id')
if system_id and _has_model_field(new_asset, 'system'):
from assets.models import ITSBollardSystem
try:
system_obj = ITSBollardSystem.objects.get(pk=system_id)
new_asset.system = system_obj
if not getattr(new_asset, 'location', None) and system_obj.location:
new_asset.location = system_obj.location
except ITSBollardSystem.DoesNotExist:
pass
# Set additional data # Set additional data
extra_data = data.get('data', {}) extra_data = data.get('data', {})
if isinstance(extra_data, dict): if isinstance(extra_data, dict):

View file

@ -1,11 +1,9 @@
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, FileResponse, Http404 from django.http import JsonResponse, HttpResponseForbidden
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
@ -354,62 +352,10 @@ 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, Vue détaillée d'une Localisation Bâtiment Public avec son arborescence unifiée de localisations et d'assets physiques.
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
@ -423,7 +369,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().select_related('floor_location') documents = building.documents.all()
content_type = ContentType.objects.get_for_model(PublicBuilding) content_type = ContentType.objects.get_for_model(PublicBuilding)
@ -448,116 +394,6 @@ 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,
@ -572,14 +408,6 @@ 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

@ -461,8 +461,6 @@ ELEMENT_TYPE_LABELS = {
'controlcentergeoasset': _('Équipement CdC'), 'controlcentergeoasset': _('Équipement CdC'),
# --- ITS --- # --- ITS ---
'itsgeoasset': _('Équipement ITS'), 'itsgeoasset': _('Équipement ITS'),
'itsbollardsystem': _('Système de bornes'),
'itsbollardplot': _('Plot de borne'),
# --- Propreté --- # --- Propreté ---
'cleanlitterbin': _('Corbeille'), 'cleanlitterbin': _('Corbeille'),
'cleanglasscontainer': _('Conteneur verre'), 'cleanglasscontainer': _('Conteneur verre'),
@ -472,8 +470,6 @@ ELEMENT_TYPE_LABELS = {
# --- Espaces plein air --- # --- Espaces plein air ---
'outdoorlocation': _('Espace public plein air'), 'outdoorlocation': _('Espace public plein air'),
'outdoorasset': _('Équipement plein air'), 'outdoorasset': _('Équipement plein air'),
# --- Zones de gestion ---
'managementzone': _('Zone de gestion'),
} }
@ -557,7 +553,6 @@ def build_thematic_config(thematics):
}, },
} }
management_zones_url = reverse('assets:management_zones_geojson')
result = {} result = {}
for thematic in thematics: for thematic in thematics:
code = thematic.code code = thematic.code
@ -565,7 +560,6 @@ def build_thematic_config(thematics):
entry = dict(_THEMATIC_URLS[code]) entry = dict(_THEMATIC_URLS[code])
entry['name'] = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic) entry['name'] = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic)
entry['icon'] = thematic.icon if hasattr(thematic, 'icon') else '' entry['icon'] = thematic.icon if hasattr(thematic, 'icon') else ''
entry['management_zones_geojson_url'] = management_zones_url
result[code] = entry result[code] = entry
return result return result
@ -681,7 +675,7 @@ def build_conditional_filter_config(accessible_thematics, user=None):
# Roads / public lighting / sign / artworks: municipality on locations (only if more than 1 option) # Roads / public lighting / sign / artworks: municipality on locations (only if more than 1 option)
if len(municipality_options) > 1: if len(municipality_options) > 1:
for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks', 'nature']: for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks']:
if thematic_code in accessible_codes: if thematic_code in accessible_codes:
config.setdefault(thematic_code, []).append({ config.setdefault(thematic_code, []).append({
'key': 'municipality', 'key': 'municipality',
@ -752,114 +746,7 @@ def build_conditional_filter_config(accessible_thematics, user=None):
# Nature # Nature
if 'nature' in accessible_codes: if 'nature' in accessible_codes:
from assets.models import NatureTree config['nature'] = [
def _get_muni_region(m):
if not m:
return None
code = str(getattr(m, 'niscode', '') or '').strip()
if code.startswith('21'):
return 'BRU'
elif code[:2] in ('25', '51', '52', '53', '55', '56', '57', '61', '62', '63', '81', '82', '83', '84', '85', '91', '92', '93') or (code and code[0] in ('5', '6', '8', '9')):
return 'WAL'
elif (code and code[0] in ('1', '3', '4', '7')) or code[:2] in ('23', '24'):
return 'VLG'
name = (getattr(m, 'name_fr', '') or '').lower()
if any(b in name for b in ('bruxelles', 'watermael', 'boitsfort', 'berchem', 'ixelles', 'etterbeek', 'uccle', 'woluwe', 'schaerbeek', 'anderlecht', 'forest', 'jette', 'ganshoren', 'evere', 'saint-gilles', 'saint-josse', 'auderghem', 'molenbeek', 'koekelberg')):
return 'BRU'
return None
regional_labels = {
'BRU': _('Région (Bruxelles Mobilité)'),
'WAL': _('Région (Région wallonne)'),
'VLG': _('Région (Région flamande)'),
}
user_cfg = getattr(user, 'config', None)
user_is_admin = getattr(user, 'is_superuser', False) or (user_cfg and (user_cfg.has_role('admin') or user_cfg.has_role('top_manager')))
user_is_ext = (user_cfg is None) or (not user_cfg.is_intern)
user_must_limit = user_is_ext or (user_cfg and user_cfg.limit_assets_to_contracts)
visible_trees_qs = NatureTree.objects.exclude(status='removed')
if not user_is_admin and user_must_limit:
visible_trees_qs = visible_trees_qs.filter(location__municipality__in=municipalities)
tree_stats = list(visible_trees_qs.values('management_type', 'location__municipality_id').distinct())
has_trees = len(tree_stats) > 0
muni_list = list(municipalities)
management_type_options = []
if len(muni_list) == 1:
single_muni = muni_list[0]
reg = _get_muni_region(single_muni) or 'BRU'
reg_label = regional_labels.get(reg, _('Région'))
has_municipal = any(t['management_type'] == 'municipal' for t in tree_stats) if has_trees else True
has_regional = any(t['management_type'] == 'regional' for t in tree_stats) if has_trees else True
if has_municipal:
management_type_options.append({
'value': 'municipal',
'label': _('Commune (%(name)s)') % {'name': single_muni.name}
})
if has_regional:
management_type_options.append({
'value': 'regional',
'label': reg_label
})
elif len(muni_list) > 1:
muni_map = {m.id: m for m in muni_list}
# Municipal options for each municipality with municipal trees
for m in muni_list:
has_m_trees = any(
t['management_type'] == 'municipal' and t['location__municipality_id'] == m.id
for t in tree_stats
) if has_trees else True
if has_m_trees:
management_type_options.append({
'value': f'municipal_{m.id}',
'label': _('Commune (%(name)s)') % {'name': m.name}
})
# Regional options grouped by region
regions_with_regional_trees = set()
for t in tree_stats:
if t['management_type'] == 'regional':
mid = t['location__municipality_id']
m_obj = muni_map.get(mid)
if m_obj:
reg = _get_muni_region(m_obj)
if reg:
regions_with_regional_trees.add(reg)
if not has_trees:
for m in muni_list:
reg = _get_muni_region(m)
if reg:
regions_with_regional_trees.add(reg)
for reg_code, reg_val in [('BRU', 'regional_bruxelles'), ('WAL', 'regional_wallonie'), ('VLG', 'regional_flandre')]:
if reg_code in regions_with_regional_trees:
management_type_options.append({
'value': reg_val,
'label': regional_labels[reg_code]
})
else:
management_type_options = [
{'value': 'municipal', 'label': _('Communal')},
{'value': 'regional', 'label': _('Régional')},
]
nature_filters = []
if management_type_options:
nature_filters.append({
'key': 'management_type',
'label': _('Gestionnaire'),
'type': 'multi',
'options': management_type_options,
})
nature_filters.extend([
{ {
'key': 'location_type', 'key': 'location_type',
'label': _('Type de localisation'), 'label': _('Type de localisation'),
@ -875,8 +762,7 @@ def build_conditional_filter_config(accessible_thematics, user=None):
'type': 'checkbox', 'type': 'checkbox',
'value': '1', 'value': '1',
}, },
]) ]
config['nature'] = nature_filters
# Water # Water
if 'water' in accessible_codes: if 'water' in accessible_codes:
@ -1050,7 +936,6 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
from assets.permissions import ( from assets.permissions import (
can_edit_location_for_thematic, can_edit_location_for_thematic,
get_allowed_actions_for_thematic, get_allowed_actions_for_thematic,
can_edit_management_zones,
) )
location_type_by_thematic = { location_type_by_thematic = {
@ -1063,7 +948,6 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
asset_thematics = [] asset_thematics = []
location_thematics = [] location_thematics = []
management_zone_thematics = []
for thematic in accessible_thematics: for thematic in accessible_thematics:
thematic_name = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic) thematic_name = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic)
@ -1090,18 +974,9 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
'location_label': location_meta['label'], 'location_label': location_meta['label'],
}) })
if can_edit_management_zones(user, thematic):
management_zone_thematics.append({
'code': thematic.code,
'name': thematic_name,
'icon': thematic_icon,
})
return { return {
'asset_thematics': asset_thematics, 'asset_thematics': asset_thematics,
'location_thematics': location_thematics, 'location_thematics': location_thematics,
'management_zone_thematics': management_zone_thematics,
'management_zone_create_url': reverse('assets:management_zone_create'),
} }

View file

@ -10,7 +10,7 @@ from django.contrib.gis.db.models.functions import Transform
from django.contrib.gis.geos import GEOSGeometry, Point from django.contrib.gis.geos import GEOSGeometry, Point
from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.db.models.functions import Distance
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q, Case, When, IntegerField, CharField, Count from django.db.models import Q, Case, When, IntegerField, CharField
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.db.models import Func, Prefetch from django.db.models import Func, Prefetch
from django.db import connection from django.db import connection
@ -25,10 +25,7 @@ from collections import defaultdict
from common.utils import check_thematic_access from common.utils import check_thematic_access
from common.models import Thematic, UserConfig, UserContractAccess, UserThematics from common.models import Thematic, UserConfig, UserContractAccess, UserThematics
from assets.models import ( from assets.models import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, AssetCategory
ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, AssetCategory,
ITSBollardSystem, ITSBollardPlot, ITSBollardSystemAsset, ITSBollardSystemContract
)
from assets.permissions import can_edit_location_for_thematic, get_allowed_actions_for_thematic from assets.permissions import can_edit_location_for_thematic, get_allowed_actions_for_thematic
from assets.forms import ITSLocationDocumentForm from assets.forms import ITSLocationDocumentForm
@ -300,19 +297,11 @@ def its_assets_geojson(request):
if lon is None or lat is None: if lon is None or lat is None:
location = getattr(obj, 'location', None) location = getattr(obj, 'location', None)
system = getattr(obj, 'system', None)
if location is None and system is not None:
location = getattr(system, 'location', None)
if location is not None: if location is not None:
if lon is None: if lon is None:
lon = getattr(location, 'lon', None) lon = getattr(location, 'lon', None)
if lat is None: if lat is None:
lat = getattr(location, 'lat', None) lat = getattr(location, 'lat', None)
if (lon is None or lat is None) and system is not None:
if lon is None:
lon = getattr(system, 'lon', None)
if lat is None:
lat = getattr(system, 'lat', None)
if geometry is None and lon is not None and lat is not None: if geometry is None and lon is not None and lat is not None:
geometry = { geometry = {
@ -324,42 +313,25 @@ def its_assets_geojson(request):
def obj_to_feature(obj, asset_type=None): def obj_to_feature(obj, asset_type=None):
geometry, lon, lat = build_geometry_and_coords(obj) geometry, lon, lat = build_geometry_and_coords(obj)
props = {
"code": obj.code,
"name": obj.get_name(),
"category": obj.category.get_name() if obj.category else None,
"category_id": obj.category_id,
"model": obj.model.get_name() if obj.model else None,
"model_id": obj.model_id,
"status": obj.status,
"validation_status": obj.validation_status,
"lon": lon,
"lat": lat,
"asset_type": asset_type,
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
"element_type": get_element_type_label(asset_type or ''),
"thematic": "its",
"is_location": False,
}
if asset_type == "itsbollardsystem":
props["width"] = obj.width
props["direction"] = obj.get_direction_display() if hasattr(obj, 'get_direction_display') else obj.direction
props["plots_count"] = getattr(obj, 'plots_total', None)
if props["plots_count"] is None and hasattr(obj, 'plots'):
props["plots_count"] = obj.plots.count()
if obj.geom:
props["length"] = round(obj.geom.length, 2)
elif asset_type == "itsbollardplot":
props["position"] = obj.position
props["bollard_type"] = obj.get_bollard_type_display() if hasattr(obj, 'get_bollard_type_display') else obj.bollard_type
if getattr(obj, 'system', None):
props["system_code"] = obj.system.code
props["system_name"] = obj.system.get_name() if hasattr(obj.system, 'get_name') else (obj.system.name_fr or obj.system.name_nl or obj.system.code)
return { return {
"type": "Feature", "type": "Feature",
"id": obj.id, "id": obj.id,
"geometry": geometry, "geometry": geometry,
"properties": props, "properties": {
"code": obj.code,
"name": obj.get_name(),
"category": obj.category.get_name() if obj.category else None,
"model": obj.model.get_name() if obj.model else None,
"status": obj.status,
"validation_status": obj.validation_status,
"lon": lon,
"lat": lat,
"asset_type": asset_type,
"detail_url": obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
"element_type": get_element_type_label(asset_type or ''),
"thematic": "its",
"is_location": False,
},
} }
def add_assets_to_features(queryset, asset_type, features, feature_count): def add_assets_to_features(queryset, asset_type, features, feature_count):
@ -376,35 +348,16 @@ def its_assets_geojson(request):
.select_related('location', 'category', 'model') .select_related('location', 'category', 'model')
.all() .all()
) )
bollard_system_qs = (
ITSBollardSystem.objects
.select_related('location', 'category', 'model')
.annotate(plots_total=Count('plots'))
.all()
)
bollard_plot_qs = (
ITSBollardPlot.objects
.select_related('system', 'location', 'category', 'model')
.all()
)
# --------- Filtres métier existants # --------- Filtres métier existants
if its_location_ids: if its_location_ids:
its_asset_qs = its_asset_qs.filter(location__id__in=its_location_ids) its_asset_qs = its_asset_qs.filter(location__id__in=its_location_ids)
bollard_system_qs = bollard_system_qs.filter(location__id__in=its_location_ids)
bollard_plot_qs = bollard_plot_qs.filter(
Q(location__id__in=its_location_ids) | Q(system__location__id__in=its_location_ids)
)
if models: if models:
its_asset_qs = its_asset_qs.filter(model__id__in=models) its_asset_qs = its_asset_qs.filter(model__id__in=models)
bollard_system_qs = bollard_system_qs.filter(model__id__in=models)
bollard_plot_qs = bollard_plot_qs.filter(model__id__in=models)
if categories: if categories:
its_asset_qs = its_asset_qs.filter(category__id__in=categories) its_asset_qs = its_asset_qs.filter(category__id__in=categories)
bollard_system_qs = bollard_system_qs.filter(category__id__in=categories)
bollard_plot_qs = bollard_plot_qs.filter(category__id__in=categories)
if filter_geom: if filter_geom:
its_asset_qs = its_asset_qs.filter( its_asset_qs = its_asset_qs.filter(
@ -412,24 +365,11 @@ def its_assets_geojson(request):
Q(geom__isnull=True, location__geom__intersects=filter_geom) | Q(geom__isnull=True, location__geom__intersects=filter_geom) |
Q(geom__isnull=True, location__isnull=True) Q(geom__isnull=True, location__isnull=True)
) )
bollard_system_qs = bollard_system_qs.filter(
Q(geom__intersects=filter_geom) |
Q(geom__isnull=True, location__geom__intersects=filter_geom) |
Q(geom__isnull=True, location__isnull=True)
)
bollard_plot_qs = bollard_plot_qs.filter(
Q(geom__intersects=filter_geom) |
Q(geom__isnull=True, system__geom__intersects=filter_geom) |
Q(geom__isnull=True, location__geom__intersects=filter_geom) |
Q(geom__isnull=True, system__isnull=True)
)
# Recherche plein texte # Recherche plein texte
if request.method == 'POST': if request.method == 'POST':
from assets.views.core import apply_search from assets.views.core import apply_search
its_asset_qs = apply_search(its_asset_qs, data) its_asset_qs = apply_search(its_asset_qs, data)
bollard_system_qs = apply_search(bollard_system_qs, data)
bollard_plot_qs = apply_search(bollard_plot_qs, data)
# --------- Limitation pour utilisateurs externes # --------- Limitation pour utilisateurs externes
user = request.user user = request.user
@ -448,32 +388,26 @@ def its_assets_geojson(request):
can_view_assets=True can_view_assets=True
).values_list('contract_id', flat=True) ).values_list('contract_id', flat=True)
# Ne garder que les assets qui ont un contrat actif auquel l'utilisateur a accès # Ne garder que les assets qui ont un contrat actif
# auquel l'utilisateur a accès
its_asset_qs = its_asset_qs.filter( its_asset_qs = its_asset_qs.filter(
its_assets_contracts__status='active', its_assets_contracts__status='active',
its_assets_contracts__contract_id__in=accessible_contract_ids its_assets_contracts__contract_id__in=accessible_contract_ids
).distinct() ).distinct()
bollard_system_qs = bollard_system_qs.filter(
bollard_systems_contracts__status='active',
bollard_systems_contracts__contract_id__in=accessible_contract_ids
).distinct()
bollard_plot_qs = bollard_plot_qs.filter(
system__bollard_systems_contracts__status='active',
system__bollard_systems_contracts__contract_id__in=accessible_contract_ids
).distinct()
else: else:
# Non authentifié : ne rien exposer (plus sûr) # Non authentifié : ne rien exposer (plus sûr)
its_asset_qs = its_asset_qs.none() its_asset_qs = its_asset_qs.none()
bollard_system_qs = bollard_system_qs.none()
bollard_plot_qs = bollard_plot_qs.none() # --------- Cap de sécurité
its_asset_qs = its_asset_qs[0:MAX_ASSET_FEATURES+1]
if len(its_asset_qs) >= MAX_ASSET_FEATURES:
return JsonResponse({"error": f"{_("Trop d'objets à afficher")} (> {MAX_ASSET_FEATURES})"}, status=413)
# --------- Construction GeoJSON # --------- Construction GeoJSON
features = [] features = []
feature_count = 0 feature_count = 0
for qs, asset_type in [ for qs, asset_type in [
(its_asset_qs, "itsgeoasset"), (its_asset_qs, "itsgeoasset"),
(bollard_system_qs, "itsbollardsystem"),
(bollard_plot_qs, "itsbollardplot"),
]: ]:
feature_count, limit_reached = add_assets_to_features(qs, asset_type, features, feature_count) feature_count, limit_reached = add_assets_to_features(qs, asset_type, features, feature_count)
if limit_reached: if limit_reached:
@ -669,9 +603,7 @@ def its_assets_autocomplete(request):
raw_query = raw_query_original.strip() raw_query = raw_query_original.strip()
# CT ids (calculés une fois) # CT ids (calculés une fois)
geoasset_ct_id = ContentType.objects.get_for_model(ITSGeoAsset).id equipment_ct_id = ContentType.objects.get_for_model(ITSGeoAsset).id
bollard_system_ct_id = ContentType.objects.get_for_model(ITSBollardSystem).id
bollard_plot_ct_id = ContentType.objects.get_for_model(ITSBollardPlot).id
location_ct_id = ContentType.objects.get_for_model(ITSLocation).id location_ct_id = ContentType.objects.get_for_model(ITSLocation).id
qn = strip_accents_lower(raw_query) qn = strip_accents_lower(raw_query)
@ -694,71 +626,68 @@ def its_assets_autocomplete(request):
# en cas d'ID invalide ou autre, on ignore le filtre # en cas d'ID invalide ou autre, on ignore le filtre
location_ids = None location_ids = None
def query_model(model_class, ct_id, extra_select=()): # Base queryset accent-insensible + actifs uniquement
q = ( qs = (
model_class.objects ITSGeoAsset.objects
.annotate( .annotate(
code_u=Lower(Unaccent('code')), code_u=Lower(Unaccent('code')),
name_fr_u=Lower(Unaccent('name_fr')), name_fr_u=Lower(Unaccent('name_fr')),
name_nl_u=Lower(Unaccent('name_nl')), name_nl_u=Lower(Unaccent('name_nl')),
)
) )
if has_search_term:
q = q.filter(
Q(code_u__contains=qn) |
Q(name_fr_u__contains=qn) |
Q(name_nl_u__contains=qn)
)
q = q.exclude(status='removed')
q = q.select_related('location', 'model', 'category', *extra_select)
if location_ids:
if hasattr(model_class, 'system'):
q = q.filter(Q(location_id__in=location_ids) | Q(system__location_id__in=location_ids))
else:
q = q.filter(location_id__in=location_ids)
if category_id is not None:
q = q.filter(
Q(category_id=category_id) |
Q(category__isnull=True, model__category_id=category_id)
)
if has_search_term:
priority = Case(
When(code_u=qn, then=0),
When(code_u__startswith=qn, then=1),
When(name_fr_u__startswith=qn, then=2),
When(name_nl_u__startswith=qn, then=3),
When(code_u__contains=qn, then=4),
When(name_fr_u__contains=qn, then=5),
When(name_nl_u__contains=qn, then=6),
default=7,
output_field=IntegerField(),
)
q = q.annotate(_prio=priority).order_by('_prio', 'code')[:50]
else:
q = q.order_by('code')[:50]
results = []
for obj in q:
results.append((obj, ct_id))
return results
all_results = (
query_model(ITSGeoAsset, geoasset_ct_id) +
query_model(ITSBollardSystem, bollard_system_ct_id) +
query_model(ITSBollardPlot, bollard_plot_ct_id, ('system', 'system__location'))
) )
# Si un terme de recherche est fourni, filtrer dessus
if has_search_term:
qs = qs.filter(
Q(code_u__contains=qn) |
Q(name_fr_u__contains=qn) |
Q(name_nl_u__contains=qn)
)
qs = qs.exclude(status='removed') # <-- exclure les supprimés
qs = qs.select_related(
'location',
'model',
'category',
)
if location_ids:
qs = qs.filter(location_id__in=location_ids)
if category_id is not None:
qs = qs.filter(
Q(category_id=category_id) |
Q(category__isnull=True, model__category_id=category_id)
)
# Priorité : exact > code startswith > nom startswith > contient
if has_search_term:
priority = Case(
When(code_u=qn, then=0),
When(code_u__startswith=qn, then=1),
When(name_fr_u__startswith=qn, then=2),
When(name_nl_u__startswith=qn, then=3),
When(code_u__contains=qn, then=4),
When(name_fr_u__contains=qn, then=5),
When(name_nl_u__contains=qn, then=6),
default=7,
output_field=IntegerField(),
)
qs = qs.annotate(_prio=priority).order_by('_prio', 'code')[:100]
else:
# Sans terme de recherche, tri par code uniquement
qs = qs.order_by('code')[:100]
data = [] data = []
for a, ct_id in all_results[:100]: for a in qs:
# equipment_type depuis model/category
equipment_type = None equipment_type = None
if getattr(a, 'model', None): if getattr(a, 'model', None):
equipment_type = getattr(a.model, 'name', None) or getattr(a.model, 'code', None) equipment_type = getattr(a.model, 'name', None) or getattr(a.model, 'code', None)
if not equipment_type and getattr(a, 'category', None): if not equipment_type and getattr(a, 'category', None):
equipment_type = getattr(a.category, 'name', None) equipment_type = getattr(a.category, 'name', None)
# coordonnées: asset > geom(4326) > location
lon = a.lon lon = a.lon
lat = a.lat lat = a.lat
@ -769,33 +698,24 @@ def its_assets_autocomplete(request):
g = a.geom.transform(4326, clone=True) g = a.geom.transform(4326, clone=True)
coords = getattr(g, 'coords', None) coords = getattr(g, 'coords', None)
if coords: if coords:
if isinstance(coords[0], (int, float)): if lon is None: lon = coords[0]
if lon is None: lon = coords[0] if lat is None: lat = coords[1]
if lat is None: lat = coords[1]
elif isinstance(coords[0], (list, tuple)) and coords[0]:
# Pour LineString (ITSBollardSystem)
mid = len(coords) // 2
if lon is None: lon = coords[mid][0]
if lat is None: lat = coords[mid][1]
except Exception: except Exception:
pass pass
loc = getattr(a, 'location', None) if (lon is None or lat is None) and getattr(a, 'location', None):
if loc is None and hasattr(a, 'system') and a.system: if lon is None and getattr(a.location, 'lon', None) is not None:
loc = getattr(a.system, 'location', None) lon = a.location.lon
if lat is None and getattr(a.location, 'lat', None) is not None:
lat = a.location.lat
if (lon is None or lat is None) and loc:
if lon is None and getattr(loc, 'lon', None) is not None:
lon = loc.lon
if lat is None and getattr(loc, 'lat', None) is not None:
lat = loc.lat
equipment_label = f"{a.get_name()} ({a.code})" if a.code else a.get_name() equipment_label = f"{a.get_name()} ({a.code})" if a.code else a.get_name()
label = f"{a.code} - {equipment_label}" if a.code else equipment_label label = f"{a.code} - {equipment_label}" if a.code else equipment_label
location_label = ( location_label = (
f"{loc.get_name()} ({loc.code})" f"{a.location.get_name()} ({a.location.code})"
if loc else None if getattr(a, "location", None) else None
) )
data.append({ data.append({
@ -805,9 +725,9 @@ def its_assets_autocomplete(request):
"equipment_id": a.id, "equipment_id": a.id,
"equipment_label": equipment_label, "equipment_label": equipment_label,
"equipment_type": equipment_type, "equipment_type": equipment_type,
"equipment_ct": ct_id, "equipment_ct": equipment_ct_id,
"location_id": loc.id if loc else None, "location_id": a.location.id if getattr(a, "location", None) else None,
"location_label": location_label, "location_label": location_label,
"location_ct": location_ct_id, "location_ct": location_ct_id,

View file

@ -1,427 +0,0 @@
import json
from django.contrib import messages
from django.contrib.auth.decorators import login_required
from django.http import JsonResponse, HttpResponseForbidden
from django.shortcuts import render, get_object_or_404, redirect
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.http import require_GET, require_POST, require_http_methods
from common.models import Thematic, UserConfig
from assets.models import (
ManagementZone,
ManagementZoneType,
ManagementZoneDocument,
)
from assets.forms import ManagementZoneForm, ManagementZoneDocumentForm
from assets.permissions import (
can_view_management_zones,
can_edit_management_zones,
can_delete_management_zone,
get_editable_thematics_for_management_zones,
get_user_asset_thematic_access,
)
@login_required
@require_GET
def management_zones_geojson(request):
"""
Retourne la collection GeoJSON des zones de gestion actives avec géométrie.
Filtre optionnel par ?thematic=<thematic_code>.
Vérifie que l'utilisateur a accès en lecture aux assets de la thématique.
"""
thematic_code = request.GET.get('thematic')
user = request.user
if thematic_code:
thematic = Thematic.objects.filter(code=thematic_code).first()
if not thematic:
return JsonResponse({'type': 'FeatureCollection', 'features': []})
can_view, _can_edit = get_user_asset_thematic_access(user, thematic)
if not can_view:
return HttpResponseForbidden("Access denied to this thematic management zones.")
zones_qs = ManagementZone.objects.filter(
thematic=thematic,
is_active=True,
geom__isnull=False
).select_related('thematic', 'zone_type')
else:
if user.is_superuser:
zones_qs = ManagementZone.objects.filter(is_active=True, geom__isnull=False)
else:
try:
user_config = UserConfig.objects.get(user=user)
if user_config.roles.filter(name__in=['admin', 'operator', 'manager']).exists():
allowed_thematics = Thematic.objects.all()
else:
allowed_thematics = Thematic.objects.filter(
userthematics__user_config=user_config,
userthematics__can_view_assets=True
)
zones_qs = ManagementZone.objects.filter(
thematic__in=allowed_thematics,
is_active=True,
geom__isnull=False
)
except UserConfig.DoesNotExist:
return JsonResponse({'type': 'FeatureCollection', 'features': []})
zones_qs = zones_qs.select_related('thematic', 'zone_type')
features = []
for zone in zones_qs:
geom_json = zone.geojson_geometry
if not geom_json:
continue
centroid = zone.geom.centroid.clone() if zone.geom else None
if centroid:
if centroid.srid and centroid.srid != 4326:
centroid.transform(4326)
lon = round(centroid.x, 7)
lat = round(centroid.y, 7)
else:
lon = None
lat = None
area = round(zone.geom.area, 1) if getattr(zone.geom, 'area', None) is not None else None
category_name = zone.zone_type.get_name() if zone.zone_type else ""
features.append({
'type': 'Feature',
'id': zone.id,
'properties': {
'id': zone.id,
'code': zone.code,
'name': zone.get_name(),
'thematic': zone.thematic.code,
'thematic_name': zone.thematic.name,
'zone_type_code': zone.zone_type.code if zone.zone_type else None,
'zone_type_name': category_name,
'category': category_name,
'color': zone.get_color(),
'instructions': zone.instructions or "",
'description': zone.description or "",
'seasonality_notes': zone.seasonality_notes or "",
'target_frequency_days': zone.target_frequency_days,
'status': 'active' if zone.is_active else 'inactive',
'element_type': str(_('Zone de gestion')),
'asset_type': 'managementzone',
'detail_url': reverse('assets:management_zone_detail', kwargs={'pk': zone.pk}),
'lon': lon,
'lat': lat,
'area': area,
'is_location': False,
'is_management_zone': True,
},
'geometry': geom_json
})
return JsonResponse({
'type': 'FeatureCollection',
'features': features
})
@login_required
def management_zones_index(request):
"""
Page de listing et de consultation des zones de gestion avec filtres et carte de synthèse.
"""
user = request.user
if not can_view_management_zones(user):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation d'accéder aux zones de gestion."))
if user.is_superuser:
accessible_thematics = Thematic.objects.all()
else:
try:
user_config = UserConfig.objects.get(user=user)
if user_config.roles.filter(name__in=['admin', 'operator', 'manager']).exists():
accessible_thematics = Thematic.objects.all()
else:
accessible_thematics = Thematic.objects.filter(
userthematics__user_config=user_config,
userthematics__can_view_assets=True
)
except UserConfig.DoesNotExist:
accessible_thematics = Thematic.objects.none()
zones_qs = ManagementZone.objects.filter(
thematic__in=accessible_thematics
).select_related('thematic', 'zone_type', 'created_by')
# Filtre par thématique
thematic_code = request.GET.get('thematic', '').strip()
selected_thematic = None
if thematic_code:
selected_thematic = accessible_thematics.filter(code=thematic_code).first()
if selected_thematic:
zones_qs = zones_qs.filter(thematic=selected_thematic)
# Filtre par type
zone_type_id = request.GET.get('zone_type', '').strip()
if zone_type_id:
zones_qs = zones_qs.filter(zone_type_id=zone_type_id)
# Filtre par statut actif/inactif
status_filter = request.GET.get('status', '').strip()
if status_filter == 'active':
zones_qs = zones_qs.filter(is_active=True)
elif status_filter == 'inactive':
zones_qs = zones_qs.filter(is_active=False)
# Recherche plein texte
q = request.GET.get('q', '').strip()
if q:
from django.db.models import Q
zones_qs = zones_qs.filter(
Q(code__icontains=q) |
Q(name_fr__icontains=q) |
Q(name_nl__icontains=q) |
Q(description__icontains=q) |
Q(instructions__icontains=q) |
Q(seasonality_notes__icontains=q)
)
zones_qs = zones_qs.order_by('thematic__name_fr', 'code')
# Types disponibles pour le filtre
if selected_thematic:
available_types = ManagementZoneType.objects.filter(thematic=selected_thematic)
else:
available_types = ManagementZoneType.objects.filter(thematic__in=accessible_thematics)
can_create = can_edit_management_zones(user)
editable_thematics = get_editable_thematics_for_management_zones(user)
zones_list = []
for zone in zones_qs:
zones_list.append({
'zone': zone,
'can_edit': can_edit_management_zones(user, thematic=zone.thematic)
})
context = {
'zones_list': zones_list,
'accessible_thematics': accessible_thematics,
'selected_thematic': selected_thematic,
'selected_thematic_code': thematic_code,
'available_types': available_types,
'selected_type_id': zone_type_id,
'selected_status': status_filter,
'search_query': q,
'can_create': can_create,
'editable_thematics': editable_thematics,
'total_count': zones_qs.count(),
}
return render(request, 'assets/management_zones/management_zones_index.html', context)
@login_required
def management_zone_create(request):
"""
Création d'une nouvelle zone de gestion avec tracé géographique sur la carte.
"""
user = request.user
if not can_edit_management_zones(user):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation de créer des zones de gestion."))
editable_thematics = get_editable_thematics_for_management_zones(user)
if not editable_thematics.exists():
return HttpResponseForbidden(_("Aucune thématique disponible pour la création de zones."))
initial = {}
thematic_code = request.GET.get('thematic')
if thematic_code:
thematic = editable_thematics.filter(code=thematic_code).first()
if thematic:
initial['thematic'] = thematic
if request.method == 'POST':
form = ManagementZoneForm(request.POST, user=user)
if form.is_valid():
target_thematic = form.cleaned_data['thematic']
if not can_edit_management_zones(user, thematic=target_thematic):
return HttpResponseForbidden(_("Vous n'avez pas le droit de créer une zone pour cette thématique."))
zone = form.save(commit=False)
zone.created_by = user
zone.save()
messages.success(request, _("La zone de gestion « %(code)s » a été créée avec succès.") % {'code': zone.code})
return redirect('assets:management_zone_detail', pk=zone.pk)
else:
form = ManagementZoneForm(user=user, initial=initial)
return render(request, 'assets/management_zones/management_zones_form.html', {
'form': form,
'is_create': True,
'title': _("Nouvelle zone de gestion"),
})
@login_required
def management_zone_edit(request, pk):
"""
Modification d'une zone de gestion existante et de sa géométrie.
"""
user = request.user
zone = get_object_or_404(ManagementZone.objects.select_related('thematic'), pk=pk)
if not can_edit_management_zones(user, thematic=zone.thematic):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation de modifier cette zone de gestion."))
if request.method == 'POST':
form = ManagementZoneForm(request.POST, instance=zone, user=user)
if form.is_valid():
target_thematic = form.cleaned_data['thematic']
if not can_edit_management_zones(user, thematic=target_thematic):
return HttpResponseForbidden(_("Vous n'avez pas le droit d'assigner cette zone à cette thématique."))
form.save()
messages.success(request, _("La zone de gestion « %(code)s » a été mise à jour.") % {'code': zone.code})
return redirect('assets:management_zone_detail', pk=zone.pk)
else:
form = ManagementZoneForm(instance=zone, user=user)
return render(request, 'assets/management_zones/management_zones_form.html', {
'form': form,
'zone': zone,
'is_create': False,
'title': _("Modifier la zone de gestion %(code)s") % {'code': zone.code},
})
@login_required
def management_zone_detail(request, pk):
"""
Fiche détaillée d'une zone de gestion : informations, consignes, calendrier, carte, documents et équipements.
"""
user = request.user
zone = get_object_or_404(
ManagementZone.objects.select_related('thematic', 'zone_type', 'created_by'),
pk=pk
)
if not can_view_management_zones(user, thematic=zone.thematic):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation de consulter cette zone de gestion."))
can_edit = can_edit_management_zones(user, thematic=zone.thematic)
documents = zone.documents.select_related('uploaded_by').order_by('-upload_date')
explicit_assets = zone.explicit_assets.select_related('content_type').all()
doc_form = ManagementZoneDocumentForm() if can_edit else None
# Calcul de superficie indicative si géométrie présente (SRID 3812 en mètres)
area_m2 = None
area_ha = None
if zone.geom:
try:
area_m2 = round(zone.geom.area, 1)
area_ha = round(area_m2 / 10000, 2)
except Exception:
pass
return render(request, 'assets/management_zones/management_zones_detail.html', {
'zone': zone,
'can_edit': can_edit,
'documents': documents,
'explicit_assets': explicit_assets,
'doc_form': doc_form,
'area_m2': area_m2,
'area_ha': area_ha,
})
@login_required
@require_http_methods(["GET", "POST"])
def management_zone_delete(request, pk):
"""
Suppression d'une zone de gestion.
"""
user = request.user
zone = get_object_or_404(ManagementZone.objects.select_related('thematic'), pk=pk)
if not can_delete_management_zone(user, zone):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation de supprimer cette zone de gestion."))
if request.method == 'POST':
code = zone.code
thematic_code = zone.thematic.code
zone.delete()
messages.success(request, _("La zone de gestion « %(code)s » a été supprimée.") % {'code': code})
return redirect(f"{reverse('assets:management_zones_index')}?thematic={thematic_code}")
return render(request, 'assets/management_zones/management_zones_confirm_delete.html', {
'zone': zone,
})
@login_required
@require_POST
def management_zone_add_document(request, pk):
"""
Ajout de documents techniques ou de consignes à la zone de gestion.
"""
user = request.user
zone = get_object_or_404(ManagementZone.objects.select_related('thematic'), pk=pk)
if not can_edit_management_zones(user, thematic=zone.thematic):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation d'ajouter des documents à cette zone."))
form = ManagementZoneDocumentForm(request.POST, request.FILES)
if form.is_valid():
files = request.FILES.getlist('documents')
for f in files:
ManagementZoneDocument.objects.create(
zone=zone,
file=f,
uploaded_by=user
)
messages.success(request, _("%(count)d document(s) ajouté(s) avec succès.") % {'count': len(files)})
else:
messages.error(request, _("Erreur lors de l'ajout des documents."))
return redirect('assets:management_zone_detail', pk=zone.pk)
@login_required
@require_POST
def management_zone_delete_document(request, pk, document_id):
"""
Suppression d'un document attaché à la zone de gestion.
"""
user = request.user
zone = get_object_or_404(ManagementZone.objects.select_related('thematic'), pk=pk)
if not can_edit_management_zones(user, thematic=zone.thematic):
return HttpResponseForbidden(_("Vous n'avez pas l'autorisation de supprimer ce document."))
doc = get_object_or_404(ManagementZoneDocument, pk=document_id, zone=zone)
doc.delete()
messages.success(request, _("Le document a été supprimé avec succès."))
return redirect('assets:management_zone_detail', pk=zone.pk)
@login_required
@require_GET
def management_zone_types_ajax(request):
"""
Endpoint JSON retournant les types de zones pour une thématique donnée.
"""
thematic_id = request.GET.get('thematic_id')
thematic_code = request.GET.get('thematic_code')
qs = ManagementZoneType.objects.all()
if thematic_id:
qs = qs.filter(thematic_id=thematic_id)
elif thematic_code:
qs = qs.filter(thematic__code=thematic_code)
types = [
{
'id': t.id,
'name': t.get_name(),
'code': t.code,
'color': t.default_color
}
for t in qs
]
return JsonResponse({'types': types})

View file

@ -1,11 +1,11 @@
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404
from django.http import JsonResponse, Http404 from django.http import JsonResponse, Http404
from django.contrib.gis.db.models.functions import Transform from django.contrib.gis.db.models.functions import Transform
from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon from django.contrib.gis.geos import GEOSGeometry, Point, MultiPolygon
from django.contrib.gis.db.models.functions import Distance from django.contrib.gis.db.models.functions import Distance
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import get_language, gettext as _ from django.utils.translation import get_language, gettext as _
from django.db.models import F, Func, Q, Case, When, CharField, IntegerField, Value, Sum, Count from django.db.models import F, Func, Q, Case, When, CharField, IntegerField, Value
from django.db.models import Max from django.db.models import Max
from django.db.models.functions import Lower from django.db.models.functions import Lower
from django.views.decorators.http import require_GET from django.views.decorators.http import require_GET
@ -23,20 +23,94 @@ from common.models import UserThematics
from assets.models import NatureLocation, NatureGreenSurface, NatureAssetModel, NatureTree, NatureShrub, NatureBench, AssetCategory, NatureRWIZ, NatureRWIAsset from assets.models import NatureLocation, NatureGreenSurface, NatureAssetModel, NatureTree, NatureShrub, NatureBench, AssetCategory, NatureRWIZ, NatureRWIAsset
from assets.models import GreenSurfacesComment, GreenSurfacesCommentPhoto from assets.models import GreenSurfacesComment, GreenSurfacesCommentPhoto
from interventions.models import Intervention, InterventionLocation, InterventionAsset from interventions.models import InterventionLocation
from interventions.views.drafts import InterventionDraftManager from interventions.views.drafts import InterventionDraftManager
@check_thematic_access('nature') @check_thematic_access('nature')
def nature_index(request): def nature_index(request):
query_params = request.GET.copy() thematic = 'nature'
if 'thematic' not in query_params: lang = get_language()
query_params['thematic'] = 'nature'
return redirect(f"{reverse('assets:index')}?{query_params.urlencode()}") # Get filters from URL
statuses = request.GET.getlist('status')
highway = request.GET.get('highway') == "1"
selected_categories = request.GET.getlist('category')
selected_models = request.GET.getlist('model')
selected_location_type = request.GET.get('location_type', '')
# Get preventive draft ID (if any) for selecting assets
preventive_draft_id = request.GET.get('preventive_draft_id')
if preventive_draft_id:
drafts = InterventionDraftManager(request.session)
draft_data = drafts.get(preventive_draft_id)
form_data = draft_data.get('form_data', {})
preventive_element_type = form_data.get('type_of_selected_elements')
if preventive_element_type == 'assets':
selected_assets = draft_data.get('assets', []) or []
else:
selected_locations_ids = draft_data.get('locations', [])
thematic_choices_qs = (
UserThematics.objects
.filter(user_config=request.user.config, can_view_assets=True)
.select_related('thematic')
.order_by(f'thematic__{"name_nl" if lang == "nl" else "name_fr"}')
)
thematics = [tc.thematic for tc in thematic_choices_qs]
selected_thematic = {
"code": thematic,
"name": next((t.name for t in thematics if t.code == thematic), None),
"icon": next((t.icon for t in thematics if t.code == thematic), None),
}
categories = list(AssetCategory.objects.filter(thematic__code = 'nature'))
categories.sort(key=lambda m: m.get_name().lower())
category_choices = [(m.id, m.get_name()) for m in categories]
models = list(NatureAssetModel.objects.all())
models.sort(key=lambda m: (m.get_name() or "").lower())
model_choices = [(m.id, m.get_name()) for m in models]
category_model_map = defaultdict(list)
models = NatureAssetModel.objects.all()
for model in models:
if model.category:
display_name = model.get_name()
category_model_map[str(model.category_id)].append({
"key": str(model.id),
"label": display_name,
})
context = {
'thematics': thematics,
'category_choices': category_choices,
'model_choices': model_choices,
'category_model_map': dict(category_model_map),
'selected_thematic': selected_thematic,
'selected_statuses': statuses,
'selected_categories': selected_categories,
'selected_models': selected_models,
'highway': highway,
'selected_location_type': selected_location_type,
'preventive_draft_id': preventive_draft_id,
'preventive_element_type': preventive_element_type if 'preventive_element_type' in locals() else '',
'selected_locations_ids': selected_locations_ids if 'selected_locations_ids' in locals() else [],
"selected_assets_json": json.dumps(selected_assets) if 'selected_assets' in locals() else [],
'locations_tab_title': _("Voiries"),
'locations_assets_title': "Assets",
}
return render(request, "assets/nature_index.html", context)
def nature_locations(request): def nature_locations(request):
return redirect(f"{reverse('assets:index')}?thematic=nature") pass
@check_thematic_access('nature') @check_thematic_access('nature')
@ -44,304 +118,11 @@ def nature_locations_detail(request, location_id):
section = get_object_or_404(NatureLocation, id=location_id) section = get_object_or_404(NatureLocation, id=location_id)
content_type = ContentType.objects.get_for_model(section) content_type = ContentType.objects.get_for_model(section)
# 1. Direct assets linked to this location (evaluated once) interventions = [obj.intervention for obj in InterventionLocation.objects.filter(content_type=content_type, object_id=section.id).select_related('intervention').order_by('-intervention__creation_time')]
trees = list(section.nature_trees.select_related('model', 'category').all())
green_surfaces = list(section.nature_green_surfaces.select_related('model', 'category').all())
shrubs = list(section.nature_shrubs.select_related('model', 'category').all())
benches = list(section.nature_benches.select_related('model', 'category').all())
# 2. Key figures & metrics
trees_count = len(trees)
trees_active_count = sum(1 for t in trees if getattr(t, 'status', None) == 'active')
trees_remarkable_count = sum(1 for t in trees if getattr(t, 'is_remarkable', False))
green_surfaces_count = len(green_surfaces)
total_green_surface = sum((gs.surface_area or 0.0) for gs in green_surfaces)
def is_massif_arbore(gs):
if not getattr(gs, 'model', None):
return False
n_fr = (gs.model.name_fr or '').lower()
n_nl = (gs.model.name_nl or '').lower()
code = (gs.model.code or '').upper()
return (
'arboré' in n_fr or 'arbore' in n_fr or
'boom' in n_nl or 'hout' in n_nl or
'ARBORE' in code
)
def is_pre_fleuri(gs):
if not getattr(gs, 'model', None):
return False
n_fr = (gs.model.name_fr or '').lower()
n_nl = (gs.model.name_nl or '').lower()
code = (gs.model.code or '').upper()
return (
'fleuri' in n_fr or 'fleur' in n_fr or
'bloem' in n_nl or
'FLEURI' in code
)
surface_massif_arbore = sum((gs.surface_area or 0.0) for gs in green_surfaces if is_massif_arbore(gs))
massif_arbore_count = sum(1 for gs in green_surfaces if is_massif_arbore(gs))
surface_pre_fleuri = sum((gs.surface_area or 0.0) for gs in green_surfaces if is_pre_fleuri(gs))
pre_fleuri_count = sum(1 for gs in green_surfaces if is_pre_fleuri(gs))
# Breakdown by green surface typology
type_map = {}
for gs in green_surfaces:
m_id = gs.model_id or 0
if m_id not in type_map:
m_name = gs.model.get_name() if getattr(gs, 'model', None) else str(_("Autre / Non défini"))
m_color = (gs.model.color if (getattr(gs, 'model', None) and gs.model.color) else '#4caf50')
type_map[m_id] = {
'name': m_name,
'color': m_color,
'count': 0,
'total_area': 0.0,
}
type_map[m_id]['count'] += 1
type_map[m_id]['total_area'] += (gs.surface_area or 0.0)
surfaces_by_type = sorted(type_map.values(), key=lambda x: x['total_area'], reverse=True)
for item in surfaces_by_type:
item['percent'] = round((item['total_area'] / total_green_surface * 100), 1) if total_green_surface > 0 else 0
shrubs_count = len(shrubs)
benches_count = len(benches)
total_assets_count = trees_count + green_surfaces_count + shrubs_count + benches_count
# 3. Interventions linked to location + linked to assets on this location
location_intervention_ids = set(InterventionLocation.objects.filter(
content_type=content_type, object_id=section.id
).values_list('intervention_id', flat=True))
tree_ids = [t.id for t in trees]
gs_ids = [gs.id for gs in green_surfaces]
shrub_ids = [s.id for s in shrubs]
bench_ids = [b.id for b in benches]
asset_tuples = [
(ContentType.objects.get_for_model(NatureTree), tree_ids),
(ContentType.objects.get_for_model(NatureGreenSurface), gs_ids),
(ContentType.objects.get_for_model(NatureShrub), shrub_ids),
(ContentType.objects.get_for_model(NatureBench), bench_ids),
]
for ct, ids in asset_tuples:
if ids:
int_ids = InterventionAsset.objects.filter(
content_type=ct, object_id__in=ids
).values_list('intervention_id', flat=True)
location_intervention_ids.update(int_ids)
interventions = list(Intervention.objects.filter(
id__in=location_intervention_ids
).select_related('contract').order_by('-creation_time'))
# 4. Documents
documents = []
try:
from documents.models import DocumentAttachment
attachments = DocumentAttachment.objects.filter(
content_type=content_type, object_id=section.id
).select_related('document').prefetch_related('document__versions')
for att in attachments:
doc = att.document
latest_version = doc.latest_version if hasattr(doc, 'latest_version') else None
documents.append({
'title': doc.title or getattr(latest_version, 'filename', str(doc)),
'date': doc.created_at,
'tag': doc.tag.name_fr if (hasattr(doc, 'tag') and doc.tag) else '',
'url': latest_version.file.url if (latest_version and latest_version.file) else None,
'thumbnail_url': latest_version.thumbnail.url if (latest_version and getattr(latest_version, 'thumbnail', None)) else None,
})
except Exception:
pass
if hasattr(section, 'documents'):
for doc in section.documents.all():
documents.append({
'title': doc.file.name if hasattr(doc, 'file') and doc.file else str(doc),
'date': getattr(doc, 'upload_date', None),
'tag': '',
'url': doc.file.url if (hasattr(doc, 'file') and doc.file) else None,
'thumbnail_url': doc.thumbnail.url if (hasattr(doc, 'thumbnail') and doc.thumbnail) else None,
})
# 5. Serialized GeoJSON for map display
assets_features = []
for gs in green_surfaces:
geom = None
if gs.geojson:
try:
geom = json.loads(gs.geojson)
except Exception:
geom = None
elif gs.geom:
try:
geom = json.loads(gs.geom.transform(4326, clone=True).geojson)
except Exception:
geom = None
if geom:
assets_features.append({
"type": "Feature",
"id": f"gs_{gs.id}",
"geometry": geom,
"properties": {
"id": gs.id,
"code": gs.code,
"name": gs.get_name(),
"model": gs.model.get_name() if gs.model else '',
"asset_type": "naturegreensurface",
"area": f"{gs.surface_area:.1f} m²" if gs.surface_area else '',
"color": gs.model.color if (gs.model and gs.model.color) else '#4caf50',
"status": gs.get_status_display(),
"lon": gs.lon,
"lat": gs.lat,
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturegreensurface', 'asset_id': gs.id}),
}
})
for tree in trees:
geom = None
if tree.geojson:
try:
geom = json.loads(tree.geojson)
except Exception:
geom = None
elif tree.lon is not None and tree.lat is not None:
geom = {"type": "Point", "coordinates": [tree.lon, tree.lat]}
if geom:
assets_features.append({
"type": "Feature",
"id": f"tree_{tree.id}",
"geometry": geom,
"properties": {
"id": tree.id,
"code": tree.code,
"name": tree.get_name() or (tree.model.get_name() if tree.model else ''),
"model": tree.model.get_name() if tree.model else '',
"scientific_name": tree.model.scientific_name if (tree.model and tree.model.scientific_name) else '',
"asset_type": "naturetree",
"color": "#2e7d32",
"vitality": tree.get_vitality_display() if tree.vitality else '',
"stage": tree.get_development_stage_display() if tree.development_stage else '',
"height": f"{tree.height} m" if tree.height else '',
"status": tree.get_status_display(),
"is_remarkable": tree.is_remarkable,
"lon": tree.lon,
"lat": tree.lat,
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturetree', 'asset_id': tree.id}),
}
})
for shrub in shrubs:
geom = None
if shrub.geojson:
try:
geom = json.loads(shrub.geojson)
except Exception:
geom = None
elif shrub.lon is not None and shrub.lat is not None:
geom = {"type": "Point", "coordinates": [shrub.lon, shrub.lat]}
if geom:
assets_features.append({
"type": "Feature",
"id": f"shrub_{shrub.id}",
"geometry": geom,
"properties": {
"id": shrub.id,
"code": shrub.code,
"model": shrub.model.get_name() if shrub.model else '',
"asset_type": "natureshrub",
"color": "#81c784",
"height": f"{shrub.height} m" if shrub.height else '',
"status": shrub.get_status_display(),
"lon": shrub.lon,
"lat": shrub.lat,
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'natureshrub', 'asset_id': shrub.id}),
}
})
for bench in benches:
geom = None
if bench.geojson:
try:
geom = json.loads(bench.geojson)
except Exception:
geom = None
elif bench.lon is not None and bench.lat is not None:
geom = {"type": "Point", "coordinates": [bench.lon, bench.lat]}
if geom:
assets_features.append({
"type": "Feature",
"id": f"bench_{bench.id}",
"geometry": geom,
"properties": {
"id": bench.id,
"code": bench.code,
"model": bench.model.get_name() if bench.model else '',
"asset_type": "naturebench",
"color": "#8d6e63",
"material": bench.material or '',
"status": bench.get_status_display(),
"lon": bench.lon,
"lat": bench.lat,
"detail_url": reverse('assets:nature_assets_detail', kwargs={'asset_model': 'naturebench', 'asset_id': bench.id}),
}
})
assets_geojson = json.dumps({"type": "FeatureCollection", "features": assets_features})
# Location geometry for map
section_geojson = section.geojson
if not section_geojson and section.geom:
try:
section_geojson = section.geom.transform(4326, clone=True).geojson
except Exception:
section_geojson = None
feature_lon = section.lon
feature_lat = section.lat
if (feature_lon is None or feature_lat is None) and section.geom:
try:
pt = section.geom.transform(4326, clone=True).centroid
feature_lon = pt.x
feature_lat = pt.y
except Exception:
pass
context = { context = {
'section': section, 'section': section,
'trees': trees,
'green_surfaces': green_surfaces,
'shrubs': shrubs,
'benches': benches,
'trees_count': trees_count,
'trees_active_count': trees_active_count,
'trees_remarkable_count': trees_remarkable_count,
'green_surfaces_count': green_surfaces_count,
'total_green_surface': total_green_surface,
'surface_massif_arbore': surface_massif_arbore,
'massif_arbore_count': massif_arbore_count,
'surface_pre_fleuri': surface_pre_fleuri,
'pre_fleuri_count': pre_fleuri_count,
'surfaces_by_type': surfaces_by_type,
'shrubs_count': shrubs_count,
'benches_count': benches_count,
'total_assets_count': total_assets_count,
'interventions': interventions, 'interventions': interventions,
'documents': documents,
'assets_geojson': assets_geojson,
'section_geojson': section_geojson,
'feature_lon': feature_lon,
'feature_lat': feature_lat,
} }
return render(request, "assets/nature_locations_detail.html", context) return render(request, "assets/nature_locations_detail.html", context)
@ -378,30 +159,10 @@ def nature_locations_geojson(request):
filter_geom = None filter_geom = None
from assets.permissions import get_user_accessible_municipalities
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
municipalities = ensure_list(data.get('municipality')) if request.method == "POST" else request.GET.getlist('municipality')
include_naturelocation = location_type in ('', 'naturelocation') include_naturelocation = location_type in ('', 'naturelocation')
nature_locations = NatureLocation.objects.all() if include_naturelocation else NatureLocation.objects.none() nature_locations = NatureLocation.objects.all() if include_naturelocation else NatureLocation.objects.none()
if not is_admin and must_limit:
accessible_munis = get_user_accessible_municipalities(user)
accessible_muni_ids = list(accessible_munis.values_list('id', flat=True))
if municipalities:
valid_munis = [mid for mid in municipalities if (int(mid) if str(mid).isdigit() else mid) in accessible_muni_ids]
nature_locations = nature_locations.filter(municipality_id__in=valid_munis)
else:
nature_locations = nature_locations.filter(municipality_id__in=accessible_muni_ids)
elif municipalities:
nature_locations = nature_locations.filter(municipality_id__in=municipalities)
if highway: if highway:
nature_locations = nature_locations.filter(is_highway=highway) nature_locations = nature_locations.filter(is_highway=highway)
@ -497,7 +258,7 @@ def ensure_list(value):
return [value] return [value]
MAX_ASSET_FEATURES = 10000 # limite imposée MAX_ASSET_FEATURES = 5000 # limite imposée
@check_thematic_access('nature') @check_thematic_access('nature')
def nature_assets_geojson(request): def nature_assets_geojson(request):
@ -516,7 +277,6 @@ def nature_assets_geojson(request):
models = ensure_list(data.get('model')) models = ensure_list(data.get('model'))
highway = data.get('highway') == "1" highway = data.get('highway') == "1"
location_type = data.get('location_type', '') location_type = data.get('location_type', '')
management_types = ensure_list(data.get('management_type'))
# Récupération et parsing de la géométrie # Récupération et parsing de la géométrie
filter_geom_raw = data.get("filter_geom") filter_geom_raw = data.get("filter_geom")
@ -537,12 +297,11 @@ def nature_assets_geojson(request):
models = request.GET.getlist('model', []) models = request.GET.getlist('model', [])
highway = request.GET.get('highway') == "1" highway = request.GET.get('highway') == "1"
location_type = request.GET.get('location_type', '') location_type = request.GET.get('location_type', '')
management_types = request.GET.getlist('management_type', [])
filter_geom = None filter_geom = None
_search_q = (data.get('q') or '').strip() if request.method == 'POST' else '' _search_q = (data.get('q') or '').strip() if request.method == 'POST' else ''
if not request.GET.get('unified') and not sections_ids and not categories and not models and not filter_geom and not _search_q and not highway and not location_type and not management_types: # return empty geojson if no param with section_id if not request.GET.get('unified') and not sections_ids and not categories and not models and not filter_geom and not _search_q and not highway and not location_type: # return empty geojson if no param with section_id
geojson_data = { geojson_data = {
"type": "FeatureCollection", "type": "FeatureCollection",
"features": [], "features": [],
@ -612,13 +371,6 @@ def nature_assets_geojson(request):
"thematic": "nature", "thematic": "nature",
"is_location": False, "is_location": False,
"structure_type": obj.get('structure_type', ''), "structure_type": obj.get('structure_type', ''),
"managing_authority": obj.get('managing_authority') or '',
"management_type": obj.get('management_type') or '',
"is_remarkable": obj.get('is_remarkable', False),
"scientific_name": obj.get('scientific_name') or '',
"vernacular_name": obj.get('vernacular_name') or '',
"is_dead": bool(obj.get('is_dead') or obj.get('vitality') == 'dead' or obj.get('status') in ('dead', 'archived')),
"vitality": obj.get('vitality') or '',
} }
} }
@ -645,35 +397,6 @@ def nature_assets_geojson(request):
NatureBench.objects.all() NatureBench.objects.all()
) )
from assets.permissions import get_user_accessible_municipalities
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
municipalities = ensure_list(data.get('municipality')) if request.method == "POST" else request.GET.getlist('municipality')
if not is_admin and must_limit:
accessible_munis = get_user_accessible_municipalities(user)
accessible_muni_ids = list(accessible_munis.values_list('id', flat=True))
if municipalities:
valid_munis = [mid for mid in municipalities if (int(mid) if str(mid).isdigit() else mid) in accessible_muni_ids]
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=valid_munis)
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=valid_munis)
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=valid_munis)
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=valid_munis)
else:
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=accessible_muni_ids)
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=accessible_muni_ids)
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=accessible_muni_ids)
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=accessible_muni_ids)
elif municipalities:
naturegreensurface_qs = naturegreensurface_qs.filter(location__municipality_id__in=municipalities)
naturetree_qs = naturetree_qs.filter(location__municipality_id__in=municipalities)
natureshrub_qs = natureshrub_qs.filter(location__municipality_id__in=municipalities)
naturebench_qs = naturebench_qs.filter(location__municipality_id__in=municipalities)
include_naturelocation = location_type in ('', 'naturelocation') include_naturelocation = location_type in ('', 'naturelocation')
if not include_naturelocation: if not include_naturelocation:
@ -682,6 +405,7 @@ def nature_assets_geojson(request):
natureshrub_qs = natureshrub_qs.none() natureshrub_qs = natureshrub_qs.none()
naturebench_qs = naturebench_qs.none() naturebench_qs = naturebench_qs.none()
if sections_ids: if sections_ids:
naturegreensurface_qs = naturegreensurface_qs.filter(location__id__in=sections_ids) naturegreensurface_qs = naturegreensurface_qs.filter(location__id__in=sections_ids)
naturetree_qs = naturetree_qs.filter(location__id__in=sections_ids) naturetree_qs = naturetree_qs.filter(location__id__in=sections_ids)
@ -698,45 +422,6 @@ def nature_assets_geojson(request):
naturetree_qs = naturetree_qs.filter(model__id__in=models) naturetree_qs = naturetree_qs.filter(model__id__in=models)
natureshrub_qs = natureshrub_qs.filter(model__id__in=models) natureshrub_qs = natureshrub_qs.filter(model__id__in=models)
naturebench_qs = naturebench_qs.filter(model__id__in=models) naturebench_qs = naturebench_qs.filter(model__id__in=models)
if management_types:
q_mgmt = Q()
for val in management_types:
val_str = str(val).strip()
if val_str == 'municipal':
q_mgmt |= Q(management_type='municipal')
elif val_str == 'regional':
q_mgmt |= Q(management_type='regional')
elif val_str == 'regional_bruxelles':
q_mgmt |= Q(management_type='regional') & (
Q(location__municipality__niscode__startswith='21') |
Q(location__municipality__name_fr__icontains='Bruxelles') |
Q(location__municipality__name_fr__icontains='Watermael') |
Q(location__municipality__name_fr__icontains='Berchem')
)
elif val_str == 'regional_wallonie':
q_mgmt |= Q(management_type='regional') & (
Q(location__municipality__niscode__startswith=('25', '5', '6', '8', '9')) |
Q(location__municipality__name_fr__icontains='Court-Saint')
)
elif val_str == 'regional_flandre':
q_mgmt |= Q(management_type='regional') & (
Q(location__municipality__niscode__startswith=('1', '3', '4', '7', '23', '24'))
)
elif val_str.startswith('municipal_'):
m_id = val_str.split('_', 1)[1]
if m_id.isdigit():
q_mgmt |= Q(management_type='municipal', location__municipality_id=int(m_id))
elif val_str.startswith('authority:'):
auth_name = val_str.split(':', 1)[1]
q_mgmt |= Q(managing_authority__iexact=auth_name)
else:
q_mgmt |= Q(managing_authority__icontains=val_str) | Q(management_type=val_str)
naturetree_qs = naturetree_qs.filter(q_mgmt)
naturegreensurface_qs = naturegreensurface_qs.none()
natureshrub_qs = natureshrub_qs.none()
naturebench_qs = naturebench_qs.none()
if categories: if categories:
naturegreensurface_qs = naturegreensurface_qs.filter(category__id__in=categories) naturegreensurface_qs = naturegreensurface_qs.filter(category__id__in=categories)
naturetree_qs = naturetree_qs.filter(category__id__in=categories) naturetree_qs = naturetree_qs.filter(category__id__in=categories)
@ -812,10 +497,7 @@ def nature_assets_geojson(request):
.values( # on limite strictement les colonnes .values( # on limite strictement les colonnes
'id', 'code', 'name', 'status', 'validation_status', 'lon', 'lat', 'id', 'code', 'name', 'status', 'validation_status', 'lon', 'lat',
'category_name', 'model_name', 'color', 'geojson', 'category_name', 'model_name', 'color', 'geojson',
'category_id', 'model_id', 'location_lon', 'location_lat', 'category_id', 'model_id', 'location_lon', 'location_lat'
'managing_authority', 'management_type', 'is_remarkable',
'scientific_name', 'vernacular_name',
'is_dead', 'vitality'
) )
.order_by('code') .order_by('code')
)[:MAX_ASSET_FEATURES + 1] )[:MAX_ASSET_FEATURES + 1]
@ -887,24 +569,12 @@ def nature_locations_autocomplete(request):
if len(raw_query) < 2: if len(raw_query) < 2:
return JsonResponse([], safe=False) return JsonResponse([], safe=False)
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
# Normalise la requête (équivalent de unaccent+lower côté Python) # Normalise la requête (équivalent de unaccent+lower côté Python)
qn = strip_accents_lower(raw_query) qn = strip_accents_lower(raw_query)
# Prépare des colonnes "recherchables" unaccentées + minuscules # Prépare des colonnes "recherchables" unaccentées + minuscules
qs = NatureLocation.objects.all()
if not is_admin and must_limit:
from assets.permissions import get_user_accessible_municipalities
accessible_munis = get_user_accessible_municipalities(user)
qs = qs.filter(municipality__in=accessible_munis)
qs = ( qs = (
qs NatureLocation.objects
.annotate( .annotate(
code_u=Lower(Unaccent('code')), code_u=Lower(Unaccent('code')),
name_fr_u=Lower(Unaccent('name_fr')), name_fr_u=Lower(Unaccent('name_fr')),
@ -969,25 +639,13 @@ def nature_assets_autocomplete(request):
location_filter = {'location_id': location_id} location_filter = {'location_id': location_id}
user = request.user
user_config = getattr(user, 'config', None)
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
is_external = (user_config is None) or (not user_config.is_intern)
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
def base_qs(model, extra_filter=None): def base_qs(model, extra_filter=None):
ann = { ann = {
'code_u': Lower(Unaccent('code')), 'code_u': Lower(Unaccent('code')),
'name_fr_u': Lower(Unaccent('name_fr')), 'name_fr_u': Lower(Unaccent('name_fr')),
'name_nl_u': Lower(Unaccent('name_nl')), 'name_nl_u': Lower(Unaccent('name_nl')),
} }
q = model.objects.all() q = model.objects.annotate(**ann)
if not is_admin and must_limit:
from assets.permissions import get_user_accessible_municipalities
accessible_munis = get_user_accessible_municipalities(user)
q = q.filter(location__municipality__in=accessible_munis)
q = q.annotate(**ann)
# Si un terme de recherche est fourni, filtrer dessus # Si un terme de recherche est fourni, filtrer dessus
if has_search_term: if has_search_term:

View file

@ -61,7 +61,7 @@ def structures_index(request):
selected_locations_ids = draft_data.get('locations', []) selected_locations_ids = draft_data.get('locations', [])
user_config, _created = UserConfig.objects.get_or_create(user=request.user) user_config, _ = UserConfig.objects.get_or_create(user=request.user)
thematic_choices_qs = ( thematic_choices_qs = (
UserThematics.objects UserThematics.objects
.filter(user_config=user_config, can_view_assets=True) .filter(user_config=user_config, can_view_assets=True)

View file

@ -26,7 +26,6 @@ from django.utils.translation import get_language, gettext as _
from django.utils.timezone import make_aware, get_current_timezone from django.utils.timezone import make_aware, get_current_timezone
import json import json
import re
from datetime import datetime from datetime import datetime
import unicodedata import unicodedata
from collections import defaultdict from collections import defaultdict
@ -3745,77 +3744,6 @@ def update_staged_pole_position(request, intersection_id, pole_id):
}) })
def find_db_lantern_model(model_name):
"""
Finds the most suitable TrafficLightLanternModel from the database based on model_name.
Gracefully handles variants such as '2V200 cyclo-piéto + HP', '2V200 cyclo-piéton + HP',
'2V200 piéton+HP', tram, cyclist models, etc.
"""
if not model_name:
return TrafficLightLanternModel.objects.first()
m = TrafficLightLanternModel.objects.filter(name_fr__iexact=model_name).first()
if m:
return m
m_nl = TrafficLightLanternModel.objects.filter(name_nl__iexact=model_name).first()
if m_nl:
return m_nl
model_lower = str(model_name).lower()
if "cyclo" in model_lower:
qs = TrafficLightLanternModel.objects.filter(
Q(name_fr__icontains="cyclo") | Q(name_nl__icontains="cyclo") |
Q(name_fr__icontains="fietsers- en voetgangers") | Q(name_nl__icontains="fietsers- en voetgangers")
)
if "hp" in model_lower or "haut-parleur" in model_lower:
m = qs.filter(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
else:
m = qs.exclude(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
if m:
return m
if qs.exists():
return qs.first()
elif any(w in model_lower for w in ["piét", "piet", "voetganger"]):
qs = TrafficLightLanternModel.objects.filter(
Q(name_fr__icontains="piét") | Q(name_fr__icontains="piet") | Q(name_nl__icontains="voetganger")
).exclude(Q(name_fr__icontains="cyclo") | Q(name_nl__icontains="cyclo"))
if "hp" in model_lower or "haut-parleur" in model_lower:
m = qs.filter(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
else:
m = qs.exclude(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
if m:
return m
elif "tram" in model_lower:
if "300" in model_lower:
m = TrafficLightLanternModel.objects.filter(name_fr__icontains="tram").filter(name_fr__icontains="300").first()
if m:
return m
m = TrafficLightLanternModel.objects.filter(name_fr__icontains="tram").first()
if m:
return m
elif any(w in model_lower for w in ["cycliste", "cycl", "fiets"]):
m = TrafficLightLanternModel.objects.filter(
Q(name_fr__icontains="cycl") | Q(name_nl__icontains="fiets")
).exclude(Q(name_fr__icontains="piét") | Q(name_fr__icontains="piet") | Q(name_nl__icontains="voetganger")).first()
if m:
return m
return TrafficLightLanternModel.objects.first()
def is_stib_detector(detector_obj, code=""):
"""
Checks if a detector is a STIB/tram detection loop (e.g. dT1, dT2, DT1),
which is provided/managed by STIB and not present in cross plans.
"""
name = (getattr(detector_obj, "name", None) or getattr(detector_obj, "name_fr", None) or "").strip()
code_str = (code or getattr(detector_obj, "code", "") or "").strip()
if re.match(r'^dt[\s_\d]*$', name, re.IGNORECASE) or name.lower().startswith("dt"):
return True
if re.search(r'\bdt\d+\b', name, re.IGNORECASE) or "stib" in name.lower():
return True
if re.search(r'(?:^|_)dt\d*(?:_|$)', code_str, re.IGNORECASE) or "stib" in code_str.lower():
return True
return False
@check_thematic_access('trafficlights', edit_permission_required=True) @check_thematic_access('trafficlights', edit_permission_required=True)
def import_cross_plan(request, intersection_id): def import_cross_plan(request, intersection_id):
from django.shortcuts import render, get_object_or_404, redirect from django.shortcuts import render, get_object_or_404, redirect
@ -4401,15 +4329,6 @@ def import_cross_plan(request, intersection_id):
geom_modified = True geom_modified = True
details_diff.append(_("Nouveau tracé déduit des poteaux")) details_diff.append(_("Nouveau tracé déduit des poteaux"))
db_coords = []
if has_geom:
try:
gw = ec.geom.clone()
gw.transform(4326)
db_coords = [[round(c[0], 6), round(c[1], 6)] for c in gw.coords]
except Exception:
db_coords = []
status = 'modified' if (is_diff or geom_modified) else 'identical' status = 'modified' if (is_diff or geom_modified) else 'identical'
compared_cables.append({ compared_cables.append({
'code': code, 'code': code,
@ -4423,7 +4342,6 @@ def import_cross_plan(request, intersection_id):
'has_db_geom': has_geom, 'has_db_geom': has_geom,
'geom_modified': geom_modified, 'geom_modified': geom_modified,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None, 'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
'db_coords': db_coords,
}) })
existing_cables.pop(code) existing_cables.pop(code)
else: else:
@ -4442,18 +4360,9 @@ def import_cross_plan(request, intersection_id):
'has_db_geom': False, 'has_db_geom': False,
'geom_modified': False, 'geom_modified': False,
'db_geom_length': None, 'db_geom_length': None,
'db_coords': [],
}) })
for code, ec in existing_cables.items(): for code, ec in existing_cables.items():
has_geom = ec.geom is not None has_geom = ec.geom is not None
db_coords = []
if has_geom:
try:
gw = ec.geom.clone()
gw.transform(4326)
db_coords = [[round(c[0], 6), round(c[1], 6)] for c in gw.coords]
except Exception:
db_coords = []
compared_cables.append({ compared_cables.append({
'code': code, 'code': code,
'name': ec.name_fr or code, 'name': ec.name_fr or code,
@ -4465,7 +4374,6 @@ def import_cross_plan(request, intersection_id):
'has_db_geom': has_geom, 'has_db_geom': has_geom,
'geom_modified': False, 'geom_modified': False,
'db_geom_length': round(ec.geom.length, 1) if has_geom else None, 'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
'db_coords': db_coords,
}) })
# ── 3. LANTERNS COMPARISON ── # ── 3. LANTERNS COMPARISON ──
@ -4505,7 +4413,7 @@ def import_cross_plan(request, intersection_id):
for dl, el in matched_pairs: for dl, el in matched_pairs:
code = dl["code"] code = dl["code"]
model_name = dl["model_name"] model_name = dl["model_name"]
db_model = find_db_lantern_model(model_name) db_model = TrafficLightLanternModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightLanternModel.objects.first()
is_rename = (el.code != code) is_rename = (el.code != code)
is_model_diff = el.model_id != (db_model.id if db_model else None) is_model_diff = el.model_id != (db_model.id if db_model else None)
@ -4515,7 +4423,7 @@ def import_cross_plan(request, intersection_id):
if is_rename: if is_rename:
details.append(f"Code : {el.code} → {code}") details.append(f"Code : {el.code} → {code}")
if is_model_diff: if is_model_diff:
details.append(f"Modèle : {el.model.name_fr if el.model else 'Aucun'} → {db_model.name_fr if db_model else model_name}") details.append(f"Modèle : {el.model.name_fr if el.model else 'Aucun'} → {model_name}")
if is_model_diff and el.name_fr and el.model and el.name_fr.strip().lower() == el.model.name_fr.strip().lower(): if is_model_diff and el.name_fr and el.model and el.name_fr.strip().lower() == el.model.name_fr.strip().lower():
display_name = dl['name'] display_name = dl['name']
@ -4526,7 +4434,7 @@ def import_cross_plan(request, intersection_id):
'code': code, 'code': code,
'name': display_name, 'name': display_name,
'status': status, 'status': status,
'deduced_model_name': db_model.name_fr if db_model else model_name, 'deduced_model_name': model_name,
'db_model_name': el.model.name_fr if el.model else '', 'db_model_name': el.model.name_fr if el.model else '',
'db_id': el.id, 'db_id': el.id,
'pole_code': dl['pole_code'], 'pole_code': dl['pole_code'],
@ -4624,9 +4532,6 @@ def import_cross_plan(request, intersection_id):
'details_diff': [] 'details_diff': []
}) })
for code, ed in existing_detectors.items(): for code, ed in existing_detectors.items():
is_stib = is_stib_detector(ed, code)
action_default = 'keep' if is_stib else 'archive'
diff_text = _("Détection STIB / Tram (fournie par la STIB, conservée par défaut)") if is_stib else _("Absent du fichier Excel (présent en DB)")
compared_detectors.append({ compared_detectors.append({
'code': code, 'code': code,
'name': ed.name or code, 'name': ed.name or code,
@ -4635,9 +4540,7 @@ def import_cross_plan(request, intersection_id):
'db_model_name': ed.model.name_fr if ed.model else '', 'db_model_name': ed.model.name_fr if ed.model else '',
'db_id': ed.id, 'db_id': ed.id,
'pole_code': ed.pole.code if ed.pole else '', 'pole_code': ed.pole.code if ed.pole else '',
'details_diff': [diff_text], 'details_diff': [_("Absent du fichier Excel (présent en DB)")]
'action_default': action_default,
'is_stib': is_stib,
}) })
# ── 5. OTHER ASSOCIATED ASSETS (NOT IN CROSS PLAN) ── # ── 5. OTHER ASSOCIATED ASSETS (NOT IN CROSS PLAN) ──
@ -4887,18 +4790,12 @@ def integrate_cross_plan(request, intersection_id):
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect
from django.contrib import messages from django.contrib import messages
from django.db import transaction from django.db import transaction
from django.db.models import ProtectedError import json
from assets.models.trafficlights import ( from assets.models.trafficlights import (
TrafficLightIntersection, TrafficLightPole, TrafficLightLantern, TrafficLightIntersection, TrafficLightPole, TrafficLightLantern,
TrafficLightDetector, TrafficLightCable, TrafficLightPoleModel, TrafficLightDetector, TrafficLightCable, TrafficLightPoleModel,
TrafficLightLanternModel, TrafficLightDetectorModel, TrafficLightCableModel, TrafficLightLanternModel, TrafficLightDetectorModel, TrafficLightCableModel
TrafficLightController, TrafficLightControllerHardware, TrafficLightNetworkHardware,
TrafficLightVoltageHardware, TrafficLightElectricalCabinet, TrafficLightAccessory,
TrafficLightRadar, TrafficLightProgramming
) )
from assets.models.sign import SignPanel
from assets.models.its import ITSGeoAsset
from assets.models.publiclighting import PublicLightingLightFixture
intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id) intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id)
filename = request.POST.get('cross_plan_filename') filename = request.POST.get('cross_plan_filename')
@ -4935,7 +4832,7 @@ def integrate_cross_plan(request, intersection_id):
return m return m
def resolve_lantern_model(name_fr): def resolve_lantern_model(name_fr):
m = find_db_lantern_model(name_fr) m = TrafficLightLanternModel.objects.filter(name_fr__iexact=name_fr).first()
if not m: if not m:
m = TrafficLightLanternModel.objects.first() m = TrafficLightLanternModel.objects.first()
if not m: if not m:
@ -5432,18 +5329,6 @@ def integrate_cross_plan(request, intersection_id):
PublicLightingLightFixture.objects.filter(traffic_light_pole=existing).update(traffic_light_pole=new_pole) PublicLightingLightFixture.objects.filter(traffic_light_pole=existing).update(traffic_light_pole=new_pole)
except Exception: except Exception:
pass pass
elif action == "archive" and existing:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
elif action == "delete" and existing:
try:
existing.delete()
except ProtectedError:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
messages.warning(request, f"Le poteau {existing.code} n'a pas pu être supprimé car d'autres éléments y font référence ; il a été archivé à la place.")
else: else:
# 'keep' (Ne rien faire) # 'keep' (Ne rien faire)
if existing: if existing:
@ -5462,17 +5347,6 @@ def integrate_cross_plan(request, intersection_id):
TrafficLightPole.objects.filter(intersection=intersection, code=code, status='active').update( TrafficLightPole.objects.filter(intersection=intersection, code=code, status='active').update(
status='archived', updated_by=request.user status='archived', updated_by=request.user
) )
elif action == "delete":
db_id = request.POST.get(f"db_id_pole_{code}")
qs = TrafficLightPole.objects.filter(pk=db_id) if db_id else TrafficLightPole.objects.filter(intersection=intersection, code=code)
for pole_to_del in qs:
try:
pole_to_del.delete()
except ProtectedError:
pole_to_del.status = 'archived'
pole_to_del.updated_by = request.user
pole_to_del.save()
messages.warning(request, f"Le poteau {pole_to_del.code} n'a pas pu être supprimé car d'autres éléments y font référence ; il a été archivé à la place.")
# Fetch active poles to match parent relation for lanterns/detectors if not resolved above # Fetch active poles to match parent relation for lanterns/detectors if not resolved above
active_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')} active_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')}
@ -5541,18 +5415,6 @@ def integrate_cross_plan(request, intersection_id):
new_cable.save() new_cable.save()
existing.replaced_by = new_cable existing.replaced_by = new_cable
existing.save() existing.save()
elif action == "archive" and existing:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
elif action == "delete" and existing:
try:
existing.delete()
except ProtectedError:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
messages.warning(request, f"Le câble {existing.code} n'a pas pu être supprimé car il est référencé ; il a été archivé à la place.")
else: else:
# 'keep' (Ne rien faire) # 'keep' (Ne rien faire)
if existing: if existing:
@ -5570,17 +5432,6 @@ def integrate_cross_plan(request, intersection_id):
TrafficLightCable.objects.filter(intersection=intersection, code=code, status='active').update( TrafficLightCable.objects.filter(intersection=intersection, code=code, status='active').update(
status='archived', updated_by=request.user status='archived', updated_by=request.user
) )
elif action == "delete":
db_id = request.POST.get(f"db_id_cable_{code}")
qs = TrafficLightCable.objects.filter(pk=db_id) if db_id else TrafficLightCable.objects.filter(intersection=intersection, code=code)
for cable_to_del in qs:
try:
cable_to_del.delete()
except ProtectedError:
cable_to_del.status = 'archived'
cable_to_del.updated_by = request.user
cable_to_del.save()
messages.warning(request, f"Le câble {cable_to_del.code} n'a pas pu être supprimé car il est référencé ; il a été archivé à la place.")
# --- 3. Lanterns integration --- # --- 3. Lanterns integration ---
for dl in deduced["lanterns"]: for dl in deduced["lanterns"]:
@ -5642,18 +5493,6 @@ def integrate_cross_plan(request, intersection_id):
) )
existing.replaced_by = new_lantern existing.replaced_by = new_lantern
existing.save() existing.save()
elif action == "archive" and existing:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
elif action == "delete" and existing:
try:
existing.delete()
except ProtectedError:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
messages.warning(request, f"La lanterne {existing.code} n'a pas pu être supprimée car elle est référencée ; elle a été archivée à la place.")
else: else:
# 'keep' # 'keep'
if existing: if existing:
@ -5673,17 +5512,6 @@ def integrate_cross_plan(request, intersection_id):
TrafficLightLantern.objects.filter(pole__intersection=intersection, code=code, status='active').update( TrafficLightLantern.objects.filter(pole__intersection=intersection, code=code, status='active').update(
status='archived', updated_by=request.user status='archived', updated_by=request.user
) )
elif action == "delete":
db_id = request.POST.get(f"db_id_lantern_{code}")
qs = TrafficLightLantern.objects.filter(pk=db_id) if db_id else TrafficLightLantern.objects.filter(pole__intersection=intersection, code=code)
for lan_to_del in qs:
try:
lan_to_del.delete()
except ProtectedError:
lan_to_del.status = 'archived'
lan_to_del.updated_by = request.user
lan_to_del.save()
messages.warning(request, f"La lanterne {lan_to_del.code} n'a pas pu être supprimée car elle est référencée ; elle a été archivée à la place.")
# --- 4. Detectors integration --- # --- 4. Detectors integration ---
for dd in deduced["detectors"]: for dd in deduced["detectors"]:
@ -5753,18 +5581,6 @@ def integrate_cross_plan(request, intersection_id):
) )
existing.replaced_by = new_det existing.replaced_by = new_det
existing.save() existing.save()
elif action == "archive" and existing:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
elif action == "delete" and existing:
try:
existing.delete()
except ProtectedError:
existing.status = 'archived'
existing.updated_by = request.user
existing.save()
messages.warning(request, f"Le détecteur {existing.code} n'a pas pu être supprimé car il est référencé ; il a été archivé à la place.")
else: else:
# 'keep' # 'keep'
if existing: if existing:
@ -5785,17 +5601,6 @@ def integrate_cross_plan(request, intersection_id):
TrafficLightDetector.objects.filter(intersection=intersection, code=code, status='active').update( TrafficLightDetector.objects.filter(intersection=intersection, code=code, status='active').update(
status='archived', updated_by=request.user status='archived', updated_by=request.user
) )
elif action == "delete":
db_id = request.POST.get(f"db_id_detector_{code}")
qs = TrafficLightDetector.objects.filter(pk=db_id) if db_id else TrafficLightDetector.objects.filter(intersection=intersection, code=code)
for det_to_del in qs:
try:
det_to_del.delete()
except ProtectedError:
det_to_del.status = 'archived'
det_to_del.updated_by = request.user
det_to_del.save()
messages.warning(request, f"Le détecteur {det_to_del.code} n'a pas pu être supprimé car il est référencé ; il a été archivé à la place.")
# --- 5. Other associated assets integration --- # --- 5. Other associated assets integration ---
for key in request.POST.keys(): for key in request.POST.keys():
@ -5805,39 +5610,36 @@ def integrate_cross_plan(request, intersection_id):
asset_type, asset_id_str = suffix.split("_", 1) asset_type, asset_id_str = suffix.split("_", 1)
is_selected = request.POST.get(f"selected_other_{suffix}") == "on" is_selected = request.POST.get(f"selected_other_{suffix}") == "on"
action = request.POST.get(key, "keep") action = request.POST.get(key, "keep")
if is_selected and action in ("archive", "delete"): if is_selected and action == "archive":
try: try:
asset_id = int(asset_id_str) asset_id = int(asset_id_str)
model_map = { if asset_type == "signpanel":
"signpanel": SignPanel, from assets.models.sign import SignPanel
"itsasset": ITSGeoAsset, SignPanel.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
"controller": TrafficLightController, elif asset_type == "itsasset":
"networkhardware": TrafficLightNetworkHardware, from assets.models.its import ITSGeoAsset
"voltagehardware": TrafficLightVoltageHardware, ITSGeoAsset.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
"electricalcabinet": TrafficLightElectricalCabinet, elif asset_type == "controller":
"accessory": TrafficLightAccessory, TrafficLightController.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
"radar": TrafficLightRadar, from assets.models.trafficlights import TrafficLightControllerHardware
"programming": TrafficLightProgramming, TrafficLightControllerHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
"lightfixture": PublicLightingLightFixture, TrafficLightNetworkHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
} TrafficLightVoltageHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user)
model_cls = model_map.get(asset_type) elif asset_type == "networkhardware":
if model_cls: TrafficLightNetworkHardware.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
if action == "archive": elif asset_type == "voltagehardware":
model_cls.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user) TrafficLightVoltageHardware.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
if asset_type == "controller": elif asset_type == "electricalcabinet":
TrafficLightControllerHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user) TrafficLightElectricalCabinet.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
TrafficLightNetworkHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user) elif asset_type == "accessory":
TrafficLightVoltageHardware.objects.filter(controller_id=asset_id).update(status='archived', updated_by=request.user) TrafficLightAccessory.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
elif action == "delete": elif asset_type == "radar":
obj = model_cls.objects.filter(pk=asset_id).first() TrafficLightRadar.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
if obj: elif asset_type == "programming":
try: TrafficLightProgramming.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
obj.delete() elif asset_type == "lightfixture":
except ProtectedError: from assets.models.publiclighting import PublicLightingLightFixture
obj.status = 'archived' PublicLightingLightFixture.objects.filter(pk=asset_id).update(status='archived', updated_by=request.user)
obj.updated_by = request.user
obj.save()
messages.warning(request, f"L'équipement {obj} n'a pas pu être supprimé car il est référencé ; il a été archivé à la place.")
except (ValueError, TypeError, Exception): except (ValueError, TypeError, Exception):
pass pass

View file

@ -506,7 +506,7 @@ class UserThematicsInline(admin.TabularInline):
'thematic', 'can_view_assets', 'can_edit_assets', 'can_validate_assets', 'can_inspect', 'thematic', 'can_view_assets', 'can_edit_assets', 'can_validate_assets', 'can_inspect',
'can_view_interventions', 'can_edit_interventions', 'can_edit_locations', 'can_view_interventions', 'can_edit_interventions', 'can_edit_locations',
'can_process_observations', 'can_process_observations',
'can_view_projects', 'can_edit_projects', 'can_edit_management_zones', 'can_view_projects', 'can_edit_projects',
'status_matrix', 'status_matrix',
] ]
readonly_fields = ['status_matrix'] readonly_fields = ['status_matrix']
@ -598,7 +598,7 @@ class UserAssetTypeAccessInline(admin.TabularInline):
@admin.register(UserThematics) @admin.register(UserThematics)
class UserThematicsAdmin(admin.ModelAdmin): class UserThematicsAdmin(admin.ModelAdmin):
list_display = ('user_config', 'thematic', 'can_view_interventions', 'can_edit_interventions', 'can_view_assets', 'can_edit_assets', 'can_validate_assets', 'can_inspect', 'can_process_observations', 'can_edit_management_zones') list_display = ('user_config', 'thematic', 'can_view_interventions', 'can_edit_interventions', 'can_view_assets', 'can_edit_assets', 'can_validate_assets', 'can_inspect', 'can_process_observations')
list_filter = ('thematic',) list_filter = ('thematic',)
search_fields = ('user_config__user__username', 'thematic__name_fr', 'thematic__name_nl') search_fields = ('user_config__user__username', 'thematic__name_fr', 'thematic__name_nl')
raw_id_fields = ['user_config'] raw_id_fields = ['user_config']
@ -620,8 +620,8 @@ class UserContractAccessAdmin(admin.ModelAdmin):
@admin.register(UserConfig) @admin.register(UserConfig)
class UserConfigAdmin(admin.ModelAdmin): class UserConfigAdmin(admin.ModelAdmin):
list_display = ("user", "display_contracts", "display_thematics", "display_views", "is_intern", "display_roles", "company", "display_member_teams", "duplicate_button") list_display = ("user", "display_contracts", "display_thematics", "display_views", "is_intern", "display_roles", "company", "display_member_teams", "duplicate_button")
list_filter = ("thematics", "roles", "company", "user__companymember__teams", "contracts", "can_view_all_posts", "can_view_all_prices", "accessible_views", "auto_logout_hours") list_filter = ("thematics", "roles", "company", "user__companymember__teams", "contracts", "accessible_views", "auto_logout_hours")
search_fields = ("user__username", "user__first_name", "user__last_name", "contracts__contract_number") search_fields = ("user__username", "contracts__contract_number")
inlines = [UserThematicsInline, UserContractAccessInline, UserWarehouseAccessInline, UserWarehouseLocationAccessInline, UserCompanyTeamAccessInline, UserAssetTypeAccessInline, UserAssetAccessInline] inlines = [UserThematicsInline, UserContractAccessInline, UserWarehouseAccessInline, UserWarehouseLocationAccessInline, UserCompanyTeamAccessInline, UserAssetTypeAccessInline, UserAssetAccessInline]
filter_horizontal = ("linked_configs", "accessible_views") filter_horizontal = ("linked_configs", "accessible_views")
actions = ["duplicate_config_action", "apply_config_from_action", "assign_company_action"] actions = ["duplicate_config_action", "apply_config_from_action", "assign_company_action"]
@ -637,10 +637,6 @@ class UserConfigAdmin(admin.ModelAdmin):
'fields': ('accessible_views',), 'fields': ('accessible_views',),
'description': "Configurer les vues auxquelles l'utilisateur a accès." 'description': "Configurer les vues auxquelles l'utilisateur a accès."
}), }),
('Permissions globales / Contrats', {
'fields': ('can_view_all_posts', 'can_view_all_prices'),
'description': "Permet de consulter les prestations et/ou les prix sur tous les contrats sans configuration contractuelle individuelle."
}),
('eSign / Signalisation', { ('eSign / Signalisation', {
'fields': ('esign_can_edit', 'esign_can_manage_rcp', 'esign_can_validate_rcp', 'esign_organizations'), 'fields': ('esign_can_edit', 'esign_can_manage_rcp', 'esign_can_validate_rcp', 'esign_organizations'),
'description': "Permissions spécifiques pour l'application eSign / Signalisation." 'description': "Permissions spécifiques pour l'application eSign / Signalisation."

View file

@ -541,20 +541,3 @@ msgstr ""
#: common/views.py:644 #: common/views.py:644
msgid "Erreur lors de la lecture du document" msgid "Erreur lors de la lecture du document"
msgstr "Erreur lors de la lecture du document" msgstr "Erreur lors de la lecture du document"
#: common/templates/common/components/document_viewer.html
msgid "Dézoomer"
msgstr "Zoom out"
#: common/templates/common/components/document_viewer.html
msgid "Zoomer"
msgstr "Zoom in"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Reset"
#: common/views.py:683
msgid "Format de fond de plan invalide"
msgstr "Invalid basemap format"

View file

@ -133,11 +133,3 @@ msgstr "@"
#: loko/common/static/common/jquery-ui.min.js:6 #: loko/common/static/common/jquery-ui.min.js:6
msgid "!" msgid "!"
msgstr "!" msgstr "!"
#: loko/common/static/common/address_search_control.js:148
msgid "Rechercher une adresse…"
msgstr "Search an address…"
msgid "Rechercher une adresse..."
msgstr "Search an address..."

View file

@ -527,20 +527,3 @@ msgstr "Accès interdit"
#: common/views.py:644 #: common/views.py:644
msgid "Erreur lors de la lecture du document" msgid "Erreur lors de la lecture du document"
msgstr "Erreur lors de la lecture du document" msgstr "Erreur lors de la lecture du document"
#: common/templates/common/components/document_viewer.html
msgid "Dézoomer"
msgstr "Dézoomer"
#: common/templates/common/components/document_viewer.html
msgid "Zoomer"
msgstr "Zoomer"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Réinitialiser"
#: common/views.py:683
msgid "Format de fond de plan invalide"
msgstr "Format de fond de plan invalide"

View file

@ -133,11 +133,3 @@ msgstr "@"
#: loko/common/static/common/jquery-ui.min.js:6 #: loko/common/static/common/jquery-ui.min.js:6
msgid "!" msgid "!"
msgstr "!" msgstr "!"
#: loko/common/static/common/address_search_control.js:148
msgid "Rechercher une adresse…"
msgstr "Rechercher une adresse…"
msgid "Rechercher une adresse..."
msgstr "Rechercher une adresse..."

View file

@ -527,20 +527,3 @@ msgstr ""
#: common/views.py:644 #: common/views.py:644
msgid "Erreur lors de la lecture du document" msgid "Erreur lors de la lecture du document"
msgstr "" msgstr ""
#: common/templates/common/components/document_viewer.html
msgid "Dézoomer"
msgstr "Uitzoomen"
#: common/templates/common/components/document_viewer.html
msgid "Zoomer"
msgstr "Inzoomen"
#: common/templates/common/components/document_viewer.html
msgid "Réinitialiser"
msgstr "Herstellen"
#: common/views.py:683
msgid "Format de fond de plan invalide"
msgstr "Ongeldig basiskaartformaat"

Some files were not shown because too many files have changed in this diff Show more