Compare commits
2 commits
d36649f3bf
...
3e35c98a46
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e35c98a46 | |||
| b5b0338e09 |
18 changed files with 2645 additions and 68 deletions
|
|
@ -1,3 +1,4 @@
|
|||
from django.contrib.gis.geos import GEOSGeometry, Polygon, MultiPolygon
|
||||
from django import forms
|
||||
|
||||
from assets.models import TrafficLightIntersectionDocument, PublicLightingStreetDocument, StructureDocument, ControlCenterDocument
|
||||
|
|
@ -459,4 +460,99 @@ class StructureExceptionalTransportForm(forms.ModelForm):
|
|||
super().__init__(*args, **kwargs)
|
||||
for name, field in self.fields.items():
|
||||
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'})
|
||||
|
|
|
|||
|
|
@ -885,3 +885,124 @@ def get_user_accessible_municipalities(user):
|
|||
lang = get_language() or 'fr'
|
||||
order_field = 'name_nl' if lang.startswith('nl') else 'name_fr'
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -199,6 +199,11 @@
|
|||
${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')} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -226,7 +231,7 @@
|
|||
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);
|
||||
renderManagementZonesSection(container, switcher, features, map, fillLayerId, lineLayerId, zonePopup, _t, thematic);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -243,23 +248,33 @@
|
|||
/**
|
||||
* Rendu HTML et interactions de la section "Zones de gestion" dans le LayerSwitcherControl.
|
||||
*/
|
||||
function renderManagementZonesSection(container, switcher, features, map, fillLayerId, lineLayerId, zonePopup, _t) {
|
||||
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">
|
||||
${_t('Aucune zone de gestion définie pour cette thématique.', 'Aucune zone de gestion définie pour cette thématique.')}
|
||||
<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 = `
|
||||
<span class="text-muted small fw-semibold">${countText}</span>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,15 @@ var activeCategories = new Set(SELECTED_CATEGORIES || []);
|
|||
var activeModels = new Set();
|
||||
var modelFilterCollapsed = true;
|
||||
var activeConditionalFilters = {};
|
||||
var currentTypeFilter = 'all'; // 'all' | 'locations' | 'assets'
|
||||
var currentTypeFilter = 'all'; // legacy fallback
|
||||
|
||||
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 searchMode = 'and';
|
||||
var shouldFitSearch = false;
|
||||
|
|
@ -658,9 +666,31 @@ function applyInitialPreferences() {
|
|||
|
||||
// Type filter
|
||||
if (prefs.type_filter) {
|
||||
currentTypeFilter = prefs.type_filter;
|
||||
const rb = document.querySelector(`input[name="unified-type-filter"][value="${prefs.type_filter}"]`);
|
||||
if (rb) rb.checked = true;
|
||||
if (Array.isArray(prefs.type_filter)) {
|
||||
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 = 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
|
||||
|
|
@ -739,7 +769,7 @@ function savePreferences() {
|
|||
models: Array.from(activeModels),
|
||||
conditional_filters: activeConditionalFilters,
|
||||
visible_columns: getVisibleColumnsPreference(),
|
||||
type_filter: currentTypeFilter,
|
||||
type_filter: getActiveTypeFilters(),
|
||||
status_filters: statusFilters,
|
||||
validation_status_filters: validationStatusFilters,
|
||||
include_no_geom: includeNoGeom,
|
||||
|
|
@ -784,9 +814,9 @@ $(document).ready(function () {
|
|||
onUncheckAll: function (rows) { rows.forEach(r => { r.state = false; selectedIds.delete(r.id); }); updateSelectionStatus(); },
|
||||
onClickRow: function (row) {
|
||||
if (!row.lon || !row.lat) return;
|
||||
const source = row.is_location ? 'locations' : 'assets';
|
||||
const source = row.is_management_zone ? 'management_zones' : (row.is_location ? 'locations' : 'assets');
|
||||
if (typeof highlightMapFeature === 'function') {
|
||||
highlightMapFeature(map, source, row.id, row.is_location ? locationshighlightState : assetshighlightState);
|
||||
highlightMapFeature(map, source, row.id, row.is_management_zone ? managementZonesHighlightState : (row.is_location ? locationshighlightState : assetshighlightState));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -865,11 +895,13 @@ $(document).ready(function () {
|
|||
renderModelFilters();
|
||||
});
|
||||
|
||||
// ─── Type radio ────────────────────────────────────────────────────────
|
||||
// ─── Type checkboxes ────────────────────────────────────────────────────
|
||||
|
||||
document.querySelectorAll('input[name="unified-type-filter"]').forEach(rb => {
|
||||
rb.addEventListener('change', function () {
|
||||
if (this.checked) currentTypeFilter = this.value;
|
||||
document.querySelectorAll('input[name="filter-element-type"]').forEach(cb => {
|
||||
cb.addEventListener('change', function () {
|
||||
applyFilters();
|
||||
updateFilterBadge();
|
||||
schedulePreferencesSave();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -884,26 +916,45 @@ $(document).ready(function () {
|
|||
}
|
||||
|
||||
// init from locked element type
|
||||
if (PREVENTIVE_ELEMENT_TYPE === 'assets') currentTypeFilter = 'assets';
|
||||
else if (PREVENTIVE_ELEMENT_TYPE === 'locations') currentTypeFilter = 'locations';
|
||||
if (PREVENTIVE_ELEMENT_TYPE === 'assets') {
|
||||
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 = 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 ─────────────────────────────────────────────────────
|
||||
|
||||
const addConfig = getUnifiedAddModalConfig();
|
||||
const assetThematics = Array.isArray(addConfig.asset_thematics) ? addConfig.asset_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 assetThematicSelect = document.getElementById('unified-add-asset-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');
|
||||
|
||||
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(managementZoneThematicSelect, managementZoneThematics, managementZoneThematics.length ? gettext('Sélectionner une thématique...') : gettext('Aucune thématique disponible'));
|
||||
|
||||
if (assetThematicSelect) assetThematicSelect.disabled = !assetThematics.length;
|
||||
if (locationThematicSelect) locationThematicSelect.disabled = !locationThematics.length;
|
||||
if (chooserEmpty) chooserEmpty.classList.toggle('d-none', assetThematics.length > 0 || locationThematics.length > 0);
|
||||
if (addButton && !assetThematics.length && !locationThematics.length) {
|
||||
if (managementZoneThematicSelect) managementZoneThematicSelect.disabled = !managementZoneThematics.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.title = gettext('Vous n\'avez pas de droits de création');
|
||||
}
|
||||
|
|
@ -962,6 +1013,17 @@ $(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 () {
|
||||
const thematicCode = document.getElementById('unified-add-asset-thematic-code')?.value || '';
|
||||
loadUnifiedAssetModels(thematicCode, this.value);
|
||||
|
|
@ -1175,8 +1237,12 @@ $(document).ready(function () {
|
|||
activeModels.clear();
|
||||
modelFilterCollapsed = true;
|
||||
activeConditionalFilters = {};
|
||||
const allRadio = document.getElementById('type-all');
|
||||
if (allRadio) { allRadio.checked = true; currentTypeFilter = 'all'; }
|
||||
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 = 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-validation-status"]').forEach(cb => { cb.checked = false; });
|
||||
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
|
||||
|
|
@ -1860,11 +1926,13 @@ function findUnifiedRowIndex(rowRef) {
|
|||
const rows = $table.bootstrapTable('getData') || [];
|
||||
|
||||
const isLocation = !!rowRef.is_location;
|
||||
const isManagementZone = !!rowRef.is_management_zone;
|
||||
const matches = (row) => {
|
||||
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 (!isLocation) {
|
||||
if (!isLocation && !isManagementZone) {
|
||||
if (String(row.thematic || '') !== String(rowRef.thematic || '')) return false;
|
||||
if (String(row.asset_type || '') !== String(rowRef.asset_type || '')) return false;
|
||||
} else {
|
||||
|
|
@ -1878,7 +1946,7 @@ function findUnifiedRowIndex(rowRef) {
|
|||
if (idx !== -1) return idx;
|
||||
|
||||
// Fallback in case some rows do not expose all discriminators.
|
||||
idx = rows.findIndex(row => !!row.is_location === isLocation && String(row.id) === String(rowRef.id));
|
||||
idx = rows.findIndex(row => !!row.is_location === isLocation && !!row.is_management_zone === isManagementZone && String(row.id) === String(rowRef.id));
|
||||
return idx;
|
||||
}
|
||||
|
||||
|
|
@ -1953,6 +2021,37 @@ function collectClickedAssets(clickEvent) {
|
|||
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,
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
function collectClickedLocations(clickEvent) {
|
||||
const layers = ['locations', 'locations-circle', 'locations-radar', 'locations-radar-outline', 'locations-outline'].filter(layerId => map.getLayer(layerId));
|
||||
if (!layers.length) return [];
|
||||
|
|
@ -2016,12 +2115,29 @@ 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) {
|
||||
const assets = collectClickedAssets(clickEvent);
|
||||
const locations = collectClickedLocations(clickEvent);
|
||||
if (!assets.length && !locations.length) return;
|
||||
const managementZones = collectClickedManagementZones(clickEvent);
|
||||
if (!assets.length && !locations.length && !managementZones.length) return;
|
||||
|
||||
const maxItems = 8;
|
||||
const visibleAssets = assets.slice(0, maxItems);
|
||||
|
|
@ -2113,10 +2229,56 @@ 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 ? ` • ${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 = `
|
||||
<div style="min-width:260px; max-width:320px;">
|
||||
${assetsSectionHtml}
|
||||
${locationsSectionHtml}
|
||||
${managementZonesSectionHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -2307,6 +2469,23 @@ 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
|
||||
const alwaysLoad = ALWAYS_LOAD_ASSETS_THEMATICS.has(code);
|
||||
const prevMode = assetsLoadModes[code] || null;
|
||||
|
|
@ -2382,6 +2561,10 @@ function refreshData() {
|
|||
f._thematic_name = thematicName;
|
||||
allFeatures.push(f);
|
||||
});
|
||||
(cache.management_zones || []).forEach(f => {
|
||||
f._thematic_name = thematicName;
|
||||
allFeatures.push(f);
|
||||
});
|
||||
});
|
||||
renderModelFilters();
|
||||
applyFilters();
|
||||
|
|
@ -2406,19 +2589,27 @@ function applyFilters() {
|
|||
let features = allFeatures.slice();
|
||||
|
||||
// Type filter
|
||||
if (currentTypeFilter === 'locations') {
|
||||
features = features.filter(f => f.properties && f.properties.is_location);
|
||||
} else if (currentTypeFilter === 'assets') {
|
||||
features = features.filter(f => f.properties && !f.properties.is_location);
|
||||
}
|
||||
const cbAssets = document.getElementById('type-cb-assets');
|
||||
const cbLocations = document.getElementById('type-cb-locations');
|
||||
const cbZones = document.getElementById('type-cb-management-zones');
|
||||
const showAssets = cbAssets ? cbAssets.checked : true;
|
||||
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
|
||||
const includeNoGeomCheckbox = document.getElementById('include-no-geom');
|
||||
const includeNoGeom = includeNoGeomCheckbox ? includeNoGeomCheckbox.checked : true;
|
||||
if (!includeNoGeom) {
|
||||
features = features.filter(f => {
|
||||
// Locations are never filtered by geometry checkbox (only assets)
|
||||
if (f.properties && f.properties.is_location) return true;
|
||||
// Locations and management zones are never filtered by geometry checkbox (only assets)
|
||||
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
|
||||
return f.geometry && (f.geometry.coordinates || f.geometry.type);
|
||||
});
|
||||
}
|
||||
|
|
@ -2435,7 +2626,7 @@ function applyFilters() {
|
|||
const selectedValidationStatuses = Array.from(document.querySelectorAll('[name="filter-validation-status"]:checked')).map(cb => cb.value);
|
||||
if (selectedValidationStatuses.length) {
|
||||
features = features.filter(f => {
|
||||
if (f.properties && f.properties.is_location) return true;
|
||||
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
|
||||
return selectedValidationStatuses.includes(f.properties && f.properties.validation_status);
|
||||
});
|
||||
}
|
||||
|
|
@ -2443,7 +2634,7 @@ function applyFilters() {
|
|||
// Category filter
|
||||
if (activeCategories.size) {
|
||||
features = features.filter(f => {
|
||||
if (f.properties && f.properties.is_location) return true;
|
||||
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
|
||||
return activeCategories.has(getFeatureCategoryValue(f));
|
||||
});
|
||||
}
|
||||
|
|
@ -2451,7 +2642,7 @@ function applyFilters() {
|
|||
// Model filter
|
||||
if (activeModels.size) {
|
||||
features = features.filter(f => {
|
||||
if (f.properties && f.properties.is_location) return true;
|
||||
if (f.properties && (f.properties.is_location || f.properties.is_management_zone)) return true;
|
||||
return activeModels.has(getFeatureModelValue(f));
|
||||
});
|
||||
}
|
||||
|
|
@ -2519,6 +2710,7 @@ function loadTableData(features) {
|
|||
length: Number.isFinite(length) ? length : 0,
|
||||
area: Number.isFinite(area) ? area : 0,
|
||||
is_location: p.is_location,
|
||||
is_management_zone: p.is_management_zone || false,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -2539,6 +2731,7 @@ function loadTableData(features) {
|
|||
|
||||
var locationshighlightState = { previousId: null };
|
||||
var assetshighlightState = { previousId: null };
|
||||
var managementZonesHighlightState = { previousId: null };
|
||||
|
||||
|
||||
function isPanelTextSpriteId(missingId) {
|
||||
|
|
@ -2611,15 +2804,20 @@ function rebuildMapSources() {
|
|||
if (typeof map === 'undefined') return;
|
||||
const locSrc = map.getSource('locations');
|
||||
const asstSrc = map.getSource('assets');
|
||||
const zoneSrc = map.getSource('management_zones');
|
||||
if (!locSrc || !asstSrc) return; // sources not yet added (before mapLoaded)
|
||||
|
||||
mergedLocationsGeojson = {
|
||||
type: 'FeatureCollection',
|
||||
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 = {
|
||||
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 && !f.properties.is_management_zone),
|
||||
};
|
||||
|
||||
applyUnifiedSignPanelOffsets(mergedAssetsGeojson);
|
||||
|
|
@ -2627,6 +2825,7 @@ function rebuildMapSources() {
|
|||
|
||||
locSrc.setData(mergedLocationsGeojson);
|
||||
asstSrc.setData(mergedAssetsGeojson);
|
||||
if (zoneSrc) zoneSrc.setData(mergedManagementZonesGeojson);
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -2642,6 +2841,7 @@ maploader.addEventListener('mapLoaded', () => {
|
|||
data: window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/'
|
||||
});
|
||||
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: [] } });
|
||||
|
||||
// Add layers in render order: fills → lines → circles/symbols (see UNIFIED_LAYERS)
|
||||
|
|
@ -2782,10 +2982,10 @@ maploader.addEventListener('mapLoaded', () => {
|
|||
});
|
||||
|
||||
// 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'].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', 'management-zones-fill', 'management-zones-line'].forEach(layerId => {
|
||||
if (!map.getLayer(layerId)) return;
|
||||
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'].includes(layerId)) {
|
||||
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)) {
|
||||
map.on('mouseenter', layerId, function () { map.getCanvas().style.cursor = 'pointer'; });
|
||||
map.on('mouseleave', layerId, function () { map.getCanvas().style.cursor = ''; });
|
||||
}
|
||||
|
|
@ -2795,7 +2995,7 @@ maploader.addEventListener('mapLoaded', () => {
|
|||
map.on('click', function (e) {
|
||||
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']
|
||||
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']
|
||||
.filter(layerId => map.getLayer(layerId));
|
||||
if (!interactiveLayers.length) {
|
||||
assetClickPopup.remove();
|
||||
|
|
@ -2856,7 +3056,7 @@ function updateFilterBadge() {
|
|||
}, 0);
|
||||
n += thematicBlocksCount;
|
||||
|
||||
if (currentTypeFilter !== 'all') n += 1;
|
||||
if (getActiveTypeFilters().length < 3) 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -384,6 +384,34 @@ 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) ────
|
||||
const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymbology.getAssetsFillLayer)
|
||||
? AssetSymbology.getAssetsFillLayer('assets')
|
||||
|
|
@ -1145,10 +1173,12 @@ const UNIFIED_LAYERS = [
|
|||
_unified_territory_mask, // 0. territory mask (outside commune)
|
||||
_unified_territory_outline, // 0b. territory boundary line
|
||||
_unified_locations_fill, // 1. polygon fills (bottom)
|
||||
_unified_management_zones_fill, // 1b. management zones fill
|
||||
_unified_assets_fill, // 2. polygon fills
|
||||
_unified_locations_outline, // 3. lines
|
||||
_unified_assets_line, // 3b. asset lines
|
||||
_unified_assets_polygon_outline, // 3c. polygon asset outlines
|
||||
_unified_management_zones_line, // 3b. management zones line
|
||||
_unified_assets_line, // 3c. asset lines
|
||||
_unified_assets_polygon_outline, // 3d. polygon asset outlines
|
||||
_unified_locations_circle, // 4. circles
|
||||
_unified_locations_radar_outline,
|
||||
_unified_locations_radar,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
{% 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 %}
|
||||
|
|
@ -0,0 +1,435 @@
|
|||
{% 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,
|
||||
cooperativeGestures: true
|
||||
});
|
||||
|
||||
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 %}
|
||||
|
|
@ -0,0 +1,507 @@
|
|||
{% 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>
|
||||
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,
|
||||
cooperativeGestures: true
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right');
|
||||
|
||||
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 %}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
{% 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,
|
||||
cooperativeGestures: true
|
||||
});
|
||||
|
||||
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" %} →
|
||||
</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 %}
|
||||
|
|
@ -1,5 +1,14 @@
|
|||
{% extends "assets/base_assets_index.html" %}
|
||||
{% 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 %}
|
||||
|
|
|
|||
|
|
@ -670,24 +670,24 @@
|
|||
<div class="filter-section-header">
|
||||
<label>{% translate "Type d'élément" %}</label>
|
||||
</div>
|
||||
<div class="segmented-control">
|
||||
<div class="filter-option segmented-option">
|
||||
<input type="radio" name="unified-type-filter" id="type-all" value="all"
|
||||
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
|
||||
{% if preventive_element_type %}disabled{% endif %}>
|
||||
<label for="type-all">{% translate "Assets & localisations" %}</label>
|
||||
<div class="filter-options d-flex gap-3 flex-wrap">
|
||||
<div class="filter-option">
|
||||
<input type="checkbox" name="filter-element-type" id="type-cb-assets" value="assets"
|
||||
{% if 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 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 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 segmented-option">
|
||||
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
|
||||
{% if preventive_element_type == 'assets' %}checked{% endif %}
|
||||
<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 %}>
|
||||
<label for="type-assets">{% translate "Assets uniquement" %}</label>
|
||||
<label for="type-cb-management-zones"><i class="bi bi-diagram-3 me-1 text-success"></i>{% translate "Zones de gestion" %}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -837,7 +837,7 @@
|
|||
{% translate "Choisissez d'abord le type d'élément, puis la thématique autorisée." %}
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">
|
||||
<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-layers"></i>{% translate "Asset" %}</h6>
|
||||
<label for="unified-add-asset-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
|
||||
|
|
@ -847,7 +847,7 @@
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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-geo-alt"></i>{% translate "Localisation" %}</h6>
|
||||
<label for="unified-add-location-thematic" class="form-label small text-muted mb-1">{% translate "Thématique" %}</label>
|
||||
|
|
@ -857,6 +857,16 @@
|
|||
</button>
|
||||
</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 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." %}
|
||||
|
|
|
|||
|
|
@ -199,3 +199,395 @@ class ManagementZoneGeojsonEndpointTests(TestCase):
|
|||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
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"')
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ from .views import core, locations, nature, publiclighting, trafficlights, artwo
|
|||
app_name = "assets"
|
||||
urlpatterns = [
|
||||
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/assets/", parking.parking_assets, name="parking_assets"),
|
||||
|
|
|
|||
|
|
@ -470,6 +470,8 @@ ELEMENT_TYPE_LABELS = {
|
|||
# --- Espaces plein air ---
|
||||
'outdoorlocation': _('Espace public plein air'),
|
||||
'outdoorasset': _('Équipement plein air'),
|
||||
# --- Zones de gestion ---
|
||||
'managementzone': _('Zone de gestion'),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -553,6 +555,7 @@ def build_thematic_config(thematics):
|
|||
},
|
||||
}
|
||||
|
||||
management_zones_url = reverse('assets:management_zones_geojson')
|
||||
result = {}
|
||||
for thematic in thematics:
|
||||
code = thematic.code
|
||||
|
|
@ -560,6 +563,7 @@ def build_thematic_config(thematics):
|
|||
entry = dict(_THEMATIC_URLS[code])
|
||||
entry['name'] = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic)
|
||||
entry['icon'] = thematic.icon if hasattr(thematic, 'icon') else ''
|
||||
entry['management_zones_geojson_url'] = management_zones_url
|
||||
result[code] = entry
|
||||
return result
|
||||
|
||||
|
|
@ -936,6 +940,7 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
|
|||
from assets.permissions import (
|
||||
can_edit_location_for_thematic,
|
||||
get_allowed_actions_for_thematic,
|
||||
can_edit_management_zones,
|
||||
)
|
||||
|
||||
location_type_by_thematic = {
|
||||
|
|
@ -948,6 +953,7 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
|
|||
|
||||
asset_thematics = []
|
||||
location_thematics = []
|
||||
management_zone_thematics = []
|
||||
|
||||
for thematic in accessible_thematics:
|
||||
thematic_name = thematic.get_name() if hasattr(thematic, 'get_name') else str(thematic)
|
||||
|
|
@ -974,9 +980,18 @@ def build_unified_add_modal_config(accessible_thematics, user=None):
|
|||
'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 {
|
||||
'asset_thematics': asset_thematics,
|
||||
'location_thematics': location_thematics,
|
||||
'management_zone_thematics': management_zone_thematics,
|
||||
'management_zone_create_url': reverse('assets:management_zone_create'),
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,26 @@
|
|||
import json
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.http import JsonResponse, HttpResponseForbidden
|
||||
from django.views.decorators.http import require_GET
|
||||
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
|
||||
from assets.permissions import get_user_asset_thematic_access
|
||||
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
|
||||
|
|
@ -23,7 +39,7 @@ def management_zones_geojson(request):
|
|||
if not thematic:
|
||||
return JsonResponse({'type': 'FeatureCollection', 'features': []})
|
||||
|
||||
can_view, _ = get_user_asset_thematic_access(user, thematic)
|
||||
can_view, _can_edit = get_user_asset_thematic_access(user, thematic)
|
||||
if not can_view:
|
||||
return HttpResponseForbidden("Access denied to this thematic management zones.")
|
||||
|
||||
|
|
@ -38,7 +54,7 @@ def management_zones_geojson(request):
|
|||
else:
|
||||
try:
|
||||
user_config = UserConfig.objects.get(user=user)
|
||||
if user_config.roles.filter(name__in=['admin', 'operator']).exists():
|
||||
if user_config.roles.filter(name__in=['admin', 'operator', 'manager']).exists():
|
||||
allowed_thematics = Thematic.objects.all()
|
||||
else:
|
||||
allowed_thematics = Thematic.objects.filter(
|
||||
|
|
@ -61,6 +77,18 @@ def management_zones_geojson(request):
|
|||
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,
|
||||
|
|
@ -71,12 +99,22 @@ def management_zones_geojson(request):
|
|||
'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': zone.zone_type.get_name() if zone.zone_type else "",
|
||||
'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
|
||||
})
|
||||
|
|
@ -85,3 +123,305 @@ def management_zones_geojson(request):
|
|||
'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})
|
||||
|
|
|
|||
|
|
@ -506,7 +506,7 @@ class UserThematicsInline(admin.TabularInline):
|
|||
'thematic', 'can_view_assets', 'can_edit_assets', 'can_validate_assets', 'can_inspect',
|
||||
'can_view_interventions', 'can_edit_interventions', 'can_edit_locations',
|
||||
'can_process_observations',
|
||||
'can_view_projects', 'can_edit_projects',
|
||||
'can_view_projects', 'can_edit_projects', 'can_edit_management_zones',
|
||||
'status_matrix',
|
||||
]
|
||||
readonly_fields = ['status_matrix']
|
||||
|
|
@ -598,7 +598,7 @@ class UserAssetTypeAccessInline(admin.TabularInline):
|
|||
|
||||
@admin.register(UserThematics)
|
||||
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')
|
||||
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_filter = ('thematic',)
|
||||
search_fields = ('user_config__user__username', 'thematic__name_fr', 'thematic__name_nl')
|
||||
raw_id_fields = ['user_config']
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
# Generated by Django 6.0.7 on 2026-10-05 13:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('common', '0017_territory'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='userthematics',
|
||||
name='can_edit_management_zones',
|
||||
field=models.BooleanField(default=False, verbose_name='Peut éditer les zones de gestion'),
|
||||
),
|
||||
]
|
||||
|
|
@ -584,6 +584,7 @@ class UserThematics(models.Model):
|
|||
can_edit_projects = models.BooleanField(default=False, verbose_name="Peut éditer les projets") # Peut créer/modifier les projets de la thématique
|
||||
can_validate_assets = models.BooleanField(default=False, verbose_name="Peut valider les assets")
|
||||
can_inspect = models.BooleanField(default=False, verbose_name="Peut réaliser des inspections")
|
||||
can_edit_management_zones = models.BooleanField(default=False, verbose_name="Peut éditer les zones de gestion")
|
||||
|
||||
class Meta:
|
||||
unique_together = ("user_config", "thematic") # Empêche les doublons
|
||||
|
|
|
|||
Loading…
Reference in a new issue