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