feat(assets): add generic ManagementZone model, admin, geojson API and map overlay

This commit is contained in:
kdeterme 2026-10-05 15:12:16 +02:00
parent 8807531eb3
commit d36649f3bf
34 changed files with 1553 additions and 7 deletions

View file

@ -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"

View file

@ -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."

View file

@ -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"

View file

@ -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."

View file

@ -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"

View file

@ -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."

View file

@ -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"

View file

@ -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')},
},
),
]

View file

@ -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

View 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")

View 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);

View file

@ -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 %}

View file

@ -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>

View 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)

View file

@ -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"),

View 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.

View file

@ -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."

View file

@ -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.

View file

@ -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."

View file

@ -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.

View file

@ -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."

View file

@ -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"