feat(contracts): add contractual lot perimeters overlay on nature assets map
This commit is contained in:
parent
f81ca93590
commit
8807531eb3
20 changed files with 859 additions and 18 deletions
|
|
@ -79,6 +79,21 @@ const draw = new MapboxDraw({
|
||||||
map.on('load', () => {
|
map.on('load', () => {
|
||||||
map.addControl(draw);
|
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();
|
map.boxZoom.enable();
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
|
|
|
||||||
|
|
@ -364,6 +364,8 @@
|
||||||
<script src="{% url 'javascript-catalog' %}"></script>
|
<script src="{% url 'javascript-catalog' %}"></script>
|
||||||
<script src="{% static 'common/base_script.js' %}"></script>
|
<script src="{% static 'common/base_script.js' %}"></script>
|
||||||
<script src="{% static 'common/base_map.js' %}"></script>
|
<script src="{% static 'common/base_map.js' %}"></script>
|
||||||
|
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
||||||
|
<script src="{% static 'contracts/js/contracts_map_overlay.js' %}"></script>
|
||||||
<script src="{% static 'assets/assets_index.js' %}"></script>
|
<script src="{% static 'assets/assets_index.js' %}"></script>
|
||||||
{% block extra_script %}
|
{% block extra_script %}
|
||||||
{% endblock extra_script %}
|
{% endblock extra_script %}
|
||||||
|
|
|
||||||
|
|
@ -221,5 +221,28 @@
|
||||||
{% endblock const_script %}
|
{% endblock const_script %}
|
||||||
|
|
||||||
{% block extra_script %}
|
{% block extra_script %}
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", function () {
|
||||||
|
function setupNatureContractsOverlay() {
|
||||||
|
if (typeof initContractsMapOverlay === 'function' && window.map) {
|
||||||
|
initContractsMapOverlay({
|
||||||
|
map: window.map,
|
||||||
|
layerSwitcher: window.layerSwitcher,
|
||||||
|
thematic: 'nature',
|
||||||
|
geojsonUrl: "{% url 'contracts:contracts_geojson' %}",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.map && window.map.loaded && window.map.loaded()) {
|
||||||
|
setupNatureContractsOverlay();
|
||||||
|
} else if (window.maploader) {
|
||||||
|
window.maploader.addEventListener('mapLoaded', () => {
|
||||||
|
setTimeout(setupNatureContractsOverlay, 100);
|
||||||
|
});
|
||||||
|
} else if (window.map) {
|
||||||
|
window.map.on('load', setupNatureContractsOverlay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock extra_script %}
|
{% endblock extra_script %}
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from django.db.models import Q
|
||||||
|
|
||||||
from functools import wraps
|
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
|
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"}
|
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
|
Retourne une queryset des contrats accessibles à un utilisateur.
|
||||||
s'il a le droit de voir les interventions ET un rôle autorisé.
|
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:
|
try:
|
||||||
config = user.config
|
config = user.config
|
||||||
except AttributeError:
|
except (AttributeError, UserConfig.DoesNotExist):
|
||||||
return Contract.objects.none()
|
return Contract.objects.none()
|
||||||
|
|
||||||
# Vérifie les rôles
|
# Vérifie les rôles
|
||||||
user_roles = set(config.roles.values_list("name", flat=True))
|
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()
|
return Contract.objects.none()
|
||||||
|
|
||||||
if "admin" in user_roles or "top_manager" in user_roles:
|
if "admin" in user_roles or "top_manager" in user_roles:
|
||||||
return Contract.objects.all()
|
return Contract.objects.all()
|
||||||
|
|
||||||
# Filtre les UserContractAccess valides
|
# 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(
|
allowed_contract_ids = UserContractAccess.objects.filter(
|
||||||
user_config=config,
|
user_config=config
|
||||||
can_view_interventions=True
|
).filter(access_filter).values_list("contract_id", flat=True)
|
||||||
).values_list("contract_id", flat=True)
|
|
||||||
|
|
||||||
return Contract.objects.filter(id__in=allowed_contract_ids)
|
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é :
|
Vérifie si un utilisateur peut accéder à un contrat donné :
|
||||||
- A un rôle autorisé
|
- A un rôle autorisé (ou superuser/admin)
|
||||||
- A un accès UserContractAccess avec can_view_interventions=True
|
- 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:
|
try:
|
||||||
config = user.config
|
config = user.config
|
||||||
except AttributeError:
|
except (AttributeError, UserConfig.DoesNotExist):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
user_roles = set(config.roles.values_list("name", flat=True))
|
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
|
return False
|
||||||
|
|
||||||
if "admin" in user_roles or "top_manager" in user_roles:
|
if "admin" in user_roles or "top_manager" in user_roles:
|
||||||
return True
|
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(
|
return UserContractAccess.objects.filter(
|
||||||
user_config=config,
|
user_config=config,
|
||||||
contract_id=contract_id,
|
contract_id=contract_id
|
||||||
can_view_interventions=True
|
).filter(access_filter).exists()
|
||||||
).exists()
|
|
||||||
|
|
||||||
|
|
||||||
def user_can_view_contract_prices(user, contract_id):
|
def user_can_view_contract_prices(user, contract_id):
|
||||||
|
|
|
||||||
362
loko/contracts/static/contracts/js/contracts_map_overlay.js
Normal file
362
loko/contracts/static/contracts/js/contracts_map_overlay.js
Normal file
|
|
@ -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 = `<div style="color: #64748b; font-size: 11px; margin-bottom: 4px;">
|
||||||
|
<i class="bi bi-calendar-range me-1"></i>${props.start_date || ''} → ${props.end_date || ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let descHtml = '';
|
||||||
|
if (props.description) {
|
||||||
|
descHtml = `<div style="color: #334155; font-size: 11px; margin-top: 4px; border-top: 1px solid #f1f5f9; padding-top: 4px; max-height: 80px; overflow-y: auto;">
|
||||||
|
${props.description}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let detailLinkHtml = '';
|
||||||
|
if (props.detail_url) {
|
||||||
|
detailLinkHtml = `<div style="margin-top: 6px; text-align: right;">
|
||||||
|
<a href="${props.detail_url}" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 11px;" target="_blank">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-1"></i>${_t('Détails du contrat', 'Détails du contrat')}
|
||||||
|
</a>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div style="font-family: inherit; font-size: 12px; padding: 2px;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 6px; margin-bottom: 4px;">
|
||||||
|
<span style="display: inline-block; width: 12px; height: 12px; border-radius: 3px; background-color: ${props.color || '#28a745'}; border: 1px solid rgba(0,0,0,0.2); flex-shrink: 0;"></span>
|
||||||
|
<strong style="font-size: 13px; color: #1e293b;">${props.contract_number}</strong>
|
||||||
|
</div>
|
||||||
|
${props.company_name ? `<div style="color: #475569; font-weight: 500; margin-bottom: 4px;"><i class="bi bi-building me-1"></i>${props.company_name}</div>` : ''}
|
||||||
|
${datesHtml}
|
||||||
|
${descHtml}
|
||||||
|
${detailLinkHtml}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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: `<span class="d-flex align-items-center gap-1"><i class="bi bi-briefcase me-1"></i>${_t('Périmètres contractuels', 'Périmètres contractuels')}</span>`,
|
||||||
|
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 = `
|
||||||
|
<div class="text-muted small px-2 py-1 fst-italic">
|
||||||
|
${_t('Aucun périmètre contractuel défini pour cette thématique.', 'Aucun périmètre contractuel défini pour cette thématique.')}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<label class="d-flex align-items-center gap-2 mb-0" style="cursor: pointer;">
|
||||||
|
<input type="checkbox" id="switch-contracts-master" class="form-check-input mt-0" checked>
|
||||||
|
<span>${_t('Afficher les lots', 'Afficher les lots')}</span>
|
||||||
|
</label>
|
||||||
|
<span class="badge bg-light text-secondary border" style="font-size: 9.5px;">${countText}</span>
|
||||||
|
`;
|
||||||
|
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 = `
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-2">
|
||||||
|
<label class="d-flex align-items-center gap-2 mb-0 flex-grow-1" style="cursor: pointer; overflow: hidden;">
|
||||||
|
<input type="checkbox" class="contract-lot-checkbox form-check-input mt-0" data-contract-id="${contractId}" checked>
|
||||||
|
<span class="contract-color-dot" style="width: 12px; height: 12px; border-radius: 3px; background-color: ${color}; border: 1px solid rgba(0,0,0,0.25); flex-shrink: 0;"></span>
|
||||||
|
<span class="contract-lot-number text-truncate" style="font-size: 11.5px; font-weight: 600; color: #1e293b;" title="${props.contract_number}">
|
||||||
|
${props.contract_number}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<button type="button" class="btn btn-sm btn-link p-0 text-muted contract-zoom-btn" title="${_t('Centrer sur ce lot', 'Centrer sur ce lot')}" data-contract-id="${contractId}" style="line-height: 1; flex-shrink: 0;">
|
||||||
|
<i class="bi bi-crosshair" style="font-size: 13px;"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${props.company_name ? `<div class="text-muted text-truncate ps-4" style="font-size: 10px; margin-top: 1px;" title="${props.company_name}"><i class="bi bi-building me-1"></i>${props.company_name}</div>` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
@ -518,3 +518,208 @@ class ContractGeometryTests(TestCase):
|
||||||
end_date=date(2028, 12, 31),
|
end_date=date(2028, 12, 31),
|
||||||
)
|
)
|
||||||
self.assertFalse(admin_instance.has_geom(contract_without_geom))
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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/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('teams/planning/duplicate/', views_teams.team_planning_duplicate_ajax, name='team_planning_duplicate_ajax'),
|
||||||
path("", views.contracts_index, name="contracts_index"),
|
path("", views.contracts_index, name="contracts_index"),
|
||||||
|
path("api/geojson/", views.contracts_geojson, name="contracts_geojson"),
|
||||||
path('<int:contract_id>/', views.contracts_detail, name='contracts_detail'),
|
path('<int:contract_id>/', views.contracts_detail, name='contracts_detail'),
|
||||||
path('orders/available/', views.available_orders, name='available_orders'),
|
path('orders/available/', views.available_orders, name='available_orders'),
|
||||||
path('orders/<int:order_id>/', views.contracts_orders_detail, name='contracts_orders_detail'),
|
path('orders/<int:order_id>/', views.contracts_orders_detail, name='contracts_orders_detail'),
|
||||||
|
|
|
||||||
|
|
@ -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 import F, Q
|
||||||
from django.db.models.functions import Cast
|
from django.db.models.functions import Cast
|
||||||
from django.db.models import CharField
|
from django.db.models import CharField
|
||||||
|
from django.contrib.gis.db.models.functions import AsGeoJSON, Transform
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
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
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1281,3 +1281,25 @@ msgstr "Reduce map"
|
||||||
|
|
||||||
#~ msgid "(aucune sous-catégorie)"
|
#~ msgid "(aucune sous-catégorie)"
|
||||||
#~ msgstr "(no subcategory)"
|
#~ 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"
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -173,3 +173,24 @@ msgstr "None"
|
||||||
|
|
||||||
msgid "(aucune sous-catégorie)"
|
msgid "(aucune sous-catégorie)"
|
||||||
msgstr "(no subcategory)"
|
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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1313,3 +1313,25 @@ msgstr "Réduire la carte"
|
||||||
|
|
||||||
#~ msgid "(aucune sous-catégorie)"
|
#~ msgid "(aucune sous-catégorie)"
|
||||||
#~ msgstr "(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"
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -173,3 +173,24 @@ msgstr "Aucun"
|
||||||
|
|
||||||
msgid "(aucune sous-catégorie)"
|
msgid "(aucune sous-catégorie)"
|
||||||
msgstr "(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"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1283,3 +1283,25 @@ msgstr "Kaart verkleinen"
|
||||||
|
|
||||||
#~ msgid "(aucune sous-catégorie)"
|
#~ msgid "(aucune sous-catégorie)"
|
||||||
#~ msgstr "(geen subcategorie)"
|
#~ 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"
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -173,3 +173,24 @@ msgstr "Geen"
|
||||||
|
|
||||||
msgid "(aucune sous-catégorie)"
|
msgid "(aucune sous-catégorie)"
|
||||||
msgstr "(geen subcategorie)"
|
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"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue