- 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
435 lines
22 KiB
HTML
435 lines
22 KiB
HTML
{% 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 %}
|