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 = `