From 8807531eb3f58b7d3b362b6c16d08a644a181ddf Mon Sep 17 00:00:00 2001 From: kdeterme Date: Mon, 5 Oct 2026 14:35:31 +0200 Subject: [PATCH] feat(contracts): add contractual lot perimeters overlay on nature assets map --- loko/assets/static/assets/assets_index.js | 15 + .../templates/assets/base_assets_index.html | 2 + .../assets/templates/assets/nature_index.html | 25 +- loko/contracts/permissions.py | 58 ++- .../contracts/js/contracts_map_overlay.js | 362 ++++++++++++++++++ loko/contracts/tests.py | 205 ++++++++++ loko/contracts/urls.py | 1 + loko/contracts/views.py | 80 ++++ loko/locale/en/LC_MESSAGES/django.mo | Bin 11188 -> 11667 bytes loko/locale/en/LC_MESSAGES/django.po | 22 ++ loko/locale/en/LC_MESSAGES/djangojs.mo | Bin 2745 -> 3232 bytes loko/locale/en/LC_MESSAGES/djangojs.po | 21 + loko/locale/fr/LC_MESSAGES/django.mo | Bin 13260 -> 13752 bytes loko/locale/fr/LC_MESSAGES/django.po | 22 ++ loko/locale/fr/LC_MESSAGES/djangojs.mo | Bin 2859 -> 3375 bytes loko/locale/fr/LC_MESSAGES/djangojs.po | 21 + loko/locale/nl/LC_MESSAGES/django.mo | Bin 11606 -> 12114 bytes loko/locale/nl/LC_MESSAGES/django.po | 22 ++ loko/locale/nl/LC_MESSAGES/djangojs.mo | Bin 2824 -> 3340 bytes loko/locale/nl/LC_MESSAGES/djangojs.po | 21 + 20 files changed, 859 insertions(+), 18 deletions(-) create mode 100644 loko/contracts/static/contracts/js/contracts_map_overlay.js 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 = `
+ + ${_t('Détails du contrat', 'Détails du contrat')} + +
`; + } + + 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 16e71c306dc52c3904207b51d1f1d6d39646ae6f..fa8224ccc23f854271d6e8acd8ceeb11d541cd66 100644 GIT binary patch delta 4437 zcmZA43v?9K0mktg2uU!8JS5SW2m|3|5+dQ1Dut3nc?f8XAYu?)k_`!l>}GdkM5JRu zpdeIm5mTU`Qo#VC7@zRu-aq6(__)L^Z`;^%d!7&=ceU!&VKWoJ3Diq zGaGnqcC^aH6{f+k*~~A>v8L^tiQoTzW*I2;2*5# z@mk7XU?!%d8FM}6VY)G{nZQ9`PE_Dk=*OWLL{->|#rP~{;YsUfcr|6!qQ0h}8qUV? z=-@D%hkbB0PQcArg74$Cv~N-}jLG4|V5Ck{ihO4(Q5~wm-WWj5z!IyAe9TIAy1o@} zz&-c~{tR_r`8A$1Q1#75&GZ7yp?%|Wpefmk@puR|lEbJQ-bOy=I6LYzXYo3`h?=1k zrcc*pqP}-f_m8sPVl740GX>S&Ty%9~3kUsiIcf%;N3B^qszdLfM)Dr2;SX^DUO+8L zCJU=_A@;;OP|r*yYGxlmmft*znyGcDdbejX|6049oJhi#QRNO)1xHX7o#kJ-;o75@}9vNN`P z3Dr;gjJo)!Tu(?gZ*S_bdlmo4=r@=4(^~ zNz~4v$wrk&qADoI**FI^@&opKhxG)i!@oz($XE7!0#Dq1lyh-3w%|Cu|NA&7;KVuP zTGN*&Mz2{BYRyNZMluzd3{!=gi7=`|%TOJOqLyrnE$=`rvFu$MI z(JIsuEWkv)|IHkzfn}(XZNx#i702M4s3o|BTC+snRhfzEXdY_hH`()}QEyF|JwFN6 zp=qd@T!32Y2)bo)G|Yi2eADr!{sgLmUt2#wHSk;OSyV?qMNR!*Q6v8c>S0Ua$3W(y zt}8$-t*wK)q0p9#QBye{)nFy6 z!Fy2;s}D8eMpT0?>NVVK%g><((qVOvauCmnQ`V1A591kB&o828AY-uoD~UZRm!c}Z z4b@Nus^be$Gt`XnxEeK(b*K@y+VT$Mde`jbKoxePeu!R2jp#V)wK|3B@p&x4%cus% z4DmWJ8FhUn>bfdSz(uGIHKFQvQ8Tmx^*gWupeV)OEA03sEz+MAE+5z=5WGH|oa2s5Lx->d5=3iqG5g zU!p4PJeis9tQDu>F4V~Xj=JHW z7;E@O&m7eC!>!|RGUch5j~kJX`3XCIV$7$gfhO>!o{j7CnSV`ve@>{Pk*FV%iKr3X zW6#e;t>Gfnjg8hY>KS<$c}~p+)cpsMp9b>|HsPO9?aVLm+6kfRd8~l>S3@f~p{aTT zHNsX*#yzMXnnU*d=cp0<+nQF$n?QLG4#!$leVb4-x&<|ZZK(TR!9+ZQ>hLj_13u<& z>~upvYDvQ3comMdmZB=2Zp)RZ8Mz-d5+CYetwjxBDeC(57>A3*s zzma_%d5v66zDv5dS2^&LX@nQSe4A)QdE^k8PF^J1Xfw9$V*e1?L>?edkSNhM$HR^N z$KzQFyS)>!y&jfF$oI*8WFiTW8lrXGO0?;Zh#s(SlDkOvR?R`XEoc_^*<<}APa-SG zcSwoe{}CKi5Iy1Rh}LHV(bnk^+wW(uhb=;;l6#4^Fu9G~NrGe?X(P+Y4x;7M)<~+z zSTcp&M9OJDw%yGBX?uJtK2Lhv@>0}Ot*wagHz@W`C_T|Fgnt&8gJcisAjw4AJ!CNP zkMP`1qW|SpTV96xWgJ5i$YbPgqV0zsvHf0qJRg5V z3dmf0ZhcI~Uu#difPKhIwk&3oQCF15bNm!pNoJDR^r5zT4^xKs6Nd~V+M37&vV;_q zLZaPC$?YVCXe%YzWC7VuhLSZz+X3V8A{gH^@ ziPUwqE%rs~o0|Pa?agU7#-;nYo(jXUOH^n3w)6vW8Mk(|MSS%QVW+0q*4aMonxwew z#u`rfonV8n(HY|08H&^ef^~sNAlzQjcWIC8ivM*bd}Uw4?Z3z>NsL#QU+s4`uJ_$_ zffjE7XZpW1;=i5tH8?>(WAR7mQjMS9`)i!qK!|?VhuQhn_v)hP8t0>oa(d2Hfky93 z^}KpOQ(UT@0B(;BAbN7($*eM8m|KJ4|LgM3y!;+n<=r!OW#i%YV}lR(iuE_Ts-WmU DWUci0 delta 3980 zcmY+`d2klx0mt#@g%Bu22@sBgT#;~}a;2a`R0IW4f^r$EC=joT5Jfc67bS>6QQ;LK z3SJW@eZj`SwvU`a4z>v41Xi`-gRNKF5KhD1$X8)lbaZrlv;tf5{bVe}OQW-}GyC(g zBi@Ex@c}FeAscp3Xw8AwuphpQR`fNFz?0Ys`xmE9z#{f%p?$m-ZEy+p$K`lBK85Gv zZXAop(TWkoLnN3SRJA?0_F4f8k3$y5N7%8EMa#p4S!qz6>3Cd2~Yb zLbRbv(DSduo>+rSLdc;ru^qGae``8=*hE9DU3*~-yY>pGqd*Xa#b;1mE zW)`6J)u2nW0$X8y++T-Y*NE1$vxNDl9Cq$Cr(Fq=XN-#X%(FsV4&fb=Omh0etO zc>GPA$Nncc3eTdKB)DoeiBRI2A+DhRe~8Oh?!1 zinu=yUBd-966?{&>euLV{}DFeDYTw7e98SAwxUb37hS5?9r>|%{2TOfIfZs4(<7b2F6bH$#Pc%D3R=%{9(Jd^9=&dD^dYp~4be@QwTD|M zIFcRch+jr;vbUojqHFg#dj3gdmigz9?svlx?3SS&m>pe&&PYwP7Hwx0a@&Npy_kPb z*v5e&xC>p&zo1jpjJJ+c+X3D0i{3nA(UD(-USElhcpf_9t1)>>(DQ3>5w1o%@K5yo zZ+mCcivG<3mNR6y!C^|E>8(Q(e=rHsq8-;dw3OZAZk^4B@fz5FvT5luT z;qB;*yn@!3eTRZQ|2;awkK_KQ=mr14=6Di)vHTYuLF+#0Q_=zL=pY=6)$}Z6E{S+qf4?Mo#Mmj1)rl8A4Bin@6ifN`liQwp!Ezx8?3+< zI4$m1qSwzy2YNHMqWw^70qfBzUWZn+6`is@=u*6aQ}GX2h+X=n=l8^<;b;YV{>^8Jj$V{e>&2^=#)=Hr+gAx(Hu7{FUBGGAX?u6bVd)MGx)(k=HCnd%z>784DIo^I2bz*N-wwoTd_YATVqwU8m)L) z++Tst$SU;aT!VIe3wqve%;0TgD=ByV-7X`0lM+9h(COPqt|Lw7+)jBSaT`bG5l1A%}CSc%-%)rBwfh_avpI8){y&2Qup-~?jUZPi6eFENuDN)$PD64y+ZCLyaqxg zahsBo%-~hAT!+t-wd6%|K5=`H>?U)_4%=Tw!Ois_MSPpNjUhgUPNR?8!(JOXLa1Ka_&oz2uRk#7iS8XOT%{C3&2%kHpw)4BOZEJ#Wr{4#nj_9BmwE1dr-vWLtfFOW8bM>E-K`1GA{4Vg-Y z6K}w#?FI@LlUmY;@IWNn&-nC)o0jf|VaUCg{3P?xE#wlCo$nsorj+E3wkwt!qP~vn z$phqS;&wA>B3FD{K9snnS5jWJ(+w}Nns{`cgGdY^2J@ow9HTK`B^46 ky7x=Dj-{XE|6h7bCbzQBk$iR8fM)rf{SLIqEgmxD2UksLD*ylh 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 1d7ed6b015d4f7c0534fbac56ba2d3e1ffdbe94f..b492a91ada53e5d367440e51f2c725fa8474ccd8 100644 GIT binary patch delta 1637 zcmY+EO>C4!7{{lu+jfiCma<5Z(g6xqK8gzEBWR*RB_2u*C89ABmfdM~WxMaP@4F!; zCVL_pJwUR7aNyvfK^tO1(vXysi7OZq4h0SfArKGBfdhsEdLi-me>)YJ>@&Z4=Hr>? zW8S@#{=U0*Z&Biup>0Q&BX^sO8G&c#)6srU7_$`KhDrDscEW$*JlMvh<`>c}gI#b3 z9Dwh@JZy&NU>p1iE`ZZ;kuf#%D+6qqTQCLhdi^h`iyp!y@F~>6#aPqD7Iwka@Flnn zw!_^}`}f0F;StyZzlPd(0=^8-!fx(2=l#NEsEs$ge#h$%AV2fS^NF8-3cHz4a;O#d zLS?WY_QE0f26UdMq4xa-73d6X=6-X>Py7KD;X^1-|AER(f>R7l3QF&U%FGg|i2I>Z zzum8oLZ$i>xQlXTVF&sT+^RsPp&a;`uBKF8Wgx@Xpi+7rZi0Wp%`lBM9sCet%X|cN z!KZZ8(Bz=bAA?%|%InABLiBH;0z2b5;ny#;k$-u1kqPcHSD|@W*pfY&~%1(X(`PT(UnNWn^KW=yS-=9Ip9=0IH^6LT&gCDg!4WC5#_Z zKU7m>uOq7wJ+!wFomE?f$RYfUOCu-rC{^aI$TmbJo@+II+ixOU{LCPH&5wlvKi2<) z9-04Jh-cUOH94$osp-AUwO9P02U=t$qVmi&0z4RxSI`q^{5h?I>k-vek5J{%BT`z` zR1TiQ|7|g&HHgZQMm8XQNZjg!iN21pu|hWQDz@lCTMVMGejt(AnXQ&=xiMKOj5mIa zD$ZtuQdG%gqpB;~Tw`*qP%7ARP_5Xkiy~*Ed}DGv6BUkD-9Y_f!ge~gV>66*$ZY*) zVz_H}V=~GVilNO_y~U{Bn;cyBK~Tyu>1?@}DcQAlq!Q(Waz2QH`o84xu2~<#=bf0E zNp5YLR}7}+w|vo*8qEjCW_>TV4sYHYJlmNm+Oi`G7vW9L;kV1#v7my-g^+C>% zy0u3>vneReuEjpLzT55t=Wy(Ly)(5g{eC9oU^#rz)g7tv^sc!)J%91kL@L>oR&LGy Pz#b;dN|1G7=yK*itCQf> delta 1166 zcmXxjOK1~89LMp0Y_e@O_K|3{)kLF4t+urm1T9)G@lvXnQlUOTv_?@Wtrn$dAwmx- zQV`eT$;Ux_fv1R4s32I-c$5|_1ra?ccoxKy-`{2&GW*&8?9A@We z32)5axd$Jyh5?^Y8Ck@&_y@OR6U&o5sD77GE1E!lHtFI!sD<1|E#Nt_HhYbl(2Qcf zZ?oJe17*|-7f~twg-W4E?>yU*#0KJne3eIb3YDQD)Qd-O8D7K=yn*UBjr{B_7rn2H z>OY4?Jy>uJ->{bW2WsZOoe`F&0kx==rno58Y1D*Uk*>Dexeqn|0BXXATznQuqFq9b zACHrNz4#^#@)jyJQ>Yi-L#^O3YR_NbYMjM({OYV@S?ae3H9qg+UKjVF-gg)oVuPsj zJ>-*rHJqnG6S#uftE;Z#Bx-B!qE`A0*@Ts&)d)u`CZHg$HsO!SJ$|Q8FgQlX7Zw~J&Db)E;iZ%I-RPDRU`U>H-k!y~cacvu% z+7e}>h1#us$Ws3orDY9OCtoR6>8ubQHl=wbRp-PH=A*eF5jz$(w(yWs+O30w+G zQ2pHmJK$&GvG4`Q??Mgr9e4;d3ybF-3eVm7GF-VD{x z?NHBq80x+k;S%^QsQJGIWw&>sbd6^*)I63zJ+~Ep2(E?vnF)7%4SYApUx4zTd!gp{ z7(4>L1f{3Hf$H~7DEqw)-vfW;jt}DDx_=Q=e;qj(hphb1sLP_Y743J0XAO z7Jf9phoSu98Q1h~awuIrA6x^D%PUCx2h%X+B(FNO@sOuOUjpzgmF zUJQ3b&0EIJ`*l1XYP>6<^mT!2ABP(`z6!2_FTyk7zrj=CYJ!0wni5oeT?=LJ8=&TK z2b7*3g3`nDP|tlCY84mb_MXvoaNLMCy$De`caC|@1xL$|q=U<`be` zyt4Pna0s@;uKkSp7}Wh=M|rZtD~_)^e#h}Oco*k?3^kAIPAulP%kgF?z1$9^m&YJ3 zV_txI{%cV7eH+S84q~!P;XV%v{I5Vg_pc!?V%~zX z%c9oe_`~qs91lSG&oI>dE`!Q1JD~J*BUHb4L)q_M_#XIWclG?va^CM9D`zX{nLf5_%s{fndA@B|;yL}N#&yPTj?;B9_`kp)f zXQ<~KzO0z{$x!_-hr0eesQGP#vir1azXt00w?XCo2ci0X8tS^2q3-)WlwID0(#tQQ z`aiI(nD=o|$19-j?}CrQi=pOyV0+PinB$31<2?gPAH%MF6MTx}t#B1QWqFbJH$kRo zZikvr4Jy9=7Ruf~gqp|Cq4cz1MWKfipvJQdY8-2!?A7m%*F)KH94hZ`gYv^iATDdZ z2^W&*-h%39|I>=NSPT#0xE&q}J6-!asCeDvj<-SOkxxU7>m{gueh4-HU%>m|kvyD7 zm?xpe`FC(3{6{GJ{|YXG2ci`D^B{APF=64tJ6yhUCAkyVlq>5;K+BDz#X4&~|gmBOE7BIiBA2!+bd_?(3 z+aDD$e+vHq*?PyjXZ`tg8Uhx?UDlf{0`8PBBKZ@ zH1{LgZb3SbwK`yX6!|i83?jdX5p8YA=aHSrHe@?;5h5RX7C8lZ26+-W579P)tV3j9 zZC4gB?}Ed~(TIGl2e}$iJl%}QKkD0O+4&qYgj|k1gapV(k*^|M$W_SKkb98&CSUDC z6x(+r3GzFL*4(`}`PaFwF@q-{zl*Fwv_`H#lv^i|!;#&{bBMN&As6I()bftkz%L-T zBX=N|A!Epm$Qg(>#b`g`AvYn*ku69Csc)ZhJF|`#I-Uv3ZvT2X<8J>t_(^0Lvfi}`A9wqA!cVyU9gZb9jogNO z1!+MZKu$-lMM7jHqU}~>GxA%=CZrR28hH@W_Q%K#`5r$VwmKc9kzH;(R#ePcw!?b) zul843{hrQm*VtI^`mz3@^|sU2ZcP28Z6?YRo7A3-?SyYRC`UnTldM{egNjdMZFVvU zgEW}&D}I>TG|E!nmi(08q&B-X^TU#FTS`&6?3dCY3R|u3uA*B%DMpppb8UBikC$I$ z{Uo(P9M@)N{5T1=l>K(oRVrnEP=?c9ngBhsK#q^tr+`PQ0xipPRZ2OL&$~DEo#{OZ`l_b9Ala;d2 zR%^3ZuC_a6q@^fKV>F)m<+>qlm0>&krPH<93Zvzr%p$)#tCk~gf}zI#RKS$cdwxUN zx0Og*au=eNC=6?}%v$#~4(+<(Q5!DxY;9IHOXDb;;VL`fg=pQgGXV}2$wj>Uc`{dk zz19t1(sbJ-V+@1Fqk;;Gv=i5fwq=+r{5ah{iu1L$o9?JmA<+DUJz{a2qKQ3x=&5qvm{olRaj=6%RAF0cyHe?J zV=LF`WH}0_6np`J$LX$ZzP+@54nGbPC5y(XOIl5jheGs&3Weq8`vj=z@$sJ8ZUR8r zW=7d#t}VeA$$Pqk$cuxj!rrz_dZnza956|@5r)S~j&@?R>4~EtW%@H7`fFM7{Nr4u zCLWf_g-lOvc1xBDsTW}NiOfY`YI-N>9IfW5lPKWf6(1X;lwKLUT6>DPuE!Q9u4DT{R91@w2qIMJ|1TJTwHNqQ-rl}t})B{ zi48?awhRuhT4uX8^e?k3PhUZHYOUXz*gOESxI$3P<=jn4Hgi4|Wi})^d71KBUl5wU za^WvY8kMdxeNi}(&x>pr5-3A)IvwQ;R>HZMEM)6(Yt|7OsH=imoX?-{q1t|Bfh11@ z?B|XhoBqOQF=4_!zB_}17e`5C20RugbgDS@FqXXE3_I0*Xde9!wg2K zpE+u=DGuCqowhl;lxkzYqU2SLf+DIExForHSVBu*Vv=#~J2W*YL0k5$?+&Z!*KOI%;Lj zB+gN?8ghS_Yl(QYLb213SSDC&6%P5ql#2qLxKb;fGEXBnDOi?9TR5?A`|CUJbZ4?H zWs+H*7aQ|2!*gq8;=(mwEE6;0m%NF3E+&<4^Qmw0X4Fc%E-3c$RW0+?K;e-WA6gG} zH~Ihdoewmfkwc95Dl=2bsQ^lq%eXIz%9+#fD0#d#OL-9H3EzwsX(nmx6q%D5*Uwno*K#|LC4FY` z+=(<&HgCGJ=_*>OM@slCYZ7yqU_?a$D66SPE7n1E9(z>x`Q@Tc-*ric$=j9SLPE?Lcau!APZ*p(D8QaqtiGAwfG$wPNrw`Ku09;YwAs3y2HQ9emFn_`u~traYVKbi5Scy8KHnolHoDwy1> zP1ed+3{_ZrpHxQ3wz*E~Zx94NX(C^Yt1i`tb0%^Qjw2WPVj3H;A(`Ea$@Hq_S~}L` zbyv|&F3IaJ+j7Ppty8>LOzS0awQJ4-GWQOPPNY^eM5$bV0m@7GFtC$;tan}NgwUG_ zrt$=5s}$?GZLv(l<1{SSBN65DhLVM}>U<-ywR>#w4&uk$kQ*52Wdh%eRjBJtA#ak4 zb1GAa=4-ds%jX|A8}u5kijh6J&4iy9*NUvdFXmjl{<78d(&o3@f##wo_Yam_xeMN1 z*J+!oF-EBPxH(C#JRw zdsnsd-}%(hw021Cjk3X>TNEOtw5@-lP40+cJ1NY&FYH>^Yd7?cjPikH!HBQrtZguv z3MSgtW>ZPqSk!433=g%9_^Pv_uubnQo%Yh+u93FEp|+kbd;U=8VE?Hbht~Hl81Ry` zEiW9)9(wMyed7ZIc74}iuRW`Gu&aOIysn;}k>1hKbGS5|%1}|;nD13O?LgQ1b>kYz zSpx&-m4o&uo|?Oc5e)VZ_RbBvqkY8!y{3_P+LUWMZR&4J_0=K>&&)laYVqvxvA(u7 zbJwY!C1~1u!&1cS+Ek}qvn5Cu441RmE4QhjPdaT#g5WSY`?NEaE7(1|CA8<*j@C05 z%zv($|6Dcyxk@jO^~5>HAvK7smMyegA(y^fkWo&3`%M SGxhwJ!{6keq<+np!~XyV30zMA delta 4091 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 delta 3974 zcmY+`dvH|M0mt#P7{XgZ8W2S!3Fbk>fEpl_LU<@>gW(~BN&^)^L#;|B1+7V0GzbNX zfGY~c6k0(MREi57MsWmdZ7VvcgLL{B#@fnYTZ&FwI<1qo-|z0pA3eiwKj+@P_uTWk zOWvP>b_88-CbgHqT#gkD&Ld=-X8M?}X&Ctw%8pMtqKEn1J4 za6AwD;xfDp*JD8l$?y!7?woi9%kWLKp}*oacmexid0y5U?8$LG+Q)_H1D9YqF2{>; z2adxRaTK0IJ5tgogaJ4f^LT!^l?wmDU3}P)7VL&=&=GHrK8gGbZF~&G{rDihgI<4Q ze%6_2I}PZJHlhz)g^qkD=HM%s%k#rgD&FvO`~tp@{0nFJ=#T$GXC$95z3wvf`x12I z)zO;hWb{Edqt`FSLD+;$LP()Au?v&-6S_1HU{_ofk2j$AZA066wt)Gk9`^BJ2ackf5QId)c{{_9U9Ua*v+?`ot^n5k? z;F;*|UW7K-gkHB6z3(Y>N%o>M@-o`~Ptg&djK}BD`;-5nGKWe5-#X&EG1G8#Ez+m3 z1D%PV#Pi2+F2}#ZD!i6nj=@H}3ZF(heu59*qEC=j4Cm2-o<{34D-*Y>_>E4pX4M0cXk*@tEL zQX%v2$bZ9$v3M3oV=2EjZkF4SSq{t4DSj-v8}0aEv;)V`P4>&^3AE!Mpl`z&ydN*1 zGjuN-$&NJ_C$np`niHF{+VDr{gU+Hieu{SJ^XSEcvke!a*H@xbT!nUE zJUa5}=n~99JJ^t<;*{NkHtaFW9M+*X?m?&iFxr8)qbJc#`a5J*!r##j7cdH{p#tql z9oo(;bfy=f?JdO|Os=M42iBtAbNHfw-GPF|Z%!v)?l%>#3xC5QCm(d%Kp&j@&@|zPrL6@piY4*BHqlM^$ z%g_;5p?hT_I>1`A1NGR=_kTVWr(|)o1^JN*o6wHzMLYJhc>X+=#B_4)nMF5ZcfiSc|7H7mLfY*AGFj zA03^B&deRr7M#U#3WwrZti--Un142KxSonVY{VRFMn|v`?dfK;!8UXR2hk3_hF=nQ^{OiuVW`WhEiguri97>7%7UIp{-YxFKB9Qhxx8~zQuV>|kN*Q>H?J^+1i z6;8p)@%T~nx}DK9UeEEH$iL8oVRyz+=!}lVt~hll^Y4wbIpG5rBeNTta2Re!r}j9y znSPIU^gQ;&|Dql4d3E-DKlE)WMQ5}E?eI9X1Jf~q%SaokcKt2W$$?CbKbz1UzlAI& z9oO7XeLS)Fb32zzAh!@l`vcO6bQA~n9r7S?BiE3L#2HvazDqJ~f1S#=iDfHsYAl1u zPI3pCK^)ObWCig@W+t)Rm?g7`zZUB)xQDDGd&wkX`5t+Ze1$yY^OsVw^deQnUq8!8 z;->Te0pItH?T+79!SXQKl&SFt zWmK*uH;|R&`^5FNus1Wi+Il(R|CaEA2Nae$Q%3Qb6YD>Yj>AIo1exvpH<10Lo;*i- z5I3QviMZizC$(faaieyWyQqAbG?OdI5Mmik+;CGppbR6v3BHz=Z<3oya-K(&C$nUB z^S)SL8|4q9u!^iFw-L*|WE&Yvenb`!%Mx-Od5By=9wnCDBrj9rhYeHNdNT9JXFMOi zMa5sXJITV1lh_#ReehXQM}A0FlgG(Wau2zQw33c;U#u*Omf$S%SUk@D$;!c)c%moz zzCS{~8qY~7`39LrrjRd@kUG?RU1~<&2kAw9iWBK=`TG;;hJxHgdPUy{I;HdakL;4J p9rTYxs;=lrdQ$P=L^?TmYa&%v^3QZl>Eur7w(^y^snTIX{|7QBY-s=h 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 633943a7dafe010d38630f4a4a41af0d4810bb05..7b55267d5f8ec774e2f325c9dcfe844a2462da22 100644 GIT binary patch delta 1665 zcmYk+PiP!v7{~E9?QWY*tZkZ_*l5kGZPQe1)N0zOCA}zrubzk6VptnV+~|Epr76xa#l!Ky7plhwwIP;x<;(#uf*0C*Fq# zaWfu5y?+!R!jrfWKSaHE8t=z>+|K^yOaI^k>cz|ce#PJaiTs%B-hce~ZQRayo=Lqp zimKo^j^bf_8lCqH>b;AoM3-;_`II8qh z{`niIQon`Me9j`OQlGJ_68R1{;g8tTXa1QBEqoc3(G`3FZ=lY_K33C2hYXnz72qk< z*_cDkpGO6{fP5{}M!k0qRr2rs{Uy}C%YD>e8LavTf8!SJ|3?mo>7yJLcOXfamr)xW zK`l6g58yF>pP)9LL(Tsfv%tIysQG76iG1Bp{fD_Y?;k9o4$m?^ir4X393TyO7&Y-d zu{ zzL&m}u7kFpuJfSw7+nD=I8{KK>QL%0<^cU5U1y=&TKay^(4X@oleouUiwS?NbD#kI zzlCgc%s*3x`cqR=mAXxpRog+gbX8Q<={7$7vF!R0ev@c+_#Ve6=sE{a(e*i02~|by zNxDkiZQHomO;@>f7WUD1(X*CL=5}qFovjqhF1Dd-*f2^O>6^LW#bUE&>z#I7sdm0d zVrPp{Es29-(sZFMb=tF)TE*6*W^9WtNt{i}opv=yD(^PkMEY&c4me(C%|`ZymQ8=j zy*7BL(@ug)*s!IhUolBX^OGa5Mzs=y&ep@AX2yu%koI~7WUieVwrVM#N==?2;zj9K7&~j m*waYgF1$b7ok;!G1k+gjwJ_E*+>PBFb@m-s4r(Q3V(tR%rS-D_ delta 1164 zcmXxkPe_zO7{~EvYj<_uEo-edwKlg}txPj1FawL|Qg$m3f(C`C=n@n{(D%1H4ez|4_nDn}XP%i?Z#0Z0^Pg(N zw~f+4-AsKLGCPW`E4fh)gw5)(3nO?ES74uy`>}y|5WDayM)4U2IE!(dM=pEMEruT} zao)c0kf7l!CUF@xAi->!D21yqjcc$C)vp_?u-E@SfHlPDu^un`_FJfa_kH}x$5R-! zyxC0o!K~id=Tn#}lYT?x7NRg5+k;Q45+; z%>4F(2kpR1RECSFE&Yz#!YI9ua4ZR2M|_G|`N__rPAP|a@kLbTm+&Btqx!u;E$|&` zAxo(K-!QM0{N#b;?6>dm7qyiEqcor%HBqyVTW~FLC)VOIWQg^n-Zz9AKa3mjijT)p z^E^O}n~bslTFEpGat`%^B68VlAD2*PzJy!x7w*F(+ar6BCcB2(@-bBA6Ub$ExM|`^ z)cc>J=9>!Ge+``W4MkMO^QiU`>L@!~eN9g&I_ut(?Ls)*7|Rdy;`go-{i6@3>w%1@O{v?yh&MK)3OU8roUP`+#HGt`uC z+wRrT>9}@MyLC=!>i?p1-a^%v-{u?jzd=QRj@s6ZRP9FKO5u!)M^3xPk(B!oIpI2^ z``z{EXdzS89xC)zUkJH{*nrChjqYx6*!>7P+@bhLVIkfWs_D(9&t%UH=7zGv*_{0Y DBLQYN 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"