diff --git a/loko/assets/static/assets/assets_index.js b/loko/assets/static/assets/assets_index.js
index 8aa951b..33a9a18 100644
--- a/loko/assets/static/assets/assets_index.js
+++ b/loko/assets/static/assets/assets_index.js
@@ -79,6 +79,21 @@ const draw = new MapboxDraw({
map.on('load', () => {
map.addControl(draw);
+ // Contrôle sélecteur de couches (LayerSwitcherControl)
+ if (typeof LayerSwitcherControl !== 'undefined') {
+ const layerSwitcher = new LayerSwitcherControl({
+ layers: [{
+ id: 'basemap',
+ name: (typeof gettext === 'function') ? gettext('Fonds de plan & Orthophotos') : 'Fonds de plan & Orthophotos',
+ icon: 'satellite',
+ beforeLayer: 'locations',
+ basemaps: window.MAP_BACKGROUNDS || [],
+ }],
+ });
+ map.addControl(layerSwitcher, 'top-right');
+ window.layerSwitcher = layerSwitcher;
+ }
+
map.boxZoom.enable();
// Initial state
diff --git a/loko/assets/templates/assets/base_assets_index.html b/loko/assets/templates/assets/base_assets_index.html
index 48477d3..9baa69e 100644
--- a/loko/assets/templates/assets/base_assets_index.html
+++ b/loko/assets/templates/assets/base_assets_index.html
@@ -364,6 +364,8 @@
+
+
{% block extra_script %}
{% endblock extra_script %}
diff --git a/loko/assets/templates/assets/nature_index.html b/loko/assets/templates/assets/nature_index.html
index 29d24a7..a5d5f86 100644
--- a/loko/assets/templates/assets/nature_index.html
+++ b/loko/assets/templates/assets/nature_index.html
@@ -221,5 +221,28 @@
{% endblock const_script %}
{% block extra_script %}
-
+
{% endblock extra_script %}
\ No newline at end of file
diff --git a/loko/contracts/permissions.py b/loko/contracts/permissions.py
index 3375386..6846e81 100644
--- a/loko/contracts/permissions.py
+++ b/loko/contracts/permissions.py
@@ -1,9 +1,10 @@
from django.core.exceptions import PermissionDenied
from django.utils.translation import gettext as _
+from django.db.models import Q
from functools import wraps
-from common.models import UserContractAccess, UserContractAccess, UserConfig, UserThematics
+from common.models import UserContractAccess, UserConfig, UserThematics
from contracts.models import Contract, ContractOrder, ContractOrderDocument, ClaimDeclarationAttachment
@@ -11,57 +12,80 @@ from contracts.models import Contract, ContractOrder, ContractOrderDocument, Cla
ALLOWED_ROLES_FOR_CONTRACT_ACCESS = {"admin", "manager", "external_manager", "controller", "top_manager"}
-def get_allowed_contracts_for_user(user):
+def get_allowed_contracts_for_user(user, for_assets=False):
"""
- Retourne une queryset des contrats accessibles à un utilisateur
- s'il a le droit de voir les interventions ET un rôle autorisé.
+ Retourne une queryset des contrats accessibles à un utilisateur.
+ Si for_assets=True, vérifie l'accès aux assets (can_view_assets=True ou rôle operator/admin).
+ Sinon, vérifie l'accès aux interventions (can_view_interventions=True).
"""
+ if getattr(user, 'is_superuser', False):
+ return Contract.objects.all()
+
try:
config = user.config
- except AttributeError:
+ except (AttributeError, UserConfig.DoesNotExist):
return Contract.objects.none()
# Vérifie les rôles
user_roles = set(config.roles.values_list("name", flat=True))
- if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS):
+ allowed_roles = ALLOWED_ROLES_FOR_CONTRACT_ACCESS
+ if for_assets:
+ allowed_roles = allowed_roles | {"operator"}
+
+ if not user_roles.intersection(allowed_roles):
return Contract.objects.none()
if "admin" in user_roles or "top_manager" in user_roles:
return Contract.objects.all()
# Filtre les UserContractAccess valides
+ if for_assets:
+ access_filter = Q(can_view_assets=True) | Q(can_view_interventions=True)
+ else:
+ access_filter = Q(can_view_interventions=True)
+
allowed_contract_ids = UserContractAccess.objects.filter(
- user_config=config,
- can_view_interventions=True
- ).values_list("contract_id", flat=True)
+ user_config=config
+ ).filter(access_filter).values_list("contract_id", flat=True)
return Contract.objects.filter(id__in=allowed_contract_ids)
-def user_has_access_to_contract(user, contract_id):
+def user_has_access_to_contract(user, contract_id, for_assets=False):
"""
Vérifie si un utilisateur peut accéder à un contrat donné :
- - A un rôle autorisé
- - A un accès UserContractAccess avec can_view_interventions=True
+ - A un rôle autorisé (ou superuser/admin)
+ - A un accès UserContractAccess valide (can_view_assets si for_assets sinon can_view_interventions)
"""
+ if getattr(user, 'is_superuser', False):
+ return True
+
try:
config = user.config
- except AttributeError:
+ except (AttributeError, UserConfig.DoesNotExist):
return False
user_roles = set(config.roles.values_list("name", flat=True))
- if not user_roles.intersection(ALLOWED_ROLES_FOR_CONTRACT_ACCESS):
+ allowed_roles = ALLOWED_ROLES_FOR_CONTRACT_ACCESS
+ if for_assets:
+ allowed_roles = allowed_roles | {"operator"}
+
+ if not user_roles.intersection(allowed_roles):
return False
if "admin" in user_roles or "top_manager" in user_roles:
return True
+ if for_assets:
+ access_filter = Q(can_view_assets=True) | Q(can_view_interventions=True)
+ else:
+ access_filter = Q(can_view_interventions=True)
+
return UserContractAccess.objects.filter(
user_config=config,
- contract_id=contract_id,
- can_view_interventions=True
- ).exists()
+ contract_id=contract_id
+ ).filter(access_filter).exists()
def user_can_view_contract_prices(user, contract_id):
diff --git a/loko/contracts/static/contracts/js/contracts_map_overlay.js b/loko/contracts/static/contracts/js/contracts_map_overlay.js
new file mode 100644
index 0000000..e879dc6
--- /dev/null
+++ b/loko/contracts/static/contracts/js/contracts_map_overlay.js
@@ -0,0 +1,362 @@
+/**
+ * Contracts Map Overlay & Layer Switcher Integration
+ *
+ * Permet d'afficher en surimpression les périmètres des lots/contrats
+ * sur la carte MapLibre (par-dessus les sections de rues et les assets)
+ * et ajoute une section dédiée "Périmètres contractuels" 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 contrats sur la carte MapLibre.
+ *
+ * @param {Object} options
+ * @param {Object} options.map - Instance de maplibregl.Map
+ * @param {Object} [options.layerSwitcher] - Instance de LayerSwitcherControl
+ * @param {string} [options.thematic='nature'] - Code de la thématique
+ * @param {string} options.geojsonUrl - URL vers l'endpoint contracts_geojson
+ * @param {string} [options.beforeLayerId] - ID de la couche avant laquelle insérer l'overlay (optionnel)
+ */
+ async function initContractsMapOverlay(options = {}) {
+ const map = options.map;
+ if (!map) {
+ console.warn('[ContractsMapOverlay] Map instance missing.');
+ return;
+ }
+
+ if (!map.loaded()) {
+ map.once('load', () => initContractsMapOverlay(options));
+ return;
+ }
+
+ const thematic = options.thematic || 'nature';
+ const geojsonUrl = options.geojsonUrl || '/contracts/api/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('[ContractsMapOverlay] Failed to fetch contracts geojson:', response.status);
+ return;
+ }
+
+ const geojson = await response.json();
+ if (!geojson || geojson.type !== 'FeatureCollection') {
+ console.warn('[ContractsMapOverlay] Invalid GeoJSON received.');
+ return;
+ }
+
+ const features = geojson.features || [];
+
+ // 1. Ajouter la source et les couches à MapLibre
+ const sourceId = 'contracts-source';
+ const fillLayerId = 'contracts-fill';
+ const lineLayerId = 'contracts-line';
+
+ if (map.getSource(sourceId)) {
+ map.getSource(sourceId).setData(geojson);
+ } else {
+ map.addSource(sourceId, {
+ type: 'geojson',
+ data: geojson
+ });
+ }
+
+ // Couche de remplissage translucide (en surimpression)
+ if (!map.getLayer(fillLayerId)) {
+ const fillLayerDef = {
+ id: fillLayerId,
+ type: 'fill',
+ source: sourceId,
+ paint: {
+ 'fill-color': ['coalesce', ['get', 'color'], '#28a745'],
+ 'fill-opacity': 0.18
+ }
+ };
+ if (beforeLayerId && map.getLayer(beforeLayerId)) {
+ map.addLayer(fillLayerDef, beforeLayerId);
+ } else {
+ map.addLayer(fillLayerDef);
+ }
+ }
+
+ // Couche de contour net pour délimiter les lots
+ if (!map.getLayer(lineLayerId)) {
+ const lineLayerDef = {
+ id: lineLayerId,
+ type: 'line',
+ source: sourceId,
+ paint: {
+ 'line-color': ['coalesce', ['get', 'color'], '#28a745'],
+ 'line-width': 2.5,
+ 'line-opacity': 0.85
+ }
+ };
+ if (beforeLayerId && map.getLayer(beforeLayerId)) {
+ map.addLayer(lineLayerDef, beforeLayerId);
+ } else {
+ map.addLayer(lineLayerDef);
+ }
+ }
+
+ // 2. Gestion des popups et du curseur sur clic
+ const contractPopup = new maplibregl.Popup({
+ closeButton: true,
+ closeOnClick: true,
+ maxWidth: '320px'
+ });
+
+ map.on('click', fillLayerId, (e) => {
+ // Si un asset est sous le clic, prioriser l'interaction asset
+ 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 datesHtml = '';
+ if (props.start_date || props.end_date) {
+ datesHtml = `
+ ${props.start_date || ''} → ${props.end_date || ''}
+
`;
+ }
+
+ let descHtml = '';
+ if (props.description) {
+ descHtml = `
+ ${props.description}
+
`;
+ }
+
+ let detailLinkHtml = '';
+ if (props.detail_url) {
+ detailLinkHtml = ``;
+ }
+
+ const html = `
+
+
+
+ ${props.contract_number}
+
+ ${props.company_name ? `
${props.company_name}
` : ''}
+ ${datesHtml}
+ ${descHtml}
+ ${detailLinkHtml}
+
+ `;
+
+ contractPopup.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: 'contract-perimeters',
+ title: `${_t('Périmètres contractuels', 'Périmètres contractuels')}`,
+ render: (container, switcher) => {
+ renderContractsSection(container, switcher, features, map, fillLayerId, lineLayerId, _t);
+ }
+ });
+
+ // Activer l'indicateur visuel sur l'icône du layer switcher si des lots sont présents
+ if (features.length > 0) {
+ layerSwitcher.setOverlayActive(true);
+ }
+ }
+
+ } catch (err) {
+ console.error('[ContractsMapOverlay] Error initializing overlay:', err);
+ }
+ }
+
+ /**
+ * Rendu HTML et interactions de la section "Périmètres contractuels" dans le LayerSwitcherControl.
+ */
+ function renderContractsSection(container, switcher, features, map, fillLayerId, lineLayerId, _t) {
+ if (!features || features.length === 0) {
+ container.innerHTML = `
+
+ ${_t('Aucun périmètre contractuel défini pour cette thématique.', 'Aucun périmètre contractuel défini pour cette thématique.')}
+
+ `;
+ return;
+ }
+
+ const countText = `${features.length} ${_t('lots', 'lots')}`;
+
+ // En-tête avec toggle général
+ const headerEl = document.createElement('div');
+ headerEl.className = 'layer-switcher-subtitle';
+ headerEl.innerHTML = `
+
+ ${countText}
+ `;
+ container.appendChild(headerEl);
+
+ // Liste des lots individuels
+ const listEl = document.createElement('div');
+ listEl.className = 'layer-switcher-contracts-list';
+ listEl.style.display = 'flex';
+ listEl.style.flexDirection = 'column';
+ listEl.style.gap = '3px';
+ listEl.style.maxHeight = '240px';
+ listEl.style.overflowY = 'auto';
+ listEl.style.paddingRight = '2px';
+
+ features.forEach((feat) => {
+ const props = feat.properties || {};
+ const color = props.color || '#28a745';
+ const contractId = feat.id;
+
+ const card = document.createElement('div');
+ card.className = 'layer-switcher-card contract-lot-card';
+ card.style.padding = '6px 8px';
+ card.style.marginBottom = '2px';
+ card.dataset.contractId = contractId;
+
+ card.innerHTML = `
+
+
+
+
+ ${props.company_name ? `${props.company_name}
` : ''}
+ `;
+
+ // Bouton zoom sur le lot
+ const zoomBtn = card.querySelector('.contract-zoom-btn');
+ zoomBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ const bbox = getGeometryBBox(feat.geometry);
+ if (bbox) {
+ map.fitBounds(bbox, { padding: 40, maxZoom: 16, duration: 800 });
+ }
+ });
+
+ listEl.appendChild(card);
+ });
+
+ container.appendChild(listEl);
+
+ const masterCheckbox = headerEl.querySelector('#switch-contracts-master');
+
+ function updateLayersVisibility() {
+ const checkedCheckboxes = container.querySelectorAll('.contract-lot-checkbox:checked');
+ const activeIds = Array.from(checkedCheckboxes).map(cb => Number(cb.dataset.contractId));
+
+ if (activeIds.length === 0) {
+ if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none');
+ if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'none');
+ masterCheckbox.checked = false;
+ masterCheckbox.indeterminate = false;
+ switcher.setOverlayActive(false);
+ } else {
+ if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
+ if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'visible');
+
+ const filter = ['in', ['get', 'id'], ['literal', activeIds]];
+ if (map.getLayer(fillLayerId)) map.setFilter(fillLayerId, filter);
+ if (map.getLayer(lineLayerId)) map.setFilter(lineLayerId, filter);
+
+ masterCheckbox.checked = activeIds.length === features.length;
+ masterCheckbox.indeterminate = activeIds.length > 0 && activeIds.length < features.length;
+ switcher.setOverlayActive(true);
+ }
+ }
+
+ // Toggles individuels
+ container.querySelectorAll('.contract-lot-checkbox').forEach((cb) => {
+ cb.addEventListener('change', (e) => {
+ e.stopPropagation();
+ updateLayersVisibility();
+ });
+ });
+
+ // Toggle master
+ masterCheckbox.addEventListener('change', (e) => {
+ e.stopPropagation();
+ const checked = masterCheckbox.checked;
+ container.querySelectorAll('.contract-lot-checkbox').forEach(cb => {
+ cb.checked = checked;
+ });
+ updateLayersVisibility();
+ });
+ }
+
+ // Exposer l'initialiseur global
+ window.initContractsMapOverlay = initContractsMapOverlay;
+
+})(window);
diff --git a/loko/contracts/tests.py b/loko/contracts/tests.py
index 575f3d3..af79075 100644
--- a/loko/contracts/tests.py
+++ b/loko/contracts/tests.py
@@ -518,3 +518,208 @@ class ContractGeometryTests(TestCase):
end_date=date(2028, 12, 31),
)
self.assertFalse(admin_instance.has_geom(contract_without_geom))
+
+
+class ContractGeometryTests(TestCase):
+ def setUp(self):
+ self.company = Company.objects.create(name="ETA Environnement")
+
+ def test_contract_with_donut_geometry_and_color(self):
+ """Test that Contract accepts a MultiPolygon with interior ring (donut hole) and color."""
+ from django.contrib.gis.geos import Polygon, MultiPolygon, Point
+ from datetime import date
+
+ # Polygon with an outer ring and an inner ring (hole)
+ # Outer ring: (0,0) to (100, 100)
+ # Inner hole: (20,20) to (40, 40)
+ outer_ring = ((0, 0), (100, 0), (100, 100), (0, 100), (0, 0))
+ hole = ((20, 20), (40, 20), (40, 40), (20, 40), (20, 20))
+ poly_with_hole = Polygon(outer_ring, hole)
+ multi_poly = MultiPolygon([poly_with_hole], srid=3812)
+
+ contract = Contract.objects.create(
+ company=self.company,
+ contract_number="CTR-ETA-LOT1",
+ start_date=date(2026, 1, 1),
+ end_date=date(2028, 12, 31),
+ geom=multi_poly,
+ color="#28a745",
+ )
+
+ self.assertEqual(contract.color, "#28a745")
+ self.assertIsNotNone(contract.geom)
+ self.assertEqual(contract.geom.srid, 3812)
+ self.assertEqual(contract.geom.geom_type, "MultiPolygon")
+
+ # GeoJSON property
+ geojson = contract.geojson_geometry
+ self.assertIsNotNone(geojson)
+ self.assertEqual(geojson["type"], "MultiPolygon")
+ # 1 polygon in multipolygon, having 2 rings (outer + hole)
+ self.assertEqual(len(geojson["coordinates"]), 1)
+ self.assertEqual(len(geojson["coordinates"][0]), 2)
+
+ # Spatial queries with hole
+ # Point inside outer ring but outside hole -> intersects
+ pt_inside = Point(10, 10, srid=3812)
+ # Point inside the hole -> does NOT intersect
+ pt_in_hole = Point(30, 30, srid=3812)
+
+ self.assertTrue(Contract.objects.filter(pk=contract.pk, geom__intersects=pt_inside).exists())
+ self.assertFalse(Contract.objects.filter(pk=contract.pk, geom__intersects=pt_in_hole).exists())
+
+ def test_contract_admin_has_geom(self):
+ """Test ContractAdmin has_geom method."""
+ from django.contrib.admin.sites import AdminSite
+ from contracts.admin import ContractAdmin
+ from datetime import date
+
+ admin_instance = ContractAdmin(Contract, AdminSite())
+ contract_without_geom = Contract.objects.create(
+ company=self.company,
+ contract_number="CTR-NOGEOM",
+ start_date=date(2026, 1, 1),
+ end_date=date(2028, 12, 31),
+ )
+ self.assertFalse(admin_instance.has_geom(contract_without_geom))
+
+
+class ContractGeojsonEndpointTests(TestCase):
+ def setUp(self):
+ from django.contrib.auth import get_user_model
+ from common.models import Thematic, Role, UserConfig, UserThematics, UserContractAccess
+ from django.contrib.gis.geos import Polygon, MultiPolygon
+ from datetime import date
+
+ User = get_user_model()
+ self.user = User.objects.create_user(username="test_nature_user", 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_view_interventions=True
+ )
+
+ self.company = Company.objects.create(name="ETA Vert")
+
+ poly = Polygon(((0, 0), (10, 0), (10, 10), (0, 10), (0, 0)))
+ self.multi_poly = MultiPolygon([poly], srid=3812)
+
+ # Nature contract with geom
+ self.contract_nature_with_geom = Contract.objects.create(
+ company=self.company,
+ contract_number="CTR-NAT-01",
+ start_date=date(2026, 1, 1),
+ end_date=date(2028, 12, 31),
+ geom=self.multi_poly,
+ color="#198754",
+ description="Lot 1 entretien espaces verts",
+ )
+ self.contract_nature_with_geom.thematics.add(self.thematic_nature)
+
+ # Nature contract without geom
+ self.contract_nature_no_geom = Contract.objects.create(
+ company=self.company,
+ contract_number="CTR-NAT-NOGEOM",
+ start_date=date(2026, 1, 1),
+ end_date=date(2028, 12, 31),
+ )
+ self.contract_nature_no_geom.thematics.add(self.thematic_nature)
+
+ # Roads contract with geom
+ self.contract_roads_with_geom = Contract.objects.create(
+ company=self.company,
+ contract_number="CTR-ROADS-01",
+ start_date=date(2026, 1, 1),
+ end_date=date(2028, 12, 31),
+ geom=self.multi_poly,
+ color="#0d6efd",
+ )
+ self.contract_roads_with_geom.thematics.add(self.thematic_roads)
+
+ # Grant access to nature contracts
+ UserContractAccess.objects.create(
+ user_config=self.config,
+ contract=self.contract_nature_with_geom,
+ can_view_assets=True,
+ can_view_interventions=True
+ )
+ UserContractAccess.objects.create(
+ user_config=self.config,
+ contract=self.contract_nature_no_geom,
+ can_view_assets=True,
+ can_view_interventions=True
+ )
+
+ def test_anonymous_access_forbidden(self):
+ url = reverse("contracts:contracts_geojson")
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 302)
+
+ def test_authenticated_nature_contracts_geojson(self):
+ self.client.login(username="test_nature_user", password="password")
+ url = reverse("contracts:contracts_geojson") + "?thematic=nature"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+
+ data = response.json()
+ self.assertEqual(data["type"], "FeatureCollection")
+ # Only 1 feature should be returned (nature with geom)
+ self.assertEqual(len(data["features"]), 1)
+ feat = data["features"][0]
+ self.assertEqual(feat["id"], self.contract_nature_with_geom.id)
+ self.assertEqual(feat["properties"]["contract_number"], "CTR-NAT-01")
+ self.assertEqual(feat["properties"]["company_name"], "ETA Vert")
+ self.assertEqual(feat["properties"]["color"], "#198754")
+ self.assertEqual(feat["properties"]["description"], "Lot 1 entretien espaces verts")
+ self.assertEqual(feat["geometry"]["type"], "MultiPolygon")
+
+ def test_superuser_access_all_contracts(self):
+ from django.contrib.auth import get_user_model
+ User = get_user_model()
+ admin = User.objects.create_superuser(username="admin_user", password="password", email="admin@example.com")
+ self.client.login(username="admin_user", password="password")
+
+ url = reverse("contracts:contracts_geojson") + "?thematic=nature"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(len(data["features"]), 1)
+
+ # For roads:
+ url_roads = reverse("contracts:contracts_geojson") + "?thematic=roads"
+ response_roads = self.client.get(url_roads)
+ self.assertEqual(response_roads.status_code, 200)
+ data_roads = response_roads.json()
+ self.assertEqual(len(data_roads["features"]), 1)
+ self.assertEqual(data_roads["features"][0]["properties"]["contract_number"], "CTR-ROADS-01")
+
+ def test_user_without_contract_access_gets_empty(self):
+ from django.contrib.auth import get_user_model
+ from common.models import Role, UserConfig, UserThematics
+ User = get_user_model()
+ other_user = User.objects.create_user(username="other_user", password="password")
+ config = UserConfig.objects.create(user=other_user)
+ role = Role.objects.get(name="manager")
+ config.roles.add(role)
+ UserThematics.objects.create(
+ user_config=config,
+ thematic=self.thematic_nature,
+ can_view_assets=True
+ )
+
+ self.client.login(username="other_user", password="password")
+ url = reverse("contracts:contracts_geojson") + "?thematic=nature"
+ response = self.client.get(url)
+ self.assertEqual(response.status_code, 200)
+ data = response.json()
+ self.assertEqual(len(data["features"]), 0)
+
+
diff --git a/loko/contracts/urls.py b/loko/contracts/urls.py
index 3f9a328..a3de7cc 100644
--- a/loko/contracts/urls.py
+++ b/loko/contracts/urls.py
@@ -19,6 +19,7 @@ urlpatterns = [
path('teams/planning/absence/', views_teams.team_planning_toggle_absence_ajax, name='team_planning_toggle_absence_ajax'),
path('teams/planning/duplicate/', views_teams.team_planning_duplicate_ajax, name='team_planning_duplicate_ajax'),
path("", views.contracts_index, name="contracts_index"),
+ path("api/geojson/", views.contracts_geojson, name="contracts_geojson"),
path('/', views.contracts_detail, name='contracts_detail'),
path('orders/available/', views.available_orders, name='available_orders'),
path('orders//', views.contracts_orders_detail, name='contracts_orders_detail'),
diff --git a/loko/contracts/views.py b/loko/contracts/views.py
index 27659fd..d170c47 100644
--- a/loko/contracts/views.py
+++ b/loko/contracts/views.py
@@ -10,6 +10,7 @@ from django.utils.translation import get_language, gettext as _, ngettext
from django.db.models import F, Q
from django.db.models.functions import Cast
from django.db.models import CharField
+from django.contrib.gis.db.models.functions import AsGeoJSON, Transform
import json
import os
@@ -1251,3 +1252,82 @@ def order_direct_post_validate(request, order_id, direct_post_id):
},
})
+
+@login_required
+@require_GET
+def contracts_geojson(request):
+ """
+ Retourne la liste des contrats sous forme de GeoJSON FeatureCollection
+ filtrée selon la thématique demandée (ex: nature) et les permissions de l'utilisateur.
+ Seuls les contrats disposant d'une géométrie (geom) sont inclus.
+ """
+ thematic_code = request.GET.get('thematic', 'nature').strip()
+
+ # 1. Vérification de l'accès à la thématique si l'utilisateur n'est pas superuser / admin
+ if not request.user.is_superuser:
+ user_config = getattr(request.user, 'config', None)
+ if not user_config:
+ return JsonResponse({"type": "FeatureCollection", "features": []})
+
+ user_roles = set(user_config.roles.values_list("name", flat=True))
+ if not user_roles.intersection({"admin", "top_manager"}):
+ has_thematic_access = UserThematics.objects.filter(
+ user_config=user_config,
+ thematic__code=thematic_code
+ ).filter(
+ Q(can_view_assets=True) | Q(can_view_interventions=True)
+ ).exists()
+ if not has_thematic_access:
+ return JsonResponse({"type": "FeatureCollection", "features": []})
+
+ # 2. Récupération des contrats autorisés pour l'utilisateur
+ allowed_contracts = get_allowed_contracts_for_user(request.user, for_assets=True)
+
+ # 3. Filtrage par thématique et géométrie non nulle
+ contracts_qs = allowed_contracts.filter(
+ thematics__code=thematic_code,
+ geom__isnull=False
+ ).select_related('company').prefetch_related('thematics').distinct()
+
+ # 4. Annotation PostGIS pour transformation rapide en GeoJSON EPSG:4326
+ contracts_qs = contracts_qs.annotate(
+ geom_geojson=AsGeoJSON(Transform('geom', 4326))
+ ).order_by('contract_number')
+
+ features = []
+ for contract in contracts_qs:
+ if not contract.geom_geojson:
+ continue
+ try:
+ geom_data = json.loads(contract.geom_geojson)
+ except (json.JSONDecodeError, TypeError):
+ continue
+
+ detail_url = ""
+ try:
+ detail_url = reverse('contracts:contracts_detail', kwargs={'contract_id': contract.id})
+ except Exception:
+ detail_url = ""
+
+ features.append({
+ "type": "Feature",
+ "id": contract.id,
+ "geometry": geom_data,
+ "properties": {
+ "id": contract.id,
+ "contract_number": contract.contract_number,
+ "company_name": contract.company.name if contract.company else "",
+ "color": contract.color or "#28a745",
+ "description": contract.description or "",
+ "start_date": contract.start_date.isoformat() if contract.start_date else "",
+ "end_date": contract.end_date.isoformat() if contract.end_date else "",
+ "detail_url": detail_url,
+ }
+ })
+
+ return JsonResponse({
+ "type": "FeatureCollection",
+ "features": features
+ })
+
+
diff --git a/loko/locale/en/LC_MESSAGES/django.mo b/loko/locale/en/LC_MESSAGES/django.mo
index 16e71c3..fa8224c 100644
Binary files a/loko/locale/en/LC_MESSAGES/django.mo and b/loko/locale/en/LC_MESSAGES/django.mo differ
diff --git a/loko/locale/en/LC_MESSAGES/django.po b/loko/locale/en/LC_MESSAGES/django.po
index 1aaca87..990e086 100644
--- a/loko/locale/en/LC_MESSAGES/django.po
+++ b/loko/locale/en/LC_MESSAGES/django.po
@@ -1281,3 +1281,25 @@ msgstr "Reduce map"
#~ msgid "(aucune sous-catégorie)"
#~ msgstr "(no subcategory)"
+
+msgid "Périmètres contractuels"
+msgstr "Contractual perimeters"
+
+msgid "Afficher les lots"
+msgstr "Show lots"
+
+msgid "Aucun périmètre contractuel défini pour cette thématique."
+msgstr "No contractual perimeter defined for this thematic."
+
+msgid "Centrer sur ce lot"
+msgstr "Center on this lot"
+
+msgid "Détails du contrat"
+msgstr "Contract details"
+
+msgid "lots"
+msgstr "lots"
+
+msgid "Fonds de plan & Orthophotos"
+msgstr "Basemaps & Orthophotos"
+
diff --git a/loko/locale/en/LC_MESSAGES/djangojs.mo b/loko/locale/en/LC_MESSAGES/djangojs.mo
index 1d7ed6b..b492a91 100644
Binary files a/loko/locale/en/LC_MESSAGES/djangojs.mo and b/loko/locale/en/LC_MESSAGES/djangojs.mo differ
diff --git a/loko/locale/en/LC_MESSAGES/djangojs.po b/loko/locale/en/LC_MESSAGES/djangojs.po
index e758eaa..dd5579e 100644
--- a/loko/locale/en/LC_MESSAGES/djangojs.po
+++ b/loko/locale/en/LC_MESSAGES/djangojs.po
@@ -173,3 +173,24 @@ msgstr "None"
msgid "(aucune sous-catégorie)"
msgstr "(no subcategory)"
+
+msgid "Périmètres contractuels"
+msgstr "Contractual perimeters"
+
+msgid "Afficher les lots"
+msgstr "Show lots"
+
+msgid "Aucun périmètre contractuel défini pour cette thématique."
+msgstr "No contractual perimeter defined for this thematic."
+
+msgid "Centrer sur ce lot"
+msgstr "Center on this lot"
+
+msgid "Détails du contrat"
+msgstr "Contract details"
+
+msgid "lots"
+msgstr "lots"
+
+msgid "Fonds de plan & Orthophotos"
+msgstr "Basemaps & Orthophotos"
diff --git a/loko/locale/fr/LC_MESSAGES/django.mo b/loko/locale/fr/LC_MESSAGES/django.mo
index 5e2ee11..af7817c 100644
Binary files a/loko/locale/fr/LC_MESSAGES/django.mo and b/loko/locale/fr/LC_MESSAGES/django.mo differ
diff --git a/loko/locale/fr/LC_MESSAGES/django.po b/loko/locale/fr/LC_MESSAGES/django.po
index 676a599..8288e74 100644
--- a/loko/locale/fr/LC_MESSAGES/django.po
+++ b/loko/locale/fr/LC_MESSAGES/django.po
@@ -1313,3 +1313,25 @@ msgstr "Réduire la carte"
#~ msgid "(aucune sous-catégorie)"
#~ msgstr "(aucune sous-catégorie)"
+
+msgid "Périmètres contractuels"
+msgstr "Périmètres contractuels"
+
+msgid "Afficher les lots"
+msgstr "Afficher les lots"
+
+msgid "Aucun périmètre contractuel défini pour cette thématique."
+msgstr "Aucun périmètre contractuel défini pour cette thématique."
+
+msgid "Centrer sur ce lot"
+msgstr "Centrer sur ce lot"
+
+msgid "Détails du contrat"
+msgstr "Détails du contrat"
+
+msgid "lots"
+msgstr "lots"
+
+msgid "Fonds de plan & Orthophotos"
+msgstr "Fonds de plan & Orthophotos"
+
diff --git a/loko/locale/fr/LC_MESSAGES/djangojs.mo b/loko/locale/fr/LC_MESSAGES/djangojs.mo
index 1a5f6e8..8ed7bcc 100644
Binary files a/loko/locale/fr/LC_MESSAGES/djangojs.mo and b/loko/locale/fr/LC_MESSAGES/djangojs.mo differ
diff --git a/loko/locale/fr/LC_MESSAGES/djangojs.po b/loko/locale/fr/LC_MESSAGES/djangojs.po
index 6abbcb0..48f6111 100644
--- a/loko/locale/fr/LC_MESSAGES/djangojs.po
+++ b/loko/locale/fr/LC_MESSAGES/djangojs.po
@@ -173,3 +173,24 @@ msgstr "Aucun"
msgid "(aucune sous-catégorie)"
msgstr "(aucune sous-catégorie)"
+
+msgid "Périmètres contractuels"
+msgstr "Périmètres contractuels"
+
+msgid "Afficher les lots"
+msgstr "Afficher les lots"
+
+msgid "Aucun périmètre contractuel défini pour cette thématique."
+msgstr "Aucun périmètre contractuel défini pour cette thématique."
+
+msgid "Centrer sur ce lot"
+msgstr "Centrer sur ce lot"
+
+msgid "Détails du contrat"
+msgstr "Détails du contrat"
+
+msgid "lots"
+msgstr "lots"
+
+msgid "Fonds de plan & Orthophotos"
+msgstr "Fonds de plan & Orthophotos"
diff --git a/loko/locale/nl/LC_MESSAGES/django.mo b/loko/locale/nl/LC_MESSAGES/django.mo
index 7b5c1d4..2def955 100644
Binary files a/loko/locale/nl/LC_MESSAGES/django.mo and b/loko/locale/nl/LC_MESSAGES/django.mo differ
diff --git a/loko/locale/nl/LC_MESSAGES/django.po b/loko/locale/nl/LC_MESSAGES/django.po
index 7379242..2b4dc6a 100644
--- a/loko/locale/nl/LC_MESSAGES/django.po
+++ b/loko/locale/nl/LC_MESSAGES/django.po
@@ -1283,3 +1283,25 @@ msgstr "Kaart verkleinen"
#~ msgid "(aucune sous-catégorie)"
#~ msgstr "(geen subcategorie)"
+
+msgid "Périmètres contractuels"
+msgstr "Contractuele perimeters"
+
+msgid "Afficher les lots"
+msgstr "Toon percelen"
+
+msgid "Aucun périmètre contractuel défini pour cette thématique."
+msgstr "Geen contractuele perimeter gedefinieerd voor dit thema."
+
+msgid "Centrer sur ce lot"
+msgstr "Centreren op dit perceel"
+
+msgid "Détails du contrat"
+msgstr "Contractdetails"
+
+msgid "lots"
+msgstr "percelen"
+
+msgid "Fonds de plan & Orthophotos"
+msgstr "Achtergrondkaarten & Orthofoto's"
+
diff --git a/loko/locale/nl/LC_MESSAGES/djangojs.mo b/loko/locale/nl/LC_MESSAGES/djangojs.mo
index 633943a..7b55267 100644
Binary files a/loko/locale/nl/LC_MESSAGES/djangojs.mo and b/loko/locale/nl/LC_MESSAGES/djangojs.mo differ
diff --git a/loko/locale/nl/LC_MESSAGES/djangojs.po b/loko/locale/nl/LC_MESSAGES/djangojs.po
index 709945c..d172e66 100644
--- a/loko/locale/nl/LC_MESSAGES/djangojs.po
+++ b/loko/locale/nl/LC_MESSAGES/djangojs.po
@@ -173,3 +173,24 @@ msgstr "Geen"
msgid "(aucune sous-catégorie)"
msgstr "(geen subcategorie)"
+
+msgid "Périmètres contractuels"
+msgstr "Contractuele perimeters"
+
+msgid "Afficher les lots"
+msgstr "Toon percelen"
+
+msgid "Aucun périmètre contractuel défini pour cette thématique."
+msgstr "Geen contractuele perimeter gedefinieerd voor dit thema."
+
+msgid "Centrer sur ce lot"
+msgstr "Centreren op dit perceel"
+
+msgid "Détails du contrat"
+msgstr "Contractdetails"
+
+msgid "lots"
+msgstr "percelen"
+
+msgid "Fonds de plan & Orthophotos"
+msgstr "Achtergrondkaarten & Orthofoto's"