From b5b0338e09c159a7a1272e547a8400e61077689b Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 5 Oct 2026 15:35:33 +0200 Subject: [PATCH] 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 --- loko/assets/forms.py | 98 +++- loko/assets/permissions.py | 121 +++++ .../assets/js/management_zones_map_overlay.js | 25 +- .../management_zones_confirm_delete.html | 38 ++ .../management_zones_detail.html | 435 +++++++++++++++ .../management_zones_form.html | 507 ++++++++++++++++++ .../management_zones_index.html | 341 ++++++++++++ .../assets/templates/assets/nature_index.html | 9 + loko/assets/tests_management_zones.py | 287 ++++++++++ loko/assets/urls.py | 9 + loko/assets/views/management_zones.py | 326 ++++++++++- loko/common/admin.py | 4 +- ...userthematics_can_edit_management_zones.py | 18 + loko/common/models.py | 1 + 14 files changed, 2207 insertions(+), 12 deletions(-) create mode 100644 loko/assets/templates/assets/management_zones/management_zones_confirm_delete.html create mode 100644 loko/assets/templates/assets/management_zones/management_zones_detail.html create mode 100644 loko/assets/templates/assets/management_zones/management_zones_form.html create mode 100644 loko/assets/templates/assets/management_zones/management_zones_index.html create mode 100644 loko/common/migrations/0018_userthematics_can_edit_management_zones.py 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('Détails & gestion', 'Détails & gestion')} → + +
`; @@ -226,7 +231,7 @@ id: 'management-zones', title: `${_t('Zones de gestion', 'Zones de gestion')}`, 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 = ` -
- ${_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} +
+ ${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 "Confirmer la suppression" %} +
+
+
+

{% 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." %} +

+ +
+ {% csrf_token %} +
+ + {% translate "Annuler" %} + + +
+
+
+
+
+{% 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 %} +
+ +
+ +
+ + {% translate "Retour à la liste" %} + + {% if can_edit %} + + {% translate "Modifier" %} + + + {% endif %} +
+
+ + +
+
+
+
+
+ +

{{ 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 "Détails & Caractéristiques" %} +
+
+
+
{% 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" }}
+
+
+
+
+ + +
+
+
+ + {% translate "Périmètre géographique" %} + + {% if zone.geom %} + {% translate "Géométrie définie" %} + {% else %} + {% translate "Aucun périmètre" %} + {% endif %} +
+
+
+
+
+
+
+ + +
+
+ + {% translate "Documents & Fiches techniques" %} ({{ documents.count }}) + + {% if can_edit %} + + {% endif %} +
+
+ {% if documents %} +
+ + + + + + + + + + + {% for doc in documents %} + + + + + + + {% endfor %} + +
{% translate "Fichier" %}{% translate "Date d'ajout" %}{% translate "Ajouté par" %}{% translate "Actions" %}
+ + {{ 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 %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
+ {% else %} +
+ + {% translate "Aucun document n'a été rattaché à cette zone de gestion." %} +
+ {% endif %} +
+
+ + +
+
+ + {% translate "Équipements rattachés explicitement" %} ({{ explicit_assets.count }}) + +
+
+ {% if explicit_assets %} +
+ + + + + + + + + + + {% for ea in explicit_assets %} + + + + + + + {% endfor %} + +
{% translate "Équipement" %}{% translate "Type" %}{% translate "Statut de rattachement" %}{% translate "Notes" %}
+ {{ 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:"-" }}
+
+ {% 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 %} +
+ +
+ +
+ {% if not is_create and zone %} + + {% translate "Annuler" %} + + {% else %} + + {% translate "Annuler" %} + + {% 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 %} + + {% endif %} + +
+ {% csrf_token %} + {{ form.geom_geojson }} + +
+ +
+ +
+
+ {% translate "Informations générales" %} +
+
+
+
+ + {{ form.code }} + {% if form.code.errors %}
{{ form.code.errors.0 }}
{% endif %} +
{% translate "Identifiant unique (ex: ZG-NAT-001)" %}
+
+ +
+ + {{ form.thematic }} + {% if form.thematic.errors %}
{{ form.thematic.errors.0 }}
{% endif %} +
+ +
+ + {{ form.name_fr }} + {% if form.name_fr.errors %}
{{ form.name_fr.errors.0 }}
{% endif %} +
+ +
+ + {{ form.name_nl }} + {% if form.name_nl.errors %}
{{ form.name_nl.errors.0 }}
{% endif %} +
+ +
+ + {{ form.zone_type }} + {% if form.zone_type.errors %}
{{ form.zone_type.errors.0 }}
{% endif %} +
+ +
+ +
+ {{ form.color }} + +
+ {% if form.color.errors %}
{{ form.color.errors.0 }}
{% endif %} +
+ +
+
+ {{ form.is_active }} + +
+
+
+
+
+ + +
+
+ {% translate "Modalités d'entretien & Consignes" %} +
+
+
+
+ +
+ {{ form.target_frequency_days }} + {% translate "jours" %} +
+
{% translate "Intervalle indicatif entre passages" %}
+ {% if form.target_frequency_days.errors %}
{{ form.target_frequency_days.errors.0 }}
{% endif %} +
+ +
+ + {{ form.seasonality_notes }} +
{% translate "Ex: Fauche en sept; interdiction taille mars-juillet" %}
+ {% if form.seasonality_notes.errors %}
{{ form.seasonality_notes.errors.0 }}
{% endif %} +
+ +
+ + {{ form.instructions }} +
{% translate "Consignes cruciales : sécurité tramway, respect des zones humides, hauteur de coupe, etc." %}
+ {% if form.instructions.errors %}
{{ form.instructions.errors.0 }}
{% endif %} +
+ +
+ + {{ form.description }} + {% if form.description.errors %}
{{ form.description.errors.0 }}
{% endif %} +
+
+
+
+
+ + +
+
+
+
+ + {% translate "Périmètre géographique" %} + ({% translate "polygone" %}) +
+
+ + + +
+
+
+
+
+ +
+ + +
+ {% if not is_create and zone %} + + {% translate "Annuler" %} + + {% else %} + + {% translate "Annuler" %} + + {% 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 %} +
+ +
+ + {% if can_create %} + + {% translate "Nouvelle zone de gestion" %} + + {% endif %} +
+ + +
+
+

+ {% 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 "Aperçu cartographique des périmètres" %} + + {% translate "Chargement de la carte..." %} +
+
+
+
+
+ + +
+
+
+ + + + + + + + + + + + + + + {% for item in zones_list %} + {% with zone=item.zone can_edit=item.can_edit %} + + + + + + + + + + + {% endwith %} + {% empty %} + + + + {% endfor %} + +
{% translate "Code" %}{% translate "Nom de la zone" %}{% translate "Thématique" %}{% translate "Type de zone" %}{% translate "Fréquence cible" %}{% translate "Saisonnalité" %}{% translate "Statut" %}{% translate "Actions" %}
+ + +
{{ 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 %} +
+
+ +

{% 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 %} +
+
+
+
+
+{% 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 %} + +{% 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