diff --git a/loko/assets/forms.py b/loko/assets/forms.py
index f3075ea..c384f82 100644
--- a/loko/assets/forms.py
+++ b/loko/assets/forms.py
@@ -1,3 +1,4 @@
+from django.contrib.gis.geos import GEOSGeometry, Polygon, MultiPolygon
from django import forms
from assets.models import TrafficLightIntersectionDocument, PublicLightingStreetDocument, StructureDocument, ControlCenterDocument
@@ -459,4 +460,99 @@ class StructureExceptionalTransportForm(forms.ModelForm):
super().__init__(*args, **kwargs)
for name, field in self.fields.items():
if not isinstance(field.widget, forms.CheckboxInput):
- field.required = False
\ No newline at end of file
+ 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'})
diff --git a/loko/assets/permissions.py b/loko/assets/permissions.py
index ac644bc..62ac89f 100644
--- a/loko/assets/permissions.py
+++ b/loko/assets/permissions.py
@@ -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()
diff --git a/loko/assets/static/assets/js/management_zones_map_overlay.js b/loko/assets/static/assets/js/management_zones_map_overlay.js
index 097250f..a44c341 100644
--- a/loko/assets/static/assets/js/management_zones_map_overlay.js
+++ b/loko/assets/static/assets/js/management_zones_map_overlay.js
@@ -199,6 +199,11 @@
${freqHtml}
${seasonHtml}
${instructionsHtml}
+
- ${_t('Aucune zone de gestion définie pour cette thématique.', 'Aucune zone de gestion définie pour cette thématique.')}
+
+
${_t('Aucune zone de gestion définie pour cette thématique.', 'Aucune zone de gestion définie pour cette thématique.')}
+
+
+
`;
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 = `
-
${countText}
+
diff --git a/loko/assets/templates/assets/management_zones/management_zones_confirm_delete.html b/loko/assets/templates/assets/management_zones/management_zones_confirm_delete.html
new file mode 100644
index 0000000..94d68da
--- /dev/null
+++ b/loko/assets/templates/assets/management_zones/management_zones_confirm_delete.html
@@ -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 %}
+
+
+
+
+
{% translate "Êtes-vous certain de vouloir supprimer définitivement la zone de gestion suivante ?" %}
+
+
{{ zone.code }} - {{ zone.get_name }}
+
{{ zone.thematic.name }} {% if zone.zone_type %}| {{ zone.zone_type.get_name }}{% endif %}
+
+
+ {% translate "Cette action est irréversible. Les documents et associations liés à cette zone seront supprimés." %}
+
+
+
+
+
+
+{% endblock content %}
diff --git a/loko/assets/templates/assets/management_zones/management_zones_detail.html b/loko/assets/templates/assets/management_zones/management_zones_detail.html
new file mode 100644
index 0000000..f96853a
--- /dev/null
+++ b/loko/assets/templates/assets/management_zones/management_zones_detail.html
@@ -0,0 +1,435 @@
+{% extends "base.html" %}
+{% load static i18n %}
+
+{% block title %}{{ zone.code }} - {{ zone.get_name }} | {{ product_name }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
{{ zone.code }} - {{ zone.get_name }}
+
+ {% if zone.name_nl and zone.name_nl != zone.name_fr %}
+
{{ zone.name_nl }}
+ {% endif %}
+
+
+ {{ zone.thematic.name }}
+
+ {% if zone.zone_type %}
+
+ {{ zone.zone_type.get_name }}
+
+ {% endif %}
+ {% if zone.is_active %}
+
+ {% translate "Active" %}
+
+ {% else %}
+
+ {% translate "Inactive" %}
+
+ {% endif %}
+ {% if area_ha %}
+
+ {{ area_ha }} ha ({{ area_m2|floatformat:0 }} m²)
+
+ {% endif %}
+
+
+
+ {% if zone.target_frequency_days %}
+
+ {% translate "Fréquence cible" %}
+ {{ zone.target_frequency_days }} {% translate "j" %}
+
+ {% endif %}
+ {% if zone.seasonality_notes %}
+
+ {% translate "Période" %}
+ {{ zone.seasonality_notes }}
+
+ {% endif %}
+
+
+
+
+
+
+ {% if zone.instructions %}
+
+
+
+
+
+
{% translate "Consignes d'exploitation et points de vigilance" %}
+
{{ zone.instructions }}
+
+
+
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+ {% translate "Description" %}
+ {{ zone.description|default:_("Aucune description renseignée")|linebreaks }}
+
+ {% translate "Thématique" %}
+ {{ zone.thematic.name }}
+
+ {% translate "Type de zone" %}
+ {{ zone.zone_type.get_name|default:"-" }}
+
+ {% translate "Couleur de repérage" %}
+
+
+
+ {{ zone.get_color }}
+
+
+
+ {% translate "Superficie estimée" %}
+
+ {% if area_m2 %}
+ {{ area_ha }} hectares ({{ area_m2|floatformat:0 }} m²)
+ {% else %}
+ {% translate "Périmètre non défini" %}
+ {% endif %}
+
+
+ {% translate "Créé par" %}
+ {% if zone.created_by %}{{ zone.created_by.get_full_name|default:zone.created_by.username }}{% else %}-{% endif %}
+
+ {% translate "Date de création" %}
+ {{ zone.created_at|date:"d/m/Y H:i" }}
+
+ {% translate "Dernière mise à jour" %}
+ {{ zone.updated_at|date:"d/m/Y H:i" }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if documents %}
+
+
+
+
+ {% translate "Fichier" %}
+ {% translate "Date d'ajout" %}
+ {% translate "Ajouté par" %}
+ {% translate "Actions" %}
+
+
+
+ {% for doc in documents %}
+
+
+
+
+ {{ doc.upload_date|date:"d/m/Y H:i" }}
+ {% if doc.uploaded_by %}{{ doc.uploaded_by.get_full_name|default:doc.uploaded_by.username }}{% else %}-{% endif %}
+
+
+
+
+ {% if can_edit %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+ {% translate "Aucun document n'a été rattaché à cette zone de gestion." %}
+
+ {% endif %}
+
+
+
+
+
+
+
+ {% if explicit_assets %}
+
+
+
+
+ {% translate "Équipement" %}
+ {% translate "Type" %}
+ {% translate "Statut de rattachement" %}
+ {% translate "Notes" %}
+
+
+
+ {% for ea in explicit_assets %}
+
+
+ {{ ea.asset|default:ea.object_id }}
+
+ {{ ea.content_type.name }}
+
+ {% if ea.is_excluded %}
+
+ {% translate "Exclu de la zone" %}
+
+ {% else %}
+
+ {% translate "Inclus dans la zone" %}
+
+ {% endif %}
+
+ {{ ea.notes|default:"-" }}
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
{% translate "Tous les équipements situés dans le périmètre géographique de la zone y sont automatiquement associés par intersection spatiale." %}
+
+ {% endif %}
+
+
+
+
+
+{% if can_edit and doc_form %}
+
+{% endif %}
+
+
+{% if can_edit %}
+
+{% endif %}
+{% endblock content %}
+
+{% block script %}
+
+{% endblock script %}
diff --git a/loko/assets/templates/assets/management_zones/management_zones_form.html b/loko/assets/templates/assets/management_zones/management_zones_form.html
new file mode 100644
index 0000000..61504fe
--- /dev/null
+++ b/loko/assets/templates/assets/management_zones/management_zones_form.html
@@ -0,0 +1,507 @@
+{% extends "base.html" %}
+{% load static i18n %}
+
+{% block title %}{{ title }} | {{ product_name }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ {% translate "Accueil" %}
+ {% translate "Assets" %}
+ {% translate "Zones de gestion" %}
+ {% if not is_create and zone %}
+ {{ zone.code }}
+ {% translate "Modifier" %}
+ {% else %}
+ {% translate "Nouvelle zone" %}
+ {% endif %}
+
+
+
+
+
+
+
+
+ {% if is_create %}
+ {% translate "Créer une zone de gestion" %}
+ {% else %}
+ {% translate "Modifier la zone de gestion" %} {{ zone.code }}
+ {% endif %}
+
+
+ {% translate "Définissez les paramètres d'entretien, les consignes et le périmètre géographique de la zone." %}
+
+
+
+ {% if form.errors %}
+
+
+ {% translate "Certains champs contiennent des erreurs." %}
+ {% translate "Veuillez vérifier les informations ci-dessous." %}
+
+
+ {% endif %}
+
+
+
+{% endblock content %}
+
+{% block script %}
+
+{% endblock script %}
diff --git a/loko/assets/templates/assets/management_zones/management_zones_index.html b/loko/assets/templates/assets/management_zones/management_zones_index.html
new file mode 100644
index 0000000..bb2193d
--- /dev/null
+++ b/loko/assets/templates/assets/management_zones/management_zones_index.html
@@ -0,0 +1,341 @@
+{% extends "base.html" %}
+{% load static i18n %}
+
+{% block title %}{% translate "Zones de gestion" %} | {{ product_name }}{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+ {% translate "Zones de gestion" %}
+
+
+ {% translate "Regroupements géographiques et fonctionnels d'équipements pour l'entretien récurrent, les plans de fauche et l'exploitation." %}
+
+
+
+
+ {{ total_count }} {% translate "zone(s) répertoriée(s)" %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% translate "Code" %}
+ {% translate "Nom de la zone" %}
+ {% translate "Thématique" %}
+ {% translate "Type de zone" %}
+ {% translate "Fréquence cible" %}
+ {% translate "Saisonnalité" %}
+ {% translate "Statut" %}
+ {% translate "Actions" %}
+
+
+
+ {% for item in zones_list %}
+ {% with zone=item.zone can_edit=item.can_edit %}
+
+
+
+
+
+ {{ zone.get_name }}
+ {% if zone.instructions %}
+
+ {{ zone.instructions }}
+
+ {% endif %}
+
+
+
+ {{ zone.thematic.name }}
+
+
+
+ {% if zone.zone_type %}
+
+ {{ zone.zone_type.get_name }}
+
+ {% else %}
+ -
+ {% endif %}
+
+
+ {% if zone.target_frequency_days %}
+
+ {{ zone.target_frequency_days }} {% translate "jours" %}
+
+ {% else %}
+ -
+ {% endif %}
+
+
+ {% if zone.seasonality_notes %}
+ {{ zone.seasonality_notes }}
+ {% else %}
+ -
+ {% endif %}
+
+
+ {% if zone.is_active %}
+ {% translate "Active" %}
+ {% else %}
+ {% translate "Inactive" %}
+ {% endif %}
+
+
+
+
+
+
+ {% if can_edit %}
+
+
+
+
+
+
+ {% endif %}
+
+
+
+ {% endwith %}
+ {% empty %}
+
+
+
+ {% translate "Aucune zone de gestion ne correspond aux critères sélectionnés." %}
+ {% if can_create %}
+
+ {% translate "Créer une première zone de gestion" %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+
+
+
+{% endblock content %}
+
+{% block script %}
+
+{% endblock script %}
diff --git a/loko/assets/templates/assets/nature_index.html b/loko/assets/templates/assets/nature_index.html
index 48a568a..55bd964 100644
--- a/loko/assets/templates/assets/nature_index.html
+++ b/loko/assets/templates/assets/nature_index.html
@@ -1,5 +1,14 @@
{% extends "assets/base_assets_index.html" %}
{% load static i18n %}
+
+{% block tab_actions %}
+
+
+
+ {% translate "Zones de gestion" %}
+
+
+{% endblock tab_actions %}
{% block locations_filter_form %}
diff --git a/loko/assets/tests_management_zones.py b/loko/assets/tests_management_zones.py
index 1878beb..81d1605 100644
--- a/loko/assets/tests_management_zones.py
+++ b/loko/assets/tests_management_zones.py
@@ -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')
diff --git a/loko/assets/urls.py b/loko/assets/urls.py
index c3db905..1871419 100644
--- a/loko/assets/urls.py
+++ b/loko/assets/urls.py
@@ -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/
/", management_zones.management_zone_detail, name="management_zone_detail"),
+ path("management-zones//edit/", management_zones.management_zone_edit, name="management_zone_edit"),
+ path("management-zones//delete/", management_zones.management_zone_delete, name="management_zone_delete"),
+ path("management-zones//add-document/", management_zones.management_zone_add_document, name="management_zone_add_document"),
+ path("management-zones//documents//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"),
diff --git a/loko/assets/views/management_zones.py b/loko/assets/views/management_zones.py
index 514140a..442aae3 100644
--- a/loko/assets/views/management_zones.py
+++ b/loko/assets/views/management_zones.py
@@ -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})
diff --git a/loko/common/admin.py b/loko/common/admin.py
index a8d6e41..de173b4 100644
--- a/loko/common/admin.py
+++ b/loko/common/admin.py
@@ -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']
diff --git a/loko/common/migrations/0018_userthematics_can_edit_management_zones.py b/loko/common/migrations/0018_userthematics_can_edit_management_zones.py
new file mode 100644
index 0000000..1235adf
--- /dev/null
+++ b/loko/common/migrations/0018_userthematics_can_edit_management_zones.py
@@ -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'),
+ ),
+ ]
diff --git a/loko/common/models.py b/loko/common/models.py
index af04e05..28a0984 100644
--- a/loko/common/models.py
+++ b/loko/common/models.py
@@ -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