feat(assets): add management zones CRUD interfaces and permissions

- Add can_edit_management_zones permission to UserThematics model and admin
- Implement ManagementZoneForm with interactive WGS84 polygon capture and SRID 3812 reprojection
- Add views and routes for listing, creating, editing, detailing, and deleting management zones
- Support document attachments (ManagementZoneDocument) and explicit asset linkages
- Add MapLibre polygon drawing tool in zone form and preview map in detail view
- Integrate management zones shortcuts in nature assets map overlay and index
- Add comprehensive unit tests for permissions, forms, and CRUD operations
This commit is contained in:
kdeterme 2026-10-05 15:35:33 +02:00
parent d36649f3bf
commit b5b0338e09
14 changed files with 2207 additions and 12 deletions

View file

@ -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
@ -460,3 +461,98 @@ class StructureExceptionalTransportForm(forms.ModelForm):
for name, field in self.fields.items():
if not isinstance(field.widget, forms.CheckboxInput):
field.required = False
# ─── Management Zones Forms ───────────────────────────────────────────────────
from assets.models import ManagementZone, ManagementZoneType, ManagementZoneDocument
class ManagementZoneForm(forms.ModelForm):
"""Formulaire de création et d'édition d'une Zone de gestion."""
geom_geojson = forms.CharField(required=False, widget=forms.HiddenInput())
class Meta:
model = ManagementZone
fields = [
'code', 'name_fr', 'name_nl', 'thematic', 'zone_type', 'color',
'target_frequency_days', 'seasonality_notes', 'instructions', 'description', 'is_active'
]
widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}),
'name_fr': forms.TextInput(attrs={'class': 'form-control'}),
'name_nl': forms.TextInput(attrs={'class': 'form-control'}),
'thematic': forms.Select(attrs={'class': 'form-select'}),
'zone_type': forms.Select(attrs={'class': 'form-select'}),
'color': forms.TextInput(attrs={'class': 'form-control form-control-color', 'type': 'color'}),
'target_frequency_days': forms.NumberInput(attrs={'class': 'form-control', 'min': 1}),
'seasonality_notes': forms.TextInput(attrs={'class': 'form-control', 'placeholder': "Ex: Tonte d'avril à octobre; fauche tardive en automne"}),
'instructions': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': "Consignes d'exploitation, précautions tramway, biodiversité..."}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
for field in ['name_nl', 'zone_type', 'color', 'target_frequency_days', 'seasonality_notes', 'instructions', 'description']:
if field in self.fields:
self.fields[field].required = False
if not self.instance.pk and not self.initial.get('color'):
self.initial['color'] = '#198754'
if self.instance and self.instance.pk and self.instance.geom:
geom_wgs84 = self.instance.geom.transform(4326, clone=True)
self.initial['geom_geojson'] = geom_wgs84.geojson
if user:
from assets.permissions import get_editable_thematics_for_management_zones
editable_thematics = get_editable_thematics_for_management_zones(user)
self.fields['thematic'].queryset = editable_thematics
if not self.instance.pk and editable_thematics.count() == 1:
self.fields['thematic'].initial = editable_thematics.first()
thematic_id = None
if self.data.get('thematic'):
thematic_id = self.data.get('thematic')
elif self.instance.pk and self.instance.thematic_id:
thematic_id = self.instance.thematic_id
elif self.initial.get('thematic'):
thematic_id = self.initial.get('thematic')
if hasattr(thematic_id, 'pk'):
thematic_id = thematic_id.pk
if thematic_id:
self.fields['zone_type'].queryset = ManagementZoneType.objects.filter(thematic_id=thematic_id)
else:
self.fields['zone_type'].queryset = ManagementZoneType.objects.all()
def save(self, commit=True):
zone = super().save(commit=False)
geojson_str = (self.cleaned_data.get('geom_geojson') or '').strip()
if geojson_str:
try:
geom = GEOSGeometry(geojson_str, srid=4326)
if isinstance(geom, Polygon):
geom = MultiPolygon(geom, srid=4326)
geom.transform(3812)
zone.geom = geom
except Exception:
pass
elif 'geom_geojson' in self.cleaned_data and not geojson_str:
zone.geom = None
if commit:
zone.save()
return zone
class ManagementZoneDocumentForm(forms.ModelForm):
documents = MultipleFileField(required=True, label="Documents")
class Meta:
model = ManagementZoneDocument
fields = ['documents']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['documents'].widget.attrs.update({'class': 'form-control'})

View file

@ -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()

View file

@ -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')} &rarr;
</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>

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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" %} &rarr;
</a>
</div>
</div>
`;
popup.setLngLat(e.lngLat).setHTML(html).addTo(map);
});
map.on('mouseenter', 'management-zones-fill', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'management-zones-fill', () => { map.getCanvas().style.cursor = ''; });
// Fit bounds to features
if (features.length > 0) {
const bounds = new maplibregl.LngLatBounds();
features.forEach(f => {
const coords = f.geometry?.coordinates;
if (!coords) return;
const addCoords = (arr) => {
if (typeof arr[0] === 'number') {
bounds.extend(arr);
} else {
arr.forEach(addCoords);
}
};
addCoords(coords);
});
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 40, maxZoom: 15 });
}
}
} catch (err) {
console.error("Error loading management zones overview map:", err);
document.getElementById("mapLoadingStatus").textContent = "";
}
});
});
</script>
{% endblock script %}

View file

@ -1,6 +1,15 @@
{% 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 %}
<form method="get" id="filter-form" class="row g-3 mb-4" style="width: 30vw;">

View file

@ -199,3 +199,290 @@ 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')

View file

@ -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"),

View file

@ -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
@ -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(
@ -85,3 +101,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})

View file

@ -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']

View file

@ -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'),
),
]

View file

@ -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