feat(assets): add generic ManagementZone model, admin, geojson API and map overlay
This commit is contained in:
parent
8807531eb3
commit
d36649f3bf
34 changed files with 1553 additions and 7 deletions
|
|
@ -745,4 +745,59 @@ class ParkingSpotAdmin(AssetGISAdmin):
|
||||||
)
|
)
|
||||||
search_fields = ("code", "name_fr", "name_nl", "note")
|
search_fields = ("code", "name_fr", "name_nl", "note")
|
||||||
filter_horizontal = ("poles", "panels")
|
filter_horizontal = ("poles", "panels")
|
||||||
raw_id_fields = ("location", "street")
|
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(
|
||||||
|
'<span style="display:inline-block;width:16px;height:16px;background-color:{};border:1px solid #999;border-radius:3px;vertical-align:middle;margin-right:6px;"></span>{}',
|
||||||
|
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(
|
||||||
|
'<span style="display:inline-block;width:16px;height:16px;background-color:{};border:1px solid #999;border-radius:3px;vertical-align:middle;margin-right:6px;"></span>{}',
|
||||||
|
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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -8710,3 +8710,57 @@ msgstr "Assets directly linked to building"
|
||||||
|
|
||||||
#~ msgid "Arceaux cargo constatés"
|
#~ msgid "Arceaux cargo constatés"
|
||||||
#~ msgstr "Observed cargo bike racks"
|
#~ 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."
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -717,3 +717,30 @@ msgstr "Validation error."
|
||||||
msgid "Erreur réseau."
|
msgid "Erreur réseau."
|
||||||
msgstr "Network error."
|
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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -8763,3 +8763,57 @@ msgstr "Assets rattachés directement au bâtiment"
|
||||||
|
|
||||||
#~ msgid "Arceaux cargo constatés"
|
#~ msgid "Arceaux cargo constatés"
|
||||||
#~ msgstr "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."
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -722,3 +722,30 @@ msgstr "Erreur lors de la validation."
|
||||||
msgid "Erreur réseau."
|
msgid "Erreur réseau."
|
||||||
msgstr "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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -8711,3 +8711,57 @@ msgstr "Assets direct gekoppeld aan gebouw"
|
||||||
|
|
||||||
#~ msgid "Arceaux cargo constatés"
|
#~ msgid "Arceaux cargo constatés"
|
||||||
#~ msgstr "Vastgestelde cargobeugels"
|
#~ 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."
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -719,3 +719,30 @@ msgstr "Fout bij validatie."
|
||||||
msgid "Erreur réseau."
|
msgid "Erreur réseau."
|
||||||
msgstr "Netwerkfout."
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -13,4 +13,5 @@ from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitt
|
||||||
from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument
|
from .buildings import PublicBuilding, BuildingAsset, BuildingEquipment, PublicBuildingDocument
|
||||||
from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
|
from .outdoor import OutdoorLocation, OutdoorAsset, OutdoorLocationDocument
|
||||||
from .parking import ParkingLocation, ParkingSpot
|
from .parking import ParkingLocation, ParkingSpot
|
||||||
from inspections.models import Inspection as AssetInspection, ParkingSpotInspection
|
from inspections.models import Inspection as AssetInspection, ParkingSpotInspection
|
||||||
|
from .management_zones import ManagementZone, ManagementZoneType, ManagementZoneAsset, ManagementZoneDocument
|
||||||
|
|
|
||||||
214
loko/assets/models/management_zones.py
Normal file
214
loko/assets/models/management_zones.py
Normal file
|
|
@ -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")
|
||||||
390
loko/assets/static/assets/js/management_zones_map_overlay.js
Normal file
390
loko/assets/static/assets/js/management_zones_map_overlay.js
Normal file
|
|
@ -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 = `<span class="badge me-1" style="background-color: ${props.color || '#198754'}; color: #fff; font-size: 10px;">
|
||||||
|
${props.zone_type_name}
|
||||||
|
</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let freqHtml = '';
|
||||||
|
if (props.target_frequency_days) {
|
||||||
|
freqHtml = `<div style="color: #64748b; font-size: 11px; margin-top: 3px;">
|
||||||
|
<i class="bi bi-arrow-repeat me-1"></i>${_t('Fréquence cible :', 'Fréquence cible :')} <strong>${props.target_frequency_days} ${_t('jours', 'jours')}</strong>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let seasonHtml = '';
|
||||||
|
if (props.seasonality_notes) {
|
||||||
|
seasonHtml = `<div style="color: #64748b; font-size: 11px; margin-top: 3px;">
|
||||||
|
<i class="bi bi-calendar3 me-1"></i>${props.seasonality_notes}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let instructionsHtml = '';
|
||||||
|
if (props.instructions) {
|
||||||
|
instructionsHtml = `<div style="background-color: #f8fafc; border-left: 3px solid ${props.color || '#198754'}; padding: 6px; margin-top: 6px; font-size: 11px; color: #334155; border-radius: 2px; max-height: 100px; overflow-y: auto;">
|
||||||
|
<div style="font-weight: 600; margin-bottom: 2px;"><i class="bi bi-exclamation-triangle me-1"></i>${_t('Consignes & points d\'attention', 'Consignes & points d\'attention')}</div>
|
||||||
|
${props.instructions}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let descHtml = '';
|
||||||
|
if (props.description) {
|
||||||
|
descHtml = `<div style="color: #475569; font-size: 11px; margin-top: 4px;">
|
||||||
|
${props.description}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: inherit; font-size: 12px; padding: 2px;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 4px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<span style="display: inline-block; width: 12px; height: 12px; border-radius: 3px; background-color: ${props.color || '#198754'}; border: 1px solid rgba(0,0,0,0.2); flex-shrink: 0;"></span>
|
||||||
|
<strong style="font-size: 13px; color: #1e293b;">${props.code}</strong>
|
||||||
|
</div>
|
||||||
|
${typeHtml}
|
||||||
|
</div>
|
||||||
|
<div style="font-weight: 600; color: #1e293b; margin-bottom: 4px;">${props.name}</div>
|
||||||
|
${descHtml}
|
||||||
|
${freqHtml}
|
||||||
|
${seasonHtml}
|
||||||
|
${instructionsHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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: `<span class="d-flex align-items-center gap-1"><i class="bi bi-bounding-box-circles me-1"></i>${_t('Zones de gestion', 'Zones de gestion')}</span>`,
|
||||||
|
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 = `
|
||||||
|
<div class="text-muted small px-2 py-1 fst-italic">
|
||||||
|
${_t('Aucune zone de gestion définie pour cette thématique.', 'Aucune zone de gestion définie pour cette thématique.')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<span class="text-muted small fw-semibold">${countText}</span>
|
||||||
|
<div class="form-check form-switch mb-0">
|
||||||
|
<input class="form-check-input management-zones-master-toggle" type="checkbox" role="switch" checked title="${_t('Afficher / masquer toutes les zones', 'Afficher / masquer toutes les zones')}">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="d-flex justify-content-between align-items-center small text-muted mb-1">
|
||||||
|
<span>${_t('Opacité', 'Opacité')}</span>
|
||||||
|
<span class="management-zones-opacity-val">20%</span>
|
||||||
|
</div>
|
||||||
|
<input type="range" class="form-range management-zones-opacity-slider" min="5" max="80" value="20" style="height: 4px;">
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="d-flex align-items-center gap-2 text-truncate me-2 zone-click-target" style="flex: 1;">
|
||||||
|
<span style="display:inline-block; width: 12px; height: 12px; border-radius: 50%; background-color: ${color}; border: 1px solid rgba(0,0,0,0.25); flex-shrink: 0;"></span>
|
||||||
|
<div class="text-truncate">
|
||||||
|
<div class="fw-semibold text-truncate text-dark" style="font-size: 11px;">${props.code}</div>
|
||||||
|
<div class="text-muted text-truncate" style="font-size: 10px;">${props.name} ${props.zone_type_name ? `(${props.zone_type_name})` : ''}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-1 flex-shrink-0">
|
||||||
|
<button type="button" class="btn btn-link btn-sm p-0 text-muted zoom-btn" title="${_t('Centrer sur la zone', 'Centrer sur la zone')}">
|
||||||
|
<i class="bi bi-geo-alt" style="font-size: 12px;"></i>
|
||||||
|
</button>
|
||||||
|
<div class="form-check form-switch mb-0 ms-1">
|
||||||
|
<input class="form-check-input zone-item-toggle" type="checkbox" role="switch" checked data-zone-id="${feat.id}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
@ -366,6 +366,7 @@
|
||||||
<script src="{% static 'common/base_map.js' %}"></script>
|
<script src="{% static 'common/base_map.js' %}"></script>
|
||||||
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
||||||
<script src="{% static 'contracts/js/contracts_map_overlay.js' %}"></script>
|
<script src="{% static 'contracts/js/contracts_map_overlay.js' %}"></script>
|
||||||
|
<script src="{% static 'assets/js/management_zones_map_overlay.js' %}"></script>
|
||||||
<script src="{% static 'assets/assets_index.js' %}"></script>
|
<script src="{% static 'assets/assets_index.js' %}"></script>
|
||||||
{% block extra_script %}
|
{% block extra_script %}
|
||||||
{% endblock extra_script %}
|
{% endblock extra_script %}
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@
|
||||||
{% block extra_script %}
|
{% block extra_script %}
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener("DOMContentLoaded", function () {
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
function setupNatureContractsOverlay() {
|
function setupNatureOverlays() {
|
||||||
if (typeof initContractsMapOverlay === 'function' && window.map) {
|
if (typeof initContractsMapOverlay === 'function' && window.map) {
|
||||||
initContractsMapOverlay({
|
initContractsMapOverlay({
|
||||||
map: window.map,
|
map: window.map,
|
||||||
|
|
@ -232,16 +232,24 @@
|
||||||
geojsonUrl: "{% url 'contracts:contracts_geojson' %}",
|
geojsonUrl: "{% url 'contracts:contracts_geojson' %}",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (typeof initManagementZonesMapOverlay === 'function' && window.map) {
|
||||||
|
initManagementZonesMapOverlay({
|
||||||
|
map: window.map,
|
||||||
|
layerSwitcher: window.layerSwitcher,
|
||||||
|
thematic: 'nature',
|
||||||
|
geojsonUrl: "{% url 'assets:management_zones_geojson' %}",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.map && window.map.loaded && window.map.loaded()) {
|
if (window.map && window.map.loaded && window.map.loaded()) {
|
||||||
setupNatureContractsOverlay();
|
setupNatureOverlays();
|
||||||
} else if (window.maploader) {
|
} else if (window.maploader) {
|
||||||
window.maploader.addEventListener('mapLoaded', () => {
|
window.maploader.addEventListener('mapLoaded', () => {
|
||||||
setTimeout(setupNatureContractsOverlay, 100);
|
setTimeout(setupNatureOverlays, 100);
|
||||||
});
|
});
|
||||||
} else if (window.map) {
|
} else if (window.map) {
|
||||||
window.map.on('load', setupNatureContractsOverlay);
|
window.map.on('load', setupNatureOverlays);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
201
loko/assets/tests_management_zones.py
Normal file
201
loko/assets/tests_management_zones.py
Normal file
|
|
@ -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)
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from django.urls import path
|
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"
|
app_name = "assets"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("", core.unified_assets_index, name="index"),
|
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/", parking.parking_index, name="parking_index"),
|
||||||
path("parking/assets/", parking.parking_assets, name="parking_assets"),
|
path("parking/assets/", parking.parking_assets, name="parking_assets"),
|
||||||
path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"),
|
path("parking/assets/geojson/", parking.parking_assets_geojson, name="parking_assets_geojson"),
|
||||||
|
|
|
||||||
87
loko/assets/views/management_zones.py
Normal file
87
loko/assets/views/management_zones.py
Normal file
|
|
@ -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=<thematic_code>.
|
||||||
|
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
|
||||||
|
})
|
||||||
Binary file not shown.
|
|
@ -1303,3 +1303,57 @@ msgstr "lots"
|
||||||
msgid "Fonds de plan & Orthophotos"
|
msgid "Fonds de plan & Orthophotos"
|
||||||
msgstr "Basemaps & 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."
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -194,3 +194,30 @@ msgstr "lots"
|
||||||
|
|
||||||
msgid "Fonds de plan & Orthophotos"
|
msgid "Fonds de plan & Orthophotos"
|
||||||
msgstr "Basemaps & 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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1335,3 +1335,57 @@ msgstr "lots"
|
||||||
msgid "Fonds de plan & Orthophotos"
|
msgid "Fonds de plan & Orthophotos"
|
||||||
msgstr "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."
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -194,3 +194,30 @@ msgstr "lots"
|
||||||
|
|
||||||
msgid "Fonds de plan & Orthophotos"
|
msgid "Fonds de plan & Orthophotos"
|
||||||
msgstr "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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1305,3 +1305,57 @@ msgstr "percelen"
|
||||||
msgid "Fonds de plan & Orthophotos"
|
msgid "Fonds de plan & Orthophotos"
|
||||||
msgstr "Achtergrondkaarten & Orthofoto's"
|
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."
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -194,3 +194,30 @@ msgstr "percelen"
|
||||||
|
|
||||||
msgid "Fonds de plan & Orthophotos"
|
msgid "Fonds de plan & Orthophotos"
|
||||||
msgstr "Achtergrondkaarten & Orthofoto's"
|
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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue