diff --git a/loko/assets/admin.py b/loko/assets/admin.py index 1996b58..a5b2233 100644 --- a/loko/assets/admin.py +++ b/loko/assets/admin.py @@ -745,4 +745,59 @@ class ParkingSpotAdmin(AssetGISAdmin): ) search_fields = ("code", "name_fr", "name_nl", "note") filter_horizontal = ("poles", "panels") - raw_id_fields = ("location", "street") \ No newline at end of file + raw_id_fields = ("location", "street") + +# ─── Management Zones ────────────────────────────────────────────────────────── + +from django.utils.html import format_html +from django.contrib.contenttypes.admin import GenericTabularInline +from .models import ManagementZoneType, ManagementZone, ManagementZoneAsset, ManagementZoneDocument + + +class ManagementZoneDocumentInline(admin.TabularInline): + model = ManagementZoneDocument + extra = 1 + + +class ManagementZoneAssetInline(GenericTabularInline): + model = ManagementZoneAsset + ct_field = 'content_type' + ct_fk_field = 'object_id' + extra = 1 + + +@admin.register(ManagementZoneType) +class ManagementZoneTypeAdmin(admin.ModelAdmin): + list_display = ('code', 'name_fr', 'name_nl', 'thematic', 'color_badge') + list_filter = ('thematic',) + search_fields = ('code', 'name_fr', 'name_nl', 'description') + + def color_badge(self, obj): + color = obj.default_color or "#198754" + return format_html( + '{}', + color, color + ) + color_badge.short_description = "Couleur" + + +@admin.register(ManagementZone) +class ManagementZoneAdmin(AssetGISAdmin): + list_display = ('code', 'name_fr', 'thematic', 'zone_type', 'color_badge', 'target_frequency_days', 'is_active', 'has_geom') + list_filter = ('thematic', 'zone_type', 'is_active') + search_fields = ('code', 'name_fr', 'name_nl', 'instructions', 'description', 'seasonality_notes') + readonly_fields = ('created_at', 'updated_at') + inlines = [ManagementZoneDocumentInline, ManagementZoneAssetInline] + + def color_badge(self, obj): + color = obj.get_color() + return format_html( + '{}', + color, color + ) + color_badge.short_description = "Couleur" + + def has_geom(self, obj): + return bool(obj.geom) + has_geom.boolean = True + has_geom.short_description = "Géométrie" diff --git a/loko/assets/locale/en/LC_MESSAGES/django.mo b/loko/assets/locale/en/LC_MESSAGES/django.mo index eab5d06..6d09db7 100644 Binary files a/loko/assets/locale/en/LC_MESSAGES/django.mo and b/loko/assets/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/assets/locale/en/LC_MESSAGES/django.po b/loko/assets/locale/en/LC_MESSAGES/django.po index 113721b..43acff1 100644 --- a/loko/assets/locale/en/LC_MESSAGES/django.po +++ b/loko/assets/locale/en/LC_MESSAGES/django.po @@ -8710,3 +8710,57 @@ msgstr "Assets directly linked to building" #~ msgid "Arceaux cargo constatés" #~ msgstr "Observed cargo bike racks" + +msgid "Type de zone de gestion" +msgstr "Management zone type" + +msgid "Types de zones de gestion" +msgstr "Management zone types" + +msgid "Zone de gestion" +msgstr "Management zone" + +msgid "Zones de gestion" +msgstr "Management zones" + +msgid "Consignes et points d'attention" +msgstr "Instructions and points of attention" + +msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..." +msgstr "Operational instructions, points of vigilance, safety rules..." + +msgid "Périmètre géographique" +msgstr "Geographical perimeter" + +msgid "Couleur d'affichage" +msgstr "Display color" + +msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone." +msgstr "Hexadecimal code (e.g. #198754). If empty, the zone type color is used." + +msgid "Fréquence cible (jours)" +msgstr "Target frequency (days)" + +msgid "Intervalle indicatif entre interventions en jours." +msgstr "Indicative interval between interventions in days." + +msgid "Saisonnalité / Calendrier" +msgstr "Seasonality / Calendar" + +msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps." +msgstr "E.g.: Mowing from April to October; late mowing in September; pruning prohibited in spring." + +msgid "Asset de zone de gestion" +msgstr "Management zone asset" + +msgid "Assets de zones de gestion" +msgstr "Management zone assets" + +msgid "Document de zone de gestion" +msgstr "Management zone document" + +msgid "Documents de zones de gestion" +msgstr "Management zone documents" + +msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre." +msgstr "If checked, this asset is excluded from the zone even if its geometry intersects the perimeter." diff --git a/loko/assets/locale/en/LC_MESSAGES/djangojs.mo b/loko/assets/locale/en/LC_MESSAGES/djangojs.mo index 8d561b1..1b58bb0 100644 Binary files a/loko/assets/locale/en/LC_MESSAGES/djangojs.mo and b/loko/assets/locale/en/LC_MESSAGES/djangojs.mo differ diff --git a/loko/assets/locale/en/LC_MESSAGES/djangojs.po b/loko/assets/locale/en/LC_MESSAGES/djangojs.po index 727b478..26058a0 100644 --- a/loko/assets/locale/en/LC_MESSAGES/djangojs.po +++ b/loko/assets/locale/en/LC_MESSAGES/djangojs.po @@ -717,3 +717,30 @@ msgstr "Validation error." msgid "Erreur réseau." msgstr "Network error." + +msgid "Zones de gestion" +msgstr "Management zones" + +msgid "Aucune zone de gestion définie pour cette thématique." +msgstr "No management zone defined for this thematic." + +msgid "zones" +msgstr "zones" + +msgid "Afficher / masquer toutes les zones" +msgstr "Show / hide all zones" + +msgid "Opacité" +msgstr "Opacity" + +msgid "Fréquence cible :" +msgstr "Target frequency:" + +msgid "jours" +msgstr "days" + +msgid "Consignes & points d'attention" +msgstr "Instructions & points of attention" + +msgid "Centrer sur la zone" +msgstr "Center on zone" diff --git a/loko/assets/locale/fr/LC_MESSAGES/django.mo b/loko/assets/locale/fr/LC_MESSAGES/django.mo index 47afbdb..ab40c87 100644 Binary files a/loko/assets/locale/fr/LC_MESSAGES/django.mo and b/loko/assets/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/assets/locale/fr/LC_MESSAGES/django.po b/loko/assets/locale/fr/LC_MESSAGES/django.po index cf6cd9d..4ee7d6d 100644 --- a/loko/assets/locale/fr/LC_MESSAGES/django.po +++ b/loko/assets/locale/fr/LC_MESSAGES/django.po @@ -8763,3 +8763,57 @@ msgstr "Assets rattachés directement au bâtiment" #~ msgid "Arceaux cargo constatés" #~ msgstr "Arceaux cargo constatés" + +msgid "Type de zone de gestion" +msgstr "Type de zone de gestion" + +msgid "Types de zones de gestion" +msgstr "Types de zones de gestion" + +msgid "Zone de gestion" +msgstr "Zone de gestion" + +msgid "Zones de gestion" +msgstr "Zones de gestion" + +msgid "Consignes et points d'attention" +msgstr "Consignes et points d'attention" + +msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..." +msgstr "Consignes d'exploitation, points de vigilance, règles de sécurité..." + +msgid "Périmètre géographique" +msgstr "Périmètre géographique" + +msgid "Couleur d'affichage" +msgstr "Couleur d'affichage" + +msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone." +msgstr "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone." + +msgid "Fréquence cible (jours)" +msgstr "Fréquence cible (jours)" + +msgid "Intervalle indicatif entre interventions en jours." +msgstr "Intervalle indicatif entre interventions en jours." + +msgid "Saisonnalité / Calendrier" +msgstr "Saisonnalité / Calendrier" + +msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps." +msgstr "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps." + +msgid "Asset de zone de gestion" +msgstr "Asset de zone de gestion" + +msgid "Assets de zones de gestion" +msgstr "Assets de zones de gestion" + +msgid "Document de zone de gestion" +msgstr "Document de zone de gestion" + +msgid "Documents de zones de gestion" +msgstr "Documents de zones de gestion" + +msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre." +msgstr "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre." diff --git a/loko/assets/locale/fr/LC_MESSAGES/djangojs.mo b/loko/assets/locale/fr/LC_MESSAGES/djangojs.mo index 03cf3c4..abe73c3 100644 Binary files a/loko/assets/locale/fr/LC_MESSAGES/djangojs.mo and b/loko/assets/locale/fr/LC_MESSAGES/djangojs.mo differ diff --git a/loko/assets/locale/fr/LC_MESSAGES/djangojs.po b/loko/assets/locale/fr/LC_MESSAGES/djangojs.po index 807bb46..79e39f8 100644 --- a/loko/assets/locale/fr/LC_MESSAGES/djangojs.po +++ b/loko/assets/locale/fr/LC_MESSAGES/djangojs.po @@ -722,3 +722,30 @@ msgstr "Erreur lors de la validation." msgid "Erreur réseau." msgstr "Erreur réseau." + +msgid "Zones de gestion" +msgstr "Zones de gestion" + +msgid "Aucune zone de gestion définie pour cette thématique." +msgstr "Aucune zone de gestion définie pour cette thématique." + +msgid "zones" +msgstr "zones" + +msgid "Afficher / masquer toutes les zones" +msgstr "Afficher / masquer toutes les zones" + +msgid "Opacité" +msgstr "Opacité" + +msgid "Fréquence cible :" +msgstr "Fréquence cible :" + +msgid "jours" +msgstr "jours" + +msgid "Consignes & points d'attention" +msgstr "Consignes & points d'attention" + +msgid "Centrer sur la zone" +msgstr "Centrer sur la zone" diff --git a/loko/assets/locale/nl/LC_MESSAGES/django.mo b/loko/assets/locale/nl/LC_MESSAGES/django.mo index 0b7e96d..2d6a183 100644 Binary files a/loko/assets/locale/nl/LC_MESSAGES/django.mo and b/loko/assets/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/assets/locale/nl/LC_MESSAGES/django.po b/loko/assets/locale/nl/LC_MESSAGES/django.po index 75e9554..118829c 100644 --- a/loko/assets/locale/nl/LC_MESSAGES/django.po +++ b/loko/assets/locale/nl/LC_MESSAGES/django.po @@ -8711,3 +8711,57 @@ msgstr "Assets direct gekoppeld aan gebouw" #~ msgid "Arceaux cargo constatés" #~ msgstr "Vastgestelde cargobeugels" + +msgid "Type de zone de gestion" +msgstr "Beheerszonetype" + +msgid "Types de zones de gestion" +msgstr "Beheerszonetypes" + +msgid "Zone de gestion" +msgstr "Beheerszone" + +msgid "Zones de gestion" +msgstr "Beheerszones" + +msgid "Consignes et points d'attention" +msgstr "Instructies en aandachtspunten" + +msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..." +msgstr "Exploitatie-instructies, aandachtspunten, veiligheidsregels..." + +msgid "Périmètre géographique" +msgstr "Geografische perimeter" + +msgid "Couleur d'affichage" +msgstr "Weergavekleur" + +msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone." +msgstr "Hexadecimale code (bijv. #198754). Indien leeg, wordt de kleur van het zonetype gebruikt." + +msgid "Fréquence cible (jours)" +msgstr "Doelfrequentie (dagen)" + +msgid "Intervalle indicatif entre interventions en jours." +msgstr "Indicatief interval tussen interventies in dagen." + +msgid "Saisonnalité / Calendrier" +msgstr "Seizoensgebondenheid / Kalender" + +msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps." +msgstr "Bijv.: Maaien van april tot oktober; laat maaien in september; snoeiverbod in het voorjaar." + +msgid "Asset de zone de gestion" +msgstr "Beheerszone-asset" + +msgid "Assets de zones de gestion" +msgstr "Beheerszone-assets" + +msgid "Document de zone de gestion" +msgstr "Beheerszonedocument" + +msgid "Documents de zones de gestion" +msgstr "Beheerszonedocumenten" + +msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre." +msgstr "Indien aangevinkt, wordt dit asset uitgesloten van de zone, zelfs als de geometrie de perimeter doorsnijdt." diff --git a/loko/assets/locale/nl/LC_MESSAGES/djangojs.mo b/loko/assets/locale/nl/LC_MESSAGES/djangojs.mo index dc3205d..ed4ecd7 100644 Binary files a/loko/assets/locale/nl/LC_MESSAGES/djangojs.mo and b/loko/assets/locale/nl/LC_MESSAGES/djangojs.mo differ diff --git a/loko/assets/locale/nl/LC_MESSAGES/djangojs.po b/loko/assets/locale/nl/LC_MESSAGES/djangojs.po index ec79502..82c08a3 100644 --- a/loko/assets/locale/nl/LC_MESSAGES/djangojs.po +++ b/loko/assets/locale/nl/LC_MESSAGES/djangojs.po @@ -719,3 +719,30 @@ msgstr "Fout bij validatie." msgid "Erreur réseau." msgstr "Netwerkfout." + +msgid "Zones de gestion" +msgstr "Beheerszones" + +msgid "Aucune zone de gestion définie pour cette thématique." +msgstr "Geen beheerszone gedefinieerd voor dit thema." + +msgid "zones" +msgstr "zones" + +msgid "Afficher / masquer toutes les zones" +msgstr "Alle zones tonen / verbergen" + +msgid "Opacité" +msgstr "Dekking" + +msgid "Fréquence cible :" +msgstr "Doelfrequentie:" + +msgid "jours" +msgstr "dagen" + +msgid "Consignes & points d'attention" +msgstr "Instructies & aandachtspunten" + +msgid "Centrer sur la zone" +msgstr "Centreren op zone" diff --git a/loko/assets/migrations/0030_managementzone_managementzonedocument_and_more.py b/loko/assets/migrations/0030_managementzone_managementzonedocument_and_more.py new file mode 100644 index 0000000..c80bac5 --- /dev/null +++ b/loko/assets/migrations/0030_managementzone_managementzonedocument_and_more.py @@ -0,0 +1,102 @@ +# Generated by Django 6.0.7 on 2026-10-05 13:06 + +import common.storages +import django.contrib.gis.db.models.fields +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('assets', '0029_publicbuildingdocument_floor_location_and_more'), + ('common', '0017_territory'), + ('contenttypes', '0002_remove_content_type_name'), + ('documents', '0004_alter_documentfolder_visibility_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ManagementZone', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=100, unique=True, verbose_name='Code')), + ('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')), + ('name_nl', models.CharField(blank=True, max_length=255, null=True, verbose_name='Nom (NL)')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), + ('instructions', models.TextField(blank=True, help_text="Consignes d'exploitation, points de vigilance, règles de sécurité...", null=True, verbose_name="Consignes et points d'attention")), + ('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Périmètre géographique')), + ('color', models.CharField(blank=True, help_text='Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone.', max_length=20, null=True, verbose_name="Couleur d'affichage")), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('target_frequency_days', models.IntegerField(blank=True, help_text='Intervalle indicatif entre interventions en jours.', null=True, verbose_name='Fréquence cible (jours)')), + ('seasonality_notes', models.CharField(blank=True, help_text="Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps.", max_length=255, null=True, verbose_name='Saisonnalité / Calendrier')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Date de création')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Date de mise à jour')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_management_zones', to=settings.AUTH_USER_MODEL, verbose_name='Créé par')), + ('thematic', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='management_zones', to='common.thematic', verbose_name='Thématique')), + ], + options={ + 'verbose_name': 'Zone de gestion', + 'verbose_name_plural': 'Zones de gestion', + 'ordering': ['thematic', 'code'], + }, + ), + migrations.CreateModel( + name='ManagementZoneDocument', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description du document')), + ('upload_date', models.DateTimeField(auto_now=True, verbose_name='Date de téléversement')), + ('status', models.CharField(choices=[('active', 'Actif'), ('archived', 'Archivé')], default='active')), + ('file', models.FileField(max_length=255, storage=common.storages.PrivateMediaStorage(), upload_to='assets_documents/management_zones/', verbose_name='Fichier')), + ('thumbnail', models.ImageField(blank=True, max_length=255, null=True, storage=common.storages.PrivateMediaStorage(), upload_to='assets_documents/management_zones/')), + ('managed_document', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_sources', to='documents.manageddocument')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par')), + ('zone', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.managementzone', verbose_name='Zone de gestion')), + ], + options={ + 'verbose_name': 'Document de zone de gestion', + 'verbose_name_plural': 'Documents de zones de gestion', + }, + ), + migrations.CreateModel( + name='ManagementZoneType', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=100, unique=True, verbose_name='Code')), + ('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')), + ('name_nl', models.CharField(blank=True, max_length=255, null=True, verbose_name='Nom (NL)')), + ('description', models.TextField(blank=True, null=True, verbose_name='Description')), + ('default_color', models.CharField(default='#198754', max_length=20, verbose_name='Couleur par défaut')), + ('thematic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='management_zone_types', to='common.thematic', verbose_name='Thématique')), + ], + options={ + 'verbose_name': 'Type de zone de gestion', + 'verbose_name_plural': 'Types de zones de gestion', + 'ordering': ['thematic', 'name_fr'], + }, + ), + migrations.AddField( + model_name='managementzone', + name='zone_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='management_zones', to='assets.managementzonetype', verbose_name='Type de zone de gestion'), + ), + migrations.CreateModel( + name='ManagementZoneAsset', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField(verbose_name="ID de l'asset")), + ('is_excluded', models.BooleanField(default=False, help_text='Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre.', verbose_name='Exclu')), + ('notes', models.CharField(blank=True, max_length=255, null=True, verbose_name='Notes')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name="Type d'asset")), + ('management_zone', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='explicit_assets', to='assets.managementzone', verbose_name='Zone de gestion')), + ], + options={ + 'verbose_name': 'Asset de zone de gestion', + 'verbose_name_plural': 'Assets de zones de gestion', + 'unique_together': {('management_zone', 'content_type', 'object_id')}, + }, + ), + ] diff --git a/loko/assets/models/__init__.py b/loko/assets/models/__init__.py index ef9106d..74b1cb7 100644 --- a/loko/assets/models/__init__.py +++ b/loko/assets/models/__init__.py @@ -13,4 +13,5 @@ from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitt from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument from .parking import ParkingLocation, ParkingSpot -from inspections.models import Inspection as AssetInspection, ParkingSpotInspection \ No newline at end of file +from inspections.models import Inspection as AssetInspection, ParkingSpotInspection +from .management_zones import ManagementZone, ManagementZoneType, ManagementZoneAsset, ManagementZoneDocument diff --git a/loko/assets/models/management_zones.py b/loko/assets/models/management_zones.py new file mode 100644 index 0000000..36e60ab --- /dev/null +++ b/loko/assets/models/management_zones.py @@ -0,0 +1,214 @@ +from django.contrib.gis.db import models +from django.contrib.contenttypes.models import ContentType +from django.contrib.contenttypes.fields import GenericForeignKey +from django.conf import settings +from django.utils.translation import gettext_lazy as _, get_language +import json + +from common.models import Thematic, Document +from common.storages import PrivateMediaStorage + +private_storage = PrivateMediaStorage() + + +class ManagementZoneType(models.Model): + """ + Typologie de zone de gestion spécifique à une thématique + (ex: Fauche tardive / refuge biodiversité, Tonte d'agrément, Onde verte coordonnée, etc.). + """ + code = models.CharField(max_length=100, unique=True, verbose_name=_("Code")) + name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)")) + name_nl = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Nom (NL)")) + thematic = models.ForeignKey( + Thematic, + on_delete=models.CASCADE, + related_name="management_zone_types", + verbose_name=_("Thématique") + ) + description = models.TextField(blank=True, null=True, verbose_name=_("Description")) + default_color = models.CharField(max_length=20, default="#198754", verbose_name=_("Couleur par défaut")) + + def get_name(self): + lang = get_language() + if lang and lang.startswith("nl") and self.name_nl: + return self.name_nl + return self.name_fr + + @property + def name(self): + return self.get_name() + + def __str__(self): + return f"{self.get_name()} ({self.thematic.name})" + + class Meta: + ordering = ["thematic", "name_fr"] + verbose_name = _("Type de zone de gestion") + verbose_name_plural = _("Types de zones de gestion") + + +class ManagementZone(models.Model): + """ + Zone de gestion générique avec géométrie polygonale. + Regroupe des équipements/assets selon une logique métier, d'exploitation ou de gestion écologique. + """ + code = models.CharField(max_length=100, unique=True, verbose_name=_("Code")) + name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)")) + name_nl = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Nom (NL)")) + thematic = models.ForeignKey( + Thematic, + on_delete=models.PROTECT, + related_name="management_zones", + verbose_name=_("Thématique") + ) + zone_type = models.ForeignKey( + ManagementZoneType, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="management_zones", + verbose_name=_("Type de zone de gestion") + ) + description = models.TextField(blank=True, null=True, verbose_name=_("Description")) + instructions = models.TextField( + blank=True, + null=True, + verbose_name=_("Consignes et points d'attention"), + help_text=_("Consignes d'exploitation, points de vigilance, règles de sécurité...") + ) + geom = models.MultiPolygonField( + srid=3812, + null=True, + blank=True, + verbose_name=_("Périmètre géographique") + ) + color = models.CharField( + max_length=20, + blank=True, + null=True, + verbose_name=_("Couleur d'affichage"), + help_text=_("Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone.") + ) + is_active = models.BooleanField(default=True, verbose_name=_("Active")) + target_frequency_days = models.IntegerField( + null=True, + blank=True, + verbose_name=_("Fréquence cible (jours)"), + help_text=_("Intervalle indicatif entre interventions en jours.") + ) + seasonality_notes = models.CharField( + max_length=255, + blank=True, + null=True, + verbose_name=_("Saisonnalité / Calendrier"), + help_text=_("Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps.") + ) + created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Date de création")) + updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Date de mise à jour")) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="created_management_zones", + verbose_name=_("Créé par") + ) + + def get_color(self): + if self.color: + return self.color + if self.zone_type and self.zone_type.default_color: + return self.zone_type.default_color + return "#198754" + + @property + def geojson_geometry(self): + """ + Retourne la géométrie GeoJSON transformée en WGS84 (EPSG:4326) + pour utilisation directe dans MapLibre / Leaflet. + """ + if not self.geom: + return None + try: + geom_4326 = self.geom.transform(4326, clone=True) + return json.loads(geom_4326.geojson) + except Exception: + return None + + def get_name(self): + lang = get_language() + if lang and lang.startswith("nl") and self.name_nl: + return self.name_nl + return self.name_fr + + @property + def name(self): + return self.get_name() + + def __str__(self): + return f"{self.code} - {self.get_name()}" + + class Meta: + ordering = ["thematic", "code"] + verbose_name = _("Zone de gestion") + verbose_name_plural = _("Zones de gestion") + + +class ManagementZoneAsset(models.Model): + """ + Liaison explicite (inclusion ou exclusion) d'un asset à une zone de gestion. + Permet d'affiner le rattachement au-delà de la stricte inclusion spatiale. + """ + management_zone = models.ForeignKey( + ManagementZone, + on_delete=models.CASCADE, + related_name="explicit_assets", + verbose_name=_("Zone de gestion") + ) + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, verbose_name=_("Type d'asset")) + object_id = models.PositiveIntegerField(verbose_name=_("ID de l'asset")) + asset = GenericForeignKey('content_type', 'object_id') + is_excluded = models.BooleanField( + default=False, + verbose_name=_("Exclu"), + help_text=_("Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre.") + ) + notes = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Notes")) + + def __str__(self): + status = "Exclu" if self.is_excluded else "Inclus" + return f"{self.management_zone.code} -> {self.content_type.model} #{self.object_id} ({status})" + + class Meta: + unique_together = ('management_zone', 'content_type', 'object_id') + verbose_name = _("Asset de zone de gestion") + verbose_name_plural = _("Assets de zones de gestion") + + +class ManagementZoneDocument(Document): + """ + Documents, fiches techniques et arrêtés associés à une zone de gestion. + """ + zone = models.ForeignKey( + ManagementZone, + on_delete=models.CASCADE, + related_name="documents", + verbose_name=_("Zone de gestion") + ) + file = models.FileField( + upload_to="assets_documents/management_zones/", + storage=private_storage, + verbose_name=_("Fichier"), + max_length=255 + ) + thumbnail = models.ImageField( + upload_to="assets_documents/management_zones/", + storage=private_storage, + null=True, + blank=True, + max_length=255 + ) + + class Meta: + verbose_name = _("Document de zone de gestion") + verbose_name_plural = _("Documents de zones de gestion") diff --git a/loko/assets/static/assets/js/management_zones_map_overlay.js b/loko/assets/static/assets/js/management_zones_map_overlay.js new file mode 100644 index 0000000..097250f --- /dev/null +++ b/loko/assets/static/assets/js/management_zones_map_overlay.js @@ -0,0 +1,390 @@ +/** + * Management Zones Map Overlay & Layer Switcher Integration + * + * Permet d'afficher en surimpression les zones de gestion (gestion écologique, + * ondes vertes, secteurs prioritaires, etc.) sur la carte MapLibre + * et ajoute une section dédiée "Zones de gestion" dans le LayerSwitcherControl. + */ + +(function (window) { + 'use strict'; + + /** + * Calcule la bounding box [[minX, minY], [maxX, maxY]] d'une géométrie GeoJSON. + */ + function getGeometryBBox(geometry) { + if (!geometry || !geometry.coordinates) return null; + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; + + function traverse(coords) { + if (typeof coords[0] === 'number') { + const [x, y] = coords; + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } else if (Array.isArray(coords)) { + for (let i = 0; i < coords.length; i++) { + traverse(coords[i]); + } + } + } + + traverse(geometry.coordinates); + if (minX === Infinity || minY === Infinity) return null; + return [[minX, minY], [maxX, maxY]]; + } + + /** + * Initialise l'overlay des zones de gestion sur la carte MapLibre. + */ + async function initManagementZonesMapOverlay(options = {}) { + const map = options.map; + if (!map) { + console.warn('[ManagementZonesMapOverlay] Map instance missing.'); + return; + } + + if (!map.loaded()) { + map.once('load', () => initManagementZonesMapOverlay(options)); + return; + } + + const thematic = options.thematic || 'nature'; + const geojsonUrl = options.geojsonUrl || '/assets/api/management-zones/geojson/'; + const layerSwitcher = options.layerSwitcher || window.layerSwitcher; + const beforeLayerId = options.beforeLayerId || null; + + const _t = (key, fallback) => (typeof gettext === 'function' ? gettext(key) : (fallback || key)); + + try { + const url = new URL(geojsonUrl, window.location.origin); + url.searchParams.set('thematic', thematic); + + const response = await fetch(url.toString(), { + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }); + + if (!response.ok) { + console.warn('[ManagementZonesMapOverlay] Failed to fetch geojson:', response.status); + return; + } + + const geojson = await response.json(); + if (!geojson || geojson.type !== 'FeatureCollection') { + console.warn('[ManagementZonesMapOverlay] Invalid GeoJSON received.'); + return; + } + + const features = geojson.features || []; + + // 1. Ajouter la source et les couches à MapLibre + const sourceId = 'management-zones-source'; + const fillLayerId = 'management-zones-fill'; + const lineLayerId = 'management-zones-line'; + + if (map.getSource(sourceId)) { + map.getSource(sourceId).setData(geojson); + } else { + map.addSource(sourceId, { + type: 'geojson', + data: geojson + }); + } + + // Couche de remplissage translucide + if (!map.getLayer(fillLayerId)) { + const fillLayerDef = { + id: fillLayerId, + type: 'fill', + source: sourceId, + paint: { + 'fill-color': ['coalesce', ['get', 'color'], '#198754'], + 'fill-opacity': 0.20 + } + }; + if (beforeLayerId && map.getLayer(beforeLayerId)) { + map.addLayer(fillLayerDef, beforeLayerId); + } else { + map.addLayer(fillLayerDef); + } + } + + // Couche de contour avec tirets pour marquer une zone de gestion + if (!map.getLayer(lineLayerId)) { + const lineLayerDef = { + id: lineLayerId, + type: 'line', + source: sourceId, + paint: { + 'line-color': ['coalesce', ['get', 'color'], '#198754'], + 'line-width': 2.5, + 'line-opacity': 0.85, + 'line-dasharray': [3, 1.5] + } + }; + if (beforeLayerId && map.getLayer(beforeLayerId)) { + map.addLayer(lineLayerDef, beforeLayerId); + } else { + map.addLayer(lineLayerDef); + } + } + + // 2. Gestion des popups et du curseur sur clic + const zonePopup = new maplibregl.Popup({ + closeButton: true, + closeOnClick: true, + maxWidth: '340px' + }); + + map.on('click', fillLayerId, (e) => { + const assetLayerIds = (window.assets_layers_defs || []).map(l => l.id).filter(id => map.getLayer(id)); + if (assetLayerIds.length > 0) { + const rendered = map.queryRenderedFeatures(e.point, { layers: assetLayerIds }); + if (rendered && rendered.length > 0) { + return; + } + } + + if (!e.features || !e.features[0]) return; + const props = e.features[0].properties || {}; + + let typeHtml = ''; + if (props.zone_type_name) { + typeHtml = ` + ${props.zone_type_name} + `; + } + + let freqHtml = ''; + if (props.target_frequency_days) { + freqHtml = `
+ ${_t('Fréquence cible :', 'Fréquence cible :')} ${props.target_frequency_days} ${_t('jours', 'jours')} +
`; + } + + let seasonHtml = ''; + if (props.seasonality_notes) { + seasonHtml = `
+ ${props.seasonality_notes} +
`; + } + + let instructionsHtml = ''; + if (props.instructions) { + instructionsHtml = `
+
${_t('Consignes & points d\'attention', 'Consignes & points d\'attention')}
+ ${props.instructions} +
`; + } + + let descHtml = ''; + if (props.description) { + descHtml = `
+ ${props.description} +
`; + } + + const html = ` +
+
+
+ + ${props.code} +
+ ${typeHtml} +
+
${props.name}
+ ${descHtml} + ${freqHtml} + ${seasonHtml} + ${instructionsHtml} +
+ `; + + zonePopup.setLngLat(e.lngLat).setHTML(html).addTo(map); + }); + + map.on('mousemove', fillLayerId, (e) => { + const assetLayerIds = (window.assets_layers_defs || []).map(l => l.id).filter(id => map.getLayer(id)); + if (assetLayerIds.length > 0) { + const rendered = map.queryRenderedFeatures(e.point, { layers: assetLayerIds }); + if (rendered && rendered.length > 0) { + return; + } + } + map.getCanvas().style.cursor = 'pointer'; + }); + + map.on('mouseleave', fillLayerId, () => { + map.getCanvas().style.cursor = ''; + }); + + // 3. Intégration dans le LayerSwitcherControl + if (layerSwitcher && typeof layerSwitcher.addCustomSection === 'function') { + layerSwitcher.addCustomSection({ + id: 'management-zones', + title: `${_t('Zones de gestion', 'Zones de gestion')}`, + render: (container, switcher) => { + renderManagementZonesSection(container, switcher, features, map, fillLayerId, lineLayerId, zonePopup, _t); + } + }); + + if (features.length > 0) { + layerSwitcher.setOverlayActive(true); + } + } + + } catch (err) { + console.error('[ManagementZonesMapOverlay] Error initializing overlay:', err); + } + } + + /** + * Rendu HTML et interactions de la section "Zones de gestion" dans le LayerSwitcherControl. + */ + function renderManagementZonesSection(container, switcher, features, map, fillLayerId, lineLayerId, zonePopup, _t) { + if (!features || features.length === 0) { + container.innerHTML = ` +
+ ${_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')}`; + + // 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} +
+ +
+ `; + container.appendChild(headerEl); + + const masterToggle = headerEl.querySelector('.management-zones-master-toggle'); + + // Curseur d'opacité + const opacityRow = document.createElement('div'); + opacityRow.className = 'px-1 mb-2'; + opacityRow.innerHTML = ` +
+ ${_t('Opacité', 'Opacité')} + 20% +
+ + `; + container.appendChild(opacityRow); + + const opacitySlider = opacityRow.querySelector('.management-zones-opacity-slider'); + const opacityValLabel = opacityRow.querySelector('.management-zones-opacity-val'); + + opacitySlider.addEventListener('input', (e) => { + const val = parseInt(e.target.value, 10); + opacityValLabel.textContent = `${val}%`; + if (map.getLayer(fillLayerId)) { + map.setPaintProperty(fillLayerId, 'fill-opacity', val / 100); + } + }); + + // Liste des zones + const listEl = document.createElement('div'); + listEl.className = 'management-zones-list d-flex flex-column gap-1'; + listEl.style.maxHeight = '240px'; + listEl.style.overflowY = 'auto'; + + const activeZoneIds = new Set(features.map(f => f.id)); + + function updateLayersVisibility() { + if (!masterToggle.checked || activeZoneIds.size === 0) { + if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none'); + if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'none'); + } else { + if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'visible'); + if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'visible'); + + const filter = ['in', ['id'], ['literal', Array.from(activeZoneIds)]]; + if (map.getLayer(fillLayerId)) map.setFilter(fillLayerId, filter); + if (map.getLayer(lineLayerId)) map.setFilter(lineLayerId, filter); + } + } + + masterToggle.addEventListener('change', () => { + const checked = masterToggle.checked; + listEl.querySelectorAll('.zone-item-toggle').forEach(chk => { + chk.checked = checked; + }); + if (checked) { + features.forEach(f => activeZoneIds.add(f.id)); + } else { + activeZoneIds.clear(); + } + updateLayersVisibility(); + }); + + features.forEach(feat => { + const props = feat.properties || {}; + const color = props.color || '#198754'; + const itemEl = document.createElement('div'); + itemEl.className = 'd-flex align-items-center justify-content-between p-1 rounded hover-bg small'; + itemEl.style.cursor = 'pointer'; + + itemEl.innerHTML = ` +
+ +
+
${props.code}
+
${props.name} ${props.zone_type_name ? `(${props.zone_type_name})` : ''}
+
+
+
+ +
+ +
+
+ `; + + const itemToggle = itemEl.querySelector('.zone-item-toggle'); + itemToggle.addEventListener('change', (e) => { + e.stopPropagation(); + if (itemToggle.checked) { + activeZoneIds.add(feat.id); + } else { + activeZoneIds.delete(feat.id); + } + masterToggle.checked = (activeZoneIds.size > 0); + updateLayersVisibility(); + }); + + function zoomToZone() { + const bbox = getGeometryBBox(feat.geometry); + if (bbox) { + map.fitBounds(bbox, { padding: 40, maxZoom: 16 }); + } + } + + itemEl.querySelector('.zoom-btn').addEventListener('click', (e) => { + e.stopPropagation(); + zoomToZone(); + }); + + itemEl.querySelector('.zone-click-target').addEventListener('click', () => { + zoomToZone(); + }); + + listEl.appendChild(itemEl); + }); + + container.appendChild(listEl); + } + + window.initManagementZonesMapOverlay = initManagementZonesMapOverlay; + +})(window); diff --git a/loko/assets/templates/assets/base_assets_index.html b/loko/assets/templates/assets/base_assets_index.html index 9baa69e..d9fce78 100644 --- a/loko/assets/templates/assets/base_assets_index.html +++ b/loko/assets/templates/assets/base_assets_index.html @@ -366,6 +366,7 @@ + {% block extra_script %} {% endblock extra_script %} diff --git a/loko/assets/templates/assets/nature_index.html b/loko/assets/templates/assets/nature_index.html index a5d5f86..48a568a 100644 --- a/loko/assets/templates/assets/nature_index.html +++ b/loko/assets/templates/assets/nature_index.html @@ -223,7 +223,7 @@ {% block extra_script %} diff --git a/loko/assets/tests_management_zones.py b/loko/assets/tests_management_zones.py new file mode 100644 index 0000000..1878beb --- /dev/null +++ b/loko/assets/tests_management_zones.py @@ -0,0 +1,201 @@ +from django.test import TestCase, Client +from django.contrib.auth import get_user_model +from django.urls import reverse +from django.contrib.gis.geos import Polygon, MultiPolygon, Point + +from common.models import Thematic, Role, UserConfig, UserThematics +from assets.models import ( + ManagementZoneType, + ManagementZone, + ManagementZoneAsset, + NatureGreenSurface, + NatureLocation, +) + +User = get_user_model() + + +class ManagementZoneModelTests(TestCase): + def setUp(self): + self.thematic_nature = Thematic.objects.create( + code="nature", + name_fr="Nature", + name_nl="Natuur" + ) + self.zone_type = ManagementZoneType.objects.create( + code="fauche_tardive", + name_fr="Fauche tardive", + name_nl="Laat maaien", + thematic=self.thematic_nature, + default_color="#28a745", + description="Zone de gestion écologique avec fauche annuelle en automne." + ) + + def test_management_zone_type_bilingual_and_str(self): + self.assertEqual(self.zone_type.name_fr, "Fauche tardive") + self.assertEqual(self.zone_type.name_nl, "Laat maaien") + self.assertIn("Fauche tardive", str(self.zone_type)) + + def test_management_zone_creation_geometry_and_color_fallback(self): + poly = Polygon(((0, 0), (100, 0), (100, 100), (0, 100), (0, 0))) + multi_poly = MultiPolygon([poly], srid=3812) + + # 1. Sans couleur explicite -> prend la couleur par défaut du type + zone1 = ManagementZone.objects.create( + code="ZG-NAT-001", + name_fr="Marais de Jette - Prairie", + name_nl="Moeras van Jette - Weide", + thematic=self.thematic_nature, + zone_type=self.zone_type, + geom=multi_poly, + target_frequency_days=365, + seasonality_notes="Fauche en septembre; pas de taille au printemps", + instructions="Attention aux zones humides et aux ornières.", + ) + self.assertEqual(zone1.get_color(), "#28a745") + self.assertEqual(zone1.geom.srid, 3812) + self.assertEqual(zone1.geom.geom_type, "MultiPolygon") + + # GeoJSON property + geojson = zone1.geojson_geometry + self.assertIsNotNone(geojson) + self.assertEqual(geojson["type"], "MultiPolygon") + + # Spatial intersection + pt_inside = Point(50, 50, srid=3812) + pt_outside = Point(200, 200, srid=3812) + self.assertTrue(ManagementZone.objects.filter(pk=zone1.pk, geom__intersects=pt_inside).exists()) + self.assertFalse(ManagementZone.objects.filter(pk=zone1.pk, geom__intersects=pt_outside).exists()) + + # 2. Avec couleur personnalisée explicite + zone2 = ManagementZone.objects.create( + code="ZG-NAT-002", + name_fr="Zone spéciale", + thematic=self.thematic_nature, + color="#ff5722", + ) + self.assertEqual(zone2.get_color(), "#ff5722") + + +class ManagementZoneAssetTests(TestCase): + def setUp(self): + self.thematic_nature = Thematic.objects.create(code="nature", name_fr="Nature", name_nl="Natuur") + self.zone = ManagementZone.objects.create( + code="ZG-NAT-TEST", + name_fr="Zone test", + thematic=self.thematic_nature, + ) + self.location = NatureLocation.objects.create(code="LOC-01", name_fr="Parc de Jette") + self.green_surface = NatureGreenSurface.objects.create( + code="GS-01", + name_fr="Pelouse 1", + location=self.location, + ) + + def test_explicit_asset_association_and_exclusion(self): + # Association explicite d'inclusion + link = ManagementZoneAsset.objects.create( + management_zone=self.zone, + asset=self.green_surface, + is_excluded=False, + notes="Inclus dans le plan" + ) + self.assertIn("Inclus", str(link)) + self.assertEqual(self.zone.explicit_assets.count(), 1) + self.assertEqual(self.zone.explicit_assets.first().asset, self.green_surface) + + # Bascule en exclusion + link.is_excluded = True + link.save() + self.assertIn("Exclu", str(link)) + + +class ManagementZoneGeojsonEndpointTests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="nature_manager", password="password") + self.role_manager = Role.objects.create(name="manager") + self.config = UserConfig.objects.create(user=self.user) + self.config.roles.add(self.role_manager) + + 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") + + UserThematics.objects.create( + user_config=self.config, + thematic=self.thematic_nature, + can_view_assets=True, + can_edit_assets=True + ) + + self.zone_type = ManagementZoneType.objects.create( + code="fauche", + name_fr="Fauche tardive", + thematic=self.thematic_nature, + default_color="#198754" + ) + + poly = Polygon(((0, 0), (10, 0), (10, 10), (0, 10), (0, 0))) + self.multi_poly = MultiPolygon([poly], srid=3812) + + self.zone_nature = ManagementZone.objects.create( + code="ZG-NAT-1", + name_fr="Prairie fleurie", + thematic=self.thematic_nature, + zone_type=self.zone_type, + geom=self.multi_poly, + instructions="Ne pas tondre au printemps", + target_frequency_days=180, + seasonality_notes="Septembre", + ) + + self.zone_roads = ManagementZone.objects.create( + code="ZG-ROAD-1", + name_fr="Itinéraire déneigement P1", + thematic=self.thematic_roads, + geom=self.multi_poly, + color="#0d6efd", + ) + + def test_anonymous_access_redirects(self): + url = reverse("assets:management_zones_geojson") + response = self.client.get(url) + self.assertEqual(response.status_code, 302) + + def test_authenticated_user_access_nature_zones(self): + self.client.login(username="nature_manager", password="password") + url = reverse("assets:management_zones_geojson") + "?thematic=nature" + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + data = response.json() + self.assertEqual(data["type"], "FeatureCollection") + self.assertEqual(len(data["features"]), 1) + + feat = data["features"][0] + self.assertEqual(feat["id"], self.zone_nature.id) + self.assertEqual(feat["properties"]["code"], "ZG-NAT-1") + self.assertEqual(feat["properties"]["name"], "Prairie fleurie") + self.assertEqual(feat["properties"]["thematic"], "nature") + self.assertEqual(feat["properties"]["zone_type_name"], "Fauche tardive") + self.assertEqual(feat["properties"]["color"], "#198754") + self.assertEqual(feat["properties"]["instructions"], "Ne pas tondre au printemps") + self.assertEqual(feat["properties"]["target_frequency_days"], 180) + self.assertEqual(feat["geometry"]["type"], "MultiPolygon") + + def test_user_without_thematic_permission_denied(self): + self.client.login(username="nature_manager", password="password") + # nature_manager n'a pas accès à la thématique 'roads' + url = reverse("assets:management_zones_geojson") + "?thematic=roads" + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + def test_superuser_access_all_thematics(self): + admin = User.objects.create_superuser(username="superadmin", password="password", email="admin@example.com") + self.client.login(username="superadmin", password="password") + + # Sans paramètre thématique, superuser reçoit toutes les zones avec géométrie + url = reverse("assets:management_zones_geojson") + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data["features"]), 2) diff --git a/loko/assets/urls.py b/loko/assets/urls.py index e085354..c3db905 100644 --- a/loko/assets/urls.py +++ b/loko/assets/urls.py @@ -1,9 +1,10 @@ from django.urls import path -from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor, parking +from .views import core, locations, nature, publiclighting, trafficlights, artworks, structures, controlcenters, sign, roads, its, clean, asset_editing, history, location_editing, export, asset_permissions, water, buildings, outdoor, parking, management_zones app_name = "assets" urlpatterns = [ path("", core.unified_assets_index, name="index"), + 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"), path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"), diff --git a/loko/assets/views/management_zones.py b/loko/assets/views/management_zones.py new file mode 100644 index 0000000..514140a --- /dev/null +++ b/loko/assets/views/management_zones.py @@ -0,0 +1,87 @@ +from django.contrib.auth.decorators import login_required +from django.http import JsonResponse, HttpResponseForbidden +from django.views.decorators.http import require_GET + +from common.models import Thematic, UserConfig +from assets.models import ManagementZone +from assets.permissions import get_user_asset_thematic_access + + +@login_required +@require_GET +def management_zones_geojson(request): + """ + Retourne la collection GeoJSON des zones de gestion actives avec géométrie. + Filtre optionnel par ?thematic=. + Vérifie que l'utilisateur a accès en lecture aux assets de la thématique. + """ + thematic_code = request.GET.get('thematic') + user = request.user + + if thematic_code: + thematic = Thematic.objects.filter(code=thematic_code).first() + if not thematic: + return JsonResponse({'type': 'FeatureCollection', 'features': []}) + + can_view, _ = get_user_asset_thematic_access(user, thematic) + if not can_view: + return HttpResponseForbidden("Access denied to this thematic management zones.") + + zones_qs = ManagementZone.objects.filter( + thematic=thematic, + is_active=True, + geom__isnull=False + ).select_related('thematic', 'zone_type') + else: + if user.is_superuser: + zones_qs = ManagementZone.objects.filter(is_active=True, geom__isnull=False) + else: + try: + user_config = UserConfig.objects.get(user=user) + if user_config.roles.filter(name__in=['admin', 'operator']).exists(): + allowed_thematics = Thematic.objects.all() + else: + allowed_thematics = Thematic.objects.filter( + userthematics__user_config=user_config, + userthematics__can_view_assets=True + ) + zones_qs = ManagementZone.objects.filter( + thematic__in=allowed_thematics, + is_active=True, + geom__isnull=False + ) + except UserConfig.DoesNotExist: + return JsonResponse({'type': 'FeatureCollection', 'features': []}) + + zones_qs = zones_qs.select_related('thematic', 'zone_type') + + features = [] + for zone in zones_qs: + geom_json = zone.geojson_geometry + if not geom_json: + continue + + features.append({ + 'type': 'Feature', + 'id': zone.id, + 'properties': { + 'id': zone.id, + 'code': zone.code, + 'name': zone.get_name(), + 'thematic': zone.thematic.code, + 'thematic_name': zone.thematic.name, + 'zone_type_code': zone.zone_type.code if zone.zone_type else None, + 'zone_type_name': zone.zone_type.get_name() if zone.zone_type else "", + 'color': zone.get_color(), + 'instructions': zone.instructions or "", + 'description': zone.description or "", + 'seasonality_notes': zone.seasonality_notes or "", + 'target_frequency_days': zone.target_frequency_days, + }, + 'geometry': geom_json + }) + + return JsonResponse({ + 'type': 'FeatureCollection', + 'features': features + }) diff --git a/loko/locale/en/LC_MESSAGES/django.mo b/loko/locale/en/LC_MESSAGES/django.mo index fa8224c..c577f72 100644 Binary files a/loko/locale/en/LC_MESSAGES/django.mo and b/loko/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/locale/en/LC_MESSAGES/django.po b/loko/locale/en/LC_MESSAGES/django.po index 990e086..e37edfe 100644 --- a/loko/locale/en/LC_MESSAGES/django.po +++ b/loko/locale/en/LC_MESSAGES/django.po @@ -1303,3 +1303,57 @@ msgstr "lots" msgid "Fonds de plan & Orthophotos" msgstr "Basemaps & Orthophotos" + +msgid "Type de zone de gestion" +msgstr "Management zone type" + +msgid "Types de zones de gestion" +msgstr "Management zone types" + +msgid "Zone de gestion" +msgstr "Management zone" + +msgid "Zones de gestion" +msgstr "Management zones" + +msgid "Consignes et points d'attention" +msgstr "Instructions and points of attention" + +msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..." +msgstr "Operational instructions, points of vigilance, safety rules..." + +msgid "Périmètre géographique" +msgstr "Geographical perimeter" + +msgid "Couleur d'affichage" +msgstr "Display color" + +msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone." +msgstr "Hexadecimal code (e.g. #198754). If empty, the zone type color is used." + +msgid "Fréquence cible (jours)" +msgstr "Target frequency (days)" + +msgid "Intervalle indicatif entre interventions en jours." +msgstr "Indicative interval between interventions in days." + +msgid "Saisonnalité / Calendrier" +msgstr "Seasonality / Calendar" + +msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps." +msgstr "E.g.: Mowing from April to October; late mowing in September; pruning prohibited in spring." + +msgid "Asset de zone de gestion" +msgstr "Management zone asset" + +msgid "Assets de zones de gestion" +msgstr "Management zone assets" + +msgid "Document de zone de gestion" +msgstr "Management zone document" + +msgid "Documents de zones de gestion" +msgstr "Management zone documents" + +msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre." +msgstr "If checked, this asset is excluded from the zone even if its geometry intersects the perimeter." diff --git a/loko/locale/en/LC_MESSAGES/djangojs.mo b/loko/locale/en/LC_MESSAGES/djangojs.mo index b492a91..fc08120 100644 Binary files a/loko/locale/en/LC_MESSAGES/djangojs.mo and b/loko/locale/en/LC_MESSAGES/djangojs.mo differ diff --git a/loko/locale/en/LC_MESSAGES/djangojs.po b/loko/locale/en/LC_MESSAGES/djangojs.po index dd5579e..807e9e9 100644 --- a/loko/locale/en/LC_MESSAGES/djangojs.po +++ b/loko/locale/en/LC_MESSAGES/djangojs.po @@ -194,3 +194,30 @@ msgstr "lots" msgid "Fonds de plan & Orthophotos" msgstr "Basemaps & Orthophotos" + +msgid "Zones de gestion" +msgstr "Management zones" + +msgid "Aucune zone de gestion définie pour cette thématique." +msgstr "No management zone defined for this thematic." + +msgid "zones" +msgstr "zones" + +msgid "Afficher / masquer toutes les zones" +msgstr "Show / hide all zones" + +msgid "Opacité" +msgstr "Opacity" + +msgid "Fréquence cible :" +msgstr "Target frequency:" + +msgid "jours" +msgstr "days" + +msgid "Consignes & points d'attention" +msgstr "Instructions & points of attention" + +msgid "Centrer sur la zone" +msgstr "Center on zone" diff --git a/loko/locale/fr/LC_MESSAGES/django.mo b/loko/locale/fr/LC_MESSAGES/django.mo index af7817c..ae1ac4e 100644 Binary files a/loko/locale/fr/LC_MESSAGES/django.mo and b/loko/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/locale/fr/LC_MESSAGES/django.po b/loko/locale/fr/LC_MESSAGES/django.po index 8288e74..809bd0a 100644 --- a/loko/locale/fr/LC_MESSAGES/django.po +++ b/loko/locale/fr/LC_MESSAGES/django.po @@ -1335,3 +1335,57 @@ msgstr "lots" msgid "Fonds de plan & Orthophotos" msgstr "Fonds de plan & Orthophotos" + +msgid "Type de zone de gestion" +msgstr "Type de zone de gestion" + +msgid "Types de zones de gestion" +msgstr "Types de zones de gestion" + +msgid "Zone de gestion" +msgstr "Zone de gestion" + +msgid "Zones de gestion" +msgstr "Zones de gestion" + +msgid "Consignes et points d'attention" +msgstr "Consignes et points d'attention" + +msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..." +msgstr "Consignes d'exploitation, points de vigilance, règles de sécurité..." + +msgid "Périmètre géographique" +msgstr "Périmètre géographique" + +msgid "Couleur d'affichage" +msgstr "Couleur d'affichage" + +msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone." +msgstr "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone." + +msgid "Fréquence cible (jours)" +msgstr "Fréquence cible (jours)" + +msgid "Intervalle indicatif entre interventions en jours." +msgstr "Intervalle indicatif entre interventions en jours." + +msgid "Saisonnalité / Calendrier" +msgstr "Saisonnalité / Calendrier" + +msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps." +msgstr "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps." + +msgid "Asset de zone de gestion" +msgstr "Asset de zone de gestion" + +msgid "Assets de zones de gestion" +msgstr "Assets de zones de gestion" + +msgid "Document de zone de gestion" +msgstr "Document de zone de gestion" + +msgid "Documents de zones de gestion" +msgstr "Documents de zones de gestion" + +msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre." +msgstr "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre." diff --git a/loko/locale/fr/LC_MESSAGES/djangojs.mo b/loko/locale/fr/LC_MESSAGES/djangojs.mo index 8ed7bcc..ad27026 100644 Binary files a/loko/locale/fr/LC_MESSAGES/djangojs.mo and b/loko/locale/fr/LC_MESSAGES/djangojs.mo differ diff --git a/loko/locale/fr/LC_MESSAGES/djangojs.po b/loko/locale/fr/LC_MESSAGES/djangojs.po index 48f6111..d4d1ebb 100644 --- a/loko/locale/fr/LC_MESSAGES/djangojs.po +++ b/loko/locale/fr/LC_MESSAGES/djangojs.po @@ -194,3 +194,30 @@ msgstr "lots" msgid "Fonds de plan & Orthophotos" msgstr "Fonds de plan & Orthophotos" + +msgid "Zones de gestion" +msgstr "Zones de gestion" + +msgid "Aucune zone de gestion définie pour cette thématique." +msgstr "Aucune zone de gestion définie pour cette thématique." + +msgid "zones" +msgstr "zones" + +msgid "Afficher / masquer toutes les zones" +msgstr "Afficher / masquer toutes les zones" + +msgid "Opacité" +msgstr "Opacité" + +msgid "Fréquence cible :" +msgstr "Fréquence cible :" + +msgid "jours" +msgstr "jours" + +msgid "Consignes & points d'attention" +msgstr "Consignes & points d'attention" + +msgid "Centrer sur la zone" +msgstr "Centrer sur la zone" diff --git a/loko/locale/nl/LC_MESSAGES/django.mo b/loko/locale/nl/LC_MESSAGES/django.mo index 2def955..84ba120 100644 Binary files a/loko/locale/nl/LC_MESSAGES/django.mo and b/loko/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/locale/nl/LC_MESSAGES/django.po b/loko/locale/nl/LC_MESSAGES/django.po index 2b4dc6a..2b31c53 100644 --- a/loko/locale/nl/LC_MESSAGES/django.po +++ b/loko/locale/nl/LC_MESSAGES/django.po @@ -1305,3 +1305,57 @@ msgstr "percelen" msgid "Fonds de plan & Orthophotos" msgstr "Achtergrondkaarten & Orthofoto's" + +msgid "Type de zone de gestion" +msgstr "Beheerszonetype" + +msgid "Types de zones de gestion" +msgstr "Beheerszonetypes" + +msgid "Zone de gestion" +msgstr "Beheerszone" + +msgid "Zones de gestion" +msgstr "Beheerszones" + +msgid "Consignes et points d'attention" +msgstr "Instructies en aandachtspunten" + +msgid "Consignes d'exploitation, points de vigilance, règles de sécurité..." +msgstr "Exploitatie-instructies, aandachtspunten, veiligheidsregels..." + +msgid "Périmètre géographique" +msgstr "Geografische perimeter" + +msgid "Couleur d'affichage" +msgstr "Weergavekleur" + +msgid "Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone." +msgstr "Hexadecimale code (bijv. #198754). Indien leeg, wordt de kleur van het zonetype gebruikt." + +msgid "Fréquence cible (jours)" +msgstr "Doelfrequentie (dagen)" + +msgid "Intervalle indicatif entre interventions en jours." +msgstr "Indicatief interval tussen interventies in dagen." + +msgid "Saisonnalité / Calendrier" +msgstr "Seizoensgebondenheid / Kalender" + +msgid "Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps." +msgstr "Bijv.: Maaien van april tot oktober; laat maaien in september; snoeiverbod in het voorjaar." + +msgid "Asset de zone de gestion" +msgstr "Beheerszone-asset" + +msgid "Assets de zones de gestion" +msgstr "Beheerszone-assets" + +msgid "Document de zone de gestion" +msgstr "Beheerszonedocument" + +msgid "Documents de zones de gestion" +msgstr "Beheerszonedocumenten" + +msgid "Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre." +msgstr "Indien aangevinkt, wordt dit asset uitgesloten van de zone, zelfs als de geometrie de perimeter doorsnijdt." diff --git a/loko/locale/nl/LC_MESSAGES/djangojs.mo b/loko/locale/nl/LC_MESSAGES/djangojs.mo index 7b55267..84484e2 100644 Binary files a/loko/locale/nl/LC_MESSAGES/djangojs.mo and b/loko/locale/nl/LC_MESSAGES/djangojs.mo differ diff --git a/loko/locale/nl/LC_MESSAGES/djangojs.po b/loko/locale/nl/LC_MESSAGES/djangojs.po index d172e66..7579e0f 100644 --- a/loko/locale/nl/LC_MESSAGES/djangojs.po +++ b/loko/locale/nl/LC_MESSAGES/djangojs.po @@ -194,3 +194,30 @@ msgstr "percelen" msgid "Fonds de plan & Orthophotos" msgstr "Achtergrondkaarten & Orthofoto's" + +msgid "Zones de gestion" +msgstr "Beheerszones" + +msgid "Aucune zone de gestion définie pour cette thématique." +msgstr "Geen beheerszone gedefinieerd voor dit thema." + +msgid "zones" +msgstr "zones" + +msgid "Afficher / masquer toutes les zones" +msgstr "Alle zones tonen / verbergen" + +msgid "Opacité" +msgstr "Dekking" + +msgid "Fréquence cible :" +msgstr "Doelfrequentie:" + +msgid "jours" +msgstr "dagen" + +msgid "Consignes & points d'attention" +msgstr "Instructies & aandachtspunten" + +msgid "Centrer sur la zone" +msgstr "Centreren op zone"