- Add can_edit_management_zones permission to UserThematics model and admin - Implement ManagementZoneForm with interactive WGS84 polygon capture and SRID 3812 reprojection - Add views and routes for listing, creating, editing, detailing, and deleting management zones - Support document attachments (ManagementZoneDocument) and explicit asset linkages - Add MapLibre polygon drawing tool in zone form and preview map in detail view - Integrate management zones shortcuts in nature assets map overlay and index - Add comprehensive unit tests for permissions, forms, and CRUD operations
488 lines
20 KiB
Python
488 lines
20 KiB
Python
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)
|
|
|
|
|
|
import json
|
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
|
from assets.permissions import (
|
|
can_view_management_zones,
|
|
can_edit_management_zones,
|
|
can_delete_management_zone,
|
|
get_editable_thematics_for_management_zones,
|
|
)
|
|
from assets.forms import ManagementZoneForm
|
|
from assets.models import ManagementZoneDocument
|
|
|
|
|
|
class ManagementZonesPermissionAndCRUDTests(TestCase):
|
|
def setUp(self):
|
|
# Users
|
|
self.user_manager = User.objects.create_user(username="mgr", password="password")
|
|
self.user_viewer = User.objects.create_user(username="viewer", password="password")
|
|
self.user_editor = User.objects.create_user(username="editor", password="password")
|
|
self.user_no_access = User.objects.create_user(username="no_access", password="password")
|
|
|
|
# Thematics
|
|
self.thematic_nature = Thematic.objects.create(code="nature", name_fr="Nature", name_nl="Natuur")
|
|
self.thematic_roads = Thematic.objects.create(code="roads", name_fr="Voiries", name_nl="Wegen")
|
|
|
|
# Roles & Configs
|
|
self.role_manager = Role.objects.create(name="manager")
|
|
self.role_operator = Role.objects.create(name="operator")
|
|
|
|
self.config_mgr = UserConfig.objects.create(user=self.user_manager)
|
|
self.config_mgr.roles.add(self.role_manager)
|
|
|
|
self.config_viewer = UserConfig.objects.create(user=self.user_viewer)
|
|
UserThematics.objects.create(
|
|
user_config=self.config_viewer,
|
|
thematic=self.thematic_nature,
|
|
can_view_assets=True,
|
|
can_edit_assets=False,
|
|
can_edit_management_zones=False
|
|
)
|
|
|
|
self.config_editor = UserConfig.objects.create(user=self.user_editor)
|
|
UserThematics.objects.create(
|
|
user_config=self.config_editor,
|
|
thematic=self.thematic_nature,
|
|
can_view_assets=True,
|
|
can_edit_assets=False,
|
|
can_edit_management_zones=True # Specific permission
|
|
)
|
|
|
|
self.config_no_access = UserConfig.objects.create(user=self.user_no_access)
|
|
|
|
# Type de zone
|
|
self.zone_type = ManagementZoneType.objects.create(
|
|
code="fauche_eco",
|
|
name_fr="Fauche écologique",
|
|
thematic=self.thematic_nature,
|
|
default_color="#20c997"
|
|
)
|
|
|
|
poly = Polygon(((0, 0), (50, 0), (50, 50), (0, 50), (0, 0)))
|
|
self.zone = ManagementZone.objects.create(
|
|
code="ZG-TEST-01",
|
|
name_fr="Zone Test Marais",
|
|
name_nl="Testzone Moeras",
|
|
thematic=self.thematic_nature,
|
|
zone_type=self.zone_type,
|
|
geom=MultiPolygon([poly], srid=3812),
|
|
target_frequency_days=90,
|
|
seasonality_notes="Avril-Septembre",
|
|
instructions="Attention à la faune aquatique",
|
|
is_active=True
|
|
)
|
|
|
|
def test_permission_functions(self):
|
|
# Manager can view and edit any thematic
|
|
self.assertTrue(can_view_management_zones(self.user_manager))
|
|
self.assertTrue(can_edit_management_zones(self.user_manager))
|
|
self.assertTrue(can_edit_management_zones(self.user_manager, thematic=self.thematic_nature))
|
|
|
|
# Viewer can view nature, but cannot edit
|
|
self.assertTrue(can_view_management_zones(self.user_viewer, thematic=self.thematic_nature))
|
|
self.assertFalse(can_view_management_zones(self.user_viewer, thematic=self.thematic_roads))
|
|
self.assertFalse(can_edit_management_zones(self.user_viewer))
|
|
self.assertFalse(can_edit_management_zones(self.user_viewer, thematic=self.thematic_nature))
|
|
|
|
# Editor with can_edit_management_zones=True
|
|
self.assertTrue(can_view_management_zones(self.user_editor, thematic=self.thematic_nature))
|
|
self.assertTrue(can_edit_management_zones(self.user_editor))
|
|
self.assertTrue(can_edit_management_zones(self.user_editor, thematic=self.thematic_nature))
|
|
self.assertFalse(can_edit_management_zones(self.user_editor, thematic=self.thematic_roads))
|
|
|
|
# Editable thematics
|
|
editable = get_editable_thematics_for_management_zones(self.user_editor)
|
|
self.assertIn(self.thematic_nature, editable)
|
|
self.assertNotIn(self.thematic_roads, editable)
|
|
|
|
def test_management_zone_form_with_geom_geojson(self):
|
|
# Test initial geojson
|
|
form = ManagementZoneForm(instance=self.zone, user=self.user_editor)
|
|
self.assertIn('geom_geojson', form.initial)
|
|
geom_dict = json.loads(form.initial['geom_geojson'])
|
|
self.assertEqual(geom_dict['type'], 'MultiPolygon')
|
|
|
|
# Test saving with new GeoJSON polygon in WGS84
|
|
geojson_polygon = {
|
|
"type": "Polygon",
|
|
"coordinates": [[[4.35, 50.84], [4.36, 50.84], [4.36, 50.85], [4.35, 50.85], [4.35, 50.84]]]
|
|
}
|
|
data = {
|
|
'code': 'ZG-NEW-01',
|
|
'name_fr': 'Nouvelle zone',
|
|
'thematic': self.thematic_nature.pk,
|
|
'zone_type': self.zone_type.pk,
|
|
'color': '#ff9900',
|
|
'target_frequency_days': 30,
|
|
'geom_geojson': json.dumps(geojson_polygon),
|
|
'is_active': True,
|
|
}
|
|
form = ManagementZoneForm(data=data, user=self.user_editor)
|
|
self.assertTrue(form.is_valid(), form.errors)
|
|
new_zone = form.save()
|
|
self.assertEqual(new_zone.code, 'ZG-NEW-01')
|
|
self.assertIsNotNone(new_zone.geom)
|
|
self.assertEqual(new_zone.geom.srid, 3812)
|
|
self.assertEqual(new_zone.geom.geom_type, 'MultiPolygon')
|
|
|
|
def test_management_zones_index_view(self):
|
|
url = reverse("assets:management_zones_index")
|
|
|
|
# Anonymous -> 302
|
|
res = self.client.get(url)
|
|
self.assertEqual(res.status_code, 302)
|
|
|
|
# No access user -> 403
|
|
self.client.login(username="no_access", password="password")
|
|
res = self.client.get(url)
|
|
self.assertEqual(res.status_code, 403)
|
|
|
|
# Viewer -> 200
|
|
self.client.login(username="viewer", password="password")
|
|
res = self.client.get(url)
|
|
self.assertEqual(res.status_code, 200)
|
|
self.assertContains(res, "ZG-TEST-01")
|
|
self.assertFalse(res.context['can_create'])
|
|
|
|
# Editor -> 200 and can_create is True
|
|
self.client.login(username="editor", password="password")
|
|
res = self.client.get(url)
|
|
self.assertEqual(res.status_code, 200)
|
|
self.assertTrue(res.context['can_create'])
|
|
|
|
def test_management_zone_create_view(self):
|
|
url = reverse("assets:management_zone_create")
|
|
|
|
# Viewer cannot create -> 403
|
|
self.client.login(username="viewer", password="password")
|
|
res = self.client.get(url)
|
|
self.assertEqual(res.status_code, 403)
|
|
|
|
# Editor can access form
|
|
self.client.login(username="editor", password="password")
|
|
res = self.client.get(url)
|
|
self.assertEqual(res.status_code, 200)
|
|
|
|
# Post valid zone
|
|
geojson_polygon = {
|
|
"type": "Polygon",
|
|
"coordinates": [[[4.35, 50.84], [4.36, 50.84], [4.36, 50.85], [4.35, 50.85], [4.35, 50.84]]]
|
|
}
|
|
post_data = {
|
|
'code': 'ZG-CREATED-01',
|
|
'name_fr': 'Zone créée',
|
|
'thematic': self.thematic_nature.pk,
|
|
'zone_type': self.zone_type.pk,
|
|
'color': '#28a745',
|
|
'target_frequency_days': 15,
|
|
'geom_geojson': json.dumps(geojson_polygon),
|
|
'instructions': 'Passage le matin uniquement',
|
|
'is_active': True,
|
|
}
|
|
res = self.client.post(url, post_data)
|
|
self.assertEqual(res.status_code, 302)
|
|
|
|
created_zone = ManagementZone.objects.get(code='ZG-CREATED-01')
|
|
self.assertEqual(created_zone.created_by, self.user_editor)
|
|
self.assertEqual(created_zone.instructions, 'Passage le matin uniquement')
|
|
self.assertEqual(created_zone.geom.srid, 3812)
|
|
|
|
def test_management_zone_edit_view(self):
|
|
url = reverse("assets:management_zone_edit", kwargs={'pk': self.zone.pk})
|
|
|
|
# Viewer cannot edit -> 403
|
|
self.client.login(username="viewer", password="password")
|
|
res = self.client.get(url)
|
|
self.assertEqual(res.status_code, 403)
|
|
|
|
# Editor can edit
|
|
self.client.login(username="editor", password="password")
|
|
res = self.client.get(url)
|
|
self.assertEqual(res.status_code, 200)
|
|
|
|
post_data = {
|
|
'code': self.zone.code,
|
|
'name_fr': 'Nom Modifié Marais',
|
|
'thematic': self.thematic_nature.pk,
|
|
'zone_type': self.zone_type.pk,
|
|
'color': '#ff5500',
|
|
'target_frequency_days': 120,
|
|
'geom_geojson': json.dumps({
|
|
"type": "Polygon",
|
|
"coordinates": [[[4.35, 50.84], [4.37, 50.84], [4.37, 50.86], [4.35, 50.86], [4.35, 50.84]]]
|
|
}),
|
|
'instructions': 'Consigne mise à jour',
|
|
'is_active': True,
|
|
}
|
|
res = self.client.post(url, post_data)
|
|
self.assertEqual(res.status_code, 302)
|
|
|
|
self.zone.refresh_from_db()
|
|
self.assertEqual(self.zone.name_fr, 'Nom Modifié Marais')
|
|
self.assertEqual(self.zone.color, '#ff5500')
|
|
self.assertEqual(self.zone.target_frequency_days, 120)
|
|
|
|
def test_management_zone_detail_view(self):
|
|
url = reverse("assets:management_zone_detail", kwargs={'pk': self.zone.pk})
|
|
|
|
# Viewer can view
|
|
self.client.login(username="viewer", password="password")
|
|
res = self.client.get(url)
|
|
self.assertEqual(res.status_code, 200)
|
|
self.assertContains(res, "ZG-TEST-01")
|
|
self.assertFalse(res.context['can_edit'])
|
|
|
|
# Editor can view with can_edit=True
|
|
self.client.login(username="editor", password="password")
|
|
res = self.client.get(url)
|
|
self.assertEqual(res.status_code, 200)
|
|
self.assertTrue(res.context['can_edit'])
|
|
|
|
def test_management_zone_delete_view(self):
|
|
zone_to_del = ManagementZone.objects.create(
|
|
code="ZG-TO-DELETE",
|
|
name_fr="A supprimer",
|
|
thematic=self.thematic_nature,
|
|
)
|
|
url = reverse("assets:management_zone_delete", kwargs={'pk': zone_to_del.pk})
|
|
|
|
# Viewer cannot delete -> 403
|
|
self.client.login(username="viewer", password="password")
|
|
res = self.client.post(url)
|
|
self.assertEqual(res.status_code, 403)
|
|
|
|
# Editor can delete
|
|
self.client.login(username="editor", password="password")
|
|
res = self.client.post(url)
|
|
self.assertEqual(res.status_code, 302)
|
|
self.assertFalse(ManagementZone.objects.filter(pk=zone_to_del.pk).exists())
|
|
|
|
def test_management_zone_document_upload_and_delete(self):
|
|
self.client.login(username="editor", password="password")
|
|
|
|
# Upload document
|
|
upload_url = reverse("assets:management_zone_add_document", kwargs={'pk': self.zone.pk})
|
|
fake_file = SimpleUploadedFile("rapport_tonte.pdf", b"Dummy PDF content", content_type="application/pdf")
|
|
res = self.client.post(upload_url, {'documents': [fake_file]})
|
|
self.assertEqual(res.status_code, 302)
|
|
|
|
self.assertEqual(self.zone.documents.count(), 1)
|
|
doc = self.zone.documents.first()
|
|
self.assertEqual(doc.uploaded_by, self.user_editor)
|
|
|
|
# Delete document
|
|
delete_url = reverse("assets:management_zone_delete_document", kwargs={'pk': self.zone.pk, 'document_id': doc.pk})
|
|
res = self.client.post(delete_url)
|
|
self.assertEqual(res.status_code, 302)
|
|
self.assertEqual(self.zone.documents.count(), 0)
|
|
|
|
def test_management_zone_types_ajax(self):
|
|
self.client.login(username="editor", password="password")
|
|
url = reverse("assets:management_zone_types_ajax") + f"?thematic_id={self.thematic_nature.pk}"
|
|
res = self.client.get(url)
|
|
self.assertEqual(res.status_code, 200)
|
|
data = res.json()
|
|
self.assertEqual(len(data['types']), 1)
|
|
self.assertEqual(data['types'][0]['code'], 'fauche_eco')
|