Merge branch 'main' of https://git.valogeo.be/Valogeo/loko
This commit is contained in:
commit
f19eae6067
193 changed files with 17278 additions and 2869 deletions
12
Dockerfile
12
Dockerfile
|
|
@ -47,7 +47,7 @@ ENV PROJ_DATA=/opt/conda/envs/loko-env/share/proj
|
||||||
# Passer INSTALL_AI_LIBS=true dans le docker-compose pour les inclure
|
# Passer INSTALL_AI_LIBS=true dans le docker-compose pour les inclure
|
||||||
ARG INSTALL_AI_LIBS=false
|
ARG INSTALL_AI_LIBS=false
|
||||||
|
|
||||||
# Copie et installation des dépendances Python
|
# Copie et installation des dépendances Python (mis en cache tant que requirements/ ne change pas)
|
||||||
COPY requirements/ /app/requirements/
|
COPY requirements/ /app/requirements/
|
||||||
RUN pip install --no-cache-dir -r requirements/base.txt && \
|
RUN pip install --no-cache-dir -r requirements/base.txt && \
|
||||||
if [ "$INSTALL_AI_LIBS" = "true" ]; then \
|
if [ "$INSTALL_AI_LIBS" = "true" ]; then \
|
||||||
|
|
@ -81,6 +81,7 @@ COPY loko/mobile/ /app/mobile/
|
||||||
COPY loko/observations/ /app/observations/
|
COPY loko/observations/ /app/observations/
|
||||||
COPY loko/controls/ /app/controls/
|
COPY loko/controls/ /app/controls/
|
||||||
COPY loko/interventions/ /app/interventions/
|
COPY loko/interventions/ /app/interventions/
|
||||||
|
COPY loko/inspections/ /app/inspections/
|
||||||
COPY loko/assets/ /app/assets/
|
COPY loko/assets/ /app/assets/
|
||||||
COPY loko/panoramax/ /app/panoramax/
|
COPY loko/panoramax/ /app/panoramax/
|
||||||
|
|
||||||
|
|
@ -100,6 +101,15 @@ COPY loko/road_detection/templates/ /app/road_detection/templates/
|
||||||
COPY loko/*.py /app/
|
COPY loko/*.py /app/
|
||||||
COPY loko/generated_passwords.txt* /app/
|
COPY loko/generated_passwords.txt* /app/
|
||||||
|
|
||||||
|
# Métadonnées de build dynamiques (placées à la fin pour préserver le cache de build Docker)
|
||||||
|
ARG GIT_COMMIT=unknown
|
||||||
|
ARG BUILD_DATE=unknown
|
||||||
|
ARG LOKO_VERSION=1.0.0
|
||||||
|
|
||||||
|
ENV GIT_COMMIT_SHA=${GIT_COMMIT}
|
||||||
|
ENV BUILD_DATE=${BUILD_DATE}
|
||||||
|
ENV LOKO_VERSION=${LOKO_VERSION}
|
||||||
|
|
||||||
# Exposer le port par défaut
|
# Exposer le port par défaut
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from django.urls import path, re_path, include
|
from django.urls import path, re_path, include
|
||||||
|
from .views_version import VersionInfoView
|
||||||
from .views import (
|
from .views import (
|
||||||
GeoProxyView,
|
GeoProxyView,
|
||||||
InterventionListCreateView,
|
InterventionListCreateView,
|
||||||
|
|
@ -10,6 +11,7 @@ from .views import (
|
||||||
app_name = "zapi"
|
app_name = "zapi"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("version/", VersionInfoView.as_view(), name="system-version"),
|
||||||
# API root (optionnel: une vue d'info/health)
|
# API root (optionnel: une vue d'info/health)
|
||||||
# path("", APIRootView.as_view(), name="root"),
|
# path("", APIRootView.as_view(), name="root"),
|
||||||
|
|
||||||
|
|
|
||||||
59
loko/api/views_version.py
Normal file
59
loko/api/views_version.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.permissions import AllowAny
|
||||||
|
|
||||||
|
|
||||||
|
def get_version_info():
|
||||||
|
version = os.environ.get('LOKO_VERSION', '1.0.0')
|
||||||
|
commit_sha = os.environ.get('GIT_COMMIT_SHA') or os.environ.get('GIT_COMMIT')
|
||||||
|
branch = os.environ.get('GIT_BRANCH', 'main')
|
||||||
|
build_date = os.environ.get('BUILD_DATE')
|
||||||
|
|
||||||
|
if not commit_sha:
|
||||||
|
for possible_path in ['/app/build_info.json', 'build_info.json']:
|
||||||
|
if os.path.exists(possible_path):
|
||||||
|
try:
|
||||||
|
with open(possible_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
commit_sha = data.get('git_commit') or data.get('commit_sha')
|
||||||
|
version = data.get('version', version)
|
||||||
|
branch = data.get('branch', branch)
|
||||||
|
build_date = data.get('build_date', build_date)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not commit_sha:
|
||||||
|
try:
|
||||||
|
commit_sha = subprocess.check_output(
|
||||||
|
['git', 'rev-parse', 'HEAD'],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
timeout=1
|
||||||
|
).decode('utf-8').strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
commit_short = commit_sha[:7] if commit_sha else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
'status': 'online',
|
||||||
|
'app': 'loko',
|
||||||
|
'version': version,
|
||||||
|
'git_commit': commit_sha,
|
||||||
|
'git_commit_short': commit_short,
|
||||||
|
'branch': branch,
|
||||||
|
'build_date': build_date,
|
||||||
|
'timestamp': timezone.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class VersionInfoView(APIView):
|
||||||
|
permission_classes = [AllowAny]
|
||||||
|
authentication_classes = []
|
||||||
|
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
return JsonResponse(get_version_info())
|
||||||
Binary file not shown.
|
|
@ -613,3 +613,107 @@ msgstr "Collapse models"
|
||||||
#: loko/assets/static/assets/unified_index.js:3726
|
#: loko/assets/static/assets/unified_index.js:3726
|
||||||
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
|
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:93
|
||||||
|
msgid "Rechercher une adresse…"
|
||||||
|
msgstr "Search an address…"
|
||||||
|
|
||||||
|
msgid "Rechercher une adresse..."
|
||||||
|
msgstr "Search an address..."
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:748
|
||||||
|
msgid "Géolocalisation..."
|
||||||
|
msgstr "Geolocation..."
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:769
|
||||||
|
msgid "Position actualisée"
|
||||||
|
msgstr "Position updated"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:773
|
||||||
|
msgid "GPS indisponible"
|
||||||
|
msgstr "GPS unavailable"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:935
|
||||||
|
msgid "Inspections (Désactivé)"
|
||||||
|
msgstr "Inspections (Disabled)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:936
|
||||||
|
msgid "Inspections (1 mois)"
|
||||||
|
msgstr "Inspections (1 month)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:937
|
||||||
|
msgid "Inspections (3 mois)"
|
||||||
|
msgstr "Inspections (3 months)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:938
|
||||||
|
msgid "Inspections (6 mois)"
|
||||||
|
msgstr "Inspections (6 months)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:939
|
||||||
|
msgid "Inspections (1 an)"
|
||||||
|
msgstr "Inspections (1 year)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:940
|
||||||
|
msgid "Inspections (Tout)"
|
||||||
|
msgstr "Inspections (All)"
|
||||||
|
|
||||||
|
msgid "Panneau"
|
||||||
|
msgstr "Sign"
|
||||||
|
|
||||||
|
msgid "Supprimer ce panneau"
|
||||||
|
msgstr "Delete this sign"
|
||||||
|
|
||||||
|
msgid "Mention (ex: Sauf riverains)"
|
||||||
|
msgstr "Mention (e.g. Except local traffic)"
|
||||||
|
|
||||||
|
msgid "Propreté :"
|
||||||
|
msgstr "Cleanliness:"
|
||||||
|
|
||||||
|
msgid "Propre"
|
||||||
|
msgstr "Clean"
|
||||||
|
|
||||||
|
msgid "Sale"
|
||||||
|
msgstr "Dirty"
|
||||||
|
|
||||||
|
msgid "Végétation"
|
||||||
|
msgstr "Vegetation"
|
||||||
|
|
||||||
|
msgid "Dégradé"
|
||||||
|
msgstr "Damaged"
|
||||||
|
|
||||||
|
msgid "Coordonnées GPS manquantes."
|
||||||
|
msgstr "Missing GPS coordinates."
|
||||||
|
|
||||||
|
msgid "Veuillez ajouter au moins un panneau."
|
||||||
|
msgstr "Please add at least one sign."
|
||||||
|
|
||||||
|
msgid "Panneaux créés avec succès !"
|
||||||
|
msgstr "Signs created successfully!"
|
||||||
|
|
||||||
|
msgid "Erreur : "
|
||||||
|
msgstr "Error: "
|
||||||
|
|
||||||
|
msgid "Impossible de créer les panneaux."
|
||||||
|
msgstr "Unable to create signs."
|
||||||
|
|
||||||
|
msgid "Erreur lors de la création : "
|
||||||
|
msgstr "Error during creation: "
|
||||||
|
|
||||||
|
msgid "Créer et Enregistrer"
|
||||||
|
msgstr "Create and Save"
|
||||||
|
|
||||||
|
msgid "Inspection enregistrée !"
|
||||||
|
msgstr "Inspection saved!"
|
||||||
|
|
||||||
|
msgid "Intervention"
|
||||||
|
msgstr "Intervention"
|
||||||
|
|
||||||
|
msgid "créée."
|
||||||
|
msgstr "created."
|
||||||
|
|
||||||
|
msgid "Erreur lors de la validation."
|
||||||
|
msgstr "Validation error."
|
||||||
|
|
||||||
|
msgid "Erreur réseau."
|
||||||
|
msgstr "Network error."
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -618,3 +618,107 @@ msgstr "Replier les modèles"
|
||||||
#: loko/assets/static/assets/unified_index.js:3726
|
#: loko/assets/static/assets/unified_index.js:3726
|
||||||
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
|
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
|
||||||
msgstr "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
|
msgstr "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:93
|
||||||
|
msgid "Rechercher une adresse…"
|
||||||
|
msgstr "Rechercher une adresse…"
|
||||||
|
|
||||||
|
msgid "Rechercher une adresse..."
|
||||||
|
msgstr "Rechercher une adresse..."
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:748
|
||||||
|
msgid "Géolocalisation..."
|
||||||
|
msgstr "Géolocalisation..."
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:769
|
||||||
|
msgid "Position actualisée"
|
||||||
|
msgstr "Position actualisée"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:773
|
||||||
|
msgid "GPS indisponible"
|
||||||
|
msgstr "GPS indisponible"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:935
|
||||||
|
msgid "Inspections (Désactivé)"
|
||||||
|
msgstr "Inspections (Désactivé)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:936
|
||||||
|
msgid "Inspections (1 mois)"
|
||||||
|
msgstr "Inspections (1 mois)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:937
|
||||||
|
msgid "Inspections (3 mois)"
|
||||||
|
msgstr "Inspections (3 mois)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:938
|
||||||
|
msgid "Inspections (6 mois)"
|
||||||
|
msgstr "Inspections (6 mois)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:939
|
||||||
|
msgid "Inspections (1 an)"
|
||||||
|
msgstr "Inspections (1 an)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:940
|
||||||
|
msgid "Inspections (Tout)"
|
||||||
|
msgstr "Inspections (Tout)"
|
||||||
|
|
||||||
|
msgid "Panneau"
|
||||||
|
msgstr "Panneau"
|
||||||
|
|
||||||
|
msgid "Supprimer ce panneau"
|
||||||
|
msgstr "Supprimer ce panneau"
|
||||||
|
|
||||||
|
msgid "Mention (ex: Sauf riverains)"
|
||||||
|
msgstr "Mention (ex: Sauf riverains)"
|
||||||
|
|
||||||
|
msgid "Propreté :"
|
||||||
|
msgstr "Propreté :"
|
||||||
|
|
||||||
|
msgid "Propre"
|
||||||
|
msgstr "Propre"
|
||||||
|
|
||||||
|
msgid "Sale"
|
||||||
|
msgstr "Sale"
|
||||||
|
|
||||||
|
msgid "Végétation"
|
||||||
|
msgstr "Végétation"
|
||||||
|
|
||||||
|
msgid "Dégradé"
|
||||||
|
msgstr "Dégradé"
|
||||||
|
|
||||||
|
msgid "Coordonnées GPS manquantes."
|
||||||
|
msgstr "Coordonnées GPS manquantes."
|
||||||
|
|
||||||
|
msgid "Veuillez ajouter au moins un panneau."
|
||||||
|
msgstr "Veuillez ajouter au moins un panneau."
|
||||||
|
|
||||||
|
msgid "Panneaux créés avec succès !"
|
||||||
|
msgstr "Panneaux créés avec succès !"
|
||||||
|
|
||||||
|
msgid "Erreur : "
|
||||||
|
msgstr "Erreur : "
|
||||||
|
|
||||||
|
msgid "Impossible de créer les panneaux."
|
||||||
|
msgstr "Impossible de créer les panneaux."
|
||||||
|
|
||||||
|
msgid "Erreur lors de la création : "
|
||||||
|
msgstr "Erreur lors de la création : "
|
||||||
|
|
||||||
|
msgid "Créer et Enregistrer"
|
||||||
|
msgstr "Créer et Enregistrer"
|
||||||
|
|
||||||
|
msgid "Inspection enregistrée !"
|
||||||
|
msgstr "Inspection enregistrée !"
|
||||||
|
|
||||||
|
msgid "Intervention"
|
||||||
|
msgstr "Intervention"
|
||||||
|
|
||||||
|
msgid "créée."
|
||||||
|
msgstr "créée."
|
||||||
|
|
||||||
|
msgid "Erreur lors de la validation."
|
||||||
|
msgstr "Erreur lors de la validation."
|
||||||
|
|
||||||
|
msgid "Erreur réseau."
|
||||||
|
msgstr "Erreur réseau."
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -615,3 +615,107 @@ msgstr "Modellen inklappen"
|
||||||
#: loko/assets/static/assets/unified_index.js:3726
|
#: loko/assets/static/assets/unified_index.js:3726
|
||||||
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
|
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:93
|
||||||
|
msgid "Rechercher une adresse…"
|
||||||
|
msgstr "Zoek een adres…"
|
||||||
|
|
||||||
|
msgid "Rechercher une adresse..."
|
||||||
|
msgstr "Zoek een adres..."
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:748
|
||||||
|
msgid "Géolocalisation..."
|
||||||
|
msgstr "Geolocatie..."
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:769
|
||||||
|
msgid "Position actualisée"
|
||||||
|
msgstr "Positie bijgewerkt"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:773
|
||||||
|
msgid "GPS indisponible"
|
||||||
|
msgstr "GPS niet beschikbaar"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:935
|
||||||
|
msgid "Inspections (Désactivé)"
|
||||||
|
msgstr "Inspecties (Uitgeschakeld)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:936
|
||||||
|
msgid "Inspections (1 mois)"
|
||||||
|
msgstr "Inspecties (1 maand)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:937
|
||||||
|
msgid "Inspections (3 mois)"
|
||||||
|
msgstr "Inspecties (3 maanden)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:938
|
||||||
|
msgid "Inspections (6 mois)"
|
||||||
|
msgstr "Inspecties (6 maanden)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:939
|
||||||
|
msgid "Inspections (1 an)"
|
||||||
|
msgstr "Inspecties (1 jaar)"
|
||||||
|
|
||||||
|
#: loko/assets/static/assets/inspection_map.js:940
|
||||||
|
msgid "Inspections (Tout)"
|
||||||
|
msgstr "Inspecties (Alles)"
|
||||||
|
|
||||||
|
msgid "Panneau"
|
||||||
|
msgstr "Bord"
|
||||||
|
|
||||||
|
msgid "Supprimer ce panneau"
|
||||||
|
msgstr "Dit bord verwijderen"
|
||||||
|
|
||||||
|
msgid "Mention (ex: Sauf riverains)"
|
||||||
|
msgstr "Vermelding (bijv. Uitgezonderd aangelanden)"
|
||||||
|
|
||||||
|
msgid "Propreté :"
|
||||||
|
msgstr "Netheid:"
|
||||||
|
|
||||||
|
msgid "Propre"
|
||||||
|
msgstr "Schoon"
|
||||||
|
|
||||||
|
msgid "Sale"
|
||||||
|
msgstr "Vuil"
|
||||||
|
|
||||||
|
msgid "Végétation"
|
||||||
|
msgstr "Vegetatie"
|
||||||
|
|
||||||
|
msgid "Dégradé"
|
||||||
|
msgstr "Beschadigd"
|
||||||
|
|
||||||
|
msgid "Coordonnées GPS manquantes."
|
||||||
|
msgstr "Ontbrekende gps-coördinaten."
|
||||||
|
|
||||||
|
msgid "Veuillez ajouter au moins un panneau."
|
||||||
|
msgstr "Voeg minimaal één bord toe."
|
||||||
|
|
||||||
|
msgid "Panneaux créés avec succès !"
|
||||||
|
msgstr "Borden succesvol aangemaakt!"
|
||||||
|
|
||||||
|
msgid "Erreur : "
|
||||||
|
msgstr "Fout: "
|
||||||
|
|
||||||
|
msgid "Impossible de créer les panneaux."
|
||||||
|
msgstr "Kan de borden niet aanmaken."
|
||||||
|
|
||||||
|
msgid "Erreur lors de la création : "
|
||||||
|
msgstr "Fout bij aanmaken: "
|
||||||
|
|
||||||
|
msgid "Créer et Enregistrer"
|
||||||
|
msgstr "Aanmaken en opslaan"
|
||||||
|
|
||||||
|
msgid "Inspection enregistrée !"
|
||||||
|
msgstr "Inspectie opgeslagen!"
|
||||||
|
|
||||||
|
msgid "Intervention"
|
||||||
|
msgstr "Interventie"
|
||||||
|
|
||||||
|
msgid "créée."
|
||||||
|
msgstr "aangemaakt."
|
||||||
|
|
||||||
|
msgid "Erreur lors de la validation."
|
||||||
|
msgstr "Fout bij validatie."
|
||||||
|
|
||||||
|
msgid "Erreur réseau."
|
||||||
|
msgstr "Netwerkfout."
|
||||||
|
|
||||||
|
|
|
||||||
210
loko/assets/management/commands/create_structure_folders.py
Normal file
210
loko/assets/management/commands/create_structure_folders.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""
|
||||||
|
Management command: create_structure_folders
|
||||||
|
===========================================
|
||||||
|
Crée les répertoires par défaut de gestion documentaire pour les structures (ouvrages d'art)
|
||||||
|
qui n'en possèdent pas encore (ex: stations et tunnels de métro récemment importés).
|
||||||
|
|
||||||
|
Pour chaque ouvrage d'art traité :
|
||||||
|
1. Crée le dossier racine rattaché à l'ouvrage via DocumentFolderAttachment (is_default=True) :
|
||||||
|
- Nom : "<code_ouvrage> - <nom_ouvrage>" (ex: "MS001 - Station Albert")
|
||||||
|
- Slug : "<code_minuscule>" (ex: "ms001")
|
||||||
|
- Dossier parent : "Structures" (slug: "assets-structures")
|
||||||
|
- Thématique : "structures"
|
||||||
|
- Visibilité : "internal"
|
||||||
|
2. Crée l'arborescence standard de sous-dossiers :
|
||||||
|
- Civil engineering
|
||||||
|
├── Inspection reports
|
||||||
|
└── Exceptional transport
|
||||||
|
- Electromechanics
|
||||||
|
└── Preventive maintenance
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
python manage.py create_structure_folders [--dry-run] [--type TYPE] [--code CODE]
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
from assets.models.structures import Structure
|
||||||
|
from common.models import Thematic
|
||||||
|
from documents.models import DocumentFolder, DocumentFolderAttachment, VisibilityScope
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_structure_folders(structure, creator_user=None, dry_run=False):
|
||||||
|
"""
|
||||||
|
Crée ou s'assure de l'existence du dossier racine et des sous-dossiers
|
||||||
|
standards pour un ouvrage d'art (Structure).
|
||||||
|
Idempotent : ne recrée pas ce qui existe déjà.
|
||||||
|
"""
|
||||||
|
structure_ct = ContentType.objects.get_for_model(Structure)
|
||||||
|
structures_thematic = Thematic.objects.filter(code='structures').first()
|
||||||
|
structures_parent_folder = DocumentFolder.objects.filter(slug='assets-structures').first()
|
||||||
|
|
||||||
|
root_slug = slugify(structure.code)
|
||||||
|
root_folder = DocumentFolder.objects.filter(slug=root_slug).first()
|
||||||
|
|
||||||
|
root_attachment = DocumentFolderAttachment.objects.filter(
|
||||||
|
content_type=structure_ct,
|
||||||
|
object_id=structure.pk,
|
||||||
|
is_default=True
|
||||||
|
).select_related('folder').first()
|
||||||
|
|
||||||
|
if not root_folder and root_attachment:
|
||||||
|
root_folder = root_attachment.folder
|
||||||
|
|
||||||
|
root_name = f"{structure.code} - {structure.get_name()}"
|
||||||
|
root_desc = f"Dossier pour la structure {structure.code} - {structure.get_name()}"
|
||||||
|
|
||||||
|
created_root = False
|
||||||
|
if not root_folder:
|
||||||
|
if not dry_run:
|
||||||
|
root_folder = DocumentFolder.objects.create(
|
||||||
|
name=root_name,
|
||||||
|
slug=root_slug,
|
||||||
|
description=root_desc,
|
||||||
|
visibility=VisibilityScope.INTERNAL,
|
||||||
|
is_predefined=True,
|
||||||
|
application_label='structures',
|
||||||
|
created_by=creator_user
|
||||||
|
)
|
||||||
|
if structures_thematic:
|
||||||
|
root_folder.thematics.add(structures_thematic)
|
||||||
|
if structures_parent_folder:
|
||||||
|
root_folder.parent_folders.add(structures_parent_folder)
|
||||||
|
created_root = True
|
||||||
|
|
||||||
|
if not dry_run and root_folder and not root_attachment:
|
||||||
|
DocumentFolderAttachment.objects.get_or_create(
|
||||||
|
content_type=structure_ct,
|
||||||
|
object_id=structure.pk,
|
||||||
|
folder=root_folder,
|
||||||
|
defaults={'is_default': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
subfolders_created = 0
|
||||||
|
|
||||||
|
def get_or_create_subfolder(parent, name, sub_suffix):
|
||||||
|
nonlocal subfolders_created
|
||||||
|
base_slug = f"{root_slug}-{sub_suffix}"[:150]
|
||||||
|
sub = DocumentFolder.objects.filter(slug=base_slug).first()
|
||||||
|
if not sub and parent and getattr(parent, 'pk', None):
|
||||||
|
sub = parent.child_folders.filter(name=name).first()
|
||||||
|
|
||||||
|
if not sub:
|
||||||
|
if not dry_run:
|
||||||
|
sub = DocumentFolder.objects.create(
|
||||||
|
name=name,
|
||||||
|
slug=base_slug,
|
||||||
|
visibility=VisibilityScope.INTERNAL,
|
||||||
|
is_predefined=False,
|
||||||
|
application_label='structures',
|
||||||
|
created_by=creator_user
|
||||||
|
)
|
||||||
|
if structures_thematic:
|
||||||
|
sub.thematics.add(structures_thematic)
|
||||||
|
if parent and getattr(parent, 'pk', None):
|
||||||
|
sub.parent_folders.add(parent)
|
||||||
|
subfolders_created += 1
|
||||||
|
return sub, True
|
||||||
|
return sub, False
|
||||||
|
|
||||||
|
# 1. Civil engineering & sous-dossiers
|
||||||
|
civil_folder, _ = get_or_create_subfolder(root_folder, "Civil engineering", "civil-engineering")
|
||||||
|
get_or_create_subfolder(civil_folder, "Inspection reports", "civil-engineering-inspection-reports")
|
||||||
|
get_or_create_subfolder(civil_folder, "Exceptional transport", "civil-engineering-exceptional-transport")
|
||||||
|
|
||||||
|
# 2. Electromechanics & sous-dossiers
|
||||||
|
em_folder, _ = get_or_create_subfolder(root_folder, "Electromechanics", "electromechanics")
|
||||||
|
get_or_create_subfolder(em_folder, "Preventive maintenance", "electromechanics-preventive-maintenance")
|
||||||
|
|
||||||
|
return {
|
||||||
|
'root_folder': root_folder,
|
||||||
|
'created_root': created_root,
|
||||||
|
'subfolders_created': subfolders_created
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Crée les répertoires par défaut de gestion documentaire pour les structures."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help="Simule l'exécution sans effectuer d'écritures en base de données."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--type',
|
||||||
|
dest='structure_type',
|
||||||
|
help="Filtre par type de structure (ex: metro_station,metro_tunnel)."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--code',
|
||||||
|
dest='structure_code',
|
||||||
|
help="Cible un ouvrage spécifique via son code (ex: MS001)."
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--user',
|
||||||
|
dest='username',
|
||||||
|
help="Nom d'utilisateur créateur des dossiers (par défaut kdeterme ou superuser)."
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options['dry_run']
|
||||||
|
structure_type = options.get('structure_type')
|
||||||
|
structure_code = options.get('structure_code')
|
||||||
|
username = options.get('username')
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING("=== Mode simulation (--dry-run) activé. Aucune modification en base. ==="))
|
||||||
|
|
||||||
|
creator_user = None
|
||||||
|
if username:
|
||||||
|
creator_user = User.objects.filter(username=username).first()
|
||||||
|
if not creator_user:
|
||||||
|
creator_user = User.objects.filter(username="kdeterme").first() or User.objects.filter(is_superuser=True).first()
|
||||||
|
|
||||||
|
qs = Structure.objects.all().order_by('code')
|
||||||
|
|
||||||
|
if structure_code:
|
||||||
|
qs = qs.filter(code__iexact=structure_code)
|
||||||
|
elif structure_type:
|
||||||
|
types = [t.strip() for t in structure_type.split(',')]
|
||||||
|
qs = qs.filter(structure_type__in=types)
|
||||||
|
|
||||||
|
structure_ct = ContentType.objects.get_for_model(Structure)
|
||||||
|
existing_attached_ids = set(
|
||||||
|
DocumentFolderAttachment.objects.filter(
|
||||||
|
content_type=structure_ct,
|
||||||
|
is_default=True
|
||||||
|
).values_list('object_id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
missing_structures = [s for s in qs if s.pk not in existing_attached_ids]
|
||||||
|
|
||||||
|
self.stdout.write(f"Structures analysées : {qs.count()} | Sans répertoire par défaut : {len(missing_structures)}")
|
||||||
|
|
||||||
|
total_roots = 0
|
||||||
|
total_subfolders = 0
|
||||||
|
|
||||||
|
target_structures = missing_structures if not structure_code else list(qs)
|
||||||
|
|
||||||
|
for structure in target_structures:
|
||||||
|
res = ensure_structure_folders(structure, creator_user=creator_user, dry_run=dry_run)
|
||||||
|
if res['created_root']:
|
||||||
|
total_roots += 1
|
||||||
|
total_subfolders += res['subfolders_created']
|
||||||
|
self.stdout.write(f" ✓ [{structure.code}] {structure.get_name()} -> Dossier racine et arborescence configurés")
|
||||||
|
|
||||||
|
status_label = "[Simulation] Répertoires qui seraient créés" if dry_run else "Répertoires créés avec succès"
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f"\n=== {status_label} ===\n"
|
||||||
|
f" - Dossiers racines créés : {total_roots}\n"
|
||||||
|
f" - Sous-dossiers créés : {total_subfolders}\n"
|
||||||
|
f" - Total dossiers : {total_roots + total_subfolders}"
|
||||||
|
))
|
||||||
278
loko/assets/management/commands/import_cse_parking.py
Normal file
278
loko/assets/management/commands/import_cse_parking.py
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.contrib.gis.geos import Point, Polygon, MultiPolygon
|
||||||
|
from django.db import transaction
|
||||||
|
from shapely.geometry import shape, Point as ShpPoint
|
||||||
|
|
||||||
|
from common.models import Thematic, Municipality
|
||||||
|
from assets.models import AssetCategory
|
||||||
|
from assets.models.sign import SignStreet
|
||||||
|
from assets.models.parking import ParkingLocation, ParkingSpot
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Import parking areas, bicycle parking stands and PMR/VE spots for Court-Saint-Étienne"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write("=== Importing Parking & Bicycle Spots for Court-Saint-Étienne ===")
|
||||||
|
|
||||||
|
parking_thematic = Thematic.objects.filter(code='parking').first()
|
||||||
|
parking_cat = AssetCategory.objects.filter(code='parking_spot').first()
|
||||||
|
bike_cat = AssetCategory.objects.filter(code='bike_parking_spot').first()
|
||||||
|
|
||||||
|
cse = Municipality.objects.get(niscode='25014')
|
||||||
|
cse_geom_wgs84 = cse.geom.clone()
|
||||||
|
cse_geom_wgs84.transform(4326)
|
||||||
|
cse_poly = shape(json.loads(cse_geom_wgs84.geojson))
|
||||||
|
|
||||||
|
streets = list(SignStreet.objects.filter(geom__isnull=False))
|
||||||
|
if not streets:
|
||||||
|
self.stderr.write("No SignStreet found in database!")
|
||||||
|
return
|
||||||
|
|
||||||
|
osm_json_path = Path("/tmp/osm_cse_sign_parking.json")
|
||||||
|
if not osm_json_path.exists():
|
||||||
|
self.stderr.write(f"OSM JSON not found: {osm_json_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(osm_json_path, 'r', encoding='utf-8') as f:
|
||||||
|
osm_data = json.load(f)
|
||||||
|
|
||||||
|
elements = osm_data.get('elements', [])
|
||||||
|
|
||||||
|
# Filter strictly inside CSE
|
||||||
|
in_cse = []
|
||||||
|
for el in elements:
|
||||||
|
lat = el.get('lat') or (el.get('center') and el['center'].get('lat'))
|
||||||
|
lon = el.get('lon') or (el.get('center') and el['center'].get('lon'))
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
p = ShpPoint(lon, lat)
|
||||||
|
if cse_poly.contains(p):
|
||||||
|
el['_pt_wgs84'] = Point(lon, lat, srid=4326)
|
||||||
|
in_cse.append(el)
|
||||||
|
|
||||||
|
self.stdout.write(f"Elements strictly in CSE: {len(in_cse)}")
|
||||||
|
|
||||||
|
# ─── 1. ParkingLocation (Zones de stationnement) ───────────────────
|
||||||
|
self.stdout.write("--- Step 1: ParkingLocation (Aires/Parcs) ---")
|
||||||
|
parking_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'parking']
|
||||||
|
self.stdout.write(f"Parking lots in CSE: {len(parking_elements)}")
|
||||||
|
|
||||||
|
# Existing locations
|
||||||
|
existing_loc_codes = set(ParkingLocation.objects.values_list('code', flat=True))
|
||||||
|
max_loc_num = 0
|
||||||
|
for c in existing_loc_codes:
|
||||||
|
if c and c.startswith('CSE-PKG-'):
|
||||||
|
try:
|
||||||
|
num = int(c.split('-')[-1])
|
||||||
|
if num > max_loc_num:
|
||||||
|
max_loc_num = num
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
new_locations = []
|
||||||
|
for p in parking_elements:
|
||||||
|
tags = p.get('tags', {})
|
||||||
|
pt_4326 = p['_pt_wgs84']
|
||||||
|
pt_3812 = pt_4326.transform(3812, clone=True)
|
||||||
|
|
||||||
|
closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812))
|
||||||
|
|
||||||
|
# Determine Name
|
||||||
|
osm_name = tags.get('name')
|
||||||
|
if osm_name:
|
||||||
|
name_fr = osm_name.strip()
|
||||||
|
else:
|
||||||
|
name_fr = f"Parking {closest_street.name_fr or 'public'}"
|
||||||
|
|
||||||
|
# Capacity
|
||||||
|
try:
|
||||||
|
cap = int(tags.get('capacity', 15))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
cap = 15
|
||||||
|
|
||||||
|
# Size proportional to capacity
|
||||||
|
size = max(16.0, min(65.0, (cap or 15) * 1.8))
|
||||||
|
half = size / 2.0
|
||||||
|
cx, cy = pt_3812.x, pt_3812.y
|
||||||
|
poly = Polygon([
|
||||||
|
(cx - half, cy - half),
|
||||||
|
(cx + half, cy - half),
|
||||||
|
(cx + half, cy + half),
|
||||||
|
(cx - half, cy + half),
|
||||||
|
(cx - half, cy - half),
|
||||||
|
], srid=3812)
|
||||||
|
mpoly = MultiPolygon([poly])
|
||||||
|
|
||||||
|
access = tags.get('access', 'public')
|
||||||
|
fee = tags.get('fee', 'non payant')
|
||||||
|
parking_type = tags.get('parking', 'surface')
|
||||||
|
desc = f"Aire de stationnement ({parking_type}). Accès: {access}. Tarification: {fee}. Capacité estimée: {cap} places."
|
||||||
|
|
||||||
|
max_loc_num += 1
|
||||||
|
code = f"CSE-PKG-{max_loc_num:04d}"
|
||||||
|
|
||||||
|
loc = ParkingLocation(
|
||||||
|
code=code,
|
||||||
|
name_fr=name_fr,
|
||||||
|
geom=mpoly,
|
||||||
|
description=desc,
|
||||||
|
address=f"{closest_street.name_fr or 'Court-Saint-Étienne'}, 1490 Court-Saint-Étienne",
|
||||||
|
|
||||||
|
)
|
||||||
|
# Sync
|
||||||
|
loc.sync_geom_and_geojson()
|
||||||
|
new_locations.append(loc)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
ParkingLocation.objects.bulk_create(new_locations)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Created {len(new_locations)} ParkingLocations. Total in DB: {ParkingLocation.objects.count()}"))
|
||||||
|
|
||||||
|
# Re-fetch all locations with spatial index
|
||||||
|
all_locations = list(ParkingLocation.objects.filter(geom__isnull=False))
|
||||||
|
|
||||||
|
# ─── 2. ParkingSpot (Vélos & Emplacements individuels) ────────────
|
||||||
|
self.stdout.write("--- Step 2: ParkingSpot (Vélos, PMR, VE) ---")
|
||||||
|
bike_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'bicycle_parking']
|
||||||
|
space_elements = [e for e in in_cse if e.get('tags', {}).get('amenity') == 'parking_space']
|
||||||
|
|
||||||
|
self.stdout.write(f"Bicycle parkings in CSE: {len(bike_elements)}")
|
||||||
|
self.stdout.write(f"Parking spaces in CSE: {len(space_elements)}")
|
||||||
|
|
||||||
|
max_spot_num = 0
|
||||||
|
for sc in ParkingSpot.objects.values_list('code', flat=True):
|
||||||
|
if sc:
|
||||||
|
try:
|
||||||
|
num = int(sc.split('-')[-1])
|
||||||
|
if num > max_spot_num:
|
||||||
|
max_spot_num = num
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
new_spots = []
|
||||||
|
|
||||||
|
# A. Bicycle parkings
|
||||||
|
for b in bike_elements:
|
||||||
|
tags = b.get('tags', {})
|
||||||
|
pt_4326 = b['_pt_wgs84']
|
||||||
|
pt_3812 = pt_4326.transform(3812, clone=True)
|
||||||
|
|
||||||
|
closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812))
|
||||||
|
closest_loc = min(all_locations, key=lambda l: l.geom.distance(pt_3812)) if all_locations else None
|
||||||
|
|
||||||
|
# Capacity & stands
|
||||||
|
try:
|
||||||
|
cap = int(tags.get('capacity', 6))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
cap = 6
|
||||||
|
|
||||||
|
btype_osm = (tags.get('bicycle_parking') or '').lower()
|
||||||
|
if 'box' in btype_osm or 'shed' in btype_osm or 'secure' in btype_osm:
|
||||||
|
stand_type = 'box'
|
||||||
|
stands_label = "Box / Abri sécurisé"
|
||||||
|
elif 'rack' in btype_osm or 'wall' in btype_osm:
|
||||||
|
stand_type = 'rack'
|
||||||
|
stands_label = "Râteau / Pince-roue"
|
||||||
|
else:
|
||||||
|
stand_type = 'normal'
|
||||||
|
stands_label = "Arceaux U standard"
|
||||||
|
|
||||||
|
# 3m x 2m polygon in 3812
|
||||||
|
cx, cy = pt_3812.x, pt_3812.y
|
||||||
|
poly = Polygon([
|
||||||
|
(cx - 1.5, cy - 1.0),
|
||||||
|
(cx + 1.5, cy - 1.0),
|
||||||
|
(cx + 1.5, cy + 1.0),
|
||||||
|
(cx - 1.5, cy + 1.0),
|
||||||
|
(cx - 1.5, cy - 1.0),
|
||||||
|
], srid=3812)
|
||||||
|
|
||||||
|
max_spot_num += 1
|
||||||
|
code = f"CSE-PKV-{max_spot_num:04d}"
|
||||||
|
osm_name = tags.get('name')
|
||||||
|
name_fr = osm_name if osm_name else f"Stationnement vélos ({stands_label}) - {closest_street.name_fr or 'Rue'}"
|
||||||
|
|
||||||
|
spot = ParkingSpot(
|
||||||
|
code=code,
|
||||||
|
name_fr=name_fr,
|
||||||
|
spot_type='bicycle',
|
||||||
|
stand_type=stand_type,
|
||||||
|
capacity=cap,
|
||||||
|
standard_stand_count=max(1, cap // 2),
|
||||||
|
geom=poly,
|
||||||
|
location=closest_loc,
|
||||||
|
street=closest_street,
|
||||||
|
|
||||||
|
category=bike_cat,
|
||||||
|
|
||||||
|
is_compliant=True,
|
||||||
|
)
|
||||||
|
spot.sync_geom_and_geojson()
|
||||||
|
new_spots.append(spot)
|
||||||
|
|
||||||
|
# B. Parking Spaces (PMR, EV charging, Car)
|
||||||
|
for s in space_elements:
|
||||||
|
tags = s.get('tags', {})
|
||||||
|
pt_4326 = s['_pt_wgs84']
|
||||||
|
pt_3812 = pt_4326.transform(3812, clone=True)
|
||||||
|
|
||||||
|
closest_street = min(streets, key=lambda st: st.geom.distance(pt_3812))
|
||||||
|
closest_loc = min(all_locations, key=lambda l: l.geom.distance(pt_3812)) if all_locations else None
|
||||||
|
|
||||||
|
# Check PMR vs EV vs standard
|
||||||
|
is_pmr = (
|
||||||
|
tags.get('capacity:disabled') in ('1', 'yes') or
|
||||||
|
tags.get('disabled') in ('yes', 'designated') or
|
||||||
|
tags.get('access') == 'disabled' or
|
||||||
|
'pmr' in str(tags).lower() or
|
||||||
|
'disabled' in str(tags).lower()
|
||||||
|
)
|
||||||
|
is_ev = (
|
||||||
|
'charging' in str(tags).lower() or
|
||||||
|
tags.get('socket:type2') or
|
||||||
|
tags.get('amenity') == 'charging_station'
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_pmr:
|
||||||
|
spot_type = 'pmr'
|
||||||
|
spot_label = "Emplacement PMR"
|
||||||
|
elif is_ev:
|
||||||
|
spot_type = 'ev_charging'
|
||||||
|
spot_label = "Borne recharge VE"
|
||||||
|
else:
|
||||||
|
spot_type = 'car_parking'
|
||||||
|
spot_label = "Emplacement automobile"
|
||||||
|
|
||||||
|
# 2.5m x 5m polygon
|
||||||
|
cx, cy = pt_3812.x, pt_3812.y
|
||||||
|
poly = Polygon([
|
||||||
|
(cx - 1.25, cy - 2.5),
|
||||||
|
(cx + 1.25, cy - 2.5),
|
||||||
|
(cx + 1.25, cy + 2.5),
|
||||||
|
(cx - 1.25, cy + 2.5),
|
||||||
|
(cx - 1.25, cy - 2.5),
|
||||||
|
], srid=3812)
|
||||||
|
|
||||||
|
max_spot_num += 1
|
||||||
|
code = f"CSE-PKS-{max_spot_num:04d}"
|
||||||
|
name_fr = f"{spot_label} - {closest_street.name_fr or 'Rue'}"
|
||||||
|
|
||||||
|
spot = ParkingSpot(
|
||||||
|
code=code,
|
||||||
|
name_fr=name_fr,
|
||||||
|
spot_type=spot_type,
|
||||||
|
capacity=1,
|
||||||
|
geom=poly,
|
||||||
|
location=closest_loc,
|
||||||
|
street=closest_street,
|
||||||
|
|
||||||
|
category=parking_cat,
|
||||||
|
|
||||||
|
is_compliant=True,
|
||||||
|
)
|
||||||
|
spot.sync_geom_and_geojson()
|
||||||
|
new_spots.append(spot)
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
|
ParkingSpot.objects.bulk_create(new_spots)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Created {len(new_spots)} ParkingSpots (total: {ParkingSpot.objects.count()})."))
|
||||||
293
loko/assets/management/commands/import_cse_signage.py
Normal file
293
loko/assets/management/commands/import_cse_signage.py
Normal file
|
|
@ -0,0 +1,293 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.gis.geos import Point, GEOSGeometry
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from common.models import Thematic
|
||||||
|
from assets.models import AssetCategory
|
||||||
|
from assets.models.sign import SignStreet, SignPole, SignPanel, SignPanelType
|
||||||
|
|
||||||
|
BELGIAN_PANEL_TYPES = [
|
||||||
|
# Priorité
|
||||||
|
('B1', 'Cédez le passage', 'Voorrang verlenen', 'B/B1.svg'),
|
||||||
|
('B5', 'Arrêt obligatoire (Stop)', 'Stop', 'B/B5.svg'),
|
||||||
|
('B9', 'Route prioritaire', 'Voorrangsweg', 'B/B9.svg'),
|
||||||
|
('B15', 'Fin de route prioritaire', 'Einde voorrangsweg', 'B/B15.svg'),
|
||||||
|
|
||||||
|
# Danger
|
||||||
|
('A1a', 'Virage dangereux à droite', 'Gevaarlijke bocht naar rechts', 'A/A1A.svg'),
|
||||||
|
('A1b', 'Virage dangereux à gauche', 'Gevaarlijke bocht naar links', 'A/A1B.svg'),
|
||||||
|
('A7a', 'Passage pour piétons', 'Voetgangersoversteekplaats', 'A/A7A.svg'),
|
||||||
|
('A14', 'Présence d\'enfants', 'Kinderen', 'A/A14.svg'),
|
||||||
|
('A23', 'Travaux', 'Werken', 'A/A23.svg'),
|
||||||
|
('A25', 'Ralentisseur de trafic', 'Verkeersdrempel', 'A/A25.svg'),
|
||||||
|
|
||||||
|
# Interdiction
|
||||||
|
('C1', 'Sens interdit', 'Verboden richting', 'C/C1.svg'),
|
||||||
|
('C3', 'Accès interdit dans les deux sens', 'Verboden toegang in beide richtingen', 'C/C3.svg'),
|
||||||
|
('C11', 'Accès interdit aux cycles', 'Verboden toegang voor fietsers', 'C/C11.svg'),
|
||||||
|
('C43', 'Vitesse maximale limitée (50/30 km/h)', 'Maximumsnelheid', 'C/C43.svg'),
|
||||||
|
('C45', 'Fin de limitation de vitesse', 'Einde snelheidsbeperking', 'C/C45.svg'),
|
||||||
|
|
||||||
|
# Obligation
|
||||||
|
('D7', 'Piste cyclable obligatoire', 'Verplicht fietspad', 'D/D7.svg'),
|
||||||
|
('D9', 'Partage piétons / cyclistes', 'Gedeeld voetpad en fietspad', 'D/D9.svg'),
|
||||||
|
|
||||||
|
# Stationnement
|
||||||
|
('E1', 'Stationnement interdit', 'Parkeerverbod', 'E/E1.svg'),
|
||||||
|
('E3', 'Arrêt et stationnement interdits', 'Stilstand- en parkeerverbod', 'E/E3.svg'),
|
||||||
|
('E9a', 'Stationnement autorisé', 'Parkeren toegelaten', 'E/E9A.svg'),
|
||||||
|
('E9b', 'Stationnement réservé aux PMR', 'Parkeren voor personen met een handicap', 'E/E9B.svg'),
|
||||||
|
('E9c', 'Stationnement camionnettes / camions', 'Parkeren voor vrachtwagens', 'E/E9C.svg'),
|
||||||
|
('E9h', 'Stationnement véhicules électriques (recharge)', 'Parkeren voor elektrische voertuigen', 'E/E9H.svg'),
|
||||||
|
|
||||||
|
# Indications
|
||||||
|
('F4a', 'Début de zone 30', 'Begin van een zone 30', 'F/F4A.svg'),
|
||||||
|
('F4b', 'Fin de zone 30', 'Einde van een zone 30', 'F/F4B.svg'),
|
||||||
|
('F12a', 'Zone résidentielle ou de rencontre', 'Woonerf of erf', 'F/F12A.svg'),
|
||||||
|
('F19', 'Voie publique à sens unique', 'Eenrichtingsverkeer', 'F/F19.svg'),
|
||||||
|
('F45', 'Impasse', 'Doodlopende weg', 'F/F45.svg'),
|
||||||
|
('F49', 'Passage pour piétons', 'Voetgangersoversteekplaats', 'F/F49.svg'),
|
||||||
|
('F99a', 'Chemin réservé piétons / cyclistes', 'Weg voorbehouden voor voetgangers en fietsers', 'F/F99A.svg'),
|
||||||
|
('F101a', 'Fin de chemin réservé', 'Einde weg voorbehouden', 'F/F101A.svg'),
|
||||||
|
]
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Import signage (PICC poles and road panels) for Court-Saint-Étienne"
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write("=== Importing Signage for Court-Saint-Étienne ===")
|
||||||
|
|
||||||
|
# 1. SignPanelType catalog
|
||||||
|
self.stdout.write("--- Step 1: SignPanelType catalog ---")
|
||||||
|
media_target_dir = Path(settings.MEDIA_ROOT) / "sign" / "signpanels_types"
|
||||||
|
media_target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
static_road_signs = Path(settings.BASE_DIR) / "assets" / "static" / "assets" / "road_signs"
|
||||||
|
if not static_road_signs.exists():
|
||||||
|
# Fallback check
|
||||||
|
static_road_signs = Path("/app/static/assets/road_signs")
|
||||||
|
|
||||||
|
created_types = 0
|
||||||
|
for code, name_fr, name_nl, rel_svg in BELGIAN_PANEL_TYPES:
|
||||||
|
pt, created = SignPanelType.objects.get_or_create(
|
||||||
|
code=code,
|
||||||
|
defaults={
|
||||||
|
'name_fr': name_fr,
|
||||||
|
'name_nl': name_nl,
|
||||||
|
'default_width': 50.0,
|
||||||
|
'default_height': 50.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Copy SVG icon if exists
|
||||||
|
svg_source = static_road_signs / rel_svg
|
||||||
|
if svg_source.exists():
|
||||||
|
dst_name = f"{code.lower()}.svg"
|
||||||
|
dst_path = media_target_dir / dst_name
|
||||||
|
if not dst_path.exists():
|
||||||
|
shutil.copyfile(svg_source, dst_path)
|
||||||
|
pt.sgv_picture = f"sign/signpanels_types/{dst_name}"
|
||||||
|
pt.save(update_fields=['sgv_picture'])
|
||||||
|
if created:
|
||||||
|
created_types += 1
|
||||||
|
self.stdout.write(f"SignPanelTypes ensured: {SignPanelType.objects.count()} (created {created_types})")
|
||||||
|
|
||||||
|
# 2. Import PICC Poles
|
||||||
|
self.stdout.write("--- Step 2: PICC Poles ---")
|
||||||
|
sign_thematic = Thematic.objects.filter(code='sign').first()
|
||||||
|
pole_category = AssetCategory.objects.filter(code='SI_POLE').first()
|
||||||
|
|
||||||
|
streets = list(SignStreet.objects.filter(geom__isnull=False))
|
||||||
|
if not streets:
|
||||||
|
self.stderr.write("No SignStreet found in database!")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Preload existing poles to avoid duplicates (< 1.5m)
|
||||||
|
existing_poles = list(SignPole.objects.all().values('id', 'code', 'geom'))
|
||||||
|
self.stdout.write(f"Existing poles in DB: {len(existing_poles)}")
|
||||||
|
|
||||||
|
# Find highest code number
|
||||||
|
max_num = 0
|
||||||
|
for ep in existing_poles:
|
||||||
|
c = ep.get('code') or ''
|
||||||
|
if c.startswith('CSE-SGP-'):
|
||||||
|
try:
|
||||||
|
num = int(c.split('-')[-1])
|
||||||
|
if num > max_num:
|
||||||
|
max_num = num
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
geojson_path = Path("/tmp/cse_picc_poles.geojson")
|
||||||
|
if not geojson_path.exists():
|
||||||
|
self.stderr.write(f"GeoJSON not found: {geojson_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(geojson_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
features = data.get('features', [])
|
||||||
|
self.stdout.write(f"PICC poles in GeoJSON: {len(features)}")
|
||||||
|
|
||||||
|
# Street poles counters for names
|
||||||
|
street_counters = {}
|
||||||
|
for s in streets:
|
||||||
|
street_counters[s.id] = SignPole.objects.filter(street=s).count()
|
||||||
|
|
||||||
|
new_poles = []
|
||||||
|
skipped_existing = 0
|
||||||
|
|
||||||
|
for feat in features:
|
||||||
|
coords = feat['geometry']['coordinates']
|
||||||
|
x, y = coords[0], coords[1]
|
||||||
|
pt = Point(x, y, srid=3812)
|
||||||
|
|
||||||
|
# Check if existing pole is close (< 1.5m)
|
||||||
|
is_duplicate = False
|
||||||
|
for ep in existing_poles:
|
||||||
|
eg = ep.get('geom')
|
||||||
|
if eg and eg.distance(pt) < 1.5:
|
||||||
|
is_duplicate = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if is_duplicate:
|
||||||
|
skipped_existing += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find closest street
|
||||||
|
closest_street = min(streets, key=lambda st: st.geom.distance(pt))
|
||||||
|
street_counters[closest_street.id] = street_counters.get(closest_street.id, 0) + 1
|
||||||
|
street_num = street_counters[closest_street.id]
|
||||||
|
|
||||||
|
max_num += 1
|
||||||
|
code = f"CSE-SGP-{max_num:04d}"
|
||||||
|
name = f"Poteau signalisation - {closest_street.name_fr or 'Rue'} #{street_num}"
|
||||||
|
|
||||||
|
pole = SignPole(
|
||||||
|
code=code,
|
||||||
|
name_fr=name,
|
||||||
|
geom=pt,
|
||||||
|
street=closest_street,
|
||||||
|
|
||||||
|
category=pole_category,
|
||||||
|
status='active',
|
||||||
|
orientation=90
|
||||||
|
)
|
||||||
|
# Sync lon, lat, geojson
|
||||||
|
pole.sync_geom_and_geojson()
|
||||||
|
new_poles.append(pole)
|
||||||
|
|
||||||
|
# Add to local cache for duplicate checking
|
||||||
|
existing_poles.append({'id': None, 'code': code, 'geom': pt})
|
||||||
|
|
||||||
|
if new_poles:
|
||||||
|
with transaction.atomic():
|
||||||
|
SignPole.objects.bulk_create(new_poles, batch_size=500)
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Created {len(new_poles)} new SignPoles (skipped {skipped_existing} existing). Total in DB: {SignPole.objects.count()}"))
|
||||||
|
else:
|
||||||
|
self.stdout.write(f"No new poles created (skipped {skipped_existing} duplicates).")
|
||||||
|
|
||||||
|
# 3. Import Panels from OSM
|
||||||
|
self.stdout.write("--- Step 3: SignPanels from OSM ---")
|
||||||
|
panel_category = AssetCategory.objects.filter(code='SI_PANEL').first()
|
||||||
|
osm_json_path = Path("/tmp/osm_cse_sign_parking.json")
|
||||||
|
|
||||||
|
if not osm_json_path.exists():
|
||||||
|
self.stderr.write(f"OSM JSON not found: {osm_json_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(osm_json_path, 'r', encoding='utf-8') as f:
|
||||||
|
osm_data = json.load(f)
|
||||||
|
|
||||||
|
osm_elements = osm_data.get('elements', [])
|
||||||
|
osm_signs = [
|
||||||
|
e for e in osm_elements
|
||||||
|
if 'traffic_sign' in e.get('tags', {}) or e.get('tags', {}).get('highway') in ('give_way', 'stop')
|
||||||
|
]
|
||||||
|
self.stdout.write(f"OSM signs found in dataset: {len(osm_signs)}")
|
||||||
|
|
||||||
|
all_poles = list(SignPole.objects.select_related('street').all())
|
||||||
|
type_b1 = SignPanelType.objects.filter(code='B1').first()
|
||||||
|
type_b5 = SignPanelType.objects.filter(code='B5').first()
|
||||||
|
type_f99a = SignPanelType.objects.filter(code='F99a').first()
|
||||||
|
|
||||||
|
max_panel_num = 0
|
||||||
|
for sp in SignPanel.objects.all().values_list('code', flat=True):
|
||||||
|
if sp and sp.startswith('CSE-PAN-'):
|
||||||
|
try:
|
||||||
|
num = int(sp.split('-')[-1])
|
||||||
|
if num > max_panel_num:
|
||||||
|
max_panel_num = num
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
panels_created = 0
|
||||||
|
for s in osm_signs:
|
||||||
|
tags = s.get('tags', {})
|
||||||
|
lat = s.get('lat') or (s.get('center') and s['center'].get('lat'))
|
||||||
|
lon = s.get('lon') or (s.get('center') and s['center'].get('lon'))
|
||||||
|
if lat is None or lon is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pt_4326 = Point(lon, lat, srid=4326)
|
||||||
|
pt_3812 = pt_4326.transform(3812, clone=True)
|
||||||
|
|
||||||
|
# Determine SignPanelType
|
||||||
|
sig = tags.get('traffic_sign') or ''
|
||||||
|
hw = tags.get('highway') or ''
|
||||||
|
panel_type = None
|
||||||
|
|
||||||
|
if 'B1' in sig or hw == 'give_way':
|
||||||
|
panel_type = type_b1
|
||||||
|
elif 'B5' in sig or hw == 'stop':
|
||||||
|
panel_type = type_b5
|
||||||
|
elif 'F99a' in sig:
|
||||||
|
panel_type = type_f99a
|
||||||
|
else:
|
||||||
|
# Try to extract code from string like "BE:B1", "BE:C43"
|
||||||
|
clean_code = sig.replace('BE:', '').split(';')[0].strip()
|
||||||
|
panel_type = SignPanelType.objects.filter(code__iexact=clean_code).first()
|
||||||
|
|
||||||
|
if not panel_type:
|
||||||
|
panel_type = type_b1 # default fallback
|
||||||
|
|
||||||
|
# Find nearest pole within 30 meters
|
||||||
|
nearest_pole = None
|
||||||
|
min_dist = float('inf')
|
||||||
|
for p in all_poles:
|
||||||
|
d = p.geom.distance(pt_3812)
|
||||||
|
if d < min_dist:
|
||||||
|
min_dist = d
|
||||||
|
nearest_pole = p
|
||||||
|
|
||||||
|
if not nearest_pole or min_dist > 35.0:
|
||||||
|
# If no pole close enough, attach to closest pole anyway or skip
|
||||||
|
if not nearest_pole:
|
||||||
|
continue
|
||||||
|
|
||||||
|
max_panel_num += 1
|
||||||
|
pcode = f"CSE-PAN-{max_panel_num:04d}"
|
||||||
|
pname = f"Panneau {panel_type.code} - {nearest_pole.street.name_fr if nearest_pole.street else 'Rue'}"
|
||||||
|
|
||||||
|
panel = SignPanel(
|
||||||
|
code=pcode,
|
||||||
|
name_fr=pname,
|
||||||
|
pole=nearest_pole,
|
||||||
|
street=nearest_pole.street,
|
||||||
|
signpanel_type=panel_type,
|
||||||
|
category=panel_category,
|
||||||
|
|
||||||
|
status='active',
|
||||||
|
orientation=nearest_pole.orientation or 90,
|
||||||
|
support_type='pole',
|
||||||
|
geom=nearest_pole.geom,
|
||||||
|
)
|
||||||
|
panel.sync_geom_and_geojson()
|
||||||
|
panel.save()
|
||||||
|
panels_created += 1
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Created {panels_created} SignPanels attached to poles. Total panels: {SignPanel.objects.count()}"))
|
||||||
125
loko/assets/management/commands/load_base_asset_categories.py
Normal file
125
loko/assets/management/commands/load_base_asset_categories.py
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
"""
|
||||||
|
Management command to load baseline asset categories (from loko-bm) and
|
||||||
|
associate existing asset models and assets with their corresponding categories.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from common.models import Thematic
|
||||||
|
from assets.models import (
|
||||||
|
AssetCategory,
|
||||||
|
CleanLitterBin, CleanLitterBinModel,
|
||||||
|
CleanGlassContainer, CleanGlassContainerModel,
|
||||||
|
RoadDrain, RoadDrainModel,
|
||||||
|
Roadway, RoadwayModel,
|
||||||
|
RoadSidewalk, RoadSidewalkModel,
|
||||||
|
RoadFurniture, RoadFurnitureModel,
|
||||||
|
StructureAssetModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Charge les catégories d'assets de base (définies dans loko-bm) et lie les modèles existants."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
'--file',
|
||||||
|
type=str,
|
||||||
|
help="Chemin vers le fichier JSON des catégories (défaut: common/fixtures/asset_categories.json)",
|
||||||
|
)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
# 1. Localiser le fichier JSON
|
||||||
|
json_path = options.get('file')
|
||||||
|
candidates = []
|
||||||
|
if not json_path:
|
||||||
|
base_dir = getattr(settings, 'BASE_DIR', None)
|
||||||
|
candidates = [
|
||||||
|
os.path.join(base_dir, 'common', 'fixtures', 'asset_categories.json') if base_dir else None,
|
||||||
|
os.path.join(base_dir, 'loko', 'common', 'fixtures', 'asset_categories.json') if base_dir else None,
|
||||||
|
'/app/common/fixtures/asset_categories.json',
|
||||||
|
'/app/loko/common/fixtures/asset_categories.json',
|
||||||
|
os.path.join(os.path.dirname(__file__), '..', '..', '..', 'common', 'fixtures', 'asset_categories.json'),
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if c and os.path.exists(c):
|
||||||
|
json_path = os.path.abspath(c)
|
||||||
|
break
|
||||||
|
|
||||||
|
if not json_path or not os.path.exists(json_path):
|
||||||
|
self.stderr.write(self.style.ERROR(f"Fichier asset_categories.json introuvable. Candidats testés: {candidates}"))
|
||||||
|
return
|
||||||
|
|
||||||
|
self.stdout.write(f"Chargement des catégories depuis : {json_path}")
|
||||||
|
with open(json_path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
created_count = 0
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
# Cache thématiques
|
||||||
|
thematics_by_code = {t.code: t for t in Thematic.objects.all()}
|
||||||
|
|
||||||
|
# 2. Création / Mise à jour des catégories
|
||||||
|
for item in data:
|
||||||
|
code = item['code']
|
||||||
|
name_fr = item['name_fr']
|
||||||
|
name_nl = item.get('name_nl') or name_fr
|
||||||
|
thematic_code = item.get('thematic_code')
|
||||||
|
thematic = thematics_by_code.get(thematic_code)
|
||||||
|
|
||||||
|
cat, created = AssetCategory.objects.update_or_create(
|
||||||
|
code=code,
|
||||||
|
defaults={
|
||||||
|
'name_fr': name_fr,
|
||||||
|
'name_nl': name_nl,
|
||||||
|
'thematic': thematic,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if created:
|
||||||
|
created_count += 1
|
||||||
|
else:
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
# Modèles Django autorisés (ContentType)
|
||||||
|
allowed_models = item.get('allowed_models', [])
|
||||||
|
if allowed_models:
|
||||||
|
cts = ContentType.objects.filter(model__in=allowed_models)
|
||||||
|
cat.allowed_models.set(cts)
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f"✅ Catégories d'assets : {created_count} créées, {updated_count} mises à jour (Total: {len(data)})."
|
||||||
|
))
|
||||||
|
|
||||||
|
# 3. Association automatique des modèles d'assets existants aux catégories correspondantes
|
||||||
|
self.link_models_to_categories()
|
||||||
|
|
||||||
|
def link_models_to_categories(self):
|
||||||
|
links = [
|
||||||
|
('CleanLitterBinModel', CleanLitterBinModel, 'CL_BASKET'),
|
||||||
|
('CleanLitterBin', CleanLitterBin, 'CL_BASKET'),
|
||||||
|
('CleanGlassContainerModel', CleanGlassContainerModel, 'CL_BUBBLE'),
|
||||||
|
('CleanGlassContainer', CleanGlassContainer, 'CL_BUBBLE'),
|
||||||
|
('RoadDrainModel', RoadDrainModel, 'RD_DRAIN'),
|
||||||
|
('RoadDrain', RoadDrain, 'RD_DRAIN'),
|
||||||
|
('RoadwayModel', RoadwayModel, 'RD_ROADWAY'),
|
||||||
|
('Roadway', Roadway, 'RD_ROADWAY'),
|
||||||
|
('RoadSidewalkModel', RoadSidewalkModel, 'RD_SIDEWALK'),
|
||||||
|
('RoadSidewalk', RoadSidewalk, 'RD_SIDEWALK'),
|
||||||
|
('RoadFurnitureModel', RoadFurnitureModel, 'RD_STREET_FURNITURE'),
|
||||||
|
('RoadFurniture', RoadFurniture, 'RD_STREET_FURNITURE'),
|
||||||
|
('StructureAssetModel', StructureAssetModel, 'TU_CIVIL_ENGINEERING'),
|
||||||
|
]
|
||||||
|
|
||||||
|
for name, model_cls, cat_code in links:
|
||||||
|
cat = AssetCategory.objects.filter(code=cat_code).first()
|
||||||
|
if not cat:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
updated = model_cls.objects.filter(category__isnull=True).update(category=cat)
|
||||||
|
if updated:
|
||||||
|
self.stdout.write(f" • {name} : {updated} enregistrement(s) lié(s) à la catégorie {cat.name_fr} ({cat_code}).")
|
||||||
|
except Exception as e:
|
||||||
|
self.stderr.write(f" ⚠️ Erreur liaison {name} -> {cat_code}: {e}")
|
||||||
|
|
@ -9,7 +9,7 @@ def create_parking_thematic(apps, schema_editor):
|
||||||
thematic, _created = Thematic.objects.get_or_create(
|
thematic, _created = Thematic.objects.get_or_create(
|
||||||
code='parking',
|
code='parking',
|
||||||
defaults={
|
defaults={
|
||||||
'name_fr': 'Stationnement & Mobilité',
|
'name_fr': 'Stationnement',
|
||||||
'name_nl': 'Parkeren & Mobiliteit',
|
'name_nl': 'Parkeren & Mobiliteit',
|
||||||
'description': 'Thématique dédiée aux emplacements de stationnement et zones de mobilité (vélos, trottinettes, PMR, livraison)',
|
'description': 'Thématique dédiée aux emplacements de stationnement et zones de mobilité (vélos, trottinettes, PMR, livraison)',
|
||||||
'icon': 'bi-p-square',
|
'icon': 'bi-p-square',
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ def create_bike_parking_category(apps, schema_editor):
|
||||||
thematic, _ = Thematic.objects.get_or_create(
|
thematic, _ = Thematic.objects.get_or_create(
|
||||||
code='parking',
|
code='parking',
|
||||||
defaults={
|
defaults={
|
||||||
'name_fr': 'Stationnement & Mobilité',
|
'name_fr': 'Stationnement',
|
||||||
'name_nl': 'Parkeren & Mobiliteit',
|
'name_nl': 'Parkeren & Mobiliteit',
|
||||||
'description': 'Thématique dédiée aux emplacements de stationnement et zones de mobilité (vélos, trottinettes, PMR, livraison)',
|
'description': 'Thématique dédiée aux emplacements de stationnement et zones de mobilité (vélos, trottinettes, PMR, livraison)',
|
||||||
'icon': 'bi-p-square',
|
'icon': 'bi-p-square',
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-09-21 11:59
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('assets', '0026_add_naturetree_specific_fields'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='structure',
|
||||||
|
name='structure_type',
|
||||||
|
field=models.CharField(blank=True, choices=[('tunnel', 'Tunnel'), ('bridge', 'Pont'), ('viaduct', 'Viaduc'), ('overpass', 'Passerelle'), ('underpass', 'Passage souterrain'), ('gallery', 'Galerie technique'), ('atypical', 'Ouvrage atypique'), ('metro_station', 'Station de métro'), ('metro_tunnel', 'Tunnel de métro')], max_length=200, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -17,13 +17,15 @@ private_storage = PrivateMediaStorage()
|
||||||
|
|
||||||
|
|
||||||
STRUCTURE_TYPE_CHOICES = (
|
STRUCTURE_TYPE_CHOICES = (
|
||||||
('tunnel', 'Tunnnel'),
|
('tunnel', _('Tunnel')),
|
||||||
('bridge', 'Pont'),
|
('bridge', _('Pont')),
|
||||||
('viaduct', 'Viaduc'),
|
('viaduct', _('Viaduc')),
|
||||||
('overpass', 'Passerelle'),
|
('overpass', _('Passerelle')),
|
||||||
('underpass', 'Passage souterrain'),
|
('underpass', _('Passage souterrain')),
|
||||||
('gallery', 'Galerie technique'),
|
('gallery', _('Galerie technique')),
|
||||||
('atypical', 'Ouvrage atypique'),
|
('atypical', _('Ouvrage atypique')),
|
||||||
|
('metro_station', _('Station de métro')),
|
||||||
|
('metro_tunnel', _('Tunnel de métro')),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -796,3 +796,92 @@ def can_edit_exceptional_transport(user):
|
||||||
and getattr(user_config, 'exceptional_transport_can_edit', False)
|
and getattr(user_config, 'exceptional_transport_can_edit', False)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_accessible_municipalities(user):
|
||||||
|
"""
|
||||||
|
Retourne le queryset des communes (Municipality) auxquelles l'utilisateur a accès.
|
||||||
|
- Si superuser ou (utilisateur interne sans limitation aux contrats) :
|
||||||
|
retourne toutes les communes (Municipality.objects.all()).
|
||||||
|
- Si utilisateur externe ou avec limit_assets_to_contracts=True :
|
||||||
|
retourne uniquement les communes associées à ses contrats accessibles
|
||||||
|
(via Contract.municipalities ou localisations liées).
|
||||||
|
"""
|
||||||
|
from common.models import Municipality, UserConfig, UserContractAccess
|
||||||
|
from django.utils.translation import get_language
|
||||||
|
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return Municipality.objects.none()
|
||||||
|
|
||||||
|
if getattr(user, 'is_superuser', False):
|
||||||
|
lang = get_language() or 'fr'
|
||||||
|
order_field = 'name_nl' if lang.startswith('nl') else 'name_fr'
|
||||||
|
return Municipality.objects.all().order_by(order_field)
|
||||||
|
|
||||||
|
user_config = getattr(user, 'config', None)
|
||||||
|
if not user_config:
|
||||||
|
try:
|
||||||
|
user_config = UserConfig.objects.get(user=user)
|
||||||
|
except UserConfig.DoesNotExist:
|
||||||
|
return Municipality.objects.none()
|
||||||
|
|
||||||
|
if user_config.has_role('admin') or user_config.has_role('top_manager'):
|
||||||
|
lang = get_language() or 'fr'
|
||||||
|
order_field = 'name_nl' if lang.startswith('nl') else 'name_fr'
|
||||||
|
return Municipality.objects.all().order_by(order_field)
|
||||||
|
|
||||||
|
is_external = not user_config.is_intern
|
||||||
|
must_limit = is_external or user_config.limit_assets_to_contracts
|
||||||
|
|
||||||
|
if not must_limit:
|
||||||
|
lang = get_language() or 'fr'
|
||||||
|
order_field = 'name_nl' if lang.startswith('nl') else 'name_fr'
|
||||||
|
return Municipality.objects.all().order_by(order_field)
|
||||||
|
|
||||||
|
# Utilisateur restreint par contrats :
|
||||||
|
accessible_contract_ids = set(
|
||||||
|
UserContractAccess.objects.filter(
|
||||||
|
user_config=user_config,
|
||||||
|
can_view_assets=True
|
||||||
|
).values_list('contract_id', flat=True)
|
||||||
|
)
|
||||||
|
if user_config.default_contract_id:
|
||||||
|
accessible_contract_ids.add(user_config.default_contract_id)
|
||||||
|
|
||||||
|
if not accessible_contract_ids:
|
||||||
|
return Municipality.objects.none()
|
||||||
|
|
||||||
|
# Communes associées via ManyToMany Contract.municipalities
|
||||||
|
municipality_ids = set(
|
||||||
|
Municipality.objects.filter(
|
||||||
|
contracts__id__in=accessible_contract_ids
|
||||||
|
).values_list('id', flat=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Communes associées via les localisations liées aux contrats
|
||||||
|
try:
|
||||||
|
from assets.models import CleanLocationContract
|
||||||
|
clean_muni_ids = CleanLocationContract.objects.filter(
|
||||||
|
contract_id__in=accessible_contract_ids,
|
||||||
|
location__municipality__isnull=False
|
||||||
|
).values_list('location__municipality_id', flat=True)
|
||||||
|
municipality_ids.update(clean_muni_ids)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from assets.models import TrafficLightContract
|
||||||
|
tl_muni_ids = TrafficLightContract.objects.filter(
|
||||||
|
contract_id__in=accessible_contract_ids,
|
||||||
|
intersection__municipality__isnull=False
|
||||||
|
).values_list('intersection__municipality_id', flat=True)
|
||||||
|
municipality_ids.update(tl_muni_ids)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not municipality_ids:
|
||||||
|
return Municipality.objects.none()
|
||||||
|
|
||||||
|
lang = get_language() or 'fr'
|
||||||
|
order_field = 'name_nl' if lang.startswith('nl') else 'name_fr'
|
||||||
|
return Municipality.objects.filter(id__in=municipality_ids).order_by(order_field)
|
||||||
|
|
|
||||||
|
|
@ -244,6 +244,15 @@
|
||||||
glyphCodepoint: 'f5e1',
|
glyphCodepoint: 'f5e1',
|
||||||
mapImage: 'icon-naturebench',
|
mapImage: 'icon-naturebench',
|
||||||
},
|
},
|
||||||
|
natureplanter: {
|
||||||
|
thematic: 'nature',
|
||||||
|
color: '#d97706',
|
||||||
|
iconUrl: '/static/assets/nature_planter_icon.svg',
|
||||||
|
iconSize: 38,
|
||||||
|
iconClass: 'bi-flower2',
|
||||||
|
glyphCodepoint: 'f3ce',
|
||||||
|
mapImage: 'icon-natureplanter',
|
||||||
|
},
|
||||||
naturerwiasset: {
|
naturerwiasset: {
|
||||||
thematic: 'nature',
|
thematic: 'nature',
|
||||||
color: '#0277bd',
|
color: '#0277bd',
|
||||||
|
|
@ -345,11 +354,21 @@
|
||||||
},
|
},
|
||||||
roadfurniture: {
|
roadfurniture: {
|
||||||
thematic: 'roads',
|
thematic: 'roads',
|
||||||
color: '#607d8b',
|
color: '#e67700',
|
||||||
iconUrl: null,
|
iconUrl: '/static/assets/road_speedbump_icon.svg?v=20260920',
|
||||||
iconClass: 'bi-map-fill',
|
iconSize: 44,
|
||||||
glyphCodepoint: 'f47e',
|
iconClass: 'bi-triangle-half',
|
||||||
mapImage: 'icon-roads',
|
glyphCodepoint: 'f66d',
|
||||||
|
mapImage: 'icon-speedbump',
|
||||||
|
},
|
||||||
|
streetfurniture: {
|
||||||
|
thematic: 'roads',
|
||||||
|
color: '#e67700',
|
||||||
|
iconUrl: '/static/assets/road_speedbump_icon.svg?v=20260920',
|
||||||
|
iconSize: 44,
|
||||||
|
iconClass: 'bi-triangle-half',
|
||||||
|
glyphCodepoint: 'f66d',
|
||||||
|
mapImage: 'icon-speedbump',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Clean
|
// Clean
|
||||||
|
|
@ -363,11 +382,12 @@
|
||||||
},
|
},
|
||||||
cleanglasscontainer: {
|
cleanglasscontainer: {
|
||||||
thematic: 'clean',
|
thematic: 'clean',
|
||||||
color: '#5e5e5e',
|
color: '#16a34a',
|
||||||
iconUrl: null,
|
iconUrl: '/static/assets/clean_glass_container_icon.svg?v=20260920',
|
||||||
iconClass: 'bi-trash3-fill',
|
iconSize: 46,
|
||||||
|
iconClass: 'bi-recycle',
|
||||||
glyphCodepoint: 'f78a',
|
glyphCodepoint: 'f78a',
|
||||||
mapImage: 'icon-clean',
|
mapImage: 'icon-cleanglasscontainer',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Artworks
|
// Artworks
|
||||||
|
|
@ -531,6 +551,14 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Specific asset_type override
|
// 3. Specific asset_type override
|
||||||
|
if (assetType === 'naturebench') {
|
||||||
|
const isPlanter = (item.asset_sub_type === 'planter') ||
|
||||||
|
['Bac à fleurs / Jardinière', 'Bloembak', 'Jardinière', 'Bac à fleurs', 'Bac à plantes'].includes(item.model) ||
|
||||||
|
(typeof item.code === 'string' && item.code.startsWith('CSE-BAC'));
|
||||||
|
if (isPlanter && ASSET_TYPE_OVERRIDES.natureplanter) {
|
||||||
|
return Object.assign({}, ASSET_TYPE_OVERRIDES.natureplanter);
|
||||||
|
}
|
||||||
|
}
|
||||||
if (assetType && ASSET_TYPE_OVERRIDES[assetType]) {
|
if (assetType && ASSET_TYPE_OVERRIDES[assetType]) {
|
||||||
return Object.assign({}, ASSET_TYPE_OVERRIDES[assetType]);
|
return Object.assign({}, ASSET_TYPE_OVERRIDES[assetType]);
|
||||||
}
|
}
|
||||||
|
|
@ -602,6 +630,7 @@
|
||||||
roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl,
|
roaddrain: injected.roaddrain || ASSET_TYPE_OVERRIDES.roaddrain.iconUrl,
|
||||||
roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl,
|
roadconcreteblockset: injected.roadconcreteblockset || ASSET_TYPE_OVERRIDES.roadconcreteblockset.iconUrl,
|
||||||
naturebench: injected.naturebench || ASSET_TYPE_OVERRIDES.naturebench.iconUrl,
|
naturebench: injected.naturebench || ASSET_TYPE_OVERRIDES.naturebench.iconUrl,
|
||||||
|
natureplanter: injected.natureplanter || (ASSET_TYPE_OVERRIDES.natureplanter && ASSET_TYPE_OVERRIDES.natureplanter.iconUrl),
|
||||||
artworkfountain: injected.artworkfountain || ASSET_TYPE_OVERRIDES.artworkfountain.iconUrl,
|
artworkfountain: injected.artworkfountain || ASSET_TYPE_OVERRIDES.artworkfountain.iconUrl,
|
||||||
artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl,
|
artworkmonument: injected.artworkmonument || ASSET_TYPE_OVERRIDES.artworkmonument.iconUrl,
|
||||||
};
|
};
|
||||||
|
|
@ -622,11 +651,13 @@
|
||||||
{ name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' },
|
{ name: 'icon-sign', codepoint: 'f82b', color: '#0022ff' },
|
||||||
{ name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' },
|
{ name: 'icon-nature', codepoint: 'f5e1', color: '#2e7d32' },
|
||||||
{ name: 'icon-naturebench', codepoint: 'f5e1', color: '#2e7d32' },
|
{ name: 'icon-naturebench', codepoint: 'f5e1', color: '#2e7d32' },
|
||||||
|
{ name: 'icon-natureplanter', codepoint: 'f3ce', color: '#d97706' },
|
||||||
{ name: 'icon-naturerwiasset', codepoint: 'f30b', color: '#0277bd' },
|
{ name: 'icon-naturerwiasset', codepoint: 'f30b', color: '#0277bd' },
|
||||||
{ name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' },
|
{ name: 'icon-roaddrain', codepoint: 'f617', color: '#1e88e5' },
|
||||||
{ name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' },
|
{ name: 'icon-structures', codepoint: 'f876', color: '#6d4c41' },
|
||||||
{ name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' },
|
{ name: 'icon-its', codepoint: 'f1d5', color: '#0288d1' },
|
||||||
{ name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' },
|
{ name: 'icon-clean', codepoint: 'f78a', color: '#5e5e5e' },
|
||||||
|
{ name: 'icon-cleanglasscontainer', codepoint: 'f78a', color: '#16a34a' },
|
||||||
{ name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' },
|
{ name: 'icon-artworks', codepoint: 'f4b0', color: '#f24141' },
|
||||||
{ name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' },
|
{ name: 'icon-artworkfountain', codepoint: 'f617', color: '#1e88e5' },
|
||||||
{ name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' },
|
{ name: 'icon-artworkmonument', codepoint: 'f8f9', color: '#f24141' },
|
||||||
|
|
@ -638,6 +669,7 @@
|
||||||
{ name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' },
|
{ name: 'icon-outdoorlocation', codepoint: 'f302', color: '#198754' },
|
||||||
{ name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' },
|
{ name: 'icon-outdoorasset', codepoint: 'f302', color: '#198754' },
|
||||||
{ name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' },
|
{ name: 'icon-roads', codepoint: 'f47e', color: '#607d8b' },
|
||||||
|
{ name: 'icon-speedbump', codepoint: 'f66d', color: '#e67700' },
|
||||||
{ name: 'icon-parking', codepoint: 'f806', color: '#0d6efd' },
|
{ name: 'icon-parking', codepoint: 'f806', color: '#0d6efd' },
|
||||||
{ name: 'icon-parkingspot', codepoint: 'f806', color: '#0d6efd' },
|
{ name: 'icon-parkingspot', codepoint: 'f806', color: '#0d6efd' },
|
||||||
{ name: 'icon-parkingspot-bicycle', codepoint: 'f18d', color: '#10b981' },
|
{ name: 'icon-parkingspot-bicycle', codepoint: 'f18d', color: '#10b981' },
|
||||||
|
|
@ -695,11 +727,26 @@
|
||||||
['==', ['get', 'asset_type'], 'trafficlightnetworkhardware'], 'icon-trafficlightsequipment',
|
['==', ['get', 'asset_type'], 'trafficlightnetworkhardware'], 'icon-trafficlightsequipment',
|
||||||
['==', ['get', 'asset_type'], 'trafficlightvoltagehardware'], 'icon-trafficlightsequipment',
|
['==', ['get', 'asset_type'], 'trafficlightvoltagehardware'], 'icon-trafficlightsequipment',
|
||||||
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], 'icon-publiclightingelectricalcabinet',
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'], 'icon-publiclightingelectricalcabinet',
|
||||||
|
['==', ['get', 'asset_type'], 'cleanglasscontainer'], 'icon-cleanglasscontainer',
|
||||||
|
['==', ['get', 'category'], 'Bulle'], 'icon-cleanglasscontainer',
|
||||||
|
['==', ['get', 'category'], 'Bulle à verre'], 'icon-cleanglasscontainer',
|
||||||
['==', ['get', 'asset_type'], 'artworkfountain'], 'icon-artworkfountain',
|
['==', ['get', 'asset_type'], 'artworkfountain'], 'icon-artworkfountain',
|
||||||
['==', ['get', 'asset_type'], 'artworkmonument'], 'icon-artworkmonument',
|
['==', ['get', 'asset_type'], 'artworkmonument'], 'icon-artworkmonument',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
], 'icon-natureplanter',
|
||||||
['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench',
|
['==', ['get', 'asset_type'], 'naturebench'], 'icon-naturebench',
|
||||||
['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain',
|
['==', ['get', 'asset_type'], 'drain'], 'icon-roaddrain',
|
||||||
['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain',
|
['==', ['get', 'asset_type'], 'roaddrain'], 'icon-roaddrain',
|
||||||
|
['==', ['get', 'category'], 'Ralentisseur'], 'icon-speedbump',
|
||||||
|
['==', ['get', 'asset_type'], 'streetfurniture'], 'icon-speedbump',
|
||||||
|
['==', ['get', 'asset_type'], 'roadfurniture'], 'icon-speedbump',
|
||||||
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset',
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 'icon-roadconcreteblockset',
|
||||||
['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset',
|
['==', ['get', 'asset_type'], 'naturerwiasset'], 'icon-naturerwiasset',
|
||||||
['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone',
|
['==', ['get', 'spot_type'], 'scooter_drop_zone'], 'icon-parkingspot-scooter_drop_zone',
|
||||||
|
|
@ -772,12 +819,24 @@
|
||||||
['coalesce', ['get', 'color'], '#2e7d32'],
|
['coalesce', ['get', 'color'], '#2e7d32'],
|
||||||
['==', ['get', 'thematic'], 'parking'],
|
['==', ['get', 'thematic'], 'parking'],
|
||||||
getParkingColorExpr(),
|
getParkingColorExpr(),
|
||||||
|
['==', ['get', 'thematic'], 'roads'],
|
||||||
|
[
|
||||||
|
'match',
|
||||||
|
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
|
||||||
|
['1', '1 - Très mauvais'], '#e03131',
|
||||||
|
['2', '2 - Mauvais'], '#f76707',
|
||||||
|
['3', '3 - Bon'], '#fcc419',
|
||||||
|
['4', '4 - Très bon'], '#74b816',
|
||||||
|
['5', '5 - Excellent'], '#2f9e44',
|
||||||
|
'#4a4a4a'
|
||||||
|
],
|
||||||
getThematicColorExpr()
|
getThematicColorExpr()
|
||||||
],
|
],
|
||||||
'fill-opacity': [
|
'fill-opacity': [
|
||||||
'case',
|
'case',
|
||||||
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 0.8,
|
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 0.8,
|
||||||
['==', ['get', 'thematic'], 'parking'], 0.38,
|
['==', ['get', 'thematic'], 'parking'], 0.38,
|
||||||
|
['==', ['get', 'thematic'], 'roads'], 0.28,
|
||||||
0.65
|
0.65
|
||||||
],
|
],
|
||||||
'fill-antialias': false,
|
'fill-antialias': false,
|
||||||
|
|
@ -811,14 +870,30 @@
|
||||||
['coalesce', ['get', 'color'], '#1b5e20'],
|
['coalesce', ['get', 'color'], '#1b5e20'],
|
||||||
['==', ['get', 'thematic'], 'parking'],
|
['==', ['get', 'thematic'], 'parking'],
|
||||||
getParkingColorExpr(),
|
getParkingColorExpr(),
|
||||||
|
['==', ['get', 'thematic'], 'roads'],
|
||||||
|
[
|
||||||
|
'match',
|
||||||
|
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
|
||||||
|
['1', '1 - Très mauvais'], '#c92a2a',
|
||||||
|
['2', '2 - Mauvais'], '#e8590c',
|
||||||
|
['3', '3 - Bon'], '#f59f00',
|
||||||
|
['4', '4 - Très bon'], '#66a80f',
|
||||||
|
['5', '5 - Excellent'], '#2b8a3e',
|
||||||
|
'#4a4a4a'
|
||||||
|
],
|
||||||
getThematicColorExpr()
|
getThematicColorExpr()
|
||||||
],
|
],
|
||||||
'line-width': [
|
'line-width': [
|
||||||
'case',
|
'case',
|
||||||
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 4,
|
['any', ['boolean', ['feature-state', 'highlighted'], false], ['boolean', ['feature-state', 'selected'], false]], 4,
|
||||||
|
['==', ['get', 'thematic'], 'roads'], 1.5,
|
||||||
2
|
2
|
||||||
],
|
],
|
||||||
'line-opacity': 0.95,
|
'line-opacity': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'thematic'], 'roads'], 0.65,
|
||||||
|
0.95
|
||||||
|
],
|
||||||
},
|
},
|
||||||
minzoom: 8,
|
minzoom: 8,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.intervention-btn {
|
.intervention-btn {
|
||||||
color: var(--brand-color);
|
color: #0f172a;
|
||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.intersection-btn-toggle{
|
.intersection-btn-toggle{
|
||||||
color: var(--brand-color);
|
color: #0f172a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.separator-top {
|
.separator-top {
|
||||||
border-top: 1px solid var(--brand-color);
|
border-top: 1px solid #e2e8f0;
|
||||||
margin-top: 2rem; /* optionnel */
|
margin-top: 2rem; /* optionnel */
|
||||||
padding-top: 2rem; /* optionnel */
|
padding-top: 2rem; /* optionnel */
|
||||||
}
|
}
|
||||||
48
loko/assets/static/assets/clean_glass_container_icon.svg
Normal file
48
loko/assets/static/assets/clean_glass_container_icon.svg
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="glassDome" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#22c55e"/>
|
||||||
|
<stop offset="40%" stop-color="#16a34a"/>
|
||||||
|
<stop offset="100%" stop-color="#15803d"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="glassBase" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#14532d"/>
|
||||||
|
<stop offset="100%" stop-color="#052e16"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="glassShadow" x="-15%" y="-15%" width="130%" height="135%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.35"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Top crane lifting ring (anneau de préhension) -->
|
||||||
|
<circle cx="32" cy="11" r="5.5" fill="none" stroke="#475569" stroke-width="3" filter="url(#glassShadow)"/>
|
||||||
|
<rect x="29.5" y="14" width="5" height="4" fill="#334155"/>
|
||||||
|
|
||||||
|
<!-- Base rim / skirt -->
|
||||||
|
<rect x="8" y="51" width="48" height="6" rx="2.5" fill="url(#glassBase)" filter="url(#glassShadow)"/>
|
||||||
|
|
||||||
|
<!-- Main Igloo / Bell Dome Body -->
|
||||||
|
<path d="M 10 51 C 10 27, 19 16, 32 16 C 45 16, 54 27, 54 51 Z"
|
||||||
|
fill="url(#glassDome)" stroke="#166534" stroke-width="1.5" filter="url(#glassShadow)"/>
|
||||||
|
|
||||||
|
<!-- Dome highlight curve -->
|
||||||
|
<path d="M 14 49 C 14 29, 21 19, 32 19" fill="none" stroke="#86efac" stroke-width="2" stroke-linecap="round" opacity="0.6"/>
|
||||||
|
|
||||||
|
<!-- Left bottle drop aperture (orifice bouteilles) -->
|
||||||
|
<circle cx="21" cy="36" r="6" fill="#0f172a" stroke="#f1f5f9" stroke-width="1.8"/>
|
||||||
|
<circle cx="21" cy="36" r="4" fill="#020617"/>
|
||||||
|
|
||||||
|
<!-- Right bottle drop aperture (orifice bouteilles) -->
|
||||||
|
<circle cx="43" cy="36" r="6" fill="#0f172a" stroke="#f1f5f9" stroke-width="1.8"/>
|
||||||
|
<circle cx="43" cy="36" r="4" fill="#020617"/>
|
||||||
|
|
||||||
|
<!-- Center white glass bottle emblem -->
|
||||||
|
<!-- Cap / lip -->
|
||||||
|
<rect x="30" y="27" width="4" height="1.5" rx="0.5" fill="#ffffff"/>
|
||||||
|
<!-- Neck -->
|
||||||
|
<rect x="30.5" y="28.5" width="3" height="5" rx="0.5" fill="#ffffff"/>
|
||||||
|
<!-- Shoulder & Body -->
|
||||||
|
<path d="M 30.5 33.5 C 28.5 35.5, 28 38, 28 40 L 28 46 C 28 47.5, 36 47.5, 36 46 L 36 40 C 36 38, 35.5 35.5, 33.5 33.5 Z" fill="#ffffff"/>
|
||||||
|
<!-- Bottle shine -->
|
||||||
|
<line x1="30" y1="38" x2="30" y2="44" stroke="#86efac" stroke-width="1" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -89,9 +89,11 @@
|
||||||
|
|
||||||
if (typeof AddressSearchControl !== 'undefined') {
|
if (typeof AddressSearchControl !== 'undefined') {
|
||||||
const autocompleteUrl = window.AUTOCOMPLETE_STREET_URL || '/common/autocomplete-street/';
|
const autocompleteUrl = window.AUTOCOMPLETE_STREET_URL || '/common/autocomplete-street/';
|
||||||
|
const i18n = window.INSPECTION_MAP_I18N || {};
|
||||||
|
const placeholder = i18n.searchAddress || (typeof gettext !== 'undefined' ? gettext('Rechercher une adresse…') : 'Rechercher une adresse…');
|
||||||
const searchCtrl = new AddressSearchControl({
|
const searchCtrl = new AddressSearchControl({
|
||||||
autocompleteUrl: autocompleteUrl,
|
autocompleteUrl: autocompleteUrl,
|
||||||
placeholder: 'Rechercher une adresse...'
|
placeholder: placeholder
|
||||||
});
|
});
|
||||||
map.addControl(searchCtrl, 'top-left');
|
map.addControl(searchCtrl, 'top-left');
|
||||||
}
|
}
|
||||||
|
|
@ -744,8 +746,9 @@
|
||||||
function locateUser() {
|
function locateUser() {
|
||||||
if (!navigator.geolocation) return;
|
if (!navigator.geolocation) return;
|
||||||
|
|
||||||
|
const i18n = window.INSPECTION_MAP_I18N || {};
|
||||||
const statusText = document.getElementById('gps-status-text');
|
const statusText = document.getElementById('gps-status-text');
|
||||||
if (statusText) statusText.textContent = 'Géolocalisation...';
|
if (statusText) statusText.textContent = i18n.geolocation || (typeof gettext !== 'undefined' ? gettext('Géolocalisation...') : 'Géolocalisation...');
|
||||||
|
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
function(position) {
|
function(position) {
|
||||||
|
|
@ -766,11 +769,11 @@
|
||||||
|
|
||||||
map.flyTo({ center: [lng, lat], zoom: 17 });
|
map.flyTo({ center: [lng, lat], zoom: 17 });
|
||||||
}
|
}
|
||||||
if (statusText) statusText.textContent = 'Position actualisée';
|
if (statusText) statusText.textContent = i18n.positionUpdated || (typeof gettext !== 'undefined' ? gettext('Position actualisée') : 'Position actualisée');
|
||||||
},
|
},
|
||||||
function(err) {
|
function(err) {
|
||||||
console.warn('GPS error:', err.message);
|
console.warn('GPS error:', err.message);
|
||||||
if (statusText) statusText.textContent = 'GPS indisponible';
|
if (statusText) statusText.textContent = i18n.gpsUnavailable || (typeof gettext !== 'undefined' ? gettext('GPS indisponible') : 'GPS indisponible');
|
||||||
},
|
},
|
||||||
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 5000 }
|
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 5000 }
|
||||||
);
|
);
|
||||||
|
|
@ -931,13 +934,17 @@
|
||||||
const STORAGE_HISTORY_PERIOD_KEY = 'loko_insp_history_period';
|
const STORAGE_HISTORY_PERIOD_KEY = 'loko_insp_history_period';
|
||||||
|
|
||||||
function getHistoryPeriodLabels() {
|
function getHistoryPeriodLabels() {
|
||||||
|
const i18n = window.INSPECTION_MAP_I18N || {};
|
||||||
|
if (i18n.periodLabels) {
|
||||||
|
return i18n.periodLabels;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
'disabled': 'Inspections (Désactivé)',
|
'disabled': (typeof gettext !== 'undefined' ? gettext('Inspections (Désactivé)') : 'Inspections (Désactivé)'),
|
||||||
'1m': 'Inspections (1 mois)',
|
'1m': (typeof gettext !== 'undefined' ? gettext('Inspections (1 mois)') : 'Inspections (1 mois)'),
|
||||||
'3m': 'Inspections (3 mois)',
|
'3m': (typeof gettext !== 'undefined' ? gettext('Inspections (3 mois)') : 'Inspections (3 mois)'),
|
||||||
'6m': 'Inspections (6 mois)',
|
'6m': (typeof gettext !== 'undefined' ? gettext('Inspections (6 mois)') : 'Inspections (6 mois)'),
|
||||||
'1y': 'Inspections (1 an)',
|
'1y': (typeof gettext !== 'undefined' ? gettext('Inspections (1 an)') : 'Inspections (1 an)'),
|
||||||
'all': 'Inspections (Tout)'
|
'all': (typeof gettext !== 'undefined' ? gettext('Inspections (Tout)') : 'Inspections (Tout)')
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -959,7 +966,7 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (labelEl) labelEl.textContent = labels[period] || 'Inspections (3 mois)';
|
if (labelEl) labelEl.textContent = labels[period] || labels['3m'] || (typeof gettext !== 'undefined' ? gettext('Inspections (3 mois)') : 'Inspections (3 mois)');
|
||||||
if (iconEl) {
|
if (iconEl) {
|
||||||
if (period === 'disabled') {
|
if (period === 'disabled') {
|
||||||
iconEl.className = 'bi bi-eye-slash text-muted me-1';
|
iconEl.className = 'bi bi-eye-slash text-muted me-1';
|
||||||
|
|
@ -1980,9 +1987,9 @@
|
||||||
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||||
<span class="badge bg-primary rounded-pill small">#${order} (${labelFr || 'Panneau'})</span>
|
<span class="badge bg-primary rounded-pill small">#${order} (${labelFr || (window.gettext ? window.gettext('Panneau') : 'Panneau')})</span>
|
||||||
${confidence ? `<span class="badge bg-success-subtle text-success border border-success-subtle extra-small">IA: ${Math.round(confidence * 100)}%</span>` : ''}
|
${confidence ? `<span class="badge bg-success-subtle text-success border border-success-subtle extra-small">IA: ${Math.round(confidence * 100)}%</span>` : ''}
|
||||||
<button type="button" class="btn btn-outline-danger btn-xs py-0 px-1.5 btn-remove-panel-row" style="font-size: 0.75rem;" title="Supprimer ce panneau">
|
<button type="button" class="btn btn-outline-danger btn-xs py-0 px-1.5 btn-remove-panel-row" style="font-size: 0.75rem;" title="${window.gettext ? window.gettext('Supprimer ce panneau') : 'Supprimer ce panneau'}">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1994,23 +2001,23 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="input-group input-group-sm mb-1">
|
<div class="input-group input-group-sm mb-1">
|
||||||
<span class="input-group-text fw-bold">Code</span>
|
<span class="input-group-text fw-bold">${window.gettext ? window.gettext('Code') : 'Code'}</span>
|
||||||
<input type="text" class="form-control form-control-sm text-uppercase panel-row-code" value="${upperCode}" placeholder="Ex: C1, D7, B1, M2...">
|
<input type="text" class="form-control form-control-sm text-uppercase panel-row-code" value="${upperCode}" placeholder="Ex: C1, D7, B1, M2...">
|
||||||
</div>
|
</div>
|
||||||
<input type="text" class="form-control form-control-sm panel-row-text" value="${text || ''}" placeholder="Mention (ex: Sauf riverains)">
|
<input type="text" class="form-control form-control-sm panel-row-text" value="${text || ''}" placeholder="${window.gettext ? window.gettext('Mention (ex: Sauf riverains)') : 'Mention (ex: Sauf riverains)'}">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 pt-1 border-top d-flex align-items-center justify-content-between">
|
<div class="mt-2 pt-1 border-top d-flex align-items-center justify-content-between">
|
||||||
<small class="text-muted extra-small">Propreté :</small>
|
<small class="text-muted extra-small">${window.gettext ? window.gettext('Propreté :') : 'Propreté :'}</small>
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_clean_${order}_${Date.now()}" value="clean" ${cleanliness === 'clean' ? 'checked' : ''}>
|
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_clean_${order}_${Date.now()}" value="clean" ${cleanliness === 'clean' ? 'checked' : ''}>
|
||||||
<label class="btn btn-outline-success btn-xs py-0 px-2" for="rc_clean_${order}_${Date.now()}">Propre</label>
|
<label class="btn btn-outline-success btn-xs py-0 px-2" for="rc_clean_${order}_${Date.now()}">${window.gettext ? window.gettext('Propre') : 'Propre'}</label>
|
||||||
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_dirty_${order}_${Date.now()}" value="dirty" ${cleanliness === 'dirty' ? 'checked' : ''}>
|
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_dirty_${order}_${Date.now()}" value="dirty" ${cleanliness === 'dirty' ? 'checked' : ''}>
|
||||||
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_dirty_${order}_${Date.now()}">Sale</label>
|
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_dirty_${order}_${Date.now()}">${window.gettext ? window.gettext('Sale') : 'Sale'}</label>
|
||||||
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_veg_${order}_${Date.now()}" value="vegetation" ${cleanliness === 'vegetation' ? 'checked' : ''}>
|
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_veg_${order}_${Date.now()}" value="vegetation" ${cleanliness === 'vegetation' ? 'checked' : ''}>
|
||||||
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_veg_${order}_${Date.now()}">Végétation</label>
|
<label class="btn btn-outline-warning btn-xs py-0 px-2" for="rc_veg_${order}_${Date.now()}">${window.gettext ? window.gettext('Végétation') : 'Végétation'}</label>
|
||||||
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_vand_${order}_${Date.now()}" value="vandalized" ${cleanliness === 'vandalized' ? 'checked' : ''}>
|
<input type="radio" class="btn-check panel-row-clean" name="row_clean_${order}_${Date.now()}" id="rc_vand_${order}_${Date.now()}" value="vandalized" ${cleanliness === 'vandalized' ? 'checked' : ''}>
|
||||||
<label class="btn btn-outline-danger btn-xs py-0 px-2" for="rc_vand_${order}_${Date.now()}">Dégradé</label>
|
<label class="btn btn-outline-danger btn-xs py-0 px-2" for="rc_vand_${order}_${Date.now()}">${window.gettext ? window.gettext('Dégradé') : 'Dégradé'}</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
@ -2043,13 +2050,13 @@
|
||||||
const lon = document.getElementById('new_sign_lon')?.value;
|
const lon = document.getElementById('new_sign_lon')?.value;
|
||||||
|
|
||||||
if (!lat || !lon) {
|
if (!lat || !lon) {
|
||||||
alert('Coordonnées GPS manquantes.');
|
alert(window.gettext ? window.gettext('Coordonnées GPS manquantes.') : 'Coordonnées GPS manquantes.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = document.querySelectorAll('.new-sign-panel-row');
|
const rows = document.querySelectorAll('.new-sign-panel-row');
|
||||||
if (!rows.length) {
|
if (!rows.length) {
|
||||||
alert('Veuillez ajouter au moins un panneau.');
|
alert(window.gettext ? window.gettext('Veuillez ajouter au moins un panneau.') : 'Veuillez ajouter au moins un panneau.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2074,7 +2081,8 @@
|
||||||
newSignPhotos.forEach(p => formData.append('photos', p));
|
newSignPhotos.forEach(p => formData.append('photos', p));
|
||||||
|
|
||||||
btnSubmit.disabled = true;
|
btnSubmit.disabled = true;
|
||||||
btnSubmit.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span> Création en cours...';
|
const creatingMsg = window.gettext ? window.gettext('Création en cours...') : 'Création en cours...';
|
||||||
|
btnSubmit.innerHTML = `<span class="spinner-border spinner-border-sm me-1"></span> ${creatingMsg}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const csrfToken = getCsrfToken();
|
const csrfToken = getCsrfToken();
|
||||||
|
|
@ -2095,7 +2103,7 @@
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
modal.hide();
|
modal.hide();
|
||||||
alert(data.message || 'Panneaux créés avec succès !');
|
alert(data.message || (window.gettext ? window.gettext('Panneaux créés avec succès !') : 'Panneaux créés avec succès !'));
|
||||||
|
|
||||||
// Basculer automatiquement sur la thématique Signalisation
|
// Basculer automatiquement sur la thématique Signalisation
|
||||||
localStorage.setItem(STORAGE_THEMATIC_KEY, 'sign');
|
localStorage.setItem(STORAGE_THEMATIC_KEY, 'sign');
|
||||||
|
|
@ -2113,13 +2121,17 @@
|
||||||
loadAssets();
|
loadAssets();
|
||||||
loadInspectionsHistory();
|
loadInspectionsHistory();
|
||||||
} else {
|
} else {
|
||||||
alert('Erreur : ' + (data.message || 'Impossible de créer les panneaux.'));
|
const errPrefix = window.gettext ? window.gettext('Erreur : ') : 'Erreur : ';
|
||||||
|
const defaultErrMsg = window.gettext ? window.gettext('Impossible de créer les panneaux.') : 'Impossible de créer les panneaux.';
|
||||||
|
alert(errPrefix + (data.message || defaultErrMsg));
|
||||||
}
|
}
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
alert('Erreur lors de la création : ' + err.message);
|
const errCreationMsg = window.gettext ? window.gettext('Erreur lors de la création : ') : 'Erreur lors de la création : ';
|
||||||
|
alert(errCreationMsg + err.message);
|
||||||
} finally {
|
} finally {
|
||||||
btnSubmit.disabled = false;
|
btnSubmit.disabled = false;
|
||||||
btnSubmit.innerHTML = '<i class="bi bi-check2-circle fs-6"></i> <span>Créer et Enregistrer</span>';
|
const createAndSaveMsg = window.gettext ? window.gettext('Créer et Enregistrer') : 'Créer et Enregistrer';
|
||||||
|
btnSubmit.innerHTML = `<i class="bi bi-check2-circle fs-6"></i> <span>${createAndSaveMsg}</span>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -2176,19 +2188,22 @@
|
||||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
const modal = bootstrap.Modal.getInstance(modalEl);
|
||||||
if (modal) modal.hide();
|
if (modal) modal.hide();
|
||||||
|
|
||||||
let msg = data.message || 'Inspection enregistrée !';
|
let msg = data.message || (window.gettext ? window.gettext('Inspection enregistrée !') : 'Inspection enregistrée !');
|
||||||
if (data.created_intervention_code) {
|
if (data.created_intervention_code) {
|
||||||
msg += ' Intervention ' + data.created_intervention_code + ' créée.';
|
const intervLabel = window.gettext ? window.gettext('Intervention') : 'Intervention';
|
||||||
|
const createdLabel = window.gettext ? window.gettext('créée.') : 'créée.';
|
||||||
|
msg += ` ${intervLabel} ${data.created_intervention_code} ${createdLabel}`;
|
||||||
}
|
}
|
||||||
alert(msg);
|
alert(msg);
|
||||||
loadAssets();
|
loadAssets();
|
||||||
} else {
|
} else {
|
||||||
alertDiv.textContent = data.error || 'Erreur lors de la validation.';
|
const defaultValError = window.gettext ? window.gettext('Erreur lors de la validation.') : 'Erreur lors de la validation.';
|
||||||
|
alertDiv.textContent = data.error || defaultValError;
|
||||||
alertDiv.classList.remove('d-none');
|
alertDiv.classList.remove('d-none');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
alertDiv.textContent = 'Erreur réseau.';
|
alertDiv.textContent = window.gettext ? window.gettext('Erreur réseau.') : 'Erreur réseau.';
|
||||||
alertDiv.classList.remove('d-none');
|
alertDiv.classList.remove('d-none');
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
|
|
||||||
60
loko/assets/static/assets/nature_planter_icon.svg
Normal file
60
loko/assets/static/assets/nature_planter_icon.svg
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="planterWood" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#ea580c"/>
|
||||||
|
<stop offset="100%" stop-color="#9a3412"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="leafGrad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#4ade80"/>
|
||||||
|
<stop offset="100%" stop-color="#15803d"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="flowerPink" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#fb7185"/>
|
||||||
|
<stop offset="100%" stop-color="#e11d48"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Foliage / Leaves -->
|
||||||
|
<!-- Left leaf -->
|
||||||
|
<path d="M 32 34 C 24 32, 14 26, 15 17 C 23 17, 28 24, 32 34 Z" fill="url(#leafGrad)"/>
|
||||||
|
<!-- Right leaf -->
|
||||||
|
<path d="M 32 34 C 40 32, 50 26, 49 17 C 41 17, 36 24, 32 34 Z" fill="url(#leafGrad)"/>
|
||||||
|
<!-- Center tall leaves -->
|
||||||
|
<path d="M 32 34 C 28 26, 26 15, 32 8 C 38 15, 36 26, 32 34 Z" fill="#22c55e"/>
|
||||||
|
<!-- Side drooping leaves -->
|
||||||
|
<path d="M 32 35 C 20 35, 10 33, 9 27 C 16 26, 25 29, 32 35 Z" fill="#16a34a"/>
|
||||||
|
<path d="M 32 35 C 44 35, 54 33, 55 27 C 48 26, 39 29, 32 35 Z" fill="#16a34a"/>
|
||||||
|
|
||||||
|
<!-- Flowers -->
|
||||||
|
<!-- Left flower -->
|
||||||
|
<circle cx="21" cy="21" r="5" fill="url(#flowerPink)"/>
|
||||||
|
<circle cx="21" cy="21" r="2" fill="#fef08a"/>
|
||||||
|
|
||||||
|
<!-- Right flower -->
|
||||||
|
<circle cx="43" cy="21" r="5" fill="#f59e0b"/>
|
||||||
|
<circle cx="43" cy="21" r="2" fill="#fffbeb"/>
|
||||||
|
|
||||||
|
<!-- Center top flower -->
|
||||||
|
<circle cx="32" cy="14" r="6" fill="url(#flowerPink)"/>
|
||||||
|
<circle cx="32" cy="14" r="2.5" fill="#fef08a"/>
|
||||||
|
|
||||||
|
<!-- Planter Box (Bac / Jardinière) -->
|
||||||
|
<!-- Rim -->
|
||||||
|
<rect x="11" y="34" width="42" height="5" rx="2" fill="#78350f"/>
|
||||||
|
<rect x="12" y="35" width="40" height="3" rx="1.5" fill="#ea580c"/>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<polygon points="14,39 50,39 46,55 18,55" fill="url(#planterWood)"/>
|
||||||
|
|
||||||
|
<!-- Wooden slats / horizontal grooves -->
|
||||||
|
<line x1="15" y1="44" x2="49" y2="44" stroke="#78350f" stroke-width="1.2" stroke-linecap="round"/>
|
||||||
|
<line x1="16.5" y1="49" x2="47.5" y2="49" stroke="#78350f" stroke-width="1.2" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Corner metal brackets / reinforcements -->
|
||||||
|
<line x1="14" y1="39" x2="18" y2="55" stroke="#475569" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="50" y1="39" x2="46" y2="55" stroke="#475569" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
|
||||||
|
<!-- Feet / Stand -->
|
||||||
|
<rect x="19" y="55" width="5" height="4" rx="1" fill="#334155"/>
|
||||||
|
<rect x="40" y="55" width="5" height="4" rx="1" fill="#334155"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
38
loko/assets/static/assets/road_speedbump_icon.svg
Normal file
38
loko/assets/static/assets/road_speedbump_icon.svg
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="sbBody" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#fb923c"/>
|
||||||
|
<stop offset="30%" stop-color="#f97316"/>
|
||||||
|
<stop offset="100%" stop-color="#c2410c"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="sbPlateau" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color="#fdba74"/>
|
||||||
|
<stop offset="100%" stop-color="#fb923c"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="sbShadow" x="-15%" y="-15%" width="130%" height="135%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-color="#000000" flood-opacity="0.4"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Drop shadow and dark road border -->
|
||||||
|
<path d="M 5 48 L 15 17 C 15.5 15.5 17 14.5 18.5 14.5 L 45.5 14.5 C 47 14.5 48.5 15.5 49 17 L 59 48 C 59.5 49.8 58.2 51.5 56.5 51.5 L 7.5 51.5 C 5.8 51.5 4.5 49.8 5 48 Z"
|
||||||
|
fill="#7c2d12" filter="url(#sbShadow)"/>
|
||||||
|
|
||||||
|
<!-- Main Speed Cushion Body (Coussin berlinois) -->
|
||||||
|
<path d="M 7 47 L 16 18 C 16.5 16.5 17.5 16 19 16 L 45 16 C 46.5 16 47.5 16.5 48 18 L 57 47 C 57.5 48.5 56.5 49.5 55 49.5 L 9 49.5 C 7.5 49.5 6.5 48.5 7 47 Z"
|
||||||
|
fill="url(#sbBody)"/>
|
||||||
|
|
||||||
|
<!-- Lower bevel lip -->
|
||||||
|
<polygon points="7,47 9.5,50 54.5,50 57,47" fill="#9a3412"/>
|
||||||
|
|
||||||
|
<!-- Upper Plateau -->
|
||||||
|
<polygon points="20,27 22,18 42,18 44,27" fill="url(#sbPlateau)" opacity="0.9"/>
|
||||||
|
|
||||||
|
<!-- 3 Reflective White Triangles (Universal Speed Cushion Markings) -->
|
||||||
|
<polygon points="12,45 20,24 28,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
|
||||||
|
<polygon points="24,45 32,24 40,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
|
||||||
|
<polygon points="36,45 44,24 52,45" fill="#ffffff" stroke="#cbd5e1" stroke-width="0.75" stroke-linejoin="round"/>
|
||||||
|
|
||||||
|
<!-- Top ridge highlight -->
|
||||||
|
<line x1="19" y1="16" x2="45" y2="16" stroke="#fed7aa" stroke-width="1.8" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
|
|
@ -10,37 +10,7 @@ const map = new maplibregl.Map({
|
||||||
antialias: true
|
antialias: true
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom Plans Control in Top Right (first, at the top)
|
// Plans & Calques are integrated into LayerSwitcherControl below
|
||||||
class CustomPlansControl {
|
|
||||||
onAdd(map) {
|
|
||||||
this._container = document.createElement('div');
|
|
||||||
this._container.className = 'maplibregl-ctrl maplibregl-ctrl-group';
|
|
||||||
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.type = 'button';
|
|
||||||
button.id = 'btn-load-georef-plan';
|
|
||||||
button.className = 'btn btn-xs btn-light bg-white border shadow-sm d-flex align-items-center gap-1';
|
|
||||||
button.style.border = 'none';
|
|
||||||
button.style.padding = '8px 12px';
|
|
||||||
button.style.height = 'auto';
|
|
||||||
button.style.width = 'auto';
|
|
||||||
button.style.fontWeight = 'bold';
|
|
||||||
button.style.fontSize = '0.75rem';
|
|
||||||
button.innerHTML = '<i class="bi bi-geo-alt-fill text-primary"></i> Plans';
|
|
||||||
|
|
||||||
// Toggle bootstrap modal natively
|
|
||||||
button.setAttribute('data-bs-toggle', 'modal');
|
|
||||||
button.setAttribute('data-bs-target', '#georefPlanModal');
|
|
||||||
|
|
||||||
this._container.appendChild(button);
|
|
||||||
return this._container;
|
|
||||||
}
|
|
||||||
onRemove() {
|
|
||||||
this._container.parentNode.removeChild(this._container);
|
|
||||||
this._map = undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
map.addControl(new CustomPlansControl(), 'top-right');
|
|
||||||
|
|
||||||
// ── Helper to determine where to place the orthophoto layer ──
|
// ── Helper to determine where to place the orthophoto layer ──
|
||||||
function getOrthoBeforeLayer(m) {
|
function getOrthoBeforeLayer(m) {
|
||||||
|
|
@ -140,9 +110,10 @@ function reorderIntersectionLayers() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Contrôle photo aérienne (LayerSwitcherControl) --- (second, below Plans button)
|
// --- Contrôle photo aérienne & Plans (LayerSwitcherControl) ---
|
||||||
|
let layerSwitcher = null;
|
||||||
if (typeof LayerSwitcherControl !== 'undefined') {
|
if (typeof LayerSwitcherControl !== 'undefined') {
|
||||||
const layerSwitcher = new LayerSwitcherControl({
|
layerSwitcher = new LayerSwitcherControl({
|
||||||
layers: [
|
layers: [
|
||||||
{
|
{
|
||||||
id: 'ortho',
|
id: 'ortho',
|
||||||
|
|
@ -883,7 +854,28 @@ if (georeferencedDocumentsDataEl) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dxfFilesDataEl = document.getElementById('dxfFilesData');
|
||||||
|
let availableDxfFiles = [];
|
||||||
|
if (dxfFilesDataEl) {
|
||||||
|
try {
|
||||||
|
availableDxfFiles = JSON.parse(dxfFilesDataEl.textContent);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error parsing dxf files data:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!availableDxfFiles.length && typeof window.dxfFiles !== 'undefined' && Array.isArray(window.dxfFiles)) {
|
||||||
|
availableDxfFiles = window.dxfFiles;
|
||||||
|
}
|
||||||
|
|
||||||
|
const crossroadPlansConfigEl = document.getElementById('crossroadPlansConfig');
|
||||||
|
let crossroadPlansConfig = {};
|
||||||
|
if (crossroadPlansConfigEl) {
|
||||||
|
try {
|
||||||
|
crossroadPlansConfig = JSON.parse(crossroadPlansConfigEl.textContent);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error parsing crossroad plans config:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function showGeorefImage(doc, active, opacity) {
|
function showGeorefImage(doc, active, opacity) {
|
||||||
const srcId = `georef-image-overlay-${doc.id}`;
|
const srcId = `georef-image-overlay-${doc.id}`;
|
||||||
|
|
@ -932,12 +924,14 @@ function showGeorefImage(doc, active, opacity) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zoom/fitBounds to document
|
// Zoom/fitBounds to document
|
||||||
|
if (doc.coords && doc.coords.length > 0) {
|
||||||
const coords = doc.coords;
|
const coords = doc.coords;
|
||||||
const bounds = coords.reduce((acc, coord) => {
|
const bounds = coords.reduce((acc, coord) => {
|
||||||
return acc.extend(coord);
|
return acc.extend(coord);
|
||||||
}, new maplibregl.LngLatBounds(coords[0], coords[0]));
|
}, new maplibregl.LngLatBounds(coords[0], coords[0]));
|
||||||
map.fitBounds(bounds, { padding: 40, maxZoom: 19 });
|
map.fitBounds(bounds, { padding: 40, maxZoom: 19 });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let fullDxfGeoJson = null;
|
let fullDxfGeoJson = null;
|
||||||
|
|
||||||
|
|
@ -981,119 +975,33 @@ function applyDxfVectorFilters() {
|
||||||
if (map.getSource('dxf-layout')) {
|
if (map.getSource('dxf-layout')) {
|
||||||
map.getSource('dxf-layout').setData(filteredData);
|
map.getSource('dxf-layout').setData(filteredData);
|
||||||
|
|
||||||
|
const isVisible = selectedLayers.length > 0;
|
||||||
['dxf-layout-fills', 'dxf-layout-lines', 'dxf-layout-symbols'].forEach(lId => {
|
['dxf-layout-fills', 'dxf-layout-lines', 'dxf-layout-symbols'].forEach(lId => {
|
||||||
if (map.getLayer(lId)) {
|
if (map.getLayer(lId)) {
|
||||||
map.setLayoutProperty(lId, 'visibility', 'visible');
|
map.setLayoutProperty(lId, 'visibility', isVisible ? 'visible' : 'none');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
reorderIntersectionLayers();
|
reorderIntersectionLayers();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateButtonActiveState() {
|
|
||||||
const btn = document.getElementById('btn-load-georef-plan');
|
|
||||||
if (!btn) return;
|
|
||||||
|
|
||||||
let anyImageActive = false;
|
|
||||||
document.querySelectorAll('.image-plan-toggle').forEach(cb => {
|
|
||||||
if (cb.checked) anyImageActive = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
let anyVectorActive = false;
|
|
||||||
document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
|
|
||||||
if (cb.checked) anyVectorActive = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (anyImageActive || anyVectorActive) {
|
|
||||||
btn.classList.remove('btn-light', 'bg-white', 'border');
|
|
||||||
btn.classList.add('btn-primary');
|
|
||||||
} else {
|
|
||||||
btn.classList.remove('btn-primary');
|
|
||||||
btn.classList.add('btn-light', 'bg-white', 'border');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const noGeorefImagesAlert = document.getElementById('no-georef-images-alert');
|
|
||||||
const imagePlansList = document.getElementById('image-plans-list');
|
|
||||||
if (imagePlansList) {
|
|
||||||
imagePlansList.innerHTML = '';
|
|
||||||
if (georeferencedDocuments.length === 0) {
|
|
||||||
if (noGeorefImagesAlert) noGeorefImagesAlert.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
if (noGeorefImagesAlert) noGeorefImagesAlert.style.display = 'none';
|
|
||||||
|
|
||||||
georeferencedDocuments.forEach(doc => {
|
|
||||||
const item = document.createElement('div');
|
|
||||||
item.className = 'image-plan-item p-3 bg-light border rounded-3 d-flex flex-column gap-2';
|
|
||||||
item.innerHTML = `
|
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
|
||||||
<span class="text-dark font-weight-bold" style="font-size: 0.9rem;">
|
|
||||||
<i class="bi ${doc.is_dxf ? 'bi-file-earmark-code text-success' : 'bi-file-earmark-image text-primary'} me-2"></i>${doc.title}
|
|
||||||
</span>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input class="form-check-input image-plan-toggle" type="checkbox" role="switch" id="switch-georef-img-${doc.id}" data-id="${doc.id}">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="image-plan-opacity-container" id="opacity-container-${doc.id}" style="display: none;">
|
|
||||||
<label class="form-label font-weight-semibold text-muted small uppercase d-flex justify-content-between mb-1">
|
|
||||||
<span>Opacité</span>
|
|
||||||
<span class="opacity-val text-primary font-weight-bold">95%</span>
|
|
||||||
</label>
|
|
||||||
<input type="range" class="form-range image-plan-opacity-slider" min="0" max="100" value="95" id="range-georef-img-opacity-${doc.id}" data-id="${doc.id}">
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
imagePlansList.appendChild(item);
|
|
||||||
|
|
||||||
const toggle = item.querySelector('.image-plan-toggle');
|
|
||||||
const opacityContainer = item.querySelector('.image-plan-opacity-container');
|
|
||||||
const slider = item.querySelector('.image-plan-opacity-slider');
|
|
||||||
const opacityVal = item.querySelector('.opacity-val');
|
|
||||||
|
|
||||||
toggle.addEventListener('change', () => {
|
|
||||||
const active = toggle.checked;
|
|
||||||
if (active) {
|
|
||||||
opacityContainer.style.display = 'block';
|
|
||||||
} else {
|
|
||||||
opacityContainer.style.display = 'none';
|
|
||||||
}
|
|
||||||
const opacity = parseFloat(slider.value) / 100.0;
|
|
||||||
showGeorefImage(doc, active, opacity);
|
|
||||||
updateButtonActiveState();
|
|
||||||
});
|
|
||||||
|
|
||||||
slider.addEventListener('input', () => {
|
|
||||||
const val = slider.value;
|
|
||||||
opacityVal.textContent = val + '%';
|
|
||||||
const opacity = parseFloat(val) / 100.0;
|
|
||||||
showGeorefImage(doc, toggle.checked, opacity);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dxfFileSelect = document.getElementById('select-dxf-file');
|
|
||||||
let detailLayersFetched = {};
|
let detailLayersFetched = {};
|
||||||
let detailLayersResponseData = {};
|
let detailLayersResponseData = {};
|
||||||
|
|
||||||
function fetchDetailLayers() {
|
function fetchDetailLayers(file, autoCheckAll = false) {
|
||||||
if (!dxfFileSelect) return;
|
if (!file) return;
|
||||||
const file = dxfFileSelect.value;
|
|
||||||
if (!file) {
|
const layersAccordion = document.getElementById('dxf-layers-accordion');
|
||||||
const section = document.getElementById('dxf-detail-layers-section');
|
if (layersAccordion) layersAccordion.style.display = 'block';
|
||||||
if (section) section.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (detailLayersFetched[file]) {
|
if (detailLayersFetched[file]) {
|
||||||
renderDetailLayers(file);
|
renderDetailLayers(file, autoCheckAll);
|
||||||
fetchDxfGeoJson(file);
|
fetchDxfGeoJson(file);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spinner = document.getElementById('dxf-detail-layers-spinner');
|
const spinner = document.getElementById('dxf-detail-layers-spinner');
|
||||||
const list = document.getElementById('dxf-detail-layers-list');
|
const list = document.getElementById('dxf-detail-layers-list');
|
||||||
const section = document.getElementById('dxf-detail-layers-section');
|
|
||||||
if (section) section.style.display = 'block';
|
|
||||||
if (spinner) spinner.style.display = 'block';
|
if (spinner) spinner.style.display = 'block';
|
||||||
if (list) list.style.display = 'none';
|
if (list) list.style.display = 'none';
|
||||||
|
|
||||||
|
|
@ -1102,41 +1010,39 @@ function fetchDetailLayers() {
|
||||||
.then(data => {
|
.then(data => {
|
||||||
detailLayersFetched[file] = data.layers || [];
|
detailLayersFetched[file] = data.layers || [];
|
||||||
detailLayersResponseData[file] = data;
|
detailLayersResponseData[file] = data;
|
||||||
renderDetailLayers(file);
|
renderDetailLayers(file, autoCheckAll);
|
||||||
fetchDxfGeoJson(file);
|
fetchDxfGeoJson(file);
|
||||||
if (spinner) spinner.style.display = 'none';
|
if (spinner) spinner.style.display = 'none';
|
||||||
if (list) list.style.display = 'block';
|
if (list) list.style.display = 'flex';
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch(err => {
|
||||||
console.error("Error fetching detail layers:", err);
|
console.error("Error fetching detail layers:", err);
|
||||||
if (spinner) {
|
if (spinner) {
|
||||||
spinner.innerHTML = `<div class="text-danger small"><i class="bi bi-exclamation-triangle-fill"></i> Erreur de chargement.</div>`;
|
spinner.innerHTML = `<div class="text-danger" style="font-size: 10px;"><i class="bi bi-exclamation-triangle-fill"></i> Erreur de chargement.</div>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderDetailLayers(file) {
|
function renderDetailLayers(file, autoCheckAll = false) {
|
||||||
const container = document.getElementById('dxf-detail-layers-list');
|
const container = document.getElementById('dxf-detail-layers-list');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
const layers = detailLayersFetched[file] || [];
|
const layers = detailLayersFetched[file] || [];
|
||||||
const responseData = detailLayersResponseData[file] || {};
|
|
||||||
const selectedLayers = responseData.selected_layers || [];
|
|
||||||
|
|
||||||
layers.forEach(layer => {
|
layers.forEach(layer => {
|
||||||
const isChecked = false;
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'form-check py-1 d-flex align-items-center justify-content-between';
|
div.className = 'd-flex align-items-center justify-content-between py-1 px-1 rounded';
|
||||||
|
div.style.fontSize = '11px';
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center gap-1 text-truncate" style="max-width: 200px;">
|
||||||
<span class="color-indicator me-2" style="display: inline-block; width: 14px; height: 14px; border-radius: 3px; background-color: ${layer.color}; border: 1px solid rgba(0,0,0,0.1);"></span>
|
<span class="color-indicator" style="width: 9px; height: 9px; border-radius: 2px; background-color: ${layer.color}; border: 1px solid rgba(0,0,0,0.15); flex-shrink: 0;"></span>
|
||||||
<label class="form-check-label text-dark font-weight-medium small" for="dxf-detail-layer-${layer.name}">
|
<label class="form-check-label text-truncate mb-0" for="dxf-detail-layer-${layer.name}" style="cursor: pointer; font-size: 11px; color: #334155;" title="${layer.name}">
|
||||||
${layer.name}
|
${layer.name}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch mb-0" style="padding-left: 1.8em;">
|
||||||
<input class="form-check-input dxf-vector-layer-toggle" type="checkbox" role="switch"
|
<input class="form-check-input dxf-vector-layer-toggle" type="checkbox" role="switch"
|
||||||
id="dxf-detail-layer-${layer.name}" data-layer="${layer.name}" ${isChecked ? 'checked' : ''}>
|
id="dxf-detail-layer-${layer.name}" data-layer="${layer.name}" ${autoCheckAll ? 'checked' : ''} style="cursor: pointer; width: 28px; height: 14px;">
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
container.appendChild(div);
|
container.appendChild(div);
|
||||||
|
|
@ -1144,39 +1050,322 @@ function renderDetailLayers(file) {
|
||||||
const toggle = div.querySelector('.dxf-vector-layer-toggle');
|
const toggle = div.querySelector('.dxf-vector-layer-toggle');
|
||||||
toggle.addEventListener('change', () => {
|
toggle.addEventListener('change', () => {
|
||||||
applyDxfVectorFilters();
|
applyDxfVectorFilters();
|
||||||
updateButtonActiveState();
|
updatePlansActiveState();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCountEl = document.getElementById('dxf-total-layers-count');
|
||||||
|
if (totalCountEl) totalCountEl.textContent = layers.length;
|
||||||
|
|
||||||
|
updatePlansActiveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlansActiveState() {
|
||||||
|
let activeImages = 0;
|
||||||
|
document.querySelectorAll('.image-plan-toggle').forEach(cb => {
|
||||||
|
if (cb.checked) activeImages++;
|
||||||
|
});
|
||||||
|
|
||||||
|
let activeDxfLayers = 0;
|
||||||
|
let totalDxfLayers = 0;
|
||||||
|
document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
|
||||||
|
totalDxfLayers++;
|
||||||
|
if (cb.checked) activeDxfLayers++;
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCount = activeImages + (activeDxfLayers > 0 ? 1 : 0);
|
||||||
|
const hasAnyActive = activeImages > 0 || activeDxfLayers > 0;
|
||||||
|
|
||||||
|
const countBadge = document.getElementById('plans-active-count-badge');
|
||||||
|
if (countBadge) {
|
||||||
|
if (hasAnyActive) {
|
||||||
|
countBadge.style.display = 'inline-block';
|
||||||
|
countBadge.textContent = `${activeCount} actif${activeCount > 1 ? 's' : ''}`;
|
||||||
|
} else {
|
||||||
|
countBadge.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const dxfActiveCountEl = document.getElementById('dxf-active-layers-count');
|
||||||
|
if (dxfActiveCountEl) dxfActiveCountEl.textContent = activeDxfLayers;
|
||||||
|
|
||||||
|
const dxfTotalCountEl = document.getElementById('dxf-total-layers-count');
|
||||||
|
if (dxfTotalCountEl) dxfTotalCountEl.textContent = totalDxfLayers;
|
||||||
|
|
||||||
|
const switchDxfMaster = document.getElementById('switch-dxf-master');
|
||||||
|
if (switchDxfMaster) {
|
||||||
|
switchDxfMaster.checked = activeDxfLayers > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const planDxfCard = document.getElementById('plan-dxf-card');
|
||||||
|
if (planDxfCard) {
|
||||||
|
planDxfCard.classList.toggle('active', activeDxfLayers > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const footerActions = document.getElementById('plans-footer-actions');
|
||||||
|
if (footerActions) {
|
||||||
|
footerActions.style.display = hasAnyActive ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const dxfToggleAllBtn = document.getElementById('dxf-detail-toggle-all-layers');
|
||||||
|
if (dxfToggleAllBtn && totalDxfLayers > 0) {
|
||||||
|
dxfToggleAllBtn.textContent = (activeDxfLayers === totalDxfLayers) ? "Décocher tout" : "Tout cocher";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (layerSwitcher && typeof layerSwitcher.setOverlayActive === 'function') {
|
||||||
|
layerSwitcher.setOverlayActive(hasAnyActive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCrossroadPlansSection(container, control) {
|
||||||
|
const importDxfUrl = crossroadPlansConfig && crossroadPlansConfig.importDxfUrl;
|
||||||
|
const hasImages = georeferencedDocuments && georeferencedDocuments.length > 0;
|
||||||
|
const hasDxf = availableDxfFiles && availableDxfFiles.length > 0;
|
||||||
|
|
||||||
|
// 1. Header with title & active badge
|
||||||
|
const headerEl = document.createElement('div');
|
||||||
|
headerEl.className = 'layer-switcher-subtitle';
|
||||||
|
headerEl.innerHTML = `
|
||||||
|
<span class="d-flex align-items-center gap-1">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="#2563eb" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"></polygon><polyline points="2 17 12 22 22 17"></polyline><polyline points="2 12 12 17 22 12"></polyline></svg>
|
||||||
|
Plans du carrefour
|
||||||
|
</span>
|
||||||
|
<span class="badge bg-light text-primary border" id="plans-active-count-badge" style="font-size: 9px; display: none;">0 actif</span>
|
||||||
|
`;
|
||||||
|
container.appendChild(headerEl);
|
||||||
|
|
||||||
|
// Empty state if neither image nor DXF
|
||||||
|
if (!hasImages && !hasDxf) {
|
||||||
|
const emptyEl = document.createElement('div');
|
||||||
|
emptyEl.className = 'text-center py-2 px-1';
|
||||||
|
emptyEl.innerHTML = `
|
||||||
|
<div class="text-muted" style="font-size: 11px; margin-bottom: 4px;">Aucun plan géoréférencé</div>
|
||||||
|
${importDxfUrl ? `<a href="${importDxfUrl}" class="layer-switcher-btn-action"><i class="bi bi-upload"></i> Importer un plan DXF</a>` : ''}
|
||||||
|
`;
|
||||||
|
container.appendChild(emptyEl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Image Plans Sub-section
|
||||||
|
if (hasImages) {
|
||||||
|
const imgTitleEl = document.createElement('div');
|
||||||
|
imgTitleEl.className = 'layer-switcher-subtitle text-muted';
|
||||||
|
imgTitleEl.style.fontSize = '9px';
|
||||||
|
imgTitleEl.innerHTML = `<span><i class="bi bi-file-earmark-image me-1"></i> Plans images (${georeferencedDocuments.length})</span>`;
|
||||||
|
container.appendChild(imgTitleEl);
|
||||||
|
|
||||||
|
georeferencedDocuments.forEach(doc => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'layer-switcher-card';
|
||||||
|
card.id = `plan-img-card-${doc.id}`;
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="d-flex align-items-center justify-content-between">
|
||||||
|
<label class="form-check-label d-flex align-items-center gap-1 mb-0" for="switch-georef-img-${doc.id}" style="cursor: pointer; font-size: 11.5px; font-weight: 600; color: #1e293b; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${doc.title}">
|
||||||
|
<i class="bi ${doc.is_dxf ? 'bi-file-earmark-code text-success' : 'bi-file-earmark-image text-primary'}" style="flex-shrink: 0; font-size: 12px;"></i>
|
||||||
|
<span style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${doc.title}</span>
|
||||||
|
</label>
|
||||||
|
<div class="form-check form-switch mb-0" style="padding-left: 2em;">
|
||||||
|
<input class="form-check-input image-plan-toggle" type="checkbox" role="switch" id="switch-georef-img-${doc.id}" data-id="${doc.id}" style="cursor: pointer; width: 28px; height: 14px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="layer-switcher-slider-row" id="opacity-container-${doc.id}" style="display: none;">
|
||||||
|
<label>Opacité</label>
|
||||||
|
<input type="range" class="form-range image-plan-opacity-slider" min="0" max="100" value="95" id="range-georef-img-opacity-${doc.id}" data-id="${doc.id}">
|
||||||
|
<span class="layer-switcher-slider-val opacity-val">95%</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(card);
|
||||||
|
|
||||||
|
const toggle = card.querySelector('.image-plan-toggle');
|
||||||
|
const opacityRow = card.querySelector('.layer-switcher-slider-row');
|
||||||
|
const slider = card.querySelector('.image-plan-opacity-slider');
|
||||||
|
const opacityVal = card.querySelector('.opacity-val');
|
||||||
|
|
||||||
|
toggle.addEventListener('change', () => {
|
||||||
|
const active = toggle.checked;
|
||||||
|
card.classList.toggle('active', active);
|
||||||
|
opacityRow.style.display = active ? 'flex' : 'none';
|
||||||
|
const opacity = parseFloat(slider.value) / 100.0;
|
||||||
|
showGeorefImage(doc, active, opacity);
|
||||||
|
updatePlansActiveState();
|
||||||
|
});
|
||||||
|
|
||||||
|
slider.addEventListener('input', () => {
|
||||||
|
const val = slider.value;
|
||||||
|
opacityVal.textContent = val + '%';
|
||||||
|
const opacity = parseFloat(val) / 100.0;
|
||||||
|
const lyrId = `georef-image-overlay-layer-${doc.id}`;
|
||||||
|
if (map.getLayer(lyrId)) {
|
||||||
|
map.setPaintProperty(lyrId, 'raster-opacity', opacity);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dxfFileSelect) {
|
// 3. DXF Vector Plan Sub-section
|
||||||
dxfFileSelect.addEventListener('change', () => {
|
if (hasDxf) {
|
||||||
fetchDetailLayers();
|
const dxfTitleEl = document.createElement('div');
|
||||||
|
dxfTitleEl.className = 'layer-switcher-subtitle text-muted mt-1';
|
||||||
|
dxfTitleEl.style.fontSize = '9px';
|
||||||
|
dxfTitleEl.innerHTML = `<span><i class="bi bi-file-earmark-code me-1"></i> Plan CAO (DXF)</span>`;
|
||||||
|
container.appendChild(dxfTitleEl);
|
||||||
|
|
||||||
|
const dxfCard = document.createElement('div');
|
||||||
|
dxfCard.className = 'layer-switcher-card';
|
||||||
|
dxfCard.id = 'plan-dxf-card';
|
||||||
|
|
||||||
|
let dxfSelectHtml = '';
|
||||||
|
if (availableDxfFiles.length === 1) {
|
||||||
|
dxfSelectHtml = `
|
||||||
|
<input type="hidden" id="select-dxf-file" value="${availableDxfFiles[0]}">
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-1">
|
||||||
|
<span class="small font-weight-bold text-dark text-truncate" style="font-size: 11.5px; max-width: 190px;" title="${availableDxfFiles[0]}">
|
||||||
|
<i class="bi bi-file-earmark-check-fill text-success me-1"></i>${availableDxfFiles[0]}
|
||||||
|
</span>
|
||||||
|
<div class="form-check form-switch mb-0" style="padding-left: 2em;">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="switch-dxf-master" style="cursor: pointer; width: 28px; height: 14px;" title="Afficher/masquer le plan CAO">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
dxfSelectHtml = `
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-1 mb-2">
|
||||||
|
<select id="select-dxf-file" class="form-select form-select-sm" style="font-size: 11px; padding: 2px 6px; flex-grow: 1;">
|
||||||
|
<option value="">-- Sélectionner un fichier DXF --</option>
|
||||||
|
${availableDxfFiles.map(f => `<option value="${f}">${f}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<div class="form-check form-switch mb-0 ps-2">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="switch-dxf-master" style="cursor: pointer; width: 28px; height: 14px;" title="Afficher/masquer le plan CAO">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
dxfCard.innerHTML = `
|
||||||
|
${dxfSelectHtml}
|
||||||
|
<div id="dxf-layers-accordion" style="display: none; margin-top: 4px;">
|
||||||
|
<div class="d-flex align-items-center justify-content-between py-1 px-1 bg-white border rounded mb-1" style="font-size: 10.5px;">
|
||||||
|
<button type="button" id="btn-toggle-dxf-accordion" class="btn btn-link p-0 text-decoration-none text-dark font-weight-bold d-flex align-items-center gap-1" style="font-size: 10.5px;">
|
||||||
|
<i class="bi bi-chevron-down" id="dxf-accordion-chevron" style="transition: transform 0.2s ease; font-size: 10px;"></i>
|
||||||
|
<span>Couches (<span id="dxf-active-layers-count">0</span>/<span id="dxf-total-layers-count">0</span>)</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" id="dxf-detail-toggle-all-layers" class="btn btn-link p-0 text-decoration-none text-primary font-weight-semibold" style="font-size: 10px;">
|
||||||
|
Tout cocher
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="dxf-detail-layers-spinner" class="text-center py-2" style="display: none;">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" style="width: 14px; height: 14px; border-width: 1.5px;" role="status"></div>
|
||||||
|
<span class="text-muted ms-1" style="font-size: 10px;">Chargement...</span>
|
||||||
|
</div>
|
||||||
|
<div id="dxf-detail-layers-list" style="max-height: 160px; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; padding-right: 2px;">
|
||||||
|
<!-- Checkboxes populated dynamically -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
container.appendChild(dxfCard);
|
||||||
|
|
||||||
|
// Wire up DXF select and master toggle
|
||||||
|
const dxfSelectEl = dxfCard.querySelector('#select-dxf-file');
|
||||||
|
const dxfMasterSwitch = dxfCard.querySelector('#switch-dxf-master');
|
||||||
|
const accordionBtn = dxfCard.querySelector('#btn-toggle-dxf-accordion');
|
||||||
|
const accordionChevron = dxfCard.querySelector('#dxf-accordion-chevron');
|
||||||
|
const layersListEl = dxfCard.querySelector('#dxf-detail-layers-list');
|
||||||
|
const layersAccordion = dxfCard.querySelector('#dxf-layers-accordion');
|
||||||
|
const toggleAllBtn = dxfCard.querySelector('#dxf-detail-toggle-all-layers');
|
||||||
|
|
||||||
|
let isAccordionExpanded = true;
|
||||||
|
|
||||||
|
if (accordionBtn) {
|
||||||
|
accordionBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
isAccordionExpanded = !isAccordionExpanded;
|
||||||
|
layersListEl.style.display = isAccordionExpanded ? 'flex' : 'none';
|
||||||
|
if (toggleAllBtn) toggleAllBtn.style.display = isAccordionExpanded ? 'inline' : 'none';
|
||||||
|
accordionChevron.style.transform = isAccordionExpanded ? 'rotate(0deg)' : 'rotate(-90deg)';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-load if there is a default select value or single dxf
|
|
||||||
if (dxfFileSelect.value) {
|
|
||||||
fetchDetailLayers();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const btnToggleAllLayers = document.getElementById('dxf-detail-toggle-all-layers');
|
if (dxfMasterSwitch) {
|
||||||
if (btnToggleAllLayers) {
|
dxfMasterSwitch.addEventListener('change', (e) => {
|
||||||
btnToggleAllLayers.addEventListener('click', () => {
|
e.stopPropagation();
|
||||||
const checkboxes = document.querySelectorAll('.dxf-vector-layer-toggle');
|
const isMasterOn = dxfMasterSwitch.checked;
|
||||||
|
dxfCard.classList.toggle('active', isMasterOn);
|
||||||
|
|
||||||
|
const file = dxfSelectEl ? dxfSelectEl.value : '';
|
||||||
|
if (!file) {
|
||||||
|
dxfMasterSwitch.checked = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!detailLayersFetched[file]) {
|
||||||
|
fetchDetailLayers(file, isMasterOn);
|
||||||
|
} else {
|
||||||
|
const checkboxes = dxfCard.querySelectorAll('.dxf-vector-layer-toggle');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = isMasterOn;
|
||||||
|
});
|
||||||
|
applyDxfVectorFilters();
|
||||||
|
updatePlansActiveState();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMasterOn) {
|
||||||
|
layersAccordion.style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dxfSelectEl && dxfSelectEl.tagName === 'SELECT') {
|
||||||
|
dxfSelectEl.addEventListener('change', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const file = dxfSelectEl.value;
|
||||||
|
if (file) {
|
||||||
|
fetchDetailLayers(file, dxfMasterSwitch.checked);
|
||||||
|
} else {
|
||||||
|
layersAccordion.style.display = 'none';
|
||||||
|
if (dxfMasterSwitch) dxfMasterSwitch.checked = false;
|
||||||
|
applyDxfVectorFilters();
|
||||||
|
updatePlansActiveState();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toggleAllBtn) {
|
||||||
|
toggleAllBtn.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const checkboxes = dxfCard.querySelectorAll('.dxf-vector-layer-toggle');
|
||||||
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
||||||
checkboxes.forEach(cb => {
|
checkboxes.forEach(cb => {
|
||||||
cb.checked = !allChecked;
|
cb.checked = !allChecked;
|
||||||
});
|
});
|
||||||
btnToggleAllLayers.textContent = allChecked ? "Tout cocher" : "Décocher tout";
|
toggleAllBtn.textContent = allChecked ? "Tout cocher" : "Décocher tout";
|
||||||
applyDxfVectorFilters();
|
applyDxfVectorFilters();
|
||||||
updateButtonActiveState();
|
updatePlansActiveState();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const btnDisableAllPlans = document.getElementById('btn-disable-all-plans');
|
// If single DXF, prepare/fetch layers
|
||||||
if (btnDisableAllPlans) {
|
if (dxfSelectEl && dxfSelectEl.value) {
|
||||||
btnDisableAllPlans.addEventListener('click', () => {
|
fetchDetailLayers(dxfSelectEl.value, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Footer Actions (Désactiver tous les plans)
|
||||||
|
const footerEl = document.createElement('div');
|
||||||
|
footerEl.id = 'plans-footer-actions';
|
||||||
|
footerEl.style.display = 'none';
|
||||||
|
footerEl.style.marginTop = '6px';
|
||||||
|
footerEl.innerHTML = `
|
||||||
|
<button type="button" id="btn-disable-all-plans" class="layer-switcher-btn-action btn-danger-light">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
|
||||||
|
Désactiver tous les plans
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
container.appendChild(footerEl);
|
||||||
|
|
||||||
|
const btnDisableAll = footerEl.querySelector('#btn-disable-all-plans');
|
||||||
|
btnDisableAll.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// Turn off image plans
|
||||||
document.querySelectorAll('.image-plan-toggle').forEach(cb => {
|
document.querySelectorAll('.image-plan-toggle').forEach(cb => {
|
||||||
if (cb.checked) {
|
if (cb.checked) {
|
||||||
cb.checked = false;
|
cb.checked = false;
|
||||||
|
|
@ -1184,19 +1373,26 @@ if (btnDisableAllPlans) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Turn off DXF layers
|
||||||
document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
|
document.querySelectorAll('.dxf-vector-layer-toggle').forEach(cb => {
|
||||||
if (cb.checked) {
|
|
||||||
cb.checked = false;
|
cb.checked = false;
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
const switchMaster = document.getElementById('switch-dxf-master');
|
||||||
|
if (switchMaster) switchMaster.checked = false;
|
||||||
|
const dxfCard = document.getElementById('plan-dxf-card');
|
||||||
|
if (dxfCard) dxfCard.classList.remove('active');
|
||||||
|
|
||||||
applyDxfVectorFilters();
|
applyDxfVectorFilters();
|
||||||
|
updatePlansActiveState();
|
||||||
const modalEl = document.getElementById('georefPlanModal');
|
});
|
||||||
if (modalEl) {
|
}
|
||||||
const modal = bootstrap.Modal.getInstance(modalEl);
|
|
||||||
if (modal) modal.hide();
|
// Register section with LayerSwitcherControl
|
||||||
}
|
if (layerSwitcher) {
|
||||||
|
layerSwitcher.addCustomSection({
|
||||||
updateButtonActiveState();
|
id: 'crossroad-plans',
|
||||||
|
render: (container, control) => {
|
||||||
|
renderCrossroadPlansSection(container, control);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -510,34 +510,66 @@ function zoomFormatter(value, row) {
|
||||||
|
|
||||||
function buildColumns() {
|
function buildColumns() {
|
||||||
return [
|
return [
|
||||||
{ field: 'check', checkbox: true, width: 36 },
|
{ field: 'check', checkbox: true, width: 36, widthUnit: 'px', align: 'center', valign: 'middle' },
|
||||||
{ field: 'actions', title: '', formatter: zoomFormatter, width: 50 },
|
{ field: 'actions', title: '', formatter: zoomFormatter, width: 44, widthUnit: 'px', align: 'center', valign: 'middle' },
|
||||||
{
|
{
|
||||||
field: 'code', title: gettext('Code'), sortable: true,
|
field: 'code', title: gettext('Code'), sortable: true, width: 135, widthUnit: 'px', valign: 'middle',
|
||||||
formatter: function (v, row) {
|
formatter: function (v, row) {
|
||||||
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${v || '-'}</a>`;
|
if (row.detail_url) return `<a href="${row.detail_url}" class="unified-code-link">${escapeHtml(v || '-')}</a>`;
|
||||||
return v || '-';
|
return `<span class="unified-code-link">${escapeHtml(v || '-')}</span>`;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ field: 'name', title: gettext('Nom'), sortable: true },
|
|
||||||
{ field: 'element_type', title: gettext('Type'), sortable: true },
|
|
||||||
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true },
|
|
||||||
{ field: 'category', title: gettext('Catégorie'), sortable: true, visible: false },
|
|
||||||
{ field: 'model', title: gettext('Modèle'), sortable: true, visible: false },
|
|
||||||
{
|
{
|
||||||
field: 'status', title: gettext('Statut'), sortable: true, visible: false,
|
field: 'name', title: gettext('Nom'), sortable: true, minWidth: 220, valign: 'middle',
|
||||||
|
formatter: function (v) {
|
||||||
|
return `<span class="unified-row-name">${escapeHtml(v || '-')}</span>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'element_type', title: gettext('Type'), sortable: true, width: 130, widthUnit: 'px', valign: 'middle',
|
||||||
|
formatter: function (v, row) {
|
||||||
|
const table = $('#unified-table');
|
||||||
|
let isCatVisible = false;
|
||||||
|
try {
|
||||||
|
isCatVisible = table.length && typeof table.bootstrapTable === 'function' &&
|
||||||
|
table.bootstrapTable('getVisibleColumns').some(c => c.field === 'category');
|
||||||
|
} catch (e) {}
|
||||||
|
if (!isCatVisible && row && row.is_location && row.category) {
|
||||||
|
return `${escapeHtml(v || '-')} <span class="badge bg-light text-secondary border fw-normal ms-1" style="font-size:0.75rem;">${escapeHtml(row.category)}</span>`;
|
||||||
|
}
|
||||||
|
return escapeHtml(v || '-');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ field: 'thematic_name', title: gettext('Thématique'), sortable: true, width: 140, widthUnit: 'px', valign: 'middle' },
|
||||||
|
{
|
||||||
|
field: 'category', title: gettext('Catégorie d\'assets'), sortable: true, width: 175, widthUnit: 'px', visible: false, valign: 'middle',
|
||||||
|
formatter: function (v, row) {
|
||||||
|
if (!v) return '-';
|
||||||
|
if (row && row.color && (row.thematic === 'publicbuildings' || row.thematic === 'buildings')) {
|
||||||
|
return `<span class="d-inline-flex align-items-center" style="gap:6px;"><span style="display:inline-block; width:9px; height:9px; border-radius:50%; background-color:${escapeHtml(row.color)}; flex-shrink:0; box-shadow:0 0 0 1px rgba(0,0,0,0.12);"></span><span class="unified-category-cell">${escapeHtml(v)}</span></span>`;
|
||||||
|
}
|
||||||
|
return `<span class="unified-category-cell">${escapeHtml(v)}</span>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ field: 'model', title: gettext('Modèle'), sortable: true, width: 140, widthUnit: 'px', visible: false, valign: 'middle' },
|
||||||
|
{
|
||||||
|
field: 'status', title: gettext('Statut'), sortable: true, width: 110, widthUnit: 'px', visible: false, valign: 'middle',
|
||||||
formatter: function (v) { return v ? `<i class="bi bi-circle-fill status_${v}"></i> ${v}` : ''; }
|
formatter: function (v) { return v ? `<i class="bi bi-circle-fill status_${v}"></i> ${v}` : ''; }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
field: 'validation_status', title: gettext('Statut de validation'), sortable: true, visible: true,
|
field: 'validation_status', title: gettext('Statut de validation'), sortable: true, width: 135, widthUnit: 'px', visible: true, valign: 'middle',
|
||||||
formatter: function (v) {
|
formatter: function (v) {
|
||||||
const labels = {
|
const labels = {
|
||||||
'to_be_validated': gettext('À valider'),
|
'to_be_validated': { text: gettext('À valider'), cls: 'bg-warning-subtle text-warning-emphasis border-warning-subtle' },
|
||||||
'validated': gettext('Validé'),
|
'validated': { text: gettext('Validé'), cls: 'bg-success-subtle text-success border-success-subtle' },
|
||||||
'to_be_completed': gettext('À compléter'),
|
'to_be_completed': { text: gettext('À compléter'), cls: 'bg-info-subtle text-info-emphasis border-info-subtle' },
|
||||||
'rejected': gettext('Rejeté')
|
'rejected': { text: gettext('Rejeté'), cls: 'bg-danger-subtle text-danger border-danger-subtle' }
|
||||||
};
|
};
|
||||||
return labels[v] || v || '-';
|
const item = labels[v];
|
||||||
|
if (item) {
|
||||||
|
return `<span class="badge ${item.cls} border fw-medium" style="font-size: 0.74rem;">${item.text}</span>`;
|
||||||
|
}
|
||||||
|
return v || '-';
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -597,7 +629,18 @@ function applyVisibleColumnsPreference(visibleColumns) {
|
||||||
* explicit thematic selection (GET params / locked_thematic_code).
|
* explicit thematic selection (GET params / locked_thematic_code).
|
||||||
*/
|
*/
|
||||||
function applyInitialPreferences() {
|
function applyInitialPreferences() {
|
||||||
if (PREVENTIVE_DRAFT_ID || LOCKED_THEMATIC_CODE || (typeof HAS_URL_THEMATICS !== 'undefined' && HAS_URL_THEMATICS)) return;
|
if (PREVENTIVE_DRAFT_ID || LOCKED_THEMATIC_CODE) return;
|
||||||
|
|
||||||
|
if (typeof HAS_URL_THEMATICS !== 'undefined' && HAS_URL_THEMATICS) {
|
||||||
|
if (!activeCategories || activeCategories.size === 0) {
|
||||||
|
getVisibleCategoryOptions().forEach(category => {
|
||||||
|
if (activeThematics.has(category.thematicCode)) {
|
||||||
|
activeCategories.add(category.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (ASSETS_INDEX_PREFS && typeof ASSETS_INDEX_PREFS === 'object' && Object.keys(ASSETS_INDEX_PREFS).length > 0) {
|
if (ASSETS_INDEX_PREFS && typeof ASSETS_INDEX_PREFS === 'object' && Object.keys(ASSETS_INDEX_PREFS).length > 0) {
|
||||||
const prefs = ASSETS_INDEX_PREFS;
|
const prefs = ASSETS_INDEX_PREFS;
|
||||||
|
|
@ -722,7 +765,7 @@ $(document).ready(function () {
|
||||||
}
|
}
|
||||||
|
|
||||||
function calcHeight() {
|
function calcHeight() {
|
||||||
return Math.max(300, $(window).height() - 210);
|
return Math.max(300, $(window).height() - 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#unified-table').bootstrapTable({
|
$('#unified-table').bootstrapTable({
|
||||||
|
|
@ -761,8 +804,13 @@ $(document).ready(function () {
|
||||||
|
|
||||||
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
|
document.querySelectorAll('.thematic-checkbox').forEach(cb => {
|
||||||
cb.addEventListener('change', function () {
|
cb.addEventListener('change', function () {
|
||||||
if (this.checked) activeThematics.add(this.value);
|
if (this.checked) {
|
||||||
else activeThematics.delete(this.value);
|
activeThematics.add(this.value);
|
||||||
|
const newCats = THEMATIC_CATEGORIES[this.value] || [];
|
||||||
|
newCats.forEach(c => activeCategories.add(`${this.value}::${c.name || ''}`));
|
||||||
|
} else {
|
||||||
|
activeThematics.delete(this.value);
|
||||||
|
}
|
||||||
renderCategoryFilters();
|
renderCategoryFilters();
|
||||||
renderModelFilters();
|
renderModelFilters();
|
||||||
renderConditionalFilters();
|
renderConditionalFilters();
|
||||||
|
|
@ -774,6 +822,7 @@ $(document).ready(function () {
|
||||||
cb.checked = true;
|
cb.checked = true;
|
||||||
activeThematics.add(cb.value);
|
activeThematics.add(cb.value);
|
||||||
});
|
});
|
||||||
|
getVisibleCategoryOptions().forEach(category => activeCategories.add(category.value));
|
||||||
renderCategoryFilters();
|
renderCategoryFilters();
|
||||||
renderModelFilters();
|
renderModelFilters();
|
||||||
renderConditionalFilters();
|
renderConditionalFilters();
|
||||||
|
|
@ -1271,6 +1320,10 @@ function hasTriggerFilter() {
|
||||||
if (Array.isArray(outdoorFilters.site_type) && outdoorFilters.site_type.length) return true;
|
if (Array.isArray(outdoorFilters.site_type) && outdoorFilters.site_type.length) return true;
|
||||||
if (Array.isArray(outdoorFilters.norm) && outdoorFilters.norm.length) return true;
|
if (Array.isArray(outdoorFilters.norm) && outdoorFilters.norm.length) return true;
|
||||||
if (outdoorFilters.pmr_accessible) return true;
|
if (outdoorFilters.pmr_accessible) return true;
|
||||||
|
const publicbuildingsFilters = activeConditionalFilters.publicbuildings || activeConditionalFilters.buildings || {};
|
||||||
|
if (Array.isArray(publicbuildingsFilters.usage_type) && publicbuildingsFilters.usage_type.length) return true;
|
||||||
|
if (publicbuildingsFilters.is_main_location) return true;
|
||||||
|
if (publicbuildingsFilters.is_erp) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1353,6 +1406,17 @@ function buildLocationsGeojsonPostBody(thematicCode) {
|
||||||
if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location;
|
if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (['publicbuildings', 'buildings'].includes(thematicCode)) {
|
||||||
|
if (Array.isArray(thematicFilters.usage_type) && thematicFilters.usage_type.length) {
|
||||||
|
payload.usage_type = thematicFilters.usage_type;
|
||||||
|
}
|
||||||
|
if (thematicFilters.is_main_location) payload.is_main_location = thematicFilters.is_main_location;
|
||||||
|
if (thematicFilters.is_erp) payload.is_erp = thematicFilters.is_erp;
|
||||||
|
if (Array.isArray(thematicFilters.location_type) && thematicFilters.location_type.length) {
|
||||||
|
payload.location_type = thematicFilters.location_type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (thematicCode === 'structures') {
|
if (thematicCode === 'structures') {
|
||||||
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
|
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
|
||||||
payload.structure_type = thematicFilters.structure_type;
|
payload.structure_type = thematicFilters.structure_type;
|
||||||
|
|
@ -1426,6 +1490,17 @@ function buildAssetsGeojsonPostBody(thematicCode, bbox, includeNonGeoFilters) {
|
||||||
payload.asset_begin_date = thematicFilters.asset_begin_date;
|
payload.asset_begin_date = thematicFilters.asset_begin_date;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (['publicbuildings', 'buildings'].includes(thematicCode)) {
|
||||||
|
if (Array.isArray(thematicFilters.usage_type) && thematicFilters.usage_type.length) {
|
||||||
|
payload.usage_type = thematicFilters.usage_type;
|
||||||
|
}
|
||||||
|
if (thematicFilters.is_main_location) {
|
||||||
|
payload.is_main_location = thematicFilters.is_main_location;
|
||||||
|
}
|
||||||
|
if (thematicFilters.is_erp) {
|
||||||
|
payload.is_erp = thematicFilters.is_erp;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (thematicCode === 'structures') {
|
if (thematicCode === 'structures') {
|
||||||
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
|
if (Array.isArray(thematicFilters.structure_type) && thematicFilters.structure_type.length) {
|
||||||
payload.structure_type = thematicFilters.structure_type;
|
payload.structure_type = thematicFilters.structure_type;
|
||||||
|
|
@ -1898,6 +1973,7 @@ function collectClickedLocations(clickEvent) {
|
||||||
thematic: p.thematic || '',
|
thematic: p.thematic || '',
|
||||||
thematic_name: f._thematic_name || p.thematic || '',
|
thematic_name: f._thematic_name || p.thematic || '',
|
||||||
element_type: p.element_type || '',
|
element_type: p.element_type || '',
|
||||||
|
usage_type_display: p.usage_type_display || '',
|
||||||
detail_url: p.detail_url || null,
|
detail_url: p.detail_url || null,
|
||||||
feature_id: id,
|
feature_id: id,
|
||||||
});
|
});
|
||||||
|
|
@ -1987,6 +2063,8 @@ function showAssetClickPopup(clickEvent) {
|
||||||
const code = escapeHtml(l.code || '-');
|
const code = escapeHtml(l.code || '-');
|
||||||
const type = escapeHtml(l.element_type || '');
|
const type = escapeHtml(l.element_type || '');
|
||||||
const thematic = escapeHtml(l.thematic_name || l.thematic || '');
|
const thematic = escapeHtml(l.thematic_name || l.thematic || '');
|
||||||
|
const usage = l.usage_type_display ? ` • ${escapeHtml(l.usage_type_display)}` : '';
|
||||||
|
const dotHtml = l.color ? `<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background-color:${escapeHtml(l.color)}; margin-right:4px; vertical-align:middle; box-shadow:0 0 0 1px rgba(0,0,0,0.15);"></span>` : '';
|
||||||
const name = escapeHtml(l.name || '');
|
const name = escapeHtml(l.name || '');
|
||||||
const detailAnchor = l.detail_url
|
const detailAnchor = l.detail_url
|
||||||
? `<a href="${escapeHtml(l.detail_url)}">${code}</a>`
|
? `<a href="${escapeHtml(l.detail_url)}">${code}</a>`
|
||||||
|
|
@ -1996,7 +2074,7 @@ function showAssetClickPopup(clickEvent) {
|
||||||
<div class="location-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
|
<div class="location-popup-item" style="display:flex; gap:0.5rem; align-items:flex-start; justify-content:space-between; padding:0.25rem 0; border-bottom:1px solid #f1f3f5;">
|
||||||
<div style="min-width:0;">
|
<div style="min-width:0;">
|
||||||
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
|
<div style="font-weight:600; line-height:1.1;">${detailAnchor}</div>
|
||||||
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${type}${thematic ? ' - ' + thematic : ''}</div>
|
<div style="font-size:0.75rem; color:#6c757d; line-height:1.2;">${dotHtml}${type}${usage}${thematic ? ' - ' + thematic : ''}</div>
|
||||||
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
|
${name ? `<div style="font-size:0.75rem; color:#495057; line-height:1.2;">${name}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
|
|
@ -2146,7 +2224,39 @@ function fitMapToFeatures(features) {
|
||||||
|
|
||||||
// ─── Data loading ─────────────────────────────────────────────────────────────
|
// ─── Data loading ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let currentTerritoryMuniSignature = '';
|
||||||
|
|
||||||
|
function updateTerritoryMaskIfNeeded() {
|
||||||
|
if (!map || typeof map.getSource !== 'function') return;
|
||||||
|
const src = map.getSource('territory');
|
||||||
|
if (!src) return;
|
||||||
|
|
||||||
|
const selectedMunis = (typeof thematicFilters !== 'undefined' && Array.isArray(thematicFilters.municipality))
|
||||||
|
? thematicFilters.municipality
|
||||||
|
: [];
|
||||||
|
const sig = selectedMunis.slice().sort().join(',');
|
||||||
|
if (sig === currentTerritoryMuniSignature) return;
|
||||||
|
currentTerritoryMuniSignature = sig;
|
||||||
|
|
||||||
|
let url = window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/';
|
||||||
|
if (selectedMunis.length > 0) {
|
||||||
|
const params = selectedMunis.map(id => `municipality=${encodeURIComponent(id)}`).join('&');
|
||||||
|
url += (url.includes('?') ? '&' : '?') + params;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(url)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
const currentSrc = map.getSource('territory');
|
||||||
|
if (currentSrc && data) currentSrc.setData(data);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.warn('Could not update territory mask:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function refreshData() {
|
function refreshData() {
|
||||||
|
updateTerritoryMaskIfNeeded();
|
||||||
// Cancel any in-flight requests from the previous call
|
// Cancel any in-flight requests from the previous call
|
||||||
if (currentFetchAbortController) { currentFetchAbortController.abort(); }
|
if (currentFetchAbortController) { currentFetchAbortController.abort(); }
|
||||||
currentFetchAbortController = new AbortController();
|
currentFetchAbortController = new AbortController();
|
||||||
|
|
@ -2386,6 +2496,7 @@ function loadTableData(features) {
|
||||||
const name = p.name || p.name_fr || p.name_nl || '';
|
const name = p.name || p.name_fr || p.name_nl || '';
|
||||||
const length = Number(p.length ?? p.length_m ?? 0);
|
const length = Number(p.length ?? p.length_m ?? 0);
|
||||||
const area = Number(p.area ?? p.area_m2 ?? p.surface_area ?? p.surface ?? 0);
|
const area = Number(p.area ?? p.area_m2 ?? p.surface_area ?? p.surface ?? 0);
|
||||||
|
const usageType = p.usage_type_display || '';
|
||||||
return {
|
return {
|
||||||
id: f.id,
|
id: f.id,
|
||||||
state: selectedIds.has(f.id),
|
state: selectedIds.has(f.id),
|
||||||
|
|
@ -2396,7 +2507,10 @@ function loadTableData(features) {
|
||||||
thematic: p.thematic || '',
|
thematic: p.thematic || '',
|
||||||
thematic_name: f._thematic_name || p.thematic || '',
|
thematic_name: f._thematic_name || p.thematic || '',
|
||||||
category_id: p.category_id != null ? String(p.category_id) : '',
|
category_id: p.category_id != null ? String(p.category_id) : '',
|
||||||
category: p.category || '',
|
category: p.category || usageType || '',
|
||||||
|
usage_type: p.usage_type || '',
|
||||||
|
usage_type_display: usageType,
|
||||||
|
color: p.color || p.display_color || '',
|
||||||
model: p.model || '',
|
model: p.model || '',
|
||||||
status: p.status || '',
|
status: p.status || '',
|
||||||
detail_url: p.detail_url || null,
|
detail_url: p.detail_url || null,
|
||||||
|
|
@ -2523,6 +2637,10 @@ maploader.loadMap('map');
|
||||||
|
|
||||||
maploader.addEventListener('mapLoaded', () => {
|
maploader.addEventListener('mapLoaded', () => {
|
||||||
// Create empty sources so setData can be called immediately after data loads
|
// Create empty sources so setData can be called immediately after data loads
|
||||||
|
map.addSource('territory', {
|
||||||
|
type: 'geojson',
|
||||||
|
data: window.TERRITORY_MASK_URL || '/common/territory-mask-geojson/'
|
||||||
|
});
|
||||||
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
map.addSource('locations', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||||
map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
map.addSource('assets', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||||
|
|
||||||
|
|
@ -2576,40 +2694,13 @@ maploader.addEventListener('mapLoaded', () => {
|
||||||
if (typeof LayerSwitcherControl !== 'undefined') {
|
if (typeof LayerSwitcherControl !== 'undefined') {
|
||||||
const layerSwitcher = new LayerSwitcherControl({
|
const layerSwitcher = new LayerSwitcherControl({
|
||||||
layers: [{
|
layers: [{
|
||||||
id: 'ortho',
|
id: 'basemap',
|
||||||
name: 'Orthophotos',
|
name: 'Fonds de plan & Orthophotos',
|
||||||
icon: 'satellite',
|
icon: 'satellite',
|
||||||
group: 'ortho',
|
beforeLayer: (map && map.getLayer('territory-mask') ? 'territory-mask' : 'locations'),
|
||||||
beforeLayer: 'locations',
|
basemaps: window.MAP_BACKGROUNDS || [],
|
||||||
layer: { type: 'raster', paint: { 'raster-opacity': 1 } },
|
|
||||||
sublayers: [
|
|
||||||
{
|
|
||||||
id: 'urbis',
|
|
||||||
name: window.MAP_ORTHO_WMS_NAME || 'Urbis (Bruxelles)',
|
|
||||||
default: true,
|
|
||||||
source: {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: [
|
|
||||||
window.MAP_ORTHO_WMS_URL || 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}'
|
|
||||||
],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution: window.MAP_ORTHO_WMS_ATTRIBUTION || '\u00a9 Brussels UrbIS',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'vlaanderen',
|
|
||||||
name: 'Vlaanderen 2023',
|
|
||||||
source: {
|
|
||||||
type: 'raster',
|
|
||||||
tiles: ['https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'],
|
|
||||||
tileSize: 256,
|
|
||||||
attribution: '\u00a9 Digitaal Vlaanderen - Orthofoto 2023',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}],
|
}],
|
||||||
});
|
});// Reset view — fly back to initial Brussels Region view
|
||||||
// Reset view — fly back to initial Brussels Region view
|
|
||||||
map.addControl({
|
map.addControl({
|
||||||
onAdd(m) {
|
onAdd(m) {
|
||||||
this._map = m;
|
this._map = m;
|
||||||
|
|
@ -3620,7 +3711,7 @@ function renderConditionalFilters() {
|
||||||
const isChecked = selected === checkedValue;
|
const isChecked = selected === checkedValue;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="w-100 mb-2">
|
<div class="conditional-filter-block">
|
||||||
<div class="filter-options">
|
<div class="filter-options">
|
||||||
<div class="filter-option">
|
<div class="filter-option">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
|
|
@ -3642,8 +3733,8 @@ function renderConditionalFilters() {
|
||||||
if (def.type === 'date') {
|
if (def.type === 'date') {
|
||||||
const inputId = `cond-${thematicCode}-${def.key}`;
|
const inputId = `cond-${thematicCode}-${def.key}`;
|
||||||
return `
|
return `
|
||||||
<div class="w-100 mb-2">
|
<div class="conditional-filter-block">
|
||||||
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
|
<div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
|
||||||
<div class="filter-options">
|
<div class="filter-options">
|
||||||
<input type="date"
|
<input type="date"
|
||||||
class="form-control form-control-sm conditional-filter-input"
|
class="form-control form-control-sm conditional-filter-input"
|
||||||
|
|
@ -3669,8 +3760,8 @@ function renderConditionalFilters() {
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="w-100 mb-2">
|
<div class="conditional-filter-block">
|
||||||
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
|
<div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
|
||||||
<div class="filter-options">
|
<div class="filter-options">
|
||||||
<select class="form-select form-select-sm conditional-filter-input"
|
<select class="form-select form-select-sm conditional-filter-input"
|
||||||
name="${escapeHtml(filterName)}"
|
name="${escapeHtml(filterName)}"
|
||||||
|
|
@ -3693,6 +3784,22 @@ function renderConditionalFilters() {
|
||||||
? (selected === String(option.value))
|
? (selected === String(option.value))
|
||||||
: selected.includes(String(option.value));
|
: selected.includes(String(option.value));
|
||||||
|
|
||||||
|
let iconClass = option.icon || '';
|
||||||
|
if (!iconClass && def.key === 'usage_type') {
|
||||||
|
const fallbackIcons = {
|
||||||
|
administrative: 'bi-building',
|
||||||
|
education: 'bi-mortarboard',
|
||||||
|
sports: 'bi-trophy',
|
||||||
|
culture: 'bi-palette',
|
||||||
|
social: 'bi-people',
|
||||||
|
technical: 'bi-tools',
|
||||||
|
housing: 'bi-house-door',
|
||||||
|
other: 'bi-building-gear'
|
||||||
|
};
|
||||||
|
iconClass = fallbackIcons[option.value] || 'bi-building';
|
||||||
|
}
|
||||||
|
const iconHtml = iconClass ? `<i class="bi ${escapeHtml(iconClass)} me-1"></i>` : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="filter-option">
|
<div class="filter-option">
|
||||||
<input type="${inputType}"
|
<input type="${inputType}"
|
||||||
|
|
@ -3704,24 +3811,31 @@ function renderConditionalFilters() {
|
||||||
data-filter-type="${escapeHtml(def.type || 'multi')}"
|
data-filter-type="${escapeHtml(def.type || 'multi')}"
|
||||||
value="${escapeHtml(String(option.value))}"
|
value="${escapeHtml(String(option.value))}"
|
||||||
${isChecked ? 'checked' : ''}>
|
${isChecked ? 'checked' : ''}>
|
||||||
<label for="${optionId}">${escapeHtml(option.label)}</label>
|
<label for="${optionId}" title="${escapeHtml(option.label)}">${iconHtml}<span class="filter-option-text">${escapeHtml(option.label)}</span></label>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const isUniformGrid = def.key === 'usage_type' || (def.options && def.options.length >= 4 && def.options.some(o => o.label && o.label.length > 15));
|
||||||
|
const optionsContainerClass = isUniformGrid ? 'filter-options filter-options-grid' : 'filter-options';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="conditional-filter-block">
|
||||||
|
<div class="conditional-filter-label">${escapeHtml(def.label || def.key)}</div>
|
||||||
|
<div class="${optionsContainerClass}">${optionsHtml}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="w-100 mb-2">
|
<div class="conditional-thematic-group">
|
||||||
<div class="small fw-semibold text-muted mb-1">${escapeHtml(def.label || def.key)}</div>
|
<div class="conditional-thematic-header">
|
||||||
<div class="filter-options">${optionsHtml}</div>
|
${icon}<span>${escapeHtml(heading)}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
<div class="conditional-thematic-body">
|
||||||
}).join('');
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="w-100 mb-2">
|
|
||||||
<div class="small fw-semibold text-muted mb-1">${icon}${escapeHtml(heading)}</div>
|
|
||||||
${blocks}
|
${blocks}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,20 @@ function loadUnifiedMapIcons(map) {
|
||||||
.catch(e => console.warn('unified_map_layers: could not load roaddrain icon', e));
|
.catch(e => console.warn('unified_map_layers: could not load roaddrain icon', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// speedbump: dedicated speed cushion / ralentisseur SVG (44 px)
|
||||||
|
if (iconUrls.speedbump && !map.hasImage('icon-speedbump')) {
|
||||||
|
loadNormalisedIcon(iconUrls.speedbump, 44)
|
||||||
|
.then(data => { if (!map.hasImage('icon-speedbump')) map.addImage('icon-speedbump', data); })
|
||||||
|
.catch(e => console.warn('unified_map_layers: could not load speedbump icon', e));
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanglasscontainer: dedicated bulle à verre SVG (46 px)
|
||||||
|
if (iconUrls.cleanglasscontainer && !map.hasImage('icon-cleanglasscontainer')) {
|
||||||
|
loadNormalisedIcon(iconUrls.cleanglasscontainer, 46)
|
||||||
|
.then(data => { if (!map.hasImage('icon-cleanglasscontainer')) map.addImage('icon-cleanglasscontainer', data); })
|
||||||
|
.catch(e => console.warn('unified_map_layers: could not load cleanglasscontainer icon', e));
|
||||||
|
}
|
||||||
|
|
||||||
// roadconcreteblockset: dedicated concrete block SVG (42 px)
|
// roadconcreteblockset: dedicated concrete block SVG (42 px)
|
||||||
if (iconUrls.roadconcreteblockset && !map.hasImage('icon-roadconcreteblockset')) {
|
if (iconUrls.roadconcreteblockset && !map.hasImage('icon-roadconcreteblockset')) {
|
||||||
loadNormalisedIcon(iconUrls.roadconcreteblockset, 42)
|
loadNormalisedIcon(iconUrls.roadconcreteblockset, 42)
|
||||||
|
|
@ -169,6 +183,13 @@ function loadUnifiedMapIcons(map) {
|
||||||
.catch(e => console.warn('unified_map_layers: could not load naturebench icon', e));
|
.catch(e => console.warn('unified_map_layers: could not load naturebench icon', e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// natureplanter: dedicated planter / flower box SVG (38 px)
|
||||||
|
if (iconUrls.natureplanter && !map.hasImage('icon-natureplanter')) {
|
||||||
|
loadNormalisedIcon(iconUrls.natureplanter, 38)
|
||||||
|
.then(data => { if (!map.hasImage('icon-natureplanter')) map.addImage('icon-natureplanter', data); })
|
||||||
|
.catch(e => console.warn('unified_map_layers: could not load natureplanter icon', e));
|
||||||
|
}
|
||||||
|
|
||||||
// artworks: dedicated fountain/statue SVGs
|
// artworks: dedicated fountain/statue SVGs
|
||||||
if (iconUrls.artworkfountain && !map.hasImage('icon-artworkfountain')) {
|
if (iconUrls.artworkfountain && !map.hasImage('icon-artworkfountain')) {
|
||||||
loadNormalisedIcon(iconUrls.artworkfountain, 48)
|
loadNormalisedIcon(iconUrls.artworkfountain, 48)
|
||||||
|
|
@ -297,6 +318,38 @@ const THEMATIC_COLOR_EXPR = (typeof AssetSymbology !== 'undefined')
|
||||||
'#0022ff'
|
'#0022ff'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ─── Layer 0 — territory mask (inverted polygon: subtle transparent gray outside the commune)
|
||||||
|
const _unified_territory_mask = {
|
||||||
|
id: 'territory-mask',
|
||||||
|
type: 'fill',
|
||||||
|
source: 'territory',
|
||||||
|
filter: ['==', ['get', 'layer'], 'mask'],
|
||||||
|
paint: {
|
||||||
|
'fill-color': '#0f172a',
|
||||||
|
'fill-opacity': 0.14,
|
||||||
|
'fill-antialias': false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Layer 0b — territory boundary (delicate dashed outline around the commune)
|
||||||
|
const _unified_territory_outline = {
|
||||||
|
id: 'territory-outline',
|
||||||
|
type: 'line',
|
||||||
|
source: 'territory',
|
||||||
|
filter: ['==', ['get', 'layer'], 'boundary'],
|
||||||
|
paint: {
|
||||||
|
'line-color': '#475569',
|
||||||
|
'line-width': [
|
||||||
|
'interpolate', ['linear'], ['zoom'],
|
||||||
|
8, 1.2,
|
||||||
|
13, 1.8,
|
||||||
|
18, 2.5
|
||||||
|
],
|
||||||
|
'line-opacity': 0.7,
|
||||||
|
'line-dasharray': [4, 2],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ─── Layer 1 — location fill (polygon locations: streets, areas) ──────────────
|
// ─── Layer 1 — location fill (polygon locations: streets, areas) ──────────────
|
||||||
// id MUST stay 'locations': LayerSwitcherControl uses beforeLayer: 'locations'.
|
// id MUST stay 'locations': LayerSwitcherControl uses beforeLayer: 'locations'.
|
||||||
const _unified_locations_fill = {
|
const _unified_locations_fill = {
|
||||||
|
|
@ -304,8 +357,29 @@ const _unified_locations_fill = {
|
||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: 'locations',
|
source: 'locations',
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': '#0094ff',
|
'fill-color': [
|
||||||
'fill-opacity': 0, // contour only — outline handled by locations-outline
|
'case',
|
||||||
|
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
|
||||||
|
[
|
||||||
|
'match',
|
||||||
|
['coalesce', ['get', 'usage_type'], ''],
|
||||||
|
'administrative', '#2563EB',
|
||||||
|
'education', '#D97706',
|
||||||
|
'sports', '#059669',
|
||||||
|
'culture', '#7C3AED',
|
||||||
|
'social', '#E11D48',
|
||||||
|
'technical', '#64748B',
|
||||||
|
'housing', '#0D9488',
|
||||||
|
'other', '#475569',
|
||||||
|
['coalesce', ['get', 'color'], '#2563EB']
|
||||||
|
],
|
||||||
|
'#0094ff'
|
||||||
|
],
|
||||||
|
'fill-opacity': [
|
||||||
|
'case',
|
||||||
|
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']], 0.45,
|
||||||
|
0 // contour only for standard street locations
|
||||||
|
],
|
||||||
'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices
|
'fill-antialias': false, // no anti-aliased edge artefacts at polygon vertices
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
@ -328,11 +402,23 @@ const _unified_assets_fill = (typeof AssetSymbology !== 'undefined' && AssetSymb
|
||||||
['coalesce', ['get', 'color'], '#2e7d32'],
|
['coalesce', ['get', 'color'], '#2e7d32'],
|
||||||
['==', ['get', 'thematic'], 'parking'],
|
['==', ['get', 'thematic'], 'parking'],
|
||||||
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'),
|
(typeof AssetSymbology !== 'undefined' ? AssetSymbology.getParkingColorExpr() : '#0d6efd'),
|
||||||
|
['==', ['get', 'thematic'], 'roads'],
|
||||||
|
[
|
||||||
|
'match',
|
||||||
|
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
|
||||||
|
['1', '1 - Très mauvais'], '#e03131',
|
||||||
|
['2', '2 - Mauvais'], '#f76707',
|
||||||
|
['3', '3 - Bon'], '#fcc419',
|
||||||
|
['4', '4 - Très bon'], '#74b816',
|
||||||
|
['5', '5 - Excellent'], '#2f9e44',
|
||||||
|
'#4a4a4a'
|
||||||
|
],
|
||||||
THEMATIC_COLOR_EXPR
|
THEMATIC_COLOR_EXPR
|
||||||
],
|
],
|
||||||
'fill-opacity': [
|
'fill-opacity': [
|
||||||
'case',
|
'case',
|
||||||
['==', ['get', 'thematic'], 'parking'], 0.35,
|
['==', ['get', 'thematic'], 'parking'], 0.35,
|
||||||
|
['==', ['get', 'thematic'], 'roads'], 0.28,
|
||||||
0.7
|
0.7
|
||||||
],
|
],
|
||||||
'fill-antialias': false,
|
'fill-antialias': false,
|
||||||
|
|
@ -354,14 +440,29 @@ const _unified_locations_outline = {
|
||||||
'line-color': [
|
'line-color': [
|
||||||
'case',
|
'case',
|
||||||
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||||||
|
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
|
||||||
|
[
|
||||||
|
'match',
|
||||||
|
['coalesce', ['get', 'usage_type'], ''],
|
||||||
|
'administrative', '#1D4ED8',
|
||||||
|
'education', '#B45309',
|
||||||
|
'sports', '#047857',
|
||||||
|
'culture', '#6D28D9',
|
||||||
|
'social', '#BE123C',
|
||||||
|
'technical', '#334155',
|
||||||
|
'housing', '#0F766E',
|
||||||
|
'other', '#1E293B',
|
||||||
|
'#1D4ED8'
|
||||||
|
],
|
||||||
'#0094ff'
|
'#0094ff'
|
||||||
],
|
],
|
||||||
'line-width': [
|
'line-width': [
|
||||||
'case',
|
'case',
|
||||||
['boolean', ['feature-state', 'highlighted'], false], 3,
|
['boolean', ['feature-state', 'highlighted'], false], 3,
|
||||||
|
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']], 2,
|
||||||
1
|
1
|
||||||
],
|
],
|
||||||
'line-opacity': 0.8,
|
'line-opacity': 0.9,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -382,8 +483,24 @@ const _unified_locations_circle = {
|
||||||
14, 5,
|
14, 5,
|
||||||
19, 10
|
19, 10
|
||||||
],
|
],
|
||||||
// Colour by intersection status (matches trafficlights_index.html)
|
// Colour by building usage_type or intersection status
|
||||||
'circle-color': [
|
'circle-color': [
|
||||||
|
'case',
|
||||||
|
['any', ['==', ['get', 'thematic'], 'publicbuildings'], ['==', ['get', 'thematic'], 'buildings']],
|
||||||
|
[
|
||||||
|
'match',
|
||||||
|
['coalesce', ['get', 'usage_type'], ''],
|
||||||
|
'administrative', '#2563EB',
|
||||||
|
'education', '#D97706',
|
||||||
|
'sports', '#059669',
|
||||||
|
'culture', '#7C3AED',
|
||||||
|
'social', '#E11D48',
|
||||||
|
'technical', '#64748B',
|
||||||
|
'housing', '#0D9488',
|
||||||
|
'other', '#475569',
|
||||||
|
['coalesce', ['get', 'color'], '#2563EB']
|
||||||
|
],
|
||||||
|
[
|
||||||
'match', ['get', 'status'],
|
'match', ['get', 'status'],
|
||||||
'active', '#0094ff',
|
'active', '#0094ff',
|
||||||
'in_preparation', '#f59f00',
|
'in_preparation', '#f59f00',
|
||||||
|
|
@ -392,6 +509,7 @@ const _unified_locations_circle = {
|
||||||
'inactive', '#e03131',
|
'inactive', '#e03131',
|
||||||
'archived', '#6c757d',
|
'archived', '#6c757d',
|
||||||
'#0094ff'
|
'#0094ff'
|
||||||
|
]
|
||||||
],
|
],
|
||||||
'circle-stroke-color': [
|
'circle-stroke-color': [
|
||||||
'case',
|
'case',
|
||||||
|
|
@ -479,10 +597,251 @@ const _unified_assets_symbol = {
|
||||||
'icon-image': (typeof AssetSymbology !== 'undefined' && AssetSymbology.getIconImageExpr) ? AssetSymbology.getIconImageExpr() : 'icon-roads',
|
'icon-image': (typeof AssetSymbology !== 'undefined' && AssetSymbology.getIconImageExpr) ? AssetSymbology.getIconImageExpr() : 'icon-roads',
|
||||||
'icon-size': [
|
'icon-size': [
|
||||||
'interpolate', ['linear'], ['zoom'],
|
'interpolate', ['linear'], ['zoom'],
|
||||||
8, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.12, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.18, 0.4],
|
8, [
|
||||||
12, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.22, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.34, 0.6],
|
'case',
|
||||||
16, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.45, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.62, 0.8],
|
['any',
|
||||||
22, ['case', ['any', ['==', ['get', 'asset_type'], 'drain'], ['==', ['get', 'asset_type'], 'roaddrain'], ['==', ['get', 'asset_type'], 'naturerwiasset']], 0.95, ['==', ['get', 'asset_type'], 'roadconcreteblockset'], 1.05, 1.2]
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.05,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.08,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.09,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.12,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.07,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.08,
|
||||||
|
0.28
|
||||||
|
],
|
||||||
|
11, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'trafficlightelectricalcabinet'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.09,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.14,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.16,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.20,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.12,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.14,
|
||||||
|
0.40
|
||||||
|
],
|
||||||
|
13, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.15,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.22,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.25,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.30,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.19,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.22,
|
||||||
|
0.52
|
||||||
|
],
|
||||||
|
15, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.24,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.35,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.40,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.45,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.34,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.38,
|
||||||
|
0.66
|
||||||
|
],
|
||||||
|
17, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.45,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.55,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.62,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.65,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.55,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.62,
|
||||||
|
0.82
|
||||||
|
],
|
||||||
|
19, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.72,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 0.80,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 0.85,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 0.85,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 0.80,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 0.85,
|
||||||
|
0.98
|
||||||
|
],
|
||||||
|
22, [
|
||||||
|
'case',
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'drain'],
|
||||||
|
['==', ['get', 'asset_type'], 'roaddrain'],
|
||||||
|
['==', ['get', 'asset_type'], 'cleanlitterbin'],
|
||||||
|
['==', ['get', 'asset_type'], 'publiclightingelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'trafficlightelectricalcabinet'],
|
||||||
|
['==', ['get', 'asset_type'], 'naturerwiasset'],
|
||||||
|
['==', ['get', 'category'], 'Avaloir'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille de rue'],
|
||||||
|
['==', ['get', 'category'], 'Corbeille'],
|
||||||
|
['==', ['get', 'category'], 'Armoires électriques']
|
||||||
|
], 0.95,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'streetfurniture'], ['==', ['get', 'asset_type'], 'roadfurniture'], ['==', ['get', 'category'], 'Ralentisseur'], ['==', ['get', 'category'], 'RD_SPEED_BUMP']], 1.05,
|
||||||
|
['any', ['==', ['get', 'asset_type'], 'cleanglasscontainer'], ['==', ['get', 'category'], 'Bulle'], ['==', ['get', 'category'], 'Bulle à verre']], 1.10,
|
||||||
|
['==', ['get', 'asset_type'], 'roadconcreteblockset'], 1.05,
|
||||||
|
['any',
|
||||||
|
['==', ['get', 'asset_type'], 'naturebench'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs / Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bloembak'],
|
||||||
|
['==', ['get', 'model'], 'Jardinière'],
|
||||||
|
['==', ['get', 'model'], 'Bac à fleurs'],
|
||||||
|
['==', ['get', 'model'], 'Bac à plantes'],
|
||||||
|
['==', ['get', 'model'], 'Banc public'],
|
||||||
|
['==', ['get', 'model'], 'Openbare bank'],
|
||||||
|
['==', ['get', 'asset_sub_type'], 'planter'],
|
||||||
|
['==', ['get', 'category'], 'Mobilier (Bacs à fleurs, ...)'],
|
||||||
|
['==', ['get', 'category'], 'NAT_FURNITURES'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BAC'],
|
||||||
|
['==', ['slice', ['get', 'code'], 0, 7], 'CSE-BNC']
|
||||||
|
], 1.05,
|
||||||
|
['==', ['get', 'asset_type'], 'naturetree'], 1.10,
|
||||||
|
1.15
|
||||||
|
]
|
||||||
],
|
],
|
||||||
'icon-anchor': [
|
'icon-anchor': [
|
||||||
'case',
|
'case',
|
||||||
|
|
@ -783,6 +1142,8 @@ const _unified_assets_polygon_outline = (typeof AssetSymbology !== 'undefined' &
|
||||||
// MapLibre renders layers in insertion order, so the array below defines the
|
// MapLibre renders layers in insertion order, so the array below defines the
|
||||||
// visual stacking: fills at the bottom, then lines, then circles/symbols on top.
|
// visual stacking: fills at the bottom, then lines, then circles/symbols on top.
|
||||||
const UNIFIED_LAYERS = [
|
const UNIFIED_LAYERS = [
|
||||||
|
_unified_territory_mask, // 0. territory mask (outside commune)
|
||||||
|
_unified_territory_outline, // 0b. territory boundary line
|
||||||
_unified_locations_fill, // 1. polygon fills (bottom)
|
_unified_locations_fill, // 1. polygon fills (bottom)
|
||||||
_unified_assets_fill, // 2. polygon fills
|
_unified_assets_fill, // 2. polygon fills
|
||||||
_unified_locations_outline, // 3. lines
|
_unified_locations_outline, // 3. lines
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,11 @@
|
||||||
paint: {
|
paint: {
|
||||||
'circle-radius': [
|
'circle-radius': [
|
||||||
'interpolate', ['linear'], ['zoom'],
|
'interpolate', ['linear'], ['zoom'],
|
||||||
8, 3,
|
8, 1.2,
|
||||||
|
11, 2.0,
|
||||||
|
13, 3.0,
|
||||||
|
15, 4.5,
|
||||||
|
17, 7.0,
|
||||||
22, 10
|
22, 10
|
||||||
],
|
],
|
||||||
// display_color is a pre-computed canonical colour name (Python-side).
|
// display_color is a pre-computed canonical colour name (Python-side).
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@
|
||||||
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
|
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<i class="bi bi-person me-1 text-muted"></i>{{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }}
|
<i class="bi bi-person me-1 text-muted"></i>{{ insp.inspector|short_name|default:"-" }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="small text-muted text-truncate d-inline-block" style="max-width: 220px;" title="{{ insp.details_summary }}">
|
<span class="small text-muted text-truncate d-inline-block" style="max-width: 220px;" title="{{ insp.details_summary }}">
|
||||||
|
|
@ -100,11 +100,31 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if insp.related_intervention %}
|
||||||
|
<div class="mb-3">
|
||||||
|
<a href="{% url 'interventions:intervention_detail' insp.related_intervention.id %}" class="btn btn-outline-primary w-100 fw-semibold" target="_blank">
|
||||||
|
<i class="bi bi-tools me-2"></i>{% translate "Consulter l'intervention" %} {{ insp.related_intervention.code }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
<ul class="list-group list-group-flush mb-3">
|
<ul class="list-group list-group-flush mb-3">
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Équipement cible" %}</span>
|
<span class="text-muted"><i class="bi bi-geo-alt me-1"></i>{% translate "Équipement cible" %}</span>
|
||||||
<span class="fw-bold text-end ms-2">{{ insp.target_display }}</span>
|
<span class="fw-bold text-end ms-2">{{ insp.target_display }}</span>
|
||||||
</li>
|
</li>
|
||||||
|
{% if insp.related_intervention %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
|
<span class="text-muted"><i class="bi bi-tools me-1"></i>{% translate "Intervention liée" %}</span>
|
||||||
|
<span class="fw-semibold text-end ms-2">
|
||||||
|
<a href="{% url 'interventions:intervention_detail' insp.related_intervention.id %}" class="text-decoration-none fw-bold" target="_blank">
|
||||||
|
<i class="bi bi-box-arrow-up-right me-1"></i>{{ insp.related_intervention.code }}
|
||||||
|
</a>
|
||||||
|
{% if insp.related_intervention.sub_type %}
|
||||||
|
<span class="badge bg-light text-dark border ms-1">{{ insp.related_intervention.sub_type.get_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
|
<span class="text-muted"><i class="bi bi-calendar-event me-1"></i>{% translate "Date & Heure" %}</span>
|
||||||
<span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
|
<span class="fw-semibold">{{ insp.inspection_date|date:"d/m/Y H:i" }}</span>
|
||||||
|
|
@ -119,7 +139,7 @@
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
|
<span class="text-muted"><i class="bi bi-person me-1"></i>{% translate "Inspecteur" %}</span>
|
||||||
<span class="fw-semibold">{{ insp.inspector|short_name|default:insp.inspector.username|default:"-" }}</span>
|
<span class="fw-semibold">{{ insp.inspector|short_name|default:"-" }}</span>
|
||||||
</li>
|
</li>
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
|
||||||
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>
|
<span class="text-muted"><i class="bi bi-clock-history me-1"></i>{% translate "Date de modification" %}</span>
|
||||||
|
|
|
||||||
|
|
@ -234,7 +234,7 @@
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
{% block asset_header_title %}
|
{% block asset_header_title %}
|
||||||
<div>
|
<div>
|
||||||
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
|
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
|
||||||
{% translate "Asset" %} : {{ asset.code }}
|
{% translate "Asset" %} : {{ asset.code }}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.8rem; color: #6c757d;">
|
<div class="d-flex align-items-center gap-2 mt-1" style="font-size: 0.8rem; color: #6c757d;">
|
||||||
|
|
@ -278,7 +278,7 @@
|
||||||
{% if mobile_view or not allowed_update_fields %}
|
{% if mobile_view or not allowed_update_fields %}
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
{% block asset_header_title_readonly %}
|
{% block asset_header_title_readonly %}
|
||||||
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
|
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
|
||||||
{% if thematic.icon %}
|
{% if thematic.icon %}
|
||||||
<i class="bi {{ thematic.icon }} me-2"></i>
|
<i class="bi {{ thematic.icon }} me-2"></i>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
@ -546,7 +546,7 @@
|
||||||
</td>
|
</td>
|
||||||
</tr> -->
|
</tr> -->
|
||||||
<tr class="table-light" style="--bs-table-bg-type: transparent; --bs-table-bg-state: transparent;">
|
<tr class="table-light" style="--bs-table-bg-type: transparent; --bs-table-bg-state: transparent;">
|
||||||
<th colspan="2" class="text-muted" style="color: var(--brand-color) !important; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em;">
|
<th colspan="2" class="text-muted" style="color: #475569 !important; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.05em;">
|
||||||
{% translate "Attributs spécifiques" %}
|
{% translate "Attributs spécifiques" %}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
background-color: rgba(47, 62, 139, 0.1);
|
background-color: rgba(47, 62, 139, 0.1);
|
||||||
color: var(--brand-color, #2f3e8b);
|
color: #0f172a;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
.mobile-tab-btn.active {
|
.mobile-tab-btn.active {
|
||||||
background-color: var(--brand-color, #2f3e8b);
|
background-color: #0f172a;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
box-shadow: 0 2px 4px rgba(47, 62, 139, 0.25);
|
box-shadow: 0 2px 4px rgba(47, 62, 139, 0.25);
|
||||||
}
|
}
|
||||||
|
|
@ -137,7 +137,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-grow-1 text-truncate">
|
<div class="flex-grow-1 text-truncate">
|
||||||
<div class="d-flex align-items-center gap-1.5 flex-wrap">
|
<div class="d-flex align-items-center gap-1.5 flex-wrap">
|
||||||
<span class="fw-bold text-dark fs-6" style="color: var(--brand-color);">{{ asset.code }}</span>
|
<span class="fw-bold text-dark fs-6" style="color: #0f172a;">{{ asset.code }}</span>
|
||||||
{% if asset.status == 'active' %}
|
{% if asset.status == 'active' %}
|
||||||
<span class="badge bg-success">{{ asset.get_status_display }}</span>
|
<span class="badge bg-success">{{ asset.get_status_display }}</span>
|
||||||
{% elif asset.status == 'damaged' or asset.status == 'to_replace' %}
|
{% elif asset.status == 'damaged' or asset.status == 'to_replace' %}
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
|
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
|
||||||
{% translate "Arbre" %} : {{ asset.code }}
|
{% translate "Arbre" %} : {{ asset.code }}
|
||||||
</h2>
|
</h2>
|
||||||
{% if asset.name_fr %}
|
{% if asset.name_fr %}
|
||||||
|
|
@ -114,7 +114,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="d-flex align-items-center gap-2 flex-wrap">
|
<div class="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<h2 class="mb-0" style="color: var(--brand-color); font-size: 1.25rem;">
|
<h2 class="mb-0" style="color: #0f172a; font-size: 1.25rem; font-weight: 700;">
|
||||||
{% translate "Arbre" %} : {{ asset.code }}
|
{% translate "Arbre" %} : {{ asset.code }}
|
||||||
</h2>
|
</h2>
|
||||||
{% if asset.name_fr %}
|
{% if asset.name_fr %}
|
||||||
|
|
@ -187,7 +187,7 @@
|
||||||
{# ── CARTE 1 : IDENTITÉ DE L'ARBRE ── #}
|
{# ── CARTE 1 : IDENTITÉ DE L'ARBRE ── #}
|
||||||
<div class="card mb-3 shadow-sm">
|
<div class="card mb-3 shadow-sm">
|
||||||
<div class="card-header bg-light d-flex align-items-center justify-content-between">
|
<div class="card-header bg-light d-flex align-items-center justify-content-between">
|
||||||
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
|
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
|
||||||
<i class="bi bi-info-circle text-primary me-2"></i>{% translate "Identité & Informations générales" %}
|
<i class="bi bi-info-circle text-primary me-2"></i>{% translate "Identité & Informations générales" %}
|
||||||
</h5>
|
</h5>
|
||||||
{% if asset.tag_number %}
|
{% if asset.tag_number %}
|
||||||
|
|
@ -384,7 +384,7 @@
|
||||||
{# ── CARTE 2 : DENDROMÉTRIE & DIMENSIONS ── #}
|
{# ── CARTE 2 : DENDROMÉTRIE & DIMENSIONS ── #}
|
||||||
<div class="card mb-3 shadow-sm">
|
<div class="card mb-3 shadow-sm">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
|
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
|
||||||
<i class="bi bi-rulers text-success me-2"></i>{% translate "Fiche dendrométrique & Dimensions" %}
|
<i class="bi bi-rulers text-success me-2"></i>{% translate "Fiche dendrométrique & Dimensions" %}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -521,7 +521,7 @@
|
||||||
{# ── CARTE 3 : DIAGNOSTIC SANITAIRE & SÉCURITÉ ── #}
|
{# ── CARTE 3 : DIAGNOSTIC SANITAIRE & SÉCURITÉ ── #}
|
||||||
<div class="card mb-3 shadow-sm">
|
<div class="card mb-3 shadow-sm">
|
||||||
<div class="card-header bg-light d-flex align-items-center justify-content-between">
|
<div class="card-header bg-light d-flex align-items-center justify-content-between">
|
||||||
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
|
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
|
||||||
<i class="bi bi-shield-check text-warning me-2"></i>{% translate "Diagnostic sanitaire & Sécurité" %}
|
<i class="bi bi-shield-check text-warning me-2"></i>{% translate "Diagnostic sanitaire & Sécurité" %}
|
||||||
</h5>
|
</h5>
|
||||||
{% if asset.is_remarkable %}
|
{% if asset.is_remarkable %}
|
||||||
|
|
@ -698,7 +698,7 @@
|
||||||
{# ── CARTE 4 : IMPLANTATION & CONDUITE DE TAILLE ── #}
|
{# ── CARTE 4 : IMPLANTATION & CONDUITE DE TAILLE ── #}
|
||||||
<div class="card mb-3 shadow-sm">
|
<div class="card mb-3 shadow-sm">
|
||||||
<div class="card-header bg-light">
|
<div class="card-header bg-light">
|
||||||
<h5 class="mb-0 fs-6 fw-bold" style="color: var(--brand-color);">
|
<h5 class="mb-0 fs-6 fw-bold" style="color: #0f172a;">
|
||||||
<i class="bi bi-geo-alt text-info me-2"></i>{% translate "Implantation & Conduite de taille" %}
|
<i class="bi bi-geo-alt text-info me-2"></i>{% translate "Implantation & Conduite de taille" %}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -111,17 +111,35 @@
|
||||||
filter: ['in', ['geometry-type'], ['literal', ['Polygon', 'MultiPolygon']]],
|
filter: ['in', ['geometry-type'], ['literal', ['Polygon', 'MultiPolygon']]],
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': [
|
'fill-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'asset_type'], 'roadway'],
|
||||||
|
[
|
||||||
'match',
|
'match',
|
||||||
['get', 'asset_type'],
|
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
|
||||||
'roadway', '#4a4a4a',
|
['1', '1 - Très mauvais'], '#e03131',
|
||||||
'sidewalk', '#00bfa5',
|
['2', '2 - Mauvais'], '#f76707',
|
||||||
'#9e9e9e'
|
['3', '3 - Bon'], '#fcc419',
|
||||||
|
['4', '4 - Très bon'], '#74b816',
|
||||||
|
['5', '5 - Excellent'], '#2f9e44',
|
||||||
|
'#4a4a4a'
|
||||||
],
|
],
|
||||||
'fill-opacity': 0.7,
|
['match', ['get', 'asset_type'], 'sidewalk', '#00bfa5', '#9e9e9e']
|
||||||
|
],
|
||||||
|
'fill-opacity': 0.28,
|
||||||
'fill-outline-color': [
|
'fill-outline-color': [
|
||||||
'case',
|
'case',
|
||||||
['boolean', ['feature-state', 'highlighted'], false],
|
['boolean', ['feature-state', 'highlighted'], false], '#ffeb3b',
|
||||||
'#ffeb3b',
|
['==', ['get', 'asset_type'], 'roadway'],
|
||||||
|
[
|
||||||
|
'match',
|
||||||
|
['to-string', ['coalesce', ['get', 'quality_level_value'], ['get', 'quality_level'], '']],
|
||||||
|
['1', '1 - Très mauvais'], '#c92a2a',
|
||||||
|
['2', '2 - Mauvais'], '#e8590c',
|
||||||
|
['3', '3 - Bon'], '#f59f00',
|
||||||
|
['4', '4 - Très bon'], '#66a80f',
|
||||||
|
['5', '5 - Excellent'], '#2b8a3e',
|
||||||
|
'#00000000'
|
||||||
|
],
|
||||||
'#00000000'
|
'#00000000'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
@ -175,8 +193,12 @@
|
||||||
'icon-image': 'drain-icon',
|
'icon-image': 'drain-icon',
|
||||||
'icon-size': [
|
'icon-size': [
|
||||||
'interpolate', ['linear'], ['zoom'],
|
'interpolate', ['linear'], ['zoom'],
|
||||||
8, 0.1,
|
8, 0.05,
|
||||||
22, 1
|
11, 0.09,
|
||||||
|
13, 0.15,
|
||||||
|
15, 0.24,
|
||||||
|
17, 0.45,
|
||||||
|
22, 0.95
|
||||||
],
|
],
|
||||||
'icon-allow-overlap': true,
|
'icon-allow-overlap': true,
|
||||||
'icon-ignore-placement': true,
|
'icon-ignore-placement': true,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -241,6 +241,12 @@
|
||||||
<script type="application/json" id="georeferencedDocumentsData">
|
<script type="application/json" id="georeferencedDocumentsData">
|
||||||
{{ georeferenced_documents_json|safe }}
|
{{ georeferenced_documents_json|safe }}
|
||||||
</script>
|
</script>
|
||||||
|
{{ dxf_files|json_script:"dxfFilesData" }}
|
||||||
|
<script type="application/json" id="crossroadPlansConfig">
|
||||||
|
{
|
||||||
|
"importDxfUrl": "{% url 'assets:import_dxf' intersection.id %}"
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<!-- Modal form add document -->
|
<!-- Modal form add document -->
|
||||||
<div class="modal fade" id="addDocumentModal" tabindex="-1" aria-labelledby="addDocumentModalLabel" aria-hidden="true" style="z-index: 10050;">
|
<div class="modal fade" id="addDocumentModal" tabindex="-1" aria-labelledby="addDocumentModalLabel" aria-hidden="true" style="z-index: 10050;">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-lg">
|
||||||
|
|
@ -1108,107 +1114,6 @@
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Georeferenced Plans Selection Modal -->
|
|
||||||
<div class="modal fade" id="georefPlanModal" tabindex="-1" aria-hidden="true">
|
|
||||||
<div class="modal-dialog">
|
|
||||||
<div class="modal-content rounded-3 border-0 shadow-lg">
|
|
||||||
<div class="modal-header bg-light border-bottom-0 py-3">
|
|
||||||
<h5 class="modal-title font-weight-bold text-dark"><i class="bi bi-geo-alt-fill text-primary me-2"></i>{% translate "Plans géoréférencés" %}</h5>
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body py-3">
|
|
||||||
<!-- Nav Tabs -->
|
|
||||||
<ul class="nav nav-tabs nav-fill mb-3" id="georefPlanTabs" role="tablist">
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link active font-weight-bold" id="tab-image-plan" data-bs-toggle="tab" data-bs-target="#panel-image-plan" type="button" role="tab" aria-controls="panel-image-plan" aria-selected="true">
|
|
||||||
<i class="bi bi-file-earmark-image me-1"></i>{% translate "Plan image" %}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item" role="presentation">
|
|
||||||
<button class="nav-link font-weight-bold" id="tab-vector-plan" data-bs-toggle="tab" data-bs-target="#panel-vector-plan" type="button" role="tab" aria-controls="panel-vector-plan" aria-selected="false">
|
|
||||||
<i class="bi bi-file-earmark-code me-1"></i>{% translate "Plan CAO" %}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<!-- Tab Content -->
|
|
||||||
<div class="tab-content pt-2" id="georefPlanTabContent">
|
|
||||||
<!-- Tab 1: Image Plan -->
|
|
||||||
<div class="tab-pane fade show active" id="panel-image-plan" role="tabpanel" aria-labelledby="tab-image-plan">
|
|
||||||
<div id="image-plan-selection-container">
|
|
||||||
<div id="image-plans-list" class="d-flex flex-column gap-3 mb-2">
|
|
||||||
<!-- Dynamically populated list of images with switches and sliders -->
|
|
||||||
</div>
|
|
||||||
<div id="no-georef-images-alert" class="text-center py-4" style="display: none;">
|
|
||||||
<div class="text-muted mb-3"><i class="bi bi-info-circle" style="font-size: 2.5rem;"></i></div>
|
|
||||||
<p class="text-dark font-weight-semibold mb-1">{% translate "Aucun plan d'image géoréférencé disponible" %}</p>
|
|
||||||
<p class="text-muted small">{% translate "Géoréférencez d'abord un plan PDF ou image dans l'onglet Documents de la fiche." %}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tab 2: Vector Plan (DXF) -->
|
|
||||||
<div class="tab-pane fade" id="panel-vector-plan" role="tabpanel" aria-labelledby="tab-vector-plan">
|
|
||||||
<div id="dxf-selection-container">
|
|
||||||
{% if not dxf_files %}
|
|
||||||
<div class="text-center py-4">
|
|
||||||
<div class="text-muted mb-3"><i class="bi bi-exclamation-triangle" style="font-size: 2.5rem;"></i></div>
|
|
||||||
<p class="text-dark font-weight-semibold mb-1">{% translate "Aucun fichier DXF disponible pour ce carrefour" %}</p>
|
|
||||||
<p class="text-muted small">{% translate "Veuillez d'abord en importer un." %}</p>
|
|
||||||
<a href="{% url 'assets:import_dxf' intersection.id %}" class="btn btn-primary rounded-3 btn-sm mt-2 px-3">
|
|
||||||
<i class="bi bi-upload me-1"></i>{% translate "Importer un plan DXF" %}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<!-- File Selection -->
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label font-weight-semibold text-muted small uppercase">{% translate "Sélectionner le plan DXF" %}</label>
|
|
||||||
{% if dxf_files|length == 1 %}
|
|
||||||
<div class="p-3 bg-light border rounded-3 font-weight-semibold text-dark mb-3">
|
|
||||||
<i class="bi bi-file-earmark-check-fill text-success me-2"></i>{{ dxf_files.0 }}
|
|
||||||
<input type="hidden" id="select-dxf-file" value="{{ dxf_files.0 }}">
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<select id="select-dxf-file" class="form-select rounded-3 p-3 mb-3">
|
|
||||||
<option value="">{% translate "-- Sélectionner un plan CAO (DXF) --" %}</option>
|
|
||||||
{% for f in dxf_files %}
|
|
||||||
<option value="{{ f }}">{{ f }}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Layers selection checklist -->
|
|
||||||
<div id="dxf-detail-layers-section" style="display: none;" class="mb-3">
|
|
||||||
<label class="form-label font-weight-semibold text-muted small uppercase d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<span>{% translate "Couches vectorielles CAO" %}</span>
|
|
||||||
<button type="button" id="dxf-detail-toggle-all-layers" class="btn btn-link btn-sm p-0 text-decoration-none" style="font-size: 0.75rem;">
|
|
||||||
{% translate "Décocher tout" %}
|
|
||||||
</button>
|
|
||||||
</label>
|
|
||||||
<div id="dxf-detail-layers-spinner" class="text-center py-4" style="display: none;">
|
|
||||||
<div class="spinner-border spinner-border-sm text-primary" role="status"></div>
|
|
||||||
<div class="text-muted small mt-2">{% translate "Lecture des couches du plan..." %}</div>
|
|
||||||
</div>
|
|
||||||
<div id="dxf-detail-layers-list" class="border rounded-3 p-3 bg-white" style="max-height: 250px; overflow-y: auto;">
|
|
||||||
<!-- Checkboxes will be populated dynamically -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer border-top-0 pt-0 pb-4 justify-content-between px-4">
|
|
||||||
<button type="button" id="btn-disable-all-plans" class="btn btn-outline-danger px-3 rounded-3">
|
|
||||||
<i class="bi bi-eye-slash-fill me-1"></i>{% translate "Désactiver tout" %}
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-secondary px-4 rounded-3 font-weight-semibold shadow-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% endblock content%}
|
{% endblock content%}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1232,7 +1137,6 @@
|
||||||
const radar_preventif_icon_url = "{% static 'assets/radar_preventif_icon.svg' %}?v=20260710";
|
const radar_preventif_icon_url = "{% static 'assets/radar_preventif_icon.svg' %}?v=20260710";
|
||||||
const thematicCode = "{{ thematic_code }}";
|
const thematicCode = "{{ thematic_code }}";
|
||||||
const intersectionId = "{{ intersection.id }}";
|
const intersectionId = "{{ intersection.id }}";
|
||||||
const dxfFiles = {% if dxf_files %}{{ dxf_files|safe }}{% else %}[]{% endif %};
|
|
||||||
</script>
|
</script>
|
||||||
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
||||||
<script src="{% static 'assets/trafficlights_intersection_detail.js' %}"></script>
|
<script src="{% static 'assets/trafficlights_intersection_detail.js' %}"></script>
|
||||||
|
|
|
||||||
|
|
@ -9,44 +9,401 @@
|
||||||
<style>
|
<style>
|
||||||
#assets-search-input:focus { outline: none; box-shadow: none; border-color: #dee2e6; }
|
#assets-search-input:focus { outline: none; box-shadow: none; border-color: #dee2e6; }
|
||||||
#unified-empty-state { display: none; color: #6c757d; }
|
#unified-empty-state { display: none; color: #6c757d; }
|
||||||
/* ─── Filter modal ──────────────────────────────────── */
|
/* ─── Modern Airbnb-style Filter Modal ──────────────────── */
|
||||||
#filterModal .modal-dialog { max-width: 95%; }
|
#filterModal .modal-dialog {
|
||||||
#filterModal .modal-content { display: flex; flex-direction: column; max-height: 90vh; }
|
max-width: 820px;
|
||||||
#filterModal .modal-body { flex: 1; overflow-y: auto; }
|
margin: 1.5rem auto;
|
||||||
#filterModal .modal-footer { flex-shrink: 0; }
|
}
|
||||||
.filter-section { margin-bottom: 1rem; }
|
#filterModal .modal-content {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 18px;
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-height: 88vh;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
#filterModal .modal-header {
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
border-bottom: 1px solid #f1f5f9;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
#filterModal .modal-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
#filterModal .btn-close {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
#filterModal .btn-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
#filterModal .modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
#filterModal .modal-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-top: 1px solid #f1f5f9;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Thematic Conditional Filter Cards */
|
||||||
|
#conditional-filter-options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.85rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.conditional-thematic-group {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.95rem 1.05rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.conditional-thematic-group:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
}
|
||||||
|
.conditional-thematic-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
padding-bottom: 0.55rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.conditional-thematic-header i {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
.conditional-filter-block {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.conditional-filter-block:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.conditional-filter-label {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Uniform Responsive Grid for Filter Options (e.g. usage_type) */
|
||||||
|
.filter-options.filter-options-grid {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(225px, 1fr)) !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
.filter-options.filter-options-grid .filter-option {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.filter-options.filter-options-grid .filter-option label {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.45rem 0.85rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 38px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.filter-options.filter-options-grid .filter-option label .filter-option-text {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.filter-options.filter-options-grid .filter-option label i {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-right: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* Filter Section Cards */
|
||||||
|
.filter-section {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 1.15rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
.filter-section:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
.filter-section-header {
|
.filter-section-header {
|
||||||
display: flex; justify-content: space-between; align-items: center;
|
display: flex;
|
||||||
margin-bottom: 0.75rem; gap: 1rem;
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.85rem;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
.filter-section-header > label { font-weight: 600; color: #333; margin: 0; flex-shrink: 0; }
|
.filter-section-header > label,
|
||||||
.filter-section-buttons { display: flex; gap: 0.5rem; margin-left: auto; flex-shrink: 0; }
|
.filter-section-header > .section-title {
|
||||||
.filter-section-buttons button { padding: 0.25rem 0.5rem; font-size: 0.75rem; white-space: nowrap; }
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0f172a;
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
}
|
||||||
|
.filter-section-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.35rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.filter-section-buttons button {
|
||||||
|
padding: 0.25rem 0.65rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #64748b;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.filter-section-buttons button:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Thematic Grid (Airbnb-style visual cards) */
|
||||||
|
.thematic-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.thematic-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.thematic-tile {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.thematic-tile input[type="checkbox"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.thematic-tile label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 96px;
|
||||||
|
padding: 0.85rem 0.5rem;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1.5px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.thematic-tile .thematic-icon {
|
||||||
|
font-size: 1.85rem;
|
||||||
|
color: #475569;
|
||||||
|
margin-bottom: 0.45rem;
|
||||||
|
transition: transform 0.18s ease, color 0.18s ease;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.thematic-tile .thematic-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
line-height: 1.25;
|
||||||
|
transition: color 0.18s ease;
|
||||||
|
}
|
||||||
|
.thematic-tile:hover label {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border-color: #94a3b8;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
.thematic-tile:hover .thematic-icon {
|
||||||
|
color: #0f172a;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
.thematic-tile input[type="checkbox"]:checked + label {
|
||||||
|
background-color: #0f172a !important;
|
||||||
|
border-color: #0f172a !important;
|
||||||
|
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.22);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
.thematic-tile input[type="checkbox"]:checked + label .thematic-icon {
|
||||||
|
color: #ffffff !important;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.thematic-tile input[type="checkbox"]:checked + label .thematic-label {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chips & Tags (Categories, Models, Conditionals, Status) */
|
||||||
.filter-options {
|
.filter-options {
|
||||||
display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 2.5rem;
|
display: flex;
|
||||||
padding: 0.5rem; background-color: #f8f9fa;
|
flex-wrap: wrap;
|
||||||
border-radius: 0.375rem; border: 1px solid #dee2e6;
|
gap: 0.45rem;
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
min-height: auto !important;
|
||||||
|
}
|
||||||
|
.filter-option {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
.filter-option { display: flex; align-items: center; }
|
|
||||||
.filter-option input[type="checkbox"],
|
.filter-option input[type="checkbox"],
|
||||||
.filter-option input[type="radio"] {
|
.filter-option input[type="radio"] {
|
||||||
position: absolute; opacity: 0; width: 0; height: 0; cursor: pointer;
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
.filter-option label {
|
.filter-option label {
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
display: inline-flex;
|
||||||
margin: 0; font-weight: 500; cursor: pointer;
|
align-items: center;
|
||||||
background-color: #e7f1ff; color: #0c5cc9;
|
gap: 0.35rem;
|
||||||
border-radius: 1rem; padding: 0.35rem 0.75rem; font-size: 0.8rem;
|
padding: 0.4rem 0.85rem;
|
||||||
border: 1px solid #b8d4f1; transition: all 0.15s ease;
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #334155;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1.5px solid #e2e8f0;
|
||||||
|
border-radius: 9999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
user-select: none;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
.filter-option label:hover {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border-color: #94a3b8;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
.filter-option input[type="checkbox"]:checked + label,
|
.filter-option input[type="checkbox"]:checked + label,
|
||||||
.filter-option input[type="radio"]:checked + label {
|
.filter-option input[type="radio"]:checked + label {
|
||||||
background-color: #0c5cc9; color: #fff; border-color: #0c5cc9;
|
background-color: #0f172a !important;
|
||||||
|
border-color: #0f172a !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.16);
|
||||||
|
}
|
||||||
|
.filter-option input:disabled + label {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segmented Control (Element Type) */
|
||||||
|
.segmented-control {
|
||||||
|
display: inline-flex;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
gap: 3px;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.segmented-option label {
|
||||||
|
border-radius: 7px !important;
|
||||||
|
border: none !important;
|
||||||
|
background-color: transparent !important;
|
||||||
|
color: #64748b !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
padding: 0.45rem 1rem !important;
|
||||||
|
font-size: 0.84rem !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.segmented-option label:hover {
|
||||||
|
color: #0f172a !important;
|
||||||
|
background-color: rgba(255, 255, 255, 0.6) !important;
|
||||||
|
}
|
||||||
|
.segmented-option input[type="radio"]:checked + label {
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state helper inside filter options */
|
||||||
|
#category-filter-options > span.text-muted,
|
||||||
|
#model-filter-options > span.text-muted,
|
||||||
|
#conditional-filter-options > span.text-muted {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #64748b !important;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Filter trigger badge */
|
||||||
|
#filter-badge {
|
||||||
|
background-color: #0f172a !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||||
|
top: 2px !important;
|
||||||
|
right: -6px !important;
|
||||||
|
left: auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
min-width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
padding: 0 4px !important;
|
||||||
|
line-height: 1.25rem;
|
||||||
|
}
|
||||||
|
#filter-badge:not(.d-none) {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.filter-option label:hover { background-color: #cfe2f3; }
|
|
||||||
.filter-option input:checked + label:hover { background-color: #0a4fb0; }
|
|
||||||
.filter-option input:disabled + label { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
#map .maplibregl-popup-close-button {
|
#map .maplibregl-popup-close-button {
|
||||||
font-size: 1.35rem;
|
font-size: 1.35rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
|
|
@ -70,6 +427,226 @@
|
||||||
#map .mapboxgl-ctrl-top-right {
|
#map .mapboxgl-ctrl-top-right {
|
||||||
top: 10px !important;
|
top: 10px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Modern SaaS Table Styling ────────────────────────────────────── */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;600&display=swap');
|
||||||
|
|
||||||
|
#col-table,
|
||||||
|
.bootstrap-table {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-container {
|
||||||
|
border: 1px solid #e2e8f0 !important;
|
||||||
|
border-radius: 10px !important;
|
||||||
|
background-color: #ffffff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.03);
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-body {
|
||||||
|
overflow: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modern subtle scrollbar */
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar {
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-track {
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-body::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
#col-table,
|
||||||
|
#col-table .tab-content,
|
||||||
|
#col-table .tab-pane {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Headers */
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-header th,
|
||||||
|
.bootstrap-table.bootstrap5 #unified-table th {
|
||||||
|
background-color: #f8fafc !important;
|
||||||
|
color: #475569 !important;
|
||||||
|
font-size: 0.74rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
text-transform: uppercase !important;
|
||||||
|
letter-spacing: 0.04em !important;
|
||||||
|
border-bottom: 1.5px solid #e2e8f0 !important;
|
||||||
|
padding: 0.65rem 0.75rem !important;
|
||||||
|
vertical-align: middle !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-header th .th-inner {
|
||||||
|
padding: 0 !important;
|
||||||
|
font-size: 0.74rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
line-height: 1.4 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rows & Cells */
|
||||||
|
.bootstrap-table.bootstrap5 #unified-table td {
|
||||||
|
padding: 0.65rem 0.75rem !important;
|
||||||
|
vertical-align: middle !important;
|
||||||
|
font-size: 0.83rem !important;
|
||||||
|
color: #334155;
|
||||||
|
border-bottom: 1px solid #f1f5f9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 #unified-table tbody tr {
|
||||||
|
transition: background-color 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 #unified-table tbody tr:hover {
|
||||||
|
background-color: #f8fafc !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bootstrap-table.bootstrap5 #unified-table tbody tr.selected {
|
||||||
|
background-color: #f1f5f9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specific cell styles */
|
||||||
|
.unified-code-link {
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace !important;
|
||||||
|
font-size: 0.79rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: #0f172a !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #f8fafc;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.unified-code-link:hover {
|
||||||
|
color: #2563eb !important;
|
||||||
|
border-color: #93c5fd;
|
||||||
|
background-color: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unified-row-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #0f172a;
|
||||||
|
display: block;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unified-category-cell {
|
||||||
|
color: #334155;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent unwanted wraps */
|
||||||
|
#unified-table td[data-field="code"],
|
||||||
|
#unified-table th[data-field="code"] {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
}
|
||||||
|
#unified-table td[data-field="thematic_name"],
|
||||||
|
#unified-table th[data-field="thematic_name"] {
|
||||||
|
white-space: nowrap !important;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
#unified-table td[data-field="element_type"] {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zoom button */
|
||||||
|
.zoom-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
color: #64748b;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.zoom-btn:hover {
|
||||||
|
color: #0f172a;
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection toolbar styling */
|
||||||
|
#unified-select-all,
|
||||||
|
#unified-deselect-all {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.28rem 0.65rem;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
#unified-select-all:hover,
|
||||||
|
#unified-deselect-all:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
#unified-selection-status {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
#unified-bulk-action-dropdown .dropdown-toggle {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #475569;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.28rem 0.75rem;
|
||||||
|
}
|
||||||
|
#unified-bulk-action-dropdown .dropdown-toggle:hover {
|
||||||
|
background-color: #f1f5f9;
|
||||||
|
color: #0f172a;
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pagination bar styling */
|
||||||
|
.bootstrap-table.bootstrap5 .fixed-table-pagination {
|
||||||
|
padding: 0.65rem 0.25rem 0 0.25rem !important;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .pagination-detail .page-list .btn-group .btn {
|
||||||
|
border-radius: 6px;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
background-color: #ffffff;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .pagination .page-item .page-link {
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 0 2px;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.25rem 0.55rem;
|
||||||
|
}
|
||||||
|
.bootstrap-table.bootstrap5 .pagination .page-item.active .page-link {
|
||||||
|
background-color: #0f172a;
|
||||||
|
border-color: #0f172a;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
{% endblock head %}
|
{% endblock head %}
|
||||||
|
|
||||||
|
|
@ -77,45 +654,78 @@
|
||||||
|
|
||||||
{# ─── Modal de filtre ──────────────────────────────────────────────────────── #}
|
{# ─── Modal de filtre ──────────────────────────────────────────────────────── #}
|
||||||
<div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
|
<div class="modal fade" id="filterModal" tabindex="-1" aria-labelledby="filterModalLabel" aria-hidden="true">
|
||||||
<div class="modal-dialog modal-lg">
|
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les assets" %}</h5>
|
<h5 class="modal-title" id="filterModalLabel">{% translate "Filtrer les assets" %}</h5>
|
||||||
|
<div class="text-muted small" style="font-size: 0.8rem;">{% translate "Sélectionnez les thématiques et critères à afficher sur la carte et dans le tableau" %}</div>
|
||||||
|
</div>
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
||||||
|
{# Type d'élément #}
|
||||||
|
<div class="filter-section">
|
||||||
|
<div class="filter-section-header">
|
||||||
|
<label>{% translate "Type d'élément" %}</label>
|
||||||
|
</div>
|
||||||
|
<div class="segmented-control">
|
||||||
|
<div class="filter-option segmented-option">
|
||||||
|
<input type="radio" name="unified-type-filter" id="type-all" value="all"
|
||||||
|
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
|
||||||
|
{% if preventive_element_type %}disabled{% endif %}>
|
||||||
|
<label for="type-all">{% translate "Assets & localisations" %}</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-option segmented-option">
|
||||||
|
<input type="radio" name="unified-type-filter" id="type-locations" value="locations"
|
||||||
|
{% if preventive_element_type == 'locations' %}checked{% endif %}
|
||||||
|
{% if preventive_element_type %}disabled{% endif %}>
|
||||||
|
<label for="type-locations">{% translate "Localisations uniquement" %}</label>
|
||||||
|
</div>
|
||||||
|
<div class="filter-option segmented-option">
|
||||||
|
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
|
||||||
|
{% if preventive_element_type == 'assets' %}checked{% endif %}
|
||||||
|
{% if preventive_element_type %}disabled{% endif %}>
|
||||||
|
<label for="type-assets">{% translate "Assets uniquement" %}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{# Thématiques #}
|
{# Thématiques #}
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-header">
|
<div class="filter-section-header">
|
||||||
|
<div>
|
||||||
<label>{% translate "Thématique" %}</label>
|
<label>{% translate "Thématique" %}</label>
|
||||||
|
</div>
|
||||||
<div class="filter-section-buttons">
|
<div class="filter-section-buttons">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="thematic-select-all">{% translate "Tous" %}</button>
|
<button type="button" id="thematic-select-all">{% translate "Toutes" %}</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="thematic-deselect-all">{% translate "Aucun" %}</button>
|
<button type="button" id="thematic-deselect-all">{% translate "Aucune" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-options">
|
<div class="thematic-grid">
|
||||||
{% for thematic in accessible_thematics %}
|
{% for thematic in accessible_thematics %}
|
||||||
<div class="filter-option">
|
<div class="thematic-tile">
|
||||||
<input type="checkbox" class="thematic-checkbox"
|
<input type="checkbox" class="thematic-checkbox"
|
||||||
id="thematic-cb-{{ thematic.code }}"
|
id="thematic-cb-{{ thematic.code }}"
|
||||||
value="{{ thematic.code }}"
|
value="{{ thematic.code }}"
|
||||||
{% if thematic.code in selected_thematics %}checked{% endif %}>
|
{% if thematic.code in selected_thematics %}checked{% endif %}>
|
||||||
<label for="thematic-cb-{{ thematic.code }}">
|
<label for="thematic-cb-{{ thematic.code }}">
|
||||||
{% if thematic.icon %}<i class="bi {{ thematic.icon }} me-1"></i>{% endif %}{{ thematic.name }}
|
{% if thematic.icon %}<i class="bi {{ thematic.icon }} thematic-icon"></i>{% endif %}
|
||||||
|
<span class="thematic-label">{{ thematic.name }}</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Catégories #}
|
{# Catégories d'assets #}
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-header">
|
<div class="filter-section-header">
|
||||||
<label>{% translate "Catégorie" %}</label>
|
<label>{% translate "Catégorie d'assets" %}</label>
|
||||||
<div class="filter-section-buttons">
|
<div class="filter-section-buttons">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="category-select-all">{% translate "Toutes" %}</button>
|
<button type="button" id="category-select-all">{% translate "Toutes" %}</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="category-deselect-all">{% translate "Aucune" %}</button>
|
<button type="button" id="category-deselect-all">{% translate "Aucune" %}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="category-filter-options" class="filter-options">
|
<div id="category-filter-options" class="filter-options">
|
||||||
|
|
@ -128,9 +738,9 @@
|
||||||
<div class="filter-section-header">
|
<div class="filter-section-header">
|
||||||
<label>{% translate "Modèle" %}</label>
|
<label>{% translate "Modèle" %}</label>
|
||||||
<div class="filter-section-buttons">
|
<div class="filter-section-buttons">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="model-select-all">{% translate "Tous" %}</button>
|
<button type="button" id="model-select-all">{% translate "Tous" %}</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="model-deselect-all">{% translate "Aucun" %}</button>
|
<button type="button" id="model-deselect-all">{% translate "Aucun" %}</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary d-none" id="model-filter-toggle"></button>
|
<button type="button" class="d-none" id="model-filter-toggle"></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="model-filter-options" class="filter-options">
|
<div id="model-filter-options" class="filter-options">
|
||||||
|
|
@ -148,33 +758,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Type d'élément #}
|
|
||||||
<div class="filter-section">
|
|
||||||
<div class="filter-section-header">
|
|
||||||
<label>{% translate "Type d'élément" %}</label>
|
|
||||||
</div>
|
|
||||||
<div class="filter-options">
|
|
||||||
<div class="filter-option">
|
|
||||||
<input type="radio" name="unified-type-filter" id="type-all" value="all"
|
|
||||||
{% if preventive_element_type != 'assets' and preventive_element_type != 'locations' %}checked{% endif %}
|
|
||||||
{% if preventive_element_type %}disabled{% endif %}>
|
|
||||||
<label for="type-all">{% translate "Assets et localisations" %}</label>
|
|
||||||
</div>
|
|
||||||
<div class="filter-option">
|
|
||||||
<input type="radio" name="unified-type-filter" id="type-locations" value="locations"
|
|
||||||
{% if preventive_element_type == 'locations' %}checked{% endif %}
|
|
||||||
{% if preventive_element_type %}disabled{% endif %}>
|
|
||||||
<label for="type-locations">{% translate "Localisations uniquement" %}</label>
|
|
||||||
</div>
|
|
||||||
<div class="filter-option">
|
|
||||||
<input type="radio" name="unified-type-filter" id="type-assets" value="assets"
|
|
||||||
{% if preventive_element_type == 'assets' %}checked{% endif %}
|
|
||||||
{% if preventive_element_type %}disabled{% endif %}>
|
|
||||||
<label for="type-assets">{% translate "Assets uniquement" %}</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# Options de géométrie #}
|
{# Options de géométrie #}
|
||||||
<div class="filter-section">
|
<div class="filter-section">
|
||||||
<div class="filter-section-header">
|
<div class="filter-section-header">
|
||||||
|
|
@ -183,14 +766,17 @@
|
||||||
<div class="filter-options">
|
<div class="filter-options">
|
||||||
<div class="filter-option">
|
<div class="filter-option">
|
||||||
<input type="checkbox" name="include-no-geom" id="include-no-geom" checked>
|
<input type="checkbox" name="include-no-geom" id="include-no-geom" checked>
|
||||||
<label for="include-no-geom">{% translate "Inclure les assets sans géométrie" %}</label>
|
<label for="include-no-geom">
|
||||||
|
<i class="bi bi-geo-alt me-1"></i>{% translate "Inclure les assets sans géométrie" %}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# Statut #}
|
{# Statut & Statut de validation #}
|
||||||
<div class="row">
|
<div class="row g-3 mb-1">
|
||||||
<div class="col-12 filter-section">
|
<div class="col-md-6">
|
||||||
|
<div class="filter-section h-100 mb-0">
|
||||||
<div class="filter-section-header">
|
<div class="filter-section-header">
|
||||||
<label>{% translate "Statut" %}</label>
|
<label>{% translate "Statut" %}</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -204,12 +790,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
{# Statut de validation #}
|
<div class="filter-section h-100 mb-0">
|
||||||
<div class="row">
|
|
||||||
<div class="col-12 filter-section">
|
|
||||||
<div class="filter-section-header">
|
<div class="filter-section-header">
|
||||||
<label>{% translate "Statut de validation" %}</label>
|
<label>{% translate "Validation" %}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-options">
|
<div class="filter-options">
|
||||||
{% for validation_status_value, validation_status_label in asset_validation_status_choices %}
|
{% for validation_status_value, validation_status_label in asset_validation_status_choices %}
|
||||||
|
|
@ -221,16 +805,24 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" id="reset-filters" class="btn btn-secondary">{% translate "Réinitialiser" %}</button>
|
<button type="button" id="reset-filters" class="btn btn-link text-muted p-0 text-decoration-none d-flex align-items-center gap-1" style="font-size: 0.85rem; font-weight: 500;">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
|
<i class="bi bi-arrow-counterclockwise"></i> {% translate "Tout effacer" %}
|
||||||
<button type="button" id="apply-filters" class="btn btn-primary">{% translate "Filtrer" %}</button>
|
</button>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary px-3 py-2" data-bs-dismiss="modal" style="border-radius: 8px; font-weight: 500;">{% translate "Fermer" %}</button>
|
||||||
|
<button type="button" id="apply-filters" class="btn btn-sm btn-dark px-4 py-2" style="background-color: #0f172a; border-color: #0f172a; border-radius: 8px; font-weight: 600;">
|
||||||
|
<i class="bi bi-check2 me-1"></i>{% translate "Appliquer les filtres" %}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{# ─── Add chooser modal ────────────────────────────────────────────────────── #}
|
{# ─── Add chooser modal ────────────────────────────────────────────────────── #}
|
||||||
<div class="modal fade" id="unifiedAddChooserModal" tabindex="-1" aria-labelledby="unifiedAddChooserModalLabel" aria-hidden="true">
|
<div class="modal fade" id="unifiedAddChooserModal" tabindex="-1" aria-labelledby="unifiedAddChooserModalLabel" aria-hidden="true">
|
||||||
|
|
@ -478,7 +1070,7 @@
|
||||||
data-bs-toggle="modal" data-bs-target="#filterModal"
|
data-bs-toggle="modal" data-bs-target="#filterModal"
|
||||||
title="{% translate 'Filtres' %}">
|
title="{% translate 'Filtres' %}">
|
||||||
<i class="bi bi-funnel"></i> {% translate "Filtres" %}
|
<i class="bi bi-funnel"></i> {% translate "Filtres" %}
|
||||||
<span id="filter-badge" class="position-absolute top-0 start-100 translate-middle badge rounded-pill bg-primary d-none">
|
<span id="filter-badge" class="position-absolute badge rounded-pill bg-primary d-none">
|
||||||
<span id="filter-count">0</span>
|
<span id="filter-count">0</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -703,6 +1295,7 @@
|
||||||
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
|
<script src="{% static 'common/mapbox-gl-draw.js' %}"></script>
|
||||||
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
|
||||||
<script>
|
<script>
|
||||||
|
window.TERRITORY_MASK_URL = "{% url 'common:get_territory_mask_geojson' %}";
|
||||||
const preventive_draft_id = "{{ preventive_draft_id|default:'' }}";
|
const preventive_draft_id = "{{ preventive_draft_id|default:'' }}";
|
||||||
var selected_locations_set = new Set({{ selected_locations_ids|safe }});
|
var selected_locations_set = new Set({{ selected_locations_ids|safe }});
|
||||||
const initial_selected_assets = {{ selected_assets_json|safe }};
|
const initial_selected_assets = {{ selected_assets_json|safe }};
|
||||||
|
|
@ -741,6 +1334,9 @@
|
||||||
nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20260521a",
|
nature: "{% static 'assets/nature_tree_icon.svg' %}?v=20260521a",
|
||||||
structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b",
|
structures: "{% static 'assets/structures_bridge_tunnel_icon.svg' %}?v=20260522b",
|
||||||
roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a",
|
roaddrain: "{% static 'assets/roaddrain_grille_icon.svg' %}?v=20260521a",
|
||||||
|
speedbump: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920",
|
||||||
|
roadfurniture: "{% static 'assets/road_speedbump_icon.svg' %}?v=20260920",
|
||||||
|
cleanglasscontainer: "{% static 'assets/clean_glass_container_icon.svg' %}?v=20260920",
|
||||||
roadconcreteblockset: "{% static 'assets/road_concrete_block_icon.svg' %}?v=20260608a",
|
roadconcreteblockset: "{% static 'assets/road_concrete_block_icon.svg' %}?v=20260608a",
|
||||||
naturebench: "{% static 'assets/nature_bench_icon.svg' %}",
|
naturebench: "{% static 'assets/nature_bench_icon.svg' %}",
|
||||||
artworkfountain: "{% static 'assets/artwork_fountain_icon.svg' %}?v=20260521a",
|
artworkfountain: "{% static 'assets/artwork_fountain_icon.svg' %}?v=20260521a",
|
||||||
|
|
@ -752,7 +1348,7 @@
|
||||||
<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 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
|
<script src="{% static 'assets/sign_panels_shared.js' %}?v=20260703c"></script>
|
||||||
<script src="{% static 'assets/asset_symbology.js' %}?v=20260913"></script>
|
<script src="{% static 'assets/asset_symbology.js' %}?v=20260920_grid_options"></script>
|
||||||
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260726_pb"></script>
|
<script src="{% static 'assets/unified_map_layers.js' %}?v=20260920_grid_options"></script>
|
||||||
<script src="{% static 'assets/unified_index.js' %}?v=20260726_pb"></script>
|
<script src="{% static 'assets/unified_index.js' %}?v=20260920_grid_options"></script>
|
||||||
{% endblock script %}
|
{% endblock script %}
|
||||||
|
|
@ -2008,7 +2008,7 @@ class TrafficLightCrossPlanImportTest(TestCase):
|
||||||
import tempfile
|
import tempfile
|
||||||
from assets.utils.cross_plan_parser import parse_cross_plan
|
from assets.utils.cross_plan_parser import parse_cross_plan
|
||||||
|
|
||||||
# Workbook: Pedestrian phase aF with HP line
|
# Workbook: Cyclo-pedestrian phase bF with HP line
|
||||||
wb = openpyxl.Workbook()
|
wb = openpyxl.Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "kruisjesplan"
|
ws.title = "kruisjesplan"
|
||||||
|
|
@ -2019,10 +2019,53 @@ class TrafficLightCrossPlanImportTest(TestCase):
|
||||||
ws.cell(row=6, column=4, value="kringen")
|
ws.cell(row=6, column=4, value="kringen")
|
||||||
ws.cell(row=7, column=5, value="B01")
|
ws.cell(row=7, column=5, value="B01")
|
||||||
|
|
||||||
# Row 8: Pedestrian phase aF
|
# Row 8: Cyclo-pedestrian phase bF
|
||||||
ws.cell(row=8, column=1, value="com")
|
ws.cell(row=8, column=1, value="com")
|
||||||
ws.cell(row=8, column=2, value="1")
|
ws.cell(row=8, column=2, value="1")
|
||||||
ws.cell(row=8, column=3, value="aF")
|
ws.cell(row=8, column=3, value="bF")
|
||||||
|
ws.cell(row=8, column=5, value="X")
|
||||||
|
|
||||||
|
# Row 9: HP
|
||||||
|
ws.cell(row=9, column=1, value="com")
|
||||||
|
ws.cell(row=9, column=2, value="2")
|
||||||
|
ws.cell(row=9, column=3, value="HP")
|
||||||
|
ws.cell(row=9, column=5, value="X")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
wb.save(tmp_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deduced = parse_cross_plan(tmp_path)
|
||||||
|
lanterns = deduced["lanterns"]
|
||||||
|
self.assertEqual(len(lanterns), 1)
|
||||||
|
self.assertEqual(lanterns[0]["model_name"], "2V200 cyclo-piéto + HP")
|
||||||
|
self.assertEqual(lanterns[0]["code"], "SWB01_B01_LAN01_bF")
|
||||||
|
finally:
|
||||||
|
import os
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
def test_parse_cross_plan_pure_pedestrian_hp(self):
|
||||||
|
import openpyxl
|
||||||
|
import tempfile
|
||||||
|
from assets.utils.cross_plan_parser import parse_cross_plan
|
||||||
|
|
||||||
|
# Workbook: Pure pedestrian phase 'a' with HP line
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "kruisjesplan"
|
||||||
|
ws.cell(row=1, column=1, value="Sleutel:")
|
||||||
|
ws.cell(row=1, column=2, value="SWB01")
|
||||||
|
ws.cell(row=5, column=1, value="Câble 01 SVAVB")
|
||||||
|
ws.cell(row=6, column=3, value="richting")
|
||||||
|
ws.cell(row=6, column=4, value="kringen")
|
||||||
|
ws.cell(row=7, column=5, value="B01")
|
||||||
|
|
||||||
|
# Row 8: Pure pedestrian phase 'a'
|
||||||
|
ws.cell(row=8, column=1, value="com")
|
||||||
|
ws.cell(row=8, column=2, value="1")
|
||||||
|
ws.cell(row=8, column=3, value="a")
|
||||||
ws.cell(row=8, column=5, value="X")
|
ws.cell(row=8, column=5, value="X")
|
||||||
|
|
||||||
# Row 9: HP
|
# Row 9: HP
|
||||||
|
|
@ -2040,7 +2083,86 @@ class TrafficLightCrossPlanImportTest(TestCase):
|
||||||
lanterns = deduced["lanterns"]
|
lanterns = deduced["lanterns"]
|
||||||
self.assertEqual(len(lanterns), 1)
|
self.assertEqual(len(lanterns), 1)
|
||||||
self.assertEqual(lanterns[0]["model_name"], "2V200 piéton+HP")
|
self.assertEqual(lanterns[0]["model_name"], "2V200 piéton+HP")
|
||||||
self.assertEqual(lanterns[0]["code"], "SWB01_B01_LAN01_aF")
|
self.assertEqual(lanterns[0]["code"], "SWB01_B01_LAN01_a")
|
||||||
|
finally:
|
||||||
|
import os
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
def test_parse_cross_plan_hp_ls_common_wire_not_an_equipment(self):
|
||||||
|
import openpyxl
|
||||||
|
import tempfile
|
||||||
|
from assets.utils.cross_plan_parser import parse_cross_plan
|
||||||
|
|
||||||
|
# Workbook with pedestrian pole B01, vehicular-only pole A01,
|
||||||
|
# and a common wire HP/LS (42V) spanning across both poles.
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "kruisjesplan"
|
||||||
|
ws.cell(row=1, column=1, value="Sleutel:")
|
||||||
|
ws.cell(row=1, column=2, value="SWB01")
|
||||||
|
ws.cell(row=5, column=1, value="Câble 01 SVAVB")
|
||||||
|
ws.cell(row=6, column=3, value="richting")
|
||||||
|
ws.cell(row=6, column=4, value="kringen")
|
||||||
|
ws.cell(row=7, column=5, value="B01")
|
||||||
|
ws.cell(row=7, column=6, value="A01")
|
||||||
|
|
||||||
|
# Row 8: Vehicle phase A on A01
|
||||||
|
ws.cell(row=8, column=2, value="1")
|
||||||
|
ws.cell(row=8, column=3, value="A")
|
||||||
|
ws.cell(row=8, column=4, value="G")
|
||||||
|
ws.cell(row=8, column=6, value="X")
|
||||||
|
|
||||||
|
# Row 9: Pedestrian phase a on B01
|
||||||
|
ws.cell(row=9, column=2, value="2")
|
||||||
|
ws.cell(row=9, column=3, value="a")
|
||||||
|
ws.cell(row=9, column=4, value="gr")
|
||||||
|
ws.cell(row=9, column=5, value="X")
|
||||||
|
|
||||||
|
# Row 10: Common wire HP/LS (42V) connected to both B01 and A01
|
||||||
|
ws.cell(row=10, column=2, value="55")
|
||||||
|
ws.cell(row=10, column=3, value="HP/LS")
|
||||||
|
ws.cell(row=10, column=4, value="42V")
|
||||||
|
ws.cell(row=10, column=5, value="X")
|
||||||
|
ws.cell(row=10, column=6, value="X")
|
||||||
|
|
||||||
|
# Row 11: Common wire RADAR (42V)
|
||||||
|
ws.cell(row=11, column=2, value="56")
|
||||||
|
ws.cell(row=11, column=3, value="RADAR")
|
||||||
|
ws.cell(row=11, column=4, value="42V")
|
||||||
|
ws.cell(row=11, column=5, value="X")
|
||||||
|
|
||||||
|
# Row 12: Common wire Com (42V)
|
||||||
|
ws.cell(row=12, column=2, value="58")
|
||||||
|
ws.cell(row=12, column=3, value="Com")
|
||||||
|
ws.cell(row=12, column=4, value="42V")
|
||||||
|
ws.cell(row=12, column=5, value="X")
|
||||||
|
ws.cell(row=12, column=6, value="X")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
wb.save(tmp_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
deduced = parse_cross_plan(tmp_path)
|
||||||
|
lanterns = deduced["lanterns"]
|
||||||
|
detectors = deduced["detectors"]
|
||||||
|
|
||||||
|
# Exactly 2 lanterns: 1 vehicle for A on A01, 1 pedestrian for a on B01
|
||||||
|
self.assertEqual(len(lanterns), 2)
|
||||||
|
|
||||||
|
lan_a = next(l for l in lanterns if l["phase"] == "A")
|
||||||
|
self.assertEqual(lan_a["code"], "SWB01_A01_LAN01_A")
|
||||||
|
self.assertEqual(lan_a["model_name"], "3V200")
|
||||||
|
|
||||||
|
lan_ped = next(l for l in lanterns if l["phase"] == "a")
|
||||||
|
self.assertEqual(lan_ped["code"], "SWB01_B01_LAN01_a")
|
||||||
|
# B01 has HP/LS common wire, so pedestrian lantern gets piéton+HP
|
||||||
|
self.assertEqual(lan_ped["model_name"], "2V200 piéton+HP")
|
||||||
|
|
||||||
|
# Crucial assertion: NO equipment (lantern or detector) created for HP/LS, RADAR, or Com
|
||||||
|
self.assertFalse(any("HP" in l["code"] or "LS" in l["code"] for l in lanterns))
|
||||||
|
self.assertEqual(len(detectors), 0)
|
||||||
finally:
|
finally:
|
||||||
import os
|
import os
|
||||||
if os.path.exists(tmp_path):
|
if os.path.exists(tmp_path):
|
||||||
|
|
@ -2313,6 +2435,596 @@ class TrafficLightCrossPlanImportTest(TestCase):
|
||||||
if os.path.exists(tmp_path):
|
if os.path.exists(tmp_path):
|
||||||
os.remove(tmp_path)
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
def test_integrate_cross_plan_update_all(self):
|
||||||
|
import openpyxl
|
||||||
|
import tempfile
|
||||||
|
from django.contrib.gis.geos import Point as DjangoPoint
|
||||||
|
from assets.models import TrafficLightPole, TrafficLightPoleModel, TrafficLightLantern
|
||||||
|
|
||||||
|
pole_model = TrafficLightPoleModel.objects.first()
|
||||||
|
|
||||||
|
# 1. Existing pole B01 in DB (will be updated)
|
||||||
|
existing_pole = TrafficLightPole.objects.create(
|
||||||
|
intersection=self.intersection,
|
||||||
|
code="SWB01_B01",
|
||||||
|
name_fr="Ancien B01",
|
||||||
|
geom=DjangoPoint(149250.0, 170550.0, srid=3812),
|
||||||
|
lon=4.35,
|
||||||
|
lat=50.84,
|
||||||
|
model=pole_model,
|
||||||
|
status="active"
|
||||||
|
)
|
||||||
|
# 2. Existing untouched pole B99 in DB (should remain active after update_all)
|
||||||
|
untouched_pole = TrafficLightPole.objects.create(
|
||||||
|
intersection=self.intersection,
|
||||||
|
code="SWB01_B99",
|
||||||
|
name_fr="Poteau Untouched",
|
||||||
|
model=pole_model,
|
||||||
|
status="active"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Create cross plan workbook containing B01 (with new coordinates) and B02 (new pole)
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "kruisjesplan"
|
||||||
|
ws.cell(row=1, column=1, value="Sleutel:")
|
||||||
|
ws.cell(row=1, column=2, value="SWB01")
|
||||||
|
ws.cell(row=5, column=1, value="Câble 01 SVAVB")
|
||||||
|
ws.cell(row=6, column=3, value="richting")
|
||||||
|
ws.cell(row=6, column=4, value="kringen")
|
||||||
|
ws.cell(row=7, column=5, value="B01")
|
||||||
|
ws.cell(row=7, column=6, value="B02")
|
||||||
|
ws.cell(row=8, column=1, value="com")
|
||||||
|
ws.cell(row=8, column=2, value="1")
|
||||||
|
ws.cell(row=8, column=3, value="A")
|
||||||
|
ws.cell(row=8, column=5, value="X")
|
||||||
|
ws.cell(row=8, column=6, value="X")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
wb.save(tmp_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
|
with open(tmp_path, 'rb') as f:
|
||||||
|
uploaded_file = SimpleUploadedFile("update_plan.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
|
||||||
|
url_import = reverse("assets:import_cross_plan", args=[self.intersection.id])
|
||||||
|
response = self.client.post(url_import, {"cross_plan_file_upload": uploaded_file})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Call integrate with global_action="update_all"
|
||||||
|
url_integrate = reverse("assets:integrate_cross_plan", args=[self.intersection.id])
|
||||||
|
response_integrate = self.client.post(url_integrate, {
|
||||||
|
"cross_plan_filename": response.context["cross_plan_filename"],
|
||||||
|
"global_action": "update_all",
|
||||||
|
"name_pole_SWB01_B01": "B01 Mis à jour",
|
||||||
|
"x_3812_pole_SWB01_B01": "149260.0",
|
||||||
|
"y_3812_pole_SWB01_B01": "170560.0",
|
||||||
|
"lon_pole_SWB01_B01": "4.351",
|
||||||
|
"lat_pole_SWB01_B01": "50.841",
|
||||||
|
"name_pole_SWB01_B02": "Nouveau B02",
|
||||||
|
"x_3812_pole_SWB01_B02": "149300.0",
|
||||||
|
"y_3812_pole_SWB01_B02": "170600.0",
|
||||||
|
})
|
||||||
|
self.assertEqual(response_integrate.status_code, 302)
|
||||||
|
|
||||||
|
# Verify:
|
||||||
|
# - existing_pole should still have status="active", ID unchanged, name updated, geometry updated
|
||||||
|
existing_pole.refresh_from_db()
|
||||||
|
self.assertEqual(existing_pole.status, "active")
|
||||||
|
self.assertEqual(existing_pole.name_fr, "B01 Mis à jour")
|
||||||
|
self.assertEqual(existing_pole.geom.x, 149260.0)
|
||||||
|
self.assertEqual(existing_pole.geom.y, 170560.0)
|
||||||
|
|
||||||
|
# - untouched_pole should still be active
|
||||||
|
untouched_pole.refresh_from_db()
|
||||||
|
self.assertEqual(untouched_pole.status, "active")
|
||||||
|
|
||||||
|
# - new pole B02 should be created active
|
||||||
|
new_pole = TrafficLightPole.objects.filter(intersection=self.intersection, code="SWB01_B02", status="active").first()
|
||||||
|
self.assertIsNotNone(new_pole)
|
||||||
|
self.assertEqual(new_pole.name_fr, "Nouveau B02")
|
||||||
|
self.assertEqual(new_pole.geom.x, 149300.0)
|
||||||
|
|
||||||
|
# - lantern on B01 and lantern on B02 should be active
|
||||||
|
l1 = TrafficLightLantern.objects.filter(pole=existing_pole, status="active").first()
|
||||||
|
self.assertIsNotNone(l1)
|
||||||
|
l2 = TrafficLightLantern.objects.filter(pole=new_pole, status="active").first()
|
||||||
|
self.assertIsNotNone(l2)
|
||||||
|
finally:
|
||||||
|
import os
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
def test_import_cross_plan_dxf_geometry_diff(self):
|
||||||
|
import openpyxl
|
||||||
|
import tempfile
|
||||||
|
from django.contrib.gis.geos import Point as DjangoPoint
|
||||||
|
from unittest.mock import patch
|
||||||
|
from assets.models import TrafficLightPole, TrafficLightPoleModel
|
||||||
|
|
||||||
|
pole_model = TrafficLightPoleModel.objects.first()
|
||||||
|
|
||||||
|
# Existing pole B01 with initial coordinates in DB
|
||||||
|
existing_pole = TrafficLightPole.objects.create(
|
||||||
|
intersection=self.intersection,
|
||||||
|
code="SWB01_B01",
|
||||||
|
geom=DjangoPoint(149200.0, 170500.0, srid=3812),
|
||||||
|
lon=4.350,
|
||||||
|
lat=50.840,
|
||||||
|
model=pole_model,
|
||||||
|
status="active"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Cross plan workbook with pole B01
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "kruisjesplan"
|
||||||
|
ws.cell(row=1, column=1, value="Sleutel:")
|
||||||
|
ws.cell(row=1, column=2, value="SWB01")
|
||||||
|
ws.cell(row=5, column=1, value="Câble 01 SVAVB")
|
||||||
|
ws.cell(row=6, column=3, value="richting")
|
||||||
|
ws.cell(row=6, column=4, value="kringen")
|
||||||
|
ws.cell(row=7, column=5, value="B01")
|
||||||
|
ws.cell(row=8, column=3, value="A")
|
||||||
|
ws.cell(row=8, column=5, value="X")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
wb.save(tmp_path)
|
||||||
|
|
||||||
|
# Mock DXF poles returned with a 2.5m shift (x + 2.0, y + 1.5 -> distance = 2.5m)
|
||||||
|
mock_dxf_poles = [{
|
||||||
|
'label': 'B01',
|
||||||
|
'lon': 4.35003,
|
||||||
|
'lat': 50.84002,
|
||||||
|
'x_3812': 149202.0,
|
||||||
|
'y_3812': 170501.5
|
||||||
|
}]
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
|
with open(tmp_path, 'rb') as f:
|
||||||
|
uploaded_file = SimpleUploadedFile("dxf_diff_plan.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
|
||||||
|
with patch('assets.views.trafficlights.resolve_dxf_file_path', return_value='/dummy/path.dxf'), \
|
||||||
|
patch('assets.views.trafficlights.parse_dxf_poles', return_value=mock_dxf_poles):
|
||||||
|
url_import = reverse("assets:import_cross_plan", args=[self.intersection.id])
|
||||||
|
response = self.client.post(url_import, {
|
||||||
|
"cross_plan_file_upload": uploaded_file,
|
||||||
|
"selected_dxf": "test.dxf"
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
preview_poles = response.context["poles"]
|
||||||
|
b01 = next(p for p in preview_poles if p["code"] == "SWB01_B01")
|
||||||
|
self.assertTrue(b01["geom_modified"])
|
||||||
|
self.assertEqual(b01["distance_diff"], 2.5)
|
||||||
|
self.assertEqual(b01["status"], "modified")
|
||||||
|
self.assertEqual(b01["db_x_3812"], 149200.0)
|
||||||
|
self.assertEqual(b01["new_x_3812"], 149202.0)
|
||||||
|
self.assertTrue(any("Position modifiée (DXF)" in d for d in b01["details_diff"]))
|
||||||
|
finally:
|
||||||
|
import os
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
def test_match_pole_lanterns_intelligence_user_scenario(self):
|
||||||
|
"""
|
||||||
|
Tests the intelligent matching of lanterns when multiple lanterns are on the same pole.
|
||||||
|
In the user scenario:
|
||||||
|
- DB has:
|
||||||
|
LAN01: 3V200 tram tout droite
|
||||||
|
LAN02: 3V300 fl gauche
|
||||||
|
- Plan deduces:
|
||||||
|
LAN01_B1: 3V300 (vehicular phase B1)
|
||||||
|
LAN02_T2: 3V300 tram tout droit (tram phase T2)
|
||||||
|
The algorithm must NOT naively match LAN01_B1 to DB LAN01 and LAN02_T2 to DB LAN02.
|
||||||
|
Instead, it must match:
|
||||||
|
LAN01_B1 -> DB LAN02 (vehicular to vehicular)
|
||||||
|
LAN02_T2 -> DB LAN01 (tram to tram)
|
||||||
|
"""
|
||||||
|
from assets.utils.cross_plan_parser import match_pole_lanterns
|
||||||
|
|
||||||
|
pole_dls = [
|
||||||
|
{'code': 'SEK02_B02_LAN01_B1', 'model_name': '3V300', 'name': '3V300 sur B02 - Phase B1', 'phase': 'B1', 'pole_code': 'SEK02_B02'},
|
||||||
|
{'code': 'SEK02_B02_LAN02_T2', 'model_name': '3V300 tram tout droit', 'name': '3V300 tram sur B02 - Phase T2', 'phase': 'T2', 'pole_code': 'SEK02_B02'}
|
||||||
|
]
|
||||||
|
|
||||||
|
class DummyModel:
|
||||||
|
def __init__(self, name_fr, name_nl=''):
|
||||||
|
self.name_fr = name_fr
|
||||||
|
self.name_nl = name_nl
|
||||||
|
|
||||||
|
class DummyLantern:
|
||||||
|
def __init__(self, code, name_fr, model_name):
|
||||||
|
self.code = code
|
||||||
|
self.name_fr = name_fr
|
||||||
|
self.model = DummyModel(model_name)
|
||||||
|
|
||||||
|
el_tram = DummyLantern('SEK02_B02_LAN01', '3V200 tram tout droite', '3V200 tram tout droite')
|
||||||
|
el_car = DummyLantern('SEK02_B02_LAN02', '3V300 fl gauche', '3V300 fl gauche')
|
||||||
|
pole_els = [el_tram, el_car]
|
||||||
|
|
||||||
|
matched_pairs, unmatched_dls, unmatched_els = match_pole_lanterns(pole_dls, pole_els)
|
||||||
|
|
||||||
|
self.assertEqual(len(matched_pairs), 2)
|
||||||
|
self.assertEqual(len(unmatched_dls), 0)
|
||||||
|
self.assertEqual(len(unmatched_els), 0)
|
||||||
|
|
||||||
|
match_map = {dl['code']: el.code for dl, el in matched_pairs}
|
||||||
|
self.assertEqual(match_map['SEK02_B02_LAN01_B1'], 'SEK02_B02_LAN02')
|
||||||
|
self.assertEqual(match_map['SEK02_B02_LAN02_T2'], 'SEK02_B02_LAN01')
|
||||||
|
|
||||||
|
def test_match_pole_lanterns_cyclopieton_bf(self):
|
||||||
|
"""
|
||||||
|
Tests the user scenario on pole B01 with multiple lanterns:
|
||||||
|
DB has:
|
||||||
|
LAN01_A (3V200)
|
||||||
|
LAN02_A (3V300)
|
||||||
|
LAN03 (2V200 cyclo-piéto + HP)
|
||||||
|
Plan deduces:
|
||||||
|
LAN01_A (3V200)
|
||||||
|
LAN02_A (3V300)
|
||||||
|
LAN03_bF (2V200 cyclo-piéto + HP, phase bF)
|
||||||
|
Must match LAN03_bF -> DB LAN03 instead of archiving LAN03 and creating a new asset.
|
||||||
|
"""
|
||||||
|
from assets.utils.cross_plan_parser import match_pole_lanterns
|
||||||
|
|
||||||
|
class DummyModel:
|
||||||
|
def __init__(self, name_fr):
|
||||||
|
self.name_fr = name_fr
|
||||||
|
self.name_nl = ''
|
||||||
|
|
||||||
|
class DummyLantern:
|
||||||
|
def __init__(self, code, name_fr, model_name):
|
||||||
|
self.code = code
|
||||||
|
self.name_fr = name_fr
|
||||||
|
self.model = DummyModel(model_name)
|
||||||
|
|
||||||
|
pole_dls = [
|
||||||
|
{'code': 'SPW10_B01_LAN01_A', 'model_name': '3V200', 'name': '3V200 sur B01 - Phase A', 'phase': 'A', 'pole_code': 'SPW10_B01'},
|
||||||
|
{'code': 'SPW10_B01_LAN02_A', 'model_name': '3V300', 'name': '3V300 sur B01 - Phase A', 'phase': 'A', 'pole_code': 'SPW10_B01'},
|
||||||
|
{'code': 'SPW10_B01_LAN03_bF', 'model_name': '2V200 cyclo-piéto + HP', 'name': '2V200 cyclo-piéto + HP sur B01 - Phase bF', 'phase': 'bF', 'pole_code': 'SPW10_B01'},
|
||||||
|
]
|
||||||
|
|
||||||
|
el_v1 = DummyLantern('SPW10_B01_LAN01_A', '3V200', '3V200')
|
||||||
|
el_v2 = DummyLantern('SPW10_B01_LAN02_A', '3V300', '3V300')
|
||||||
|
el_cyclo = DummyLantern('SPW10_B01_LAN03', '2V200 cyclo-piéto + HP', '2V200 cyclo-piéto + HP')
|
||||||
|
pole_els = [el_v1, el_v2, el_cyclo]
|
||||||
|
|
||||||
|
matched_pairs, unmatched_dls, unmatched_els = match_pole_lanterns(pole_dls, pole_els)
|
||||||
|
|
||||||
|
self.assertEqual(len(matched_pairs), 3)
|
||||||
|
self.assertEqual(len(unmatched_dls), 0)
|
||||||
|
self.assertEqual(len(unmatched_els), 0)
|
||||||
|
|
||||||
|
match_map = {dl['code']: el.code for dl, el in matched_pairs}
|
||||||
|
self.assertEqual(match_map['SPW10_B01_LAN03_bF'], 'SPW10_B01_LAN03')
|
||||||
|
|
||||||
|
def test_import_cross_plan_stib_detector_preserved_by_default(self):
|
||||||
|
"""
|
||||||
|
Tests that when a DB detector is named dT1 or dT2 (STIB tram loop),
|
||||||
|
the preview recognizes it as STIB, sets action_default='keep', and proposes
|
||||||
|
to preserve it by default in the HTML decision select box.
|
||||||
|
"""
|
||||||
|
import openpyxl
|
||||||
|
import tempfile
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from assets.models import TrafficLightDetector, TrafficLightDetectorModel
|
||||||
|
|
||||||
|
det_model = TrafficLightDetectorModel.objects.first()
|
||||||
|
stib_det = TrafficLightDetector.objects.create(
|
||||||
|
intersection=self.intersection,
|
||||||
|
code="SPW10_DET_1",
|
||||||
|
name="dT1",
|
||||||
|
name_fr="dT1",
|
||||||
|
model=det_model,
|
||||||
|
status="active"
|
||||||
|
)
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "kruisjesplan"
|
||||||
|
ws.cell(row=1, column=1, value="Sleutel:")
|
||||||
|
ws.cell(row=1, column=2, value="SWB01")
|
||||||
|
ws.cell(row=5, column=1, value="Câble 01 SVAVB")
|
||||||
|
ws.cell(row=6, column=3, value="richting")
|
||||||
|
ws.cell(row=6, column=4, value="kringen")
|
||||||
|
ws.cell(row=7, column=5, value="B01")
|
||||||
|
ws.cell(row=8, column=3, value="A")
|
||||||
|
ws.cell(row=8, column=5, value="X")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
wb.save(tmp_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
with open(tmp_path, "rb") as f:
|
||||||
|
uploaded = SimpleUploadedFile("test_cross_plan.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
|
||||||
|
url = reverse("assets:import_cross_plan", args=[self.intersection.id])
|
||||||
|
response = self.client.post(url, {"cross_plan_file_upload": uploaded})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
detectors = response.context["detectors"]
|
||||||
|
stib_item = next((d for d in detectors if d["code"] == "SPW10_DET_1"), None)
|
||||||
|
self.assertIsNotNone(stib_item)
|
||||||
|
self.assertEqual(stib_item["status"], "db_only")
|
||||||
|
self.assertEqual(stib_item["action_default"], "keep")
|
||||||
|
self.assertTrue(stib_item["is_stib"])
|
||||||
|
self.assertIn("STIB", stib_item["details_diff"][0])
|
||||||
|
|
||||||
|
# Verify in HTML that 'Conserver en DB' is selected
|
||||||
|
html = response.content.decode("utf-8")
|
||||||
|
self.assertIn('name="action_detector_db_SPW10_DET_1"', html)
|
||||||
|
self.assertIn('value="keep" selected', html)
|
||||||
|
finally:
|
||||||
|
import os
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
def test_import_cross_plan_multiple_lanterns_preview(self):
|
||||||
|
"""
|
||||||
|
End-to-end view test with multiple lanterns on the same pole.
|
||||||
|
Verifies that import_cross_plan view correctly associates the lanterns in compared_lanterns
|
||||||
|
and populates the preview with the correct db_id and diffs.
|
||||||
|
"""
|
||||||
|
import openpyxl
|
||||||
|
import tempfile
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
from assets.models import TrafficLightPole, TrafficLightLantern, TrafficLightPoleModel, TrafficLightLanternModel, AssetCategory
|
||||||
|
|
||||||
|
pole_model = TrafficLightPoleModel.objects.first()
|
||||||
|
m_tram, _ = TrafficLightLanternModel.objects.get_or_create(code="3v200_tram", defaults={"name_fr": "3V200 tram tout droite", "voltage": 230})
|
||||||
|
m_turn, _ = TrafficLightLanternModel.objects.get_or_create(code="3v300_fl", defaults={"name_fr": "3V300 fl gauche", "voltage": 230})
|
||||||
|
TrafficLightLanternModel.objects.get_or_create(code="3v300_tram", defaults={"name_fr": "3V300 tram tout droit", "voltage": 230})
|
||||||
|
TrafficLightLanternModel.objects.get_or_create(code="3v300", defaults={"name_fr": "3V300", "voltage": 230})
|
||||||
|
|
||||||
|
category_pole = AssetCategory.objects.filter(code="TL_POLE").first()
|
||||||
|
category_lan = AssetCategory.objects.filter(code="TL_LANTERN").first() or AssetCategory.objects.filter(code="TL_LANTERNE").first()
|
||||||
|
|
||||||
|
pole_b02 = TrafficLightPole.objects.create(
|
||||||
|
intersection=self.intersection,
|
||||||
|
code="SWB01_B02",
|
||||||
|
model=pole_model,
|
||||||
|
category=category_pole,
|
||||||
|
status="active"
|
||||||
|
)
|
||||||
|
db_lan_tram = TrafficLightLantern.objects.create(
|
||||||
|
pole=pole_b02,
|
||||||
|
code="SWB01_B02_LAN01",
|
||||||
|
model=m_tram,
|
||||||
|
name_fr="3V200 tram tout droite",
|
||||||
|
category=category_lan,
|
||||||
|
status="active"
|
||||||
|
)
|
||||||
|
db_lan_car = TrafficLightLantern.objects.create(
|
||||||
|
pole=pole_b02,
|
||||||
|
code="SWB01_B02_LAN02",
|
||||||
|
model=m_turn,
|
||||||
|
name_fr="3V300 fl gauche",
|
||||||
|
category=category_lan,
|
||||||
|
status="active"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create Excel cross plan with pole B02 having phase B1 (vehicle) and T2 (tram)
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "kruisjesplan"
|
||||||
|
ws.cell(row=1, column=1, value="Sleutel:")
|
||||||
|
ws.cell(row=1, column=2, value="SWB01")
|
||||||
|
ws.cell(row=5, column=1, value="Câble 01 SVAVB")
|
||||||
|
ws.cell(row=6, column=3, value="richting")
|
||||||
|
ws.cell(row=6, column=4, value="kringen")
|
||||||
|
ws.cell(row=7, column=5, value="B02")
|
||||||
|
|
||||||
|
# Row 8: Phase B1 (Vehicle)
|
||||||
|
ws.cell(row=8, column=2, value="1")
|
||||||
|
ws.cell(row=8, column=3, value="B1")
|
||||||
|
ws.cell(row=8, column=4, value="Y")
|
||||||
|
ws.cell(row=8, column=5, value="X")
|
||||||
|
|
||||||
|
# Row 9: Phase T2 (Tram)
|
||||||
|
ws.cell(row=9, column=2, value="2")
|
||||||
|
ws.cell(row=9, column=3, value="T2")
|
||||||
|
ws.cell(row=9, column=4, value="Y")
|
||||||
|
ws.cell(row=9, column=5, value="X")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
wb.save(tmp_path)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
with open(tmp_path, "rb") as f:
|
||||||
|
uploaded_file = SimpleUploadedFile("test_multi_lantern.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
url_import = reverse("assets:import_cross_plan", args=[self.intersection.id])
|
||||||
|
response = self.client.post(url_import, {"cross_plan_file_upload": uploaded_file})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
lanterns = response.context["lanterns"]
|
||||||
|
lan_b1 = next(l for l in lanterns if "LAN01_B1" in l["code"])
|
||||||
|
lan_t2 = next(l for l in lanterns if "LAN02_T2" in l["code"])
|
||||||
|
|
||||||
|
# Verify that LAN01_B1 is paired with DB LAN02 (db_lan_car.id)
|
||||||
|
self.assertEqual(lan_b1["db_id"], db_lan_car.id)
|
||||||
|
self.assertEqual(lan_b1["status"], "modified")
|
||||||
|
self.assertTrue(any(f"Code : {db_lan_car.code} → {lan_b1['code']}" in d for d in lan_b1["details_diff"]))
|
||||||
|
|
||||||
|
# Verify that LAN02_T2 is paired with DB LAN01 (db_lan_tram.id)
|
||||||
|
self.assertEqual(lan_t2["db_id"], db_lan_tram.id)
|
||||||
|
self.assertEqual(lan_t2["status"], "modified")
|
||||||
|
self.assertTrue(any(f"Code : {db_lan_tram.code} → {lan_t2['code']}" in d for d in lan_t2["details_diff"]))
|
||||||
|
finally:
|
||||||
|
import os
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
def test_import_cross_plan_default_decisions_identical_vs_modified(self):
|
||||||
|
"""
|
||||||
|
Verify that:
|
||||||
|
- When pole & cable position and model are identical: status is 'identical',
|
||||||
|
default proposed decision in HTML is 'Ne rien faire (Identique)'.
|
||||||
|
- When pole position changed (DXF shift): status is 'modified',
|
||||||
|
default proposed decision in HTML is 'Mettre à jour l'asset'.
|
||||||
|
- When connected pole moved, the cable status becomes 'modified',
|
||||||
|
default proposed decision in HTML is 'Mettre à jour l'asset'.
|
||||||
|
"""
|
||||||
|
import openpyxl
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import patch
|
||||||
|
from django.contrib.gis.geos import Point as DjangoPoint, LineString as DjangoLineString
|
||||||
|
from assets.models import (
|
||||||
|
TrafficLightPole, TrafficLightCable,
|
||||||
|
TrafficLightPoleModel, TrafficLightCableModel
|
||||||
|
)
|
||||||
|
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||||
|
|
||||||
|
pole_model = TrafficLightPoleModel.objects.first()
|
||||||
|
cable_model = TrafficLightCableModel.objects.first()
|
||||||
|
|
||||||
|
# Pole A01: position will be identical (DXF matches DB)
|
||||||
|
pole_a = TrafficLightPole.objects.create(
|
||||||
|
intersection=self.intersection,
|
||||||
|
code="SWB01_A01",
|
||||||
|
name_fr="Poteau A01",
|
||||||
|
geom=DjangoPoint(149200.0, 170500.0, srid=3812),
|
||||||
|
lon=4.350,
|
||||||
|
lat=50.840,
|
||||||
|
model=pole_model,
|
||||||
|
status="active"
|
||||||
|
)
|
||||||
|
# Pole B01: position will change (DXF differs by 2.0m)
|
||||||
|
pole_b = TrafficLightPole.objects.create(
|
||||||
|
intersection=self.intersection,
|
||||||
|
code="SWB01_B01",
|
||||||
|
name_fr="Poteau B01",
|
||||||
|
geom=DjangoPoint(149300.0, 170600.0, srid=3812),
|
||||||
|
lon=4.351,
|
||||||
|
lat=50.841,
|
||||||
|
model=pole_model,
|
||||||
|
status="active"
|
||||||
|
)
|
||||||
|
# Cable 01: connects A01 and B01
|
||||||
|
cable_01 = TrafficLightCable.objects.create(
|
||||||
|
intersection=self.intersection,
|
||||||
|
code="SWB01_CAB01",
|
||||||
|
name_fr="Câble 01",
|
||||||
|
geom=DjangoLineString([(149200.0, 170500.0), (149300.0, 170600.0)], srid=3812),
|
||||||
|
model=cable_model,
|
||||||
|
status="active"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create mock workbook
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "kruisjesplan"
|
||||||
|
ws.cell(row=1, column=1, value="Sleutel:")
|
||||||
|
ws.cell(row=1, column=2, value="SWB01")
|
||||||
|
ws.cell(row=5, column=1, value=f"Câble 01 {cable_model.name_fr}")
|
||||||
|
ws.cell(row=6, column=3, value="richting")
|
||||||
|
ws.cell(row=6, column=4, value="kringen")
|
||||||
|
ws.cell(row=7, column=5, value="A01")
|
||||||
|
ws.cell(row=7, column=6, value="B01")
|
||||||
|
ws.cell(row=8, column=2, value="1")
|
||||||
|
ws.cell(row=8, column=3, value="A")
|
||||||
|
ws.cell(row=8, column=4, value="V")
|
||||||
|
ws.cell(row=8, column=5, value="X")
|
||||||
|
ws.cell(row=8, column=6, value="X")
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
|
||||||
|
tmp_path = tmp.name
|
||||||
|
wb.save(tmp_path)
|
||||||
|
|
||||||
|
# Mock DXF poles: A01 identical (0.0m diff), B01 shifted by 2.0m
|
||||||
|
mock_dxf_poles = [
|
||||||
|
{'label': 'A01', 'x_3812': 149200.0, 'y_3812': 170500.0, 'lon': 4.350, 'lat': 50.840},
|
||||||
|
{'label': 'B01', 'x_3812': 149302.0, 'y_3812': 170600.0, 'lon': 4.35102, 'lat': 50.841},
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
with open(tmp_path, "rb") as f:
|
||||||
|
uploaded_file = SimpleUploadedFile("plan_test_decisions.xlsx", f.read(), content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
|
||||||
|
|
||||||
|
with patch('assets.views.trafficlights.resolve_dxf_file_path', return_value='/dummy/test.dxf'), \
|
||||||
|
patch('assets.views.trafficlights.parse_dxf_poles', return_value=mock_dxf_poles):
|
||||||
|
url_import = reverse("assets:import_cross_plan", args=[self.intersection.id])
|
||||||
|
response = self.client.post(url_import, {
|
||||||
|
"cross_plan_file_upload": uploaded_file,
|
||||||
|
"selected_dxf": "test.dxf"
|
||||||
|
})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
# Context assertions
|
||||||
|
poles = response.context["poles"]
|
||||||
|
cables = response.context["cables"]
|
||||||
|
|
||||||
|
p_a = next(p for p in poles if p["code"] == "SWB01_A01")
|
||||||
|
p_b = next(p for p in poles if p["code"] == "SWB01_B01")
|
||||||
|
c_1 = next(c for c in cables if c["code"] == "SWB01_CAB01")
|
||||||
|
|
||||||
|
# Pole A: Identical position & model
|
||||||
|
self.assertEqual(p_a["status"], "identical")
|
||||||
|
self.assertFalse(p_a["geom_modified"])
|
||||||
|
|
||||||
|
# Pole B: Position shifted
|
||||||
|
self.assertEqual(p_b["status"], "modified")
|
||||||
|
self.assertTrue(p_b["geom_modified"])
|
||||||
|
self.assertEqual(p_b["distance_diff"], 2.0)
|
||||||
|
|
||||||
|
# Cable 1: Connected pole B01 moved -> Cable position is modified!
|
||||||
|
self.assertEqual(c_1["status"], "modified")
|
||||||
|
self.assertTrue(c_1["geom_modified"])
|
||||||
|
self.assertTrue(any("déplacement de poteaux connectés" in d for d in c_1["details_diff"]))
|
||||||
|
|
||||||
|
# HTML rendered assertions
|
||||||
|
content = response.content.decode("utf-8")
|
||||||
|
# Pole A should have 'keep' (Ne rien faire) selected
|
||||||
|
self.assertIn('name="action_pole_SWB01_A01"', content)
|
||||||
|
self.assertRegex(content, r'<select name="action_pole_SWB01_A01"[^>]*>[\s\S]*?<option value="keep" selected>')
|
||||||
|
|
||||||
|
# Pole B should have 'modify' (Mettre à jour l'asset) selected
|
||||||
|
self.assertIn('name="action_pole_SWB01_B01"', content)
|
||||||
|
self.assertRegex(content, r'<select name="action_pole_SWB01_B01"[^>]*>[\s\S]*?<option value="modify" selected>')
|
||||||
|
|
||||||
|
# Cable 1 should have 'modify' (Mettre à jour l'asset) selected because B01 moved
|
||||||
|
self.assertIn('name="action_cable_SWB01_CAB01"', content)
|
||||||
|
self.assertRegex(content, r'<select name="action_cable_SWB01_CAB01"[^>]*>[\s\S]*?<option value="modify" selected>')
|
||||||
|
|
||||||
|
# Check that cable geometry cell rendered
|
||||||
|
self.assertIn('id="geom_cell_cable_SWB01_CAB01"', content)
|
||||||
|
|
||||||
|
# Test POST integrate with 'keep' on pole A01: verifies position and properties are untouched
|
||||||
|
url_integrate = reverse("assets:integrate_cross_plan", args=[self.intersection.id])
|
||||||
|
response_integrate = self.client.post(url_integrate, {
|
||||||
|
"cross_plan_filename": response.context["cross_plan_filename"],
|
||||||
|
"selected_pole_SWB01_A01": "on",
|
||||||
|
"action_pole_SWB01_A01": "keep",
|
||||||
|
"name_pole_SWB01_A01": "Ignored Custom Name",
|
||||||
|
"x_3812_pole_SWB01_A01": "999999.0",
|
||||||
|
"y_3812_pole_SWB01_A01": "999999.0",
|
||||||
|
})
|
||||||
|
self.assertEqual(response_integrate.status_code, 302)
|
||||||
|
|
||||||
|
pole_a.refresh_from_db()
|
||||||
|
# Must remain untouched because action was "keep" (Ne rien faire)
|
||||||
|
self.assertEqual(pole_a.name_fr, "Poteau A01")
|
||||||
|
self.assertEqual(pole_a.geom.x, 149200.0)
|
||||||
|
self.assertEqual(pole_a.geom.y, 170500.0)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
import os
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
os.remove(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
class AssetDefaultPositionTest(TestCase):
|
class AssetDefaultPositionTest(TestCase):
|
||||||
def test_assign_default_position_hierarchy(self):
|
def test_assign_default_position_hierarchy(self):
|
||||||
|
|
@ -4140,3 +4852,94 @@ class ExportAssetsTest(TestCase):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class UserAccessibleMunicipalitiesTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from common.models import Municipality, UserConfig, UserContractAccess, Thematic, UserThematics
|
||||||
|
from contracts.models import Company, Contract
|
||||||
|
self.muni_a = Municipality.objects.create(name_fr="Commune A", name_nl="Gemeente A", fid="1001")
|
||||||
|
self.muni_b = Municipality.objects.create(name_fr="Commune B", name_nl="Gemeente B", fid="1002")
|
||||||
|
self.muni_c = Municipality.objects.create(name_fr="Commune C", name_nl="Gemeente C", fid="1003")
|
||||||
|
|
||||||
|
self.superuser = User.objects.create_superuser(username="admin_super", password="pass123")
|
||||||
|
self.user_intern = User.objects.create_user(username="user_intern", password="pass123")
|
||||||
|
UserConfig.objects.create(user=self.user_intern, is_intern=True, limit_assets_to_contracts=False)
|
||||||
|
|
||||||
|
self.user_ext = User.objects.create_user(username="user_ext", password="pass123")
|
||||||
|
self.ext_config = UserConfig.objects.create(user=self.user_ext, is_intern=False, limit_assets_to_contracts=True)
|
||||||
|
|
||||||
|
self.company = Company.objects.create(name="Test Company")
|
||||||
|
self.contract = Contract.objects.create(contract_number="CTR-001", company=self.company, start_date="2026-01-01", end_date="2026-12-31")
|
||||||
|
self.contract.municipalities.add(self.muni_a, self.muni_b)
|
||||||
|
|
||||||
|
UserContractAccess.objects.create(
|
||||||
|
user_config=self.ext_config,
|
||||||
|
contract=self.contract,
|
||||||
|
can_view_assets=True
|
||||||
|
)
|
||||||
|
|
||||||
|
thematic_roads, _ = Thematic.objects.get_or_create(code="roads", defaults={"name_fr": "Voiries"})
|
||||||
|
UserThematics.objects.create(user_config=self.ext_config, thematic=thematic_roads, can_view_assets=True)
|
||||||
|
|
||||||
|
def test_superuser_sees_all_municipalities(self):
|
||||||
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
munis = list(get_user_accessible_municipalities(self.superuser))
|
||||||
|
self.assertEqual(len(munis), 3)
|
||||||
|
|
||||||
|
def test_intern_unrestricted_sees_all_municipalities(self):
|
||||||
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
munis = list(get_user_accessible_municipalities(self.user_intern))
|
||||||
|
self.assertEqual(len(munis), 3)
|
||||||
|
|
||||||
|
def test_restricted_user_sees_only_contracted_municipalities(self):
|
||||||
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
munis = list(get_user_accessible_municipalities(self.user_ext))
|
||||||
|
muni_ids = {m.id for m in munis}
|
||||||
|
self.assertIn(self.muni_a.id, muni_ids)
|
||||||
|
self.assertIn(self.muni_b.id, muni_ids)
|
||||||
|
self.assertNotIn(self.muni_c.id, muni_ids)
|
||||||
|
|
||||||
|
def test_restricted_user_without_contracts_sees_none(self):
|
||||||
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
user_empty = User.objects.create_user(username="user_empty", password="pass123")
|
||||||
|
UserConfig.objects.create(user=user_empty, is_intern=False, limit_assets_to_contracts=True)
|
||||||
|
munis = list(get_user_accessible_municipalities(user_empty))
|
||||||
|
self.assertEqual(len(munis), 0)
|
||||||
|
|
||||||
|
def test_build_conditional_filter_config_restricted(self):
|
||||||
|
from assets.views.core import build_conditional_filter_config
|
||||||
|
thematic_roads = Thematic.objects.get(code="roads")
|
||||||
|
cfg = build_conditional_filter_config([thematic_roads], user=self.user_ext)
|
||||||
|
roads_filters = cfg.get('roads', [])
|
||||||
|
muni_filter = next((f for f in roads_filters if f['key'] == 'municipality'), None)
|
||||||
|
self.assertIsNotNone(muni_filter)
|
||||||
|
opt_values = {opt['value'] for opt in muni_filter['options']}
|
||||||
|
self.assertIn(str(self.muni_a.id), opt_values)
|
||||||
|
self.assertIn(str(self.muni_b.id), opt_values)
|
||||||
|
self.assertNotIn(str(self.muni_c.id), opt_values)
|
||||||
|
|
||||||
|
def test_roads_streets_geojson_restriction(self):
|
||||||
|
from assets.models.roads import RoadStreet
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
street_a = RoadStreet.objects.create(code="ST-A", name_fr="Rue A", municipality=self.muni_a)
|
||||||
|
street_c = RoadStreet.objects.create(code="ST-C", name_fr="Rue C", municipality=self.muni_c)
|
||||||
|
|
||||||
|
self.client.force_login(self.user_ext)
|
||||||
|
response = self.client.get(reverse('assets:roads_streets_geojson'))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
feature_ids = [f['id'] for f in data.get('features', [])]
|
||||||
|
self.assertIn(street_a.id, feature_ids)
|
||||||
|
self.assertNotIn(street_c.id, feature_ids)
|
||||||
|
def test_build_conditional_filter_config_single_municipality_hidden(self):
|
||||||
|
from assets.views.core import build_conditional_filter_config
|
||||||
|
# Remove muni_b from contract
|
||||||
|
self.contract.municipalities.remove(self.muni_b)
|
||||||
|
thematic_roads = Thematic.objects.get(code="roads")
|
||||||
|
cfg = build_conditional_filter_config([thematic_roads], user=self.user_ext)
|
||||||
|
roads_filters = cfg.get('roads', [])
|
||||||
|
muni_filter = next((f for f in roads_filters if f['key'] == 'municipality'), None)
|
||||||
|
# When only 1 municipality is accessible, the filter should NOT be shown
|
||||||
|
self.assertIsNone(muni_filter)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ class InspectionViewsTestCase(TestCase):
|
||||||
|
|
||||||
from common.models import UserConfig, UserThematics, Thematic
|
from common.models import UserConfig, UserThematics, Thematic
|
||||||
user_config, _ = UserConfig.objects.get_or_create(user=self.user)
|
user_config, _ = UserConfig.objects.get_or_create(user=self.user)
|
||||||
|
user_config.is_intern = True
|
||||||
|
user_config.save()
|
||||||
t_parking, _ = Thematic.objects.get_or_create(code='parking', defaults={'name_fr': 'Stationnement'})
|
t_parking, _ = Thematic.objects.get_or_create(code='parking', defaults={'name_fr': 'Stationnement'})
|
||||||
UserThematics.objects.update_or_create(user_config=user_config, thematic=t_parking, defaults={'can_inspect': True, 'can_view_assets': True})
|
UserThematics.objects.update_or_create(user_config=user_config, thematic=t_parking, defaults={'can_inspect': True, 'can_view_assets': True})
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
@ -37,7 +39,7 @@ class InspectionViewsTestCase(TestCase):
|
||||||
'standard_stand_count': 5
|
'standard_stand_count': 5
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
self.spot.geom = Polygon(((4.35, 50.85), (4.36, 50.85), (4.36, 50.86), (4.35, 50.86), (4.35, 50.85)), srid=4326)
|
self.spot.geom = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000)), srid=3812)
|
||||||
self.spot.save()
|
self.spot.save()
|
||||||
self.spot_ct = ContentType.objects.get_for_model(ParkingSpot)
|
self.spot_ct = ContentType.objects.get_for_model(ParkingSpot)
|
||||||
|
|
||||||
|
|
@ -52,7 +54,10 @@ class InspectionViewsTestCase(TestCase):
|
||||||
|
|
||||||
response = self.client.get(reverse('inspections:inspections_list'))
|
response = self.client.get(reverse('inspections:inspections_list'))
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertContains(response, 'PK-001')
|
|
||||||
|
response_mobile = self.client.get(reverse('mobile:inspections_list_mobile'))
|
||||||
|
self.assertEqual(response_mobile.status_code, 200)
|
||||||
|
self.assertContains(response_mobile, 'PK-001')
|
||||||
|
|
||||||
def test_quick_inspection_map_view(self):
|
def test_quick_inspection_map_view(self):
|
||||||
response = self.client.get(reverse('inspections:quick_inspection_map'))
|
response = self.client.get(reverse('inspections:quick_inspection_map'))
|
||||||
|
|
@ -162,7 +167,7 @@ class InspectionViewsTestCase(TestCase):
|
||||||
|
|
||||||
def test_inspections_geojson_view(self):
|
def test_inspections_geojson_view(self):
|
||||||
from django.contrib.gis.geos import Polygon
|
from django.contrib.gis.geos import Polygon
|
||||||
self.spot.geom = Polygon(((4.35, 50.85), (4.36, 50.85), (4.36, 50.86), (4.35, 50.86), (4.35, 50.85)), srid=4326)
|
self.spot.geom = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000)), srid=3812)
|
||||||
self.spot.save()
|
self.spot.save()
|
||||||
|
|
||||||
AssetInspection.objects.filter(asset_content_type=self.spot_ct, asset_object_id=self.spot.id).delete()
|
AssetInspection.objects.filter(asset_content_type=self.spot_ct, asset_object_id=self.spot.id).delete()
|
||||||
|
|
@ -198,7 +203,7 @@ class InspectionViewsTestCase(TestCase):
|
||||||
nature='visual'
|
nature='visual'
|
||||||
)
|
)
|
||||||
|
|
||||||
response = self.client.get(reverse('inspections:inspections_list') + '?tab=all&q=SEARCH-99')
|
response = self.client.get(reverse('mobile:inspections_list_mobile') + '?tab=all&q=SEARCH-99')
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertIn(insp, response.context['inspections'])
|
self.assertIn(insp, response.context['inspections'])
|
||||||
|
|
||||||
|
|
@ -209,11 +214,13 @@ class InspectionViewsTestCase(TestCase):
|
||||||
|
|
||||||
user_custom = User.objects.create_user(username='user_custom_perm', password='pwd')
|
user_custom = User.objects.create_user(username='user_custom_perm', password='pwd')
|
||||||
user_config, _ = UserConfig.objects.get_or_create(user=user_custom)
|
user_config, _ = UserConfig.objects.get_or_create(user=user_custom)
|
||||||
|
user_config.is_intern = True
|
||||||
|
user_config.save()
|
||||||
UserThematics.objects.create(user_config=user_config, thematic=t_parking, can_view_assets=True, can_inspect=False)
|
UserThematics.objects.create(user_config=user_config, thematic=t_parking, can_view_assets=True, can_inspect=False)
|
||||||
UserThematics.objects.create(user_config=user_config, thematic=t_lighting, can_view_assets=False, can_inspect=False)
|
UserThematics.objects.create(user_config=user_config, thematic=t_lighting, can_view_assets=False, can_inspect=False)
|
||||||
|
|
||||||
self.client.force_login(user_custom)
|
self.client.force_login(user_custom)
|
||||||
resp = self.client.get(reverse('inspections:inspections_list'))
|
resp = self.client.get(reverse('mobile:inspections_list_mobile'))
|
||||||
self.assertEqual(resp.status_code, 200)
|
self.assertEqual(resp.status_code, 200)
|
||||||
self.assertIn(t_parking, resp.context['thematics'])
|
self.assertIn(t_parking, resp.context['thematics'])
|
||||||
self.assertNotIn(t_lighting, resp.context['thematics'])
|
self.assertNotIn(t_lighting, resp.context['thematics'])
|
||||||
|
|
@ -246,3 +253,54 @@ class InspectionViewsTestCase(TestCase):
|
||||||
self.assertEqual(data['total_count'], 1)
|
self.assertEqual(data['total_count'], 1)
|
||||||
self.assertIn(insp, data['inspections'])
|
self.assertIn(insp, data['inspections'])
|
||||||
self.assertEqual(data['asset_pk'], self.spot.pk)
|
self.assertEqual(data['asset_pk'], self.spot.pk)
|
||||||
|
|
||||||
|
def test_asset_inspections_templatetag_renders_with_null_inspector(self):
|
||||||
|
"""Vérifie que le composant asset_inspections se rend sans erreur quand inspector est None."""
|
||||||
|
from assets.models import AssetInspection
|
||||||
|
from django.template import Template, Context
|
||||||
|
|
||||||
|
AssetInspection.objects.filter(asset_content_type=self.spot_ct, asset_object_id=self.spot.id).delete()
|
||||||
|
insp = AssetInspection.objects.create(
|
||||||
|
asset=self.spot,
|
||||||
|
inspector=None,
|
||||||
|
result_status='pending',
|
||||||
|
nature='post_intervention'
|
||||||
|
)
|
||||||
|
|
||||||
|
request = self.client.get(reverse('inspections:inspections_list')).wsgi_request
|
||||||
|
t = Template('{% load asset_tags %}{% asset_inspections asset %}')
|
||||||
|
rendered = t.render(Context({'request': request, 'asset': self.spot}))
|
||||||
|
self.assertIn('assetInspectionDetailModal', rendered)
|
||||||
|
self.assertIn('-', rendered)
|
||||||
|
|
||||||
|
def test_roadway_detail_renders_with_inspection_without_inspector(self):
|
||||||
|
"""Vérifie que la page de détail d'une Roadway avec une inspection planifiée (sans inspecteur) ne plante pas (500)."""
|
||||||
|
from assets.models.roads import RoadStreet, Roadway
|
||||||
|
from common.models import UserThematics, Thematic, UserConfig
|
||||||
|
from inspections.models import Inspection
|
||||||
|
from django.contrib.gis.geos import MultiPolygon, Polygon
|
||||||
|
|
||||||
|
t_roads, _ = Thematic.objects.get_or_create(code='roads', defaults={'name_fr': 'Voiries'})
|
||||||
|
user_config = UserConfig.objects.get(user=self.user)
|
||||||
|
UserThematics.objects.update_or_create(user_config=user_config, thematic=t_roads, defaults={'can_view_assets': True})
|
||||||
|
|
||||||
|
street = RoadStreet.objects.create(code='WAT_R22_TEST_VIEW', name_fr='Chaussée Test')
|
||||||
|
p = Polygon(((150000, 170000), (150010, 170000), (150010, 170010), (150000, 170010), (150000, 170000)))
|
||||||
|
roadway = Roadway.objects.create(
|
||||||
|
street=street,
|
||||||
|
geom=MultiPolygon(p, srid=3812)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Créer une inspection ciblant la Roadway sans inspecteur (comme une inspection de durabilité automatique)
|
||||||
|
insp = Inspection.objects.create(
|
||||||
|
asset_content_type=ContentType.objects.get_for_model(Roadway),
|
||||||
|
asset_object_id=roadway.id,
|
||||||
|
inspector=None,
|
||||||
|
nature='post_intervention',
|
||||||
|
status='scheduled'
|
||||||
|
)
|
||||||
|
|
||||||
|
url = reverse('assets:roads_assets_detail', kwargs={'asset_model': 'roadway', 'asset_id': roadway.id})
|
||||||
|
response = self.client.get(url)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,49 @@
|
||||||
import openpyxl
|
import openpyxl
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
|
import itertools
|
||||||
|
from collections import defaultdict
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
def is_hp_or_ls(text):
|
||||||
|
"""
|
||||||
|
Checks if the wire/direction/circuit text refers to loudspeakers (HP/LS).
|
||||||
|
In cross plans, HP (haut-parleur) / LS (luidspreker) is often a common wire (fil commun)
|
||||||
|
and must NOT be treated as an equipment (neither lantern nor detector).
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
t_str = str(text).strip()
|
||||||
|
t_lower = t_str.lower()
|
||||||
|
t_clean = re.sub(r'[\s/\\_\-\.:;]+', '', t_lower)
|
||||||
|
if t_clean in ('hp', 'ls', 'hpls', 'lshp', 'hpmute', 'lsmute', 'hautparleur', 'luidspreker'):
|
||||||
|
return True
|
||||||
|
if re.search(r'\b(?:hp\s*[/\\-]\s*ls|ls\s*[/\\-]\s*hp)\b', t_lower):
|
||||||
|
return True
|
||||||
|
if re.search(r'\b(?:hp|ls)\b', t_lower):
|
||||||
|
return True
|
||||||
|
if re.search(r'haut[\s-]*parleur|luidspreker', t_lower):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def is_cyclopieton_phase(phase):
|
||||||
|
"""
|
||||||
|
Checks if a phase code corresponds to a combined pedestrian + cyclist signal (cyclo-piéton).
|
||||||
|
Standard notation: lowercase letter (pedestrian) + 'F' or 'f' (fiets/cyclist), e.g. 'bF', 'aF', 'cF', 'dF', 'bF1',
|
||||||
|
or vice versa ('Fa', 'Fb').
|
||||||
|
"""
|
||||||
|
if not phase:
|
||||||
|
return False
|
||||||
|
ph = str(phase).strip()
|
||||||
|
if re.match(r'^[a-z][Ff]\d*$', ph) or re.match(r'^[Ff][a-z]\d*$', ph):
|
||||||
|
return True
|
||||||
|
if re.match(r'^[a-z]\s*[\+/&]\s*[Ff]\d*$', ph) or re.match(r'^[Ff]\s*[\+/&]\s*[a-z]\d*$', ph):
|
||||||
|
return True
|
||||||
|
ph_lower = ph.lower()
|
||||||
|
if 'cyclo' in ph_lower and ('piet' in ph_lower or 'piéto' in ph_lower or 'ped' in ph_lower):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def parse_cross_plan(excel_path):
|
def parse_cross_plan(excel_path):
|
||||||
"""
|
"""
|
||||||
Parses a crossroads cross plan (kruisjesplan) Excel file and returns
|
Parses a crossroads cross plan (kruisjesplan) Excel file and returns
|
||||||
|
|
@ -147,14 +188,21 @@ def parse_cross_plan(excel_path):
|
||||||
last_richting = None
|
last_richting = None
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
col2_val = sheet.cell(row=r, column=2).value if (richting_col and richting_col > 2) else None
|
||||||
|
|
||||||
# If the row is empty or contains section markers, skip it
|
# If the row is empty or contains section markers, skip it
|
||||||
if not val_kringen and not val_richting:
|
if not val_kringen and not val_richting and not col2_val:
|
||||||
# check if the entire row is empty
|
# check if the entire row is empty
|
||||||
if all(sheet.cell(row=r, column=c).value is None for c in range(1, max_col + 1)):
|
if all(sheet.cell(row=r, column=c).value is None for c in range(1, max_col + 1)):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if val_richting:
|
# Check if HP/LS is in richting, kringen, or col 2
|
||||||
|
if is_hp_or_ls(val_richting) or is_hp_or_ls(col2_val) or is_hp_or_ls(val_kringen):
|
||||||
|
last_richting = "HP/LS"
|
||||||
|
elif val_richting:
|
||||||
last_richting = str(val_richting).strip()
|
last_richting = str(val_richting).strip()
|
||||||
|
elif col2_val and isinstance(col2_val, str) and not str(col2_val).strip().isdigit():
|
||||||
|
last_richting = str(col2_val).strip()
|
||||||
|
|
||||||
if not last_richting:
|
if not last_richting:
|
||||||
continue
|
continue
|
||||||
|
|
@ -229,8 +277,9 @@ def parse_cross_plan(excel_path):
|
||||||
richting = conn["richting"]
|
richting = conn["richting"]
|
||||||
kringen = conn["kringen"]
|
kringen = conn["kringen"]
|
||||||
|
|
||||||
# Check if HP is present on this pole
|
# Check if HP / LS (haut-parleur / luidspreker / fil commun) is present on this pole
|
||||||
if richting.lower() == "hp":
|
# It must NOT be treated as an equipment (neither lantern nor detector).
|
||||||
|
if is_hp_or_ls(richting) or is_hp_or_ls(kringen):
|
||||||
pole_has_hp[p] = True
|
pole_has_hp[p] = True
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -332,9 +381,9 @@ def parse_cross_plan(excel_path):
|
||||||
|
|
||||||
# Standard traffic signals (vehicles, trams, cycles, pedestrians)
|
# Standard traffic signals (vehicles, trams, cycles, pedestrians)
|
||||||
# Richting codes are typically: A, B, C, T1, T2, F1, F2, a, b, c...
|
# Richting codes are typically: A, B, C, T1, T2, F1, F2, a, b, c...
|
||||||
# Ignore wiring/cabling categories like ALIM, Radar, Com, contact, etc.
|
# Ignore wiring/cabling categories like ALIM, Radar, Com, contact, HP/LS, etc.
|
||||||
normalized = re.sub(r'\s+', ' ', richting).strip()
|
normalized = re.sub(r'\s+', ' ', richting).strip()
|
||||||
if re.search(r'(?:alim|com|contact|radar|tension|comm)', normalized, re.IGNORECASE):
|
if is_hp_or_ls(richting) or is_hp_or_ls(kringen) or re.search(r'(?:alim|com|contact|radar|tension|comm|\bhp\b|\bls\b|hp/ls|ls/hp)', normalized, re.IGNORECASE):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if p not in pole_phases:
|
if p not in pole_phases:
|
||||||
|
|
@ -344,9 +393,13 @@ def parse_cross_plan(excel_path):
|
||||||
# Now generate LANTERNS for each pole
|
# Now generate LANTERNS for each pole
|
||||||
deduced_lanterns = []
|
deduced_lanterns = []
|
||||||
|
|
||||||
# helper to sort phases: vehicles first, then trams, then cycles, then pedestrians
|
# helper to sort phases: vehicles first, then trams, then cycles, then cyclo-piétons / pedestrians
|
||||||
def phase_sort_key(ph):
|
def phase_sort_key(ph):
|
||||||
if ph and ph[0].islower(): # Pedestrian
|
if is_cyclopieton_phase(ph):
|
||||||
|
if ph and ph[0].islower():
|
||||||
|
return (3, ph)
|
||||||
|
return (2, ph)
|
||||||
|
elif ph and ph[0].islower(): # Pedestrian
|
||||||
return (3, ph)
|
return (3, ph)
|
||||||
elif ph.startswith("T"): # Tram
|
elif ph.startswith("T"): # Tram
|
||||||
return (1, ph)
|
return (1, ph)
|
||||||
|
|
@ -373,7 +426,10 @@ def parse_cross_plan(excel_path):
|
||||||
|
|
||||||
if is_mast_arm and has_double_marker:
|
if is_mast_arm and has_double_marker:
|
||||||
# Determine models for both lanterns
|
# Determine models for both lanterns
|
||||||
if phase and phase[0].islower(): # Pedestrian
|
if is_cyclopieton_phase(phase):
|
||||||
|
model_name_1 = "2V200 cyclo-piéto + HP" if has_hp else "2V200 cyclo-piéto"
|
||||||
|
model_name_2 = "2V200 cyclo-piéto + HP" if has_hp else "2V200 cyclo-piéto"
|
||||||
|
elif phase and phase[0].islower(): # Pedestrian
|
||||||
model_name_1 = "2V200 piéton+HP" if has_hp else "2V200"
|
model_name_1 = "2V200 piéton+HP" if has_hp else "2V200"
|
||||||
model_name_2 = "2V200 piéton+HP" if has_hp else "2V200"
|
model_name_2 = "2V200 piéton+HP" if has_hp else "2V200"
|
||||||
elif phase.startswith("T"): # Tram
|
elif phase.startswith("T"): # Tram
|
||||||
|
|
@ -421,7 +477,12 @@ def parse_cross_plan(excel_path):
|
||||||
lantern_idx += 1
|
lantern_idx += 1
|
||||||
else:
|
else:
|
||||||
# Deduce single model
|
# Deduce single model
|
||||||
if phase and phase[0].islower(): # Pedestrian
|
if is_cyclopieton_phase(phase):
|
||||||
|
if has_hp:
|
||||||
|
model_name = "2V200 cyclo-piéto + HP"
|
||||||
|
else:
|
||||||
|
model_name = "2V200 cyclo-piéto"
|
||||||
|
elif phase and phase[0].islower(): # Pedestrian
|
||||||
if has_hp:
|
if has_hp:
|
||||||
model_name = "2V200 piéton+HP"
|
model_name = "2V200 piéton+HP"
|
||||||
else:
|
else:
|
||||||
|
|
@ -476,3 +537,313 @@ def parse_cross_plan(excel_path):
|
||||||
"lanterns": deduced_lanterns,
|
"lanterns": deduced_lanterns,
|
||||||
"detectors": deduced_detectors
|
"detectors": deduced_detectors
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def classify_lantern(model_name="", phase="", code="", name=""):
|
||||||
|
"""
|
||||||
|
Classifies a traffic light lantern based on its model name, phase, code, and description.
|
||||||
|
Returns:
|
||||||
|
category: 'tram', 'bus', 'pedestrian', 'cyclist', 'vehicle'
|
||||||
|
aspect_count: int (e.g. 1, 2, 3) or None
|
||||||
|
lens_size: int (e.g. 200, 300) or None
|
||||||
|
directions: set of strings in {'straight', 'left', 'right'}
|
||||||
|
"""
|
||||||
|
model_lower = (model_name or "").lower().strip()
|
||||||
|
name_lower = (name or "").lower().strip()
|
||||||
|
code_upper = (code or "").upper().strip()
|
||||||
|
phase_clean = (phase or "").strip()
|
||||||
|
|
||||||
|
combined_text = f"{model_lower} {name_lower}"
|
||||||
|
|
||||||
|
# 1. Category
|
||||||
|
# Check Tram first:
|
||||||
|
is_tram = (
|
||||||
|
"tram" in combined_text
|
||||||
|
or phase_clean.upper().startswith("T")
|
||||||
|
or bool(re.search(r'\bT\d+\b', code_upper))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check Bus:
|
||||||
|
is_bus = (
|
||||||
|
not is_tram and (
|
||||||
|
"bus" in combined_text
|
||||||
|
or phase_clean.upper().startswith("BUS")
|
||||||
|
or "transport en commun" in combined_text
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check Cyclo-piéton:
|
||||||
|
is_cyclopieton = (
|
||||||
|
not is_tram and not is_bus and (
|
||||||
|
is_cyclopieton_phase(phase_clean)
|
||||||
|
or bool(re.search(r'[\b_]LAN\d+_[a-z][Ff]\d*', code_upper))
|
||||||
|
or "cyclo-piét" in combined_text
|
||||||
|
or "cyclo-piet" in combined_text
|
||||||
|
or "cyclopiet" in combined_text
|
||||||
|
or ("cyclo" in combined_text and any(w in combined_text for w in ["piét", "piet", "voetganger"]))
|
||||||
|
or (any(w in combined_text for w in ["cycliste", "velo", "vélo", "fiets"])
|
||||||
|
and any(w in combined_text for w in ["piéton", "pieton", "voetganger", "pedestrian"]))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check Cyclist:
|
||||||
|
is_cyclist = (
|
||||||
|
not is_tram and not is_bus and not is_cyclopieton and (
|
||||||
|
any(w in combined_text for w in ["cycliste", "cycl", "velo", "vélo", "fiets"])
|
||||||
|
or phase_clean.upper().startswith("F")
|
||||||
|
or bool(re.search(r'\bF\d+\b', code_upper))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check Pedestrian:
|
||||||
|
is_pedestrian = (
|
||||||
|
not is_tram and not is_bus and not is_cyclopieton and not is_cyclist and (
|
||||||
|
any(w in combined_text for w in ["piéton", "pieton", "voetganger", "pedestrian"])
|
||||||
|
or (bool(phase_clean) and phase_clean[0].islower() and not phase_clean.startswith("fl"))
|
||||||
|
or phase_clean.upper().startswith("P")
|
||||||
|
or bool(re.search(r'\bP\d+\b', code_upper))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_tram:
|
||||||
|
category = "tram"
|
||||||
|
elif is_bus:
|
||||||
|
category = "bus"
|
||||||
|
elif is_cyclopieton:
|
||||||
|
category = "cyclopieton"
|
||||||
|
elif is_cyclist:
|
||||||
|
category = "cyclist"
|
||||||
|
elif is_pedestrian:
|
||||||
|
category = "pedestrian"
|
||||||
|
else:
|
||||||
|
category = "vehicle"
|
||||||
|
|
||||||
|
# 2. Aspect count (e.g. 3V, 2V, 1V)
|
||||||
|
aspect_count = None
|
||||||
|
m_aspect = re.search(r'\b(\d+)V', f"{model_name} {name}", re.IGNORECASE)
|
||||||
|
if m_aspect:
|
||||||
|
aspect_count = int(m_aspect.group(1))
|
||||||
|
|
||||||
|
# 3. Lens size (e.g. 300, 200)
|
||||||
|
lens_size = None
|
||||||
|
m_size = re.search(r'\d+V(\d+)', f"{model_name} {name}", re.IGNORECASE)
|
||||||
|
if m_size:
|
||||||
|
lens_size = int(m_size.group(1))
|
||||||
|
else:
|
||||||
|
m_size_word = re.search(r'\b(200|300)\b', f"{model_name} {name}")
|
||||||
|
if m_size_word:
|
||||||
|
lens_size = int(m_size_word.group(1))
|
||||||
|
|
||||||
|
# 4. Directions: straight, left, right
|
||||||
|
directions = set()
|
||||||
|
if re.search(r'\b(tout\s+droit[es]?|rechtdoor|straight)\b', combined_text):
|
||||||
|
directions.add("straight")
|
||||||
|
if re.search(r'\b(fl\s*gauche|flèche\s*gauche|gauche|links|left)\b', combined_text):
|
||||||
|
directions.add("left")
|
||||||
|
if re.search(r'\b(fl\s*droite|flèche\s*droite|droite|rechts|right)\b', combined_text):
|
||||||
|
if not re.search(r'\btout\s+droite\b', combined_text):
|
||||||
|
directions.add("right")
|
||||||
|
|
||||||
|
return category, aspect_count, lens_size, directions
|
||||||
|
|
||||||
|
|
||||||
|
def extract_phase_from_lantern(code="", name=""):
|
||||||
|
"""
|
||||||
|
Extracts phase identifier (e.g. 'B1', 'T2', 'b', 'F1') from lantern code or name.
|
||||||
|
"""
|
||||||
|
code_str = code or ""
|
||||||
|
name_str = name or ""
|
||||||
|
|
||||||
|
if "_" in code_str:
|
||||||
|
parts = code_str.rsplit("_", 1)
|
||||||
|
if len(parts) == 2 and not parts[1].upper().startswith("LAN"):
|
||||||
|
return parts[1]
|
||||||
|
|
||||||
|
m = re.search(r'Phase\s+([A-Za-z0-9]+)', name_str, re.IGNORECASE)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_lantern_match_score(dl, el, is_single_on_pole=False):
|
||||||
|
"""
|
||||||
|
Computes a match compatibility score between a deduced plan lantern (dl)
|
||||||
|
and an existing DB lantern (el).
|
||||||
|
Higher score indicates a more likely match. Negative score indicates a conflict.
|
||||||
|
"""
|
||||||
|
dl_code = dl.get("code", "")
|
||||||
|
el_code = getattr(el, "code", "") or (el.get("code", "") if isinstance(el, dict) else "")
|
||||||
|
|
||||||
|
# 1. Exact full code match
|
||||||
|
if dl_code and el_code and dl_code == el_code:
|
||||||
|
return 10000
|
||||||
|
|
||||||
|
dl_base_code = dl_code.rsplit("_", 1)[0] if "_" in dl_code else dl_code
|
||||||
|
el_base_code = el_code.rsplit("_", 1)[0] if "_" in el_code else el_code
|
||||||
|
|
||||||
|
is_prefix_code_match = bool(
|
||||||
|
(dl_base_code and el_code and dl_base_code == el_code)
|
||||||
|
or (dl_code and el_base_code and dl_code == el_base_code)
|
||||||
|
or (dl_base_code and el_base_code and dl_base_code == el_base_code)
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_single_on_pole and is_prefix_code_match:
|
||||||
|
return 1000
|
||||||
|
|
||||||
|
dl_model_name = dl.get("model_name", "")
|
||||||
|
dl_phase = dl.get("phase", "") or extract_phase_from_lantern(dl_code, dl.get("name", ""))
|
||||||
|
dl_name = dl.get("name", "")
|
||||||
|
|
||||||
|
el_name = getattr(el, "name_fr", "") or getattr(el, "name", "") or (el.get("name_fr", "") if isinstance(el, dict) else "")
|
||||||
|
el_model = getattr(el, "model", None)
|
||||||
|
if el_model:
|
||||||
|
el_model_name = getattr(el_model, "name_fr", "") or ""
|
||||||
|
el_model_name_nl = getattr(el_model, "name_nl", "") or ""
|
||||||
|
elif isinstance(el, dict):
|
||||||
|
el_model_name = el.get("model_name", "")
|
||||||
|
el_model_name_nl = el.get("model_name_nl", "")
|
||||||
|
else:
|
||||||
|
el_model_name = ""
|
||||||
|
el_model_name_nl = ""
|
||||||
|
|
||||||
|
el_phase = extract_phase_from_lantern(el_code, el_name)
|
||||||
|
|
||||||
|
dl_cat, dl_aspect, dl_size, dl_dirs = classify_lantern(dl_model_name, dl_phase, dl_code, dl_name)
|
||||||
|
el_cat, el_aspect, el_size, el_dirs = classify_lantern(el_model_name, el_phase, el_code, el_name)
|
||||||
|
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# 2. Category matching
|
||||||
|
if dl_cat == el_cat:
|
||||||
|
if dl_cat in ("tram", "bus", "pedestrian", "cyclist", "cyclopieton"):
|
||||||
|
score += 300
|
||||||
|
else:
|
||||||
|
score += 200
|
||||||
|
elif {dl_cat, el_cat} in ({"cyclopieton", "pedestrian"}, {"cyclopieton", "cyclist"}):
|
||||||
|
score += 50
|
||||||
|
else:
|
||||||
|
# Category mismatch penalty: e.g. tram vs car, pedestrian vs car
|
||||||
|
score -= 600
|
||||||
|
|
||||||
|
# 3. Phase matching
|
||||||
|
if dl_phase and el_phase:
|
||||||
|
if dl_phase.upper() == el_phase.upper():
|
||||||
|
score += 500
|
||||||
|
else:
|
||||||
|
score -= 300
|
||||||
|
|
||||||
|
# 4. Model exact match
|
||||||
|
if dl_model_name and el_model_name:
|
||||||
|
if dl_model_name.lower().strip() == el_model_name.lower().strip():
|
||||||
|
score += 150
|
||||||
|
elif el_model_name_nl and dl_model_name.lower().strip() == el_model_name_nl.lower().strip():
|
||||||
|
score += 150
|
||||||
|
|
||||||
|
# 5. Aspect count matching (3V vs 3V, 2V vs 2V)
|
||||||
|
if dl_aspect and el_aspect:
|
||||||
|
if dl_aspect == el_aspect:
|
||||||
|
score += 50
|
||||||
|
else:
|
||||||
|
score -= 100
|
||||||
|
|
||||||
|
# 6. Lens size matching (300 vs 300, 200 vs 200)
|
||||||
|
if dl_size and el_size:
|
||||||
|
if dl_size == el_size:
|
||||||
|
score += 30
|
||||||
|
|
||||||
|
# 7. Directional matching (straight, left, right)
|
||||||
|
if dl_dirs and el_dirs:
|
||||||
|
if dl_dirs == el_dirs:
|
||||||
|
score += 80
|
||||||
|
elif dl_dirs.intersection(el_dirs):
|
||||||
|
score += 40
|
||||||
|
else:
|
||||||
|
score -= 60
|
||||||
|
|
||||||
|
# 8. Prefix match bonus (e.g. LAN03 vs LAN03_bF on the same pole)
|
||||||
|
if is_prefix_code_match:
|
||||||
|
score += 250
|
||||||
|
|
||||||
|
# 9. LAN index tie-breaker (e.g. LAN01 vs LAN01)
|
||||||
|
m_dl = re.search(r'LAN(\d+)', dl_code, re.IGNORECASE)
|
||||||
|
m_el = re.search(r'LAN(\d+)', el_code, re.IGNORECASE)
|
||||||
|
if m_dl and m_el and m_dl.group(1) == m_el.group(1):
|
||||||
|
score += 15
|
||||||
|
|
||||||
|
# 10. Single lantern on pole baseline bonus
|
||||||
|
if is_single_on_pole:
|
||||||
|
score += 200
|
||||||
|
|
||||||
|
return score
|
||||||
|
|
||||||
|
|
||||||
|
def match_pole_lanterns(pole_dls, pole_els):
|
||||||
|
"""
|
||||||
|
Given a list of deduced lanterns (pole_dls) and DB lanterns (pole_els)
|
||||||
|
on the same pole, finds the optimal 1-to-1 matching based on scoring.
|
||||||
|
Returns:
|
||||||
|
matched_pairs: list of (dl, el) in the original order of pole_dls
|
||||||
|
unmatched_dls: list of dl (new lanterns)
|
||||||
|
unmatched_els: list of el (db_only lanterns)
|
||||||
|
"""
|
||||||
|
if not pole_dls:
|
||||||
|
return [], [], list(pole_els)
|
||||||
|
if not pole_els:
|
||||||
|
return [], list(pole_dls), []
|
||||||
|
|
||||||
|
is_single_on_pole = (len(pole_dls) == 1 and len(pole_els) == 1)
|
||||||
|
|
||||||
|
scores = {}
|
||||||
|
for i, dl in enumerate(pole_dls):
|
||||||
|
for j, el in enumerate(pole_els):
|
||||||
|
scores[(i, j)] = calculate_lantern_match_score(dl, el, is_single_on_pole=is_single_on_pole)
|
||||||
|
|
||||||
|
m = len(pole_dls)
|
||||||
|
n = len(pole_els)
|
||||||
|
|
||||||
|
best_assignment = []
|
||||||
|
best_total_score = -float('inf')
|
||||||
|
|
||||||
|
if min(m, n) <= 8:
|
||||||
|
if m <= n:
|
||||||
|
for p in itertools.permutations(range(n), m):
|
||||||
|
total = sum(scores[(i, p[i])] for i in range(m))
|
||||||
|
if total > best_total_score:
|
||||||
|
best_total_score = total
|
||||||
|
best_assignment = [(i, p[i]) for i in range(m)]
|
||||||
|
else:
|
||||||
|
for p in itertools.permutations(range(m), n):
|
||||||
|
total = sum(scores[(p[j], j)] for j in range(n))
|
||||||
|
if total > best_total_score:
|
||||||
|
best_total_score = total
|
||||||
|
best_assignment = [(p[j], j) for j in range(n)]
|
||||||
|
else:
|
||||||
|
available_dls = set(range(m))
|
||||||
|
available_els = set(range(n))
|
||||||
|
sorted_pairs = sorted(scores.items(), key=lambda item: item[1], reverse=True)
|
||||||
|
best_assignment = []
|
||||||
|
for (i, j), score in sorted_pairs:
|
||||||
|
if i in available_dls and j in available_els:
|
||||||
|
best_assignment.append((i, j))
|
||||||
|
available_dls.remove(i)
|
||||||
|
available_els.remove(j)
|
||||||
|
|
||||||
|
matched_pairs = []
|
||||||
|
matched_dl_indices = set()
|
||||||
|
matched_el_indices = set()
|
||||||
|
|
||||||
|
for i, j in best_assignment:
|
||||||
|
if scores[(i, j)] >= 0:
|
||||||
|
matched_pairs.append((pole_dls[i], pole_els[j]))
|
||||||
|
matched_dl_indices.add(i)
|
||||||
|
matched_el_indices.add(j)
|
||||||
|
|
||||||
|
# Sort matched pairs to preserve the original order of pole_dls
|
||||||
|
matched_pairs.sort(key=lambda pair: pole_dls.index(pair[0]))
|
||||||
|
|
||||||
|
unmatched_dls = [dl for i, dl in enumerate(pole_dls) if i not in matched_dl_indices]
|
||||||
|
unmatched_els = [el for j, el in enumerate(pole_els) if j not in matched_el_indices]
|
||||||
|
|
||||||
|
return matched_pairs, unmatched_dls, unmatched_els
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,13 +65,9 @@ def artworks_index(request):
|
||||||
"icon": next((t.icon for t in thematics if t.code == thematic), None),
|
"icon": next((t.icon for t in thematics if t.code == thematic), None),
|
||||||
}
|
}
|
||||||
|
|
||||||
municipality_order_field = 'name_nl' if lang == 'nl' else 'name_fr'
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
municipalities = (
|
municipalities = get_user_accessible_municipalities(request.user)
|
||||||
Municipality.objects
|
municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
|
||||||
.all()
|
|
||||||
.order_by(municipality_order_field)
|
|
||||||
)
|
|
||||||
municipality_choices = [(m.id, m.name) for m in municipalities]
|
|
||||||
|
|
||||||
# Status choices for filters
|
# Status choices for filters
|
||||||
status_choices = [
|
status_choices = [
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,19 @@ from assets.models.buildings import (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
USAGE_TYPE_COLORS = {
|
||||||
|
'administrative': '#2563EB', # Bleu cobalt
|
||||||
|
'education': '#D97706', # Ambre doré
|
||||||
|
'sports': '#059669', # Vert émeraude
|
||||||
|
'culture': '#7C3AED', # Violet pourpre
|
||||||
|
'social': '#E11D48', # Rose framboise
|
||||||
|
'technical': '#64748B', # Gris ardoise
|
||||||
|
'housing': '#0D9488', # Sarcelle
|
||||||
|
'other': '#475569', # Ardoise foncée
|
||||||
|
}
|
||||||
|
DEFAULT_BUILDING_COLOR = '#2563EB'
|
||||||
|
|
||||||
|
|
||||||
def publicbuildings_locations_geojson(request):
|
def publicbuildings_locations_geojson(request):
|
||||||
"""
|
"""
|
||||||
Vue GeoJSON renvoyant la collection des localisations de bâtiments publics pour la carte unifiée.
|
Vue GeoJSON renvoyant la collection des localisations de bâtiments publics pour la carte unifiée.
|
||||||
|
|
@ -131,10 +144,13 @@ def publicbuildings_locations_geojson(request):
|
||||||
"is_erp": b.is_erp,
|
"is_erp": b.is_erp,
|
||||||
"erp_category": b.erp_category or '',
|
"erp_category": b.erp_category or '',
|
||||||
"dpe_rating": b.dpe_rating or '',
|
"dpe_rating": b.dpe_rating or '',
|
||||||
|
"address": b.address or '',
|
||||||
|
"surface_m2": b.surface_m2,
|
||||||
|
"total_surface_m2": b.total_surface_m2,
|
||||||
"lon": b.lon,
|
"lon": b.lon,
|
||||||
"lat": b.lat,
|
"lat": b.lat,
|
||||||
"color": "#2B579A",
|
"color": USAGE_TYPE_COLORS.get(b.usage_type, DEFAULT_BUILDING_COLOR),
|
||||||
"display_color": "#2B579A",
|
"display_color": USAGE_TYPE_COLORS.get(b.usage_type, DEFAULT_BUILDING_COLOR),
|
||||||
"asset_type": "publicbuilding",
|
"asset_type": "publicbuilding",
|
||||||
"detail_url": detail_url,
|
"detail_url": detail_url,
|
||||||
"element_type": get_element_type_label("publicbuilding"),
|
"element_type": get_element_type_label("publicbuilding"),
|
||||||
|
|
@ -165,12 +181,16 @@ def publicbuildings_assets_geojson(request):
|
||||||
asset_types = data.get('asset_type') or []
|
asset_types = data.get('asset_type') or []
|
||||||
if isinstance(asset_types, str):
|
if isinstance(asset_types, str):
|
||||||
asset_types = [asset_types]
|
asset_types = [asset_types]
|
||||||
|
usage_types = data.get('usage_type') or []
|
||||||
|
if isinstance(usage_types, str):
|
||||||
|
usage_types = [usage_types]
|
||||||
|
|
||||||
search_q = (data.get('q') or '').strip()
|
search_q = (data.get('q') or '').strip()
|
||||||
else:
|
else:
|
||||||
statuses = request.GET.getlist('asset_status')
|
statuses = request.GET.getlist('asset_status')
|
||||||
categories = request.GET.getlist('category')
|
categories = request.GET.getlist('category')
|
||||||
asset_types = request.GET.getlist('asset_type', [])
|
asset_types = request.GET.getlist('asset_type', [])
|
||||||
|
usage_types = request.GET.getlist('usage_type', [])
|
||||||
search_q = request.GET.get('q', '').strip()
|
search_q = request.GET.get('q', '').strip()
|
||||||
|
|
||||||
qs = BuildingAsset.objects.select_related('building', 'location', 'parent_asset', 'category').all()
|
qs = BuildingAsset.objects.select_related('building', 'location', 'parent_asset', 'category').all()
|
||||||
|
|
@ -181,6 +201,9 @@ def publicbuildings_assets_geojson(request):
|
||||||
if asset_types:
|
if asset_types:
|
||||||
qs = qs.filter(asset_type__in=asset_types)
|
qs = qs.filter(asset_type__in=asset_types)
|
||||||
|
|
||||||
|
if usage_types:
|
||||||
|
qs = qs.filter(building__usage_type__in=usage_types)
|
||||||
|
|
||||||
if statuses:
|
if statuses:
|
||||||
qs = qs.filter(status__in=statuses)
|
qs = qs.filter(status__in=statuses)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -573,8 +573,8 @@ def build_conditional_filter_config(accessible_thematics, user=None):
|
||||||
config = {}
|
config = {}
|
||||||
|
|
||||||
# Shared lists used by multiple thematics
|
# Shared lists used by multiple thematics
|
||||||
from common.models import Municipality
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
municipalities = Municipality.objects.all().order_by('name_fr')
|
municipalities = get_user_accessible_municipalities(user)
|
||||||
municipality_options = [
|
municipality_options = [
|
||||||
{'value': str(m.id), 'label': m.name}
|
{'value': str(m.id), 'label': m.name}
|
||||||
for m in municipalities
|
for m in municipalities
|
||||||
|
|
@ -654,7 +654,7 @@ def build_conditional_filter_config(accessible_thematics, user=None):
|
||||||
'name': pt.get_name(),
|
'name': pt.get_name(),
|
||||||
'group': group_key,
|
'group': group_key,
|
||||||
'group_label': group_label,
|
'group_label': group_label,
|
||||||
'image_url': None,
|
'image_url': pt.sgv_picture.url if getattr(pt, 'sgv_picture', None) else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
controller_maintenance_provider_options = []
|
controller_maintenance_provider_options = []
|
||||||
|
|
@ -673,7 +673,8 @@ def build_conditional_filter_config(accessible_thematics, user=None):
|
||||||
for contract in contracts
|
for contract in contracts
|
||||||
]
|
]
|
||||||
|
|
||||||
# Roads / public lighting / sign / artworks: municipality on locations
|
# Roads / public lighting / sign / artworks: municipality on locations (only if more than 1 option)
|
||||||
|
if len(municipality_options) > 1:
|
||||||
for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks']:
|
for thematic_code in ['roads', 'publiclighting', 'sign', 'artworks']:
|
||||||
if thematic_code in accessible_codes:
|
if thematic_code in accessible_codes:
|
||||||
config.setdefault(thematic_code, []).append({
|
config.setdefault(thematic_code, []).append({
|
||||||
|
|
@ -788,8 +789,22 @@ def build_conditional_filter_config(accessible_thematics, user=None):
|
||||||
|
|
||||||
# Public buildings specific choices
|
# Public buildings specific choices
|
||||||
from assets.models.buildings import USAGE_TYPE_CHOICES
|
from assets.models.buildings import USAGE_TYPE_CHOICES
|
||||||
|
building_usage_icons = {
|
||||||
|
'administrative': 'bi-building',
|
||||||
|
'education': 'bi-mortarboard',
|
||||||
|
'sports': 'bi-trophy',
|
||||||
|
'culture': 'bi-palette',
|
||||||
|
'social': 'bi-people',
|
||||||
|
'technical': 'bi-tools',
|
||||||
|
'housing': 'bi-house-door',
|
||||||
|
'other': 'bi-building-gear',
|
||||||
|
}
|
||||||
building_usage_options = [
|
building_usage_options = [
|
||||||
{'value': str(key), 'label': str(value)}
|
{
|
||||||
|
'value': str(key),
|
||||||
|
'label': str(value),
|
||||||
|
'icon': building_usage_icons.get(str(key), 'bi-building'),
|
||||||
|
}
|
||||||
for key, value in USAGE_TYPE_CHOICES
|
for key, value in USAGE_TYPE_CHOICES
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -1073,6 +1088,14 @@ def unified_assets_index(request):
|
||||||
{'id': c.id, 'name': c.get_name()} for c in cats
|
{'id': c.id, 'name': c.get_name()} for c in cats
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Si des thématiques sont spécifiées dans l'URL mais sans catégories explicites,
|
||||||
|
# activer l'ensemble des catégories de ces thématiques par défaut pour charger les assets
|
||||||
|
if has_url_thematics and not selected_categories:
|
||||||
|
selected_categories = []
|
||||||
|
for t_code in selected_thematics:
|
||||||
|
for cat in thematic_categories.get(t_code, []):
|
||||||
|
selected_categories.append(f"{t_code}::{cat['name']}")
|
||||||
|
|
||||||
link_assets_url = (
|
link_assets_url = (
|
||||||
reverse(
|
reverse(
|
||||||
'interventions:intervention_preventive_link_elements',
|
'interventions:intervention_preventive_link_elements',
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,13 @@ def nature_assets_geojson(request):
|
||||||
def obj_to_feature(obj, asset_type=None):
|
def obj_to_feature(obj, asset_type=None):
|
||||||
geometry, lon, lat = build_geometry_and_coords(obj)
|
geometry, lon, lat = build_geometry_and_coords(obj)
|
||||||
detail_url = reverse('assets:nature_assets_detail', kwargs={'asset_model': asset_type, 'asset_id': obj['id']}) if asset_type else None
|
detail_url = reverse('assets:nature_assets_detail', kwargs={'asset_model': asset_type, 'asset_id': obj['id']}) if asset_type else None
|
||||||
|
model_name = obj.get('model_name') or ''
|
||||||
|
code = obj.get('code') or ''
|
||||||
|
is_planter = asset_type == 'naturebench' and (
|
||||||
|
any(w in model_name.lower() for w in ('bac', 'jardin', 'bloem', 'plante', 'fleur'))
|
||||||
|
or code.startswith('CSE-BAC')
|
||||||
|
)
|
||||||
|
element_type_label = _('Bac à fleurs') if is_planter else get_element_type_label(asset_type or '')
|
||||||
return {
|
return {
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"id": obj['id'],
|
"id": obj['id'],
|
||||||
|
|
@ -355,11 +362,12 @@ def nature_assets_geojson(request):
|
||||||
"lon": lon,
|
"lon": lon,
|
||||||
"lat": lat,
|
"lat": lat,
|
||||||
"asset_type": asset_type,
|
"asset_type": asset_type,
|
||||||
|
"asset_sub_type": "planter" if is_planter else ("bench" if asset_type == "naturebench" else ""),
|
||||||
"area": obj.get('surface_area', ''),
|
"area": obj.get('surface_area', ''),
|
||||||
"color": obj['color'] if obj['color'] else '#ccc',
|
"color": obj['color'] if obj['color'] else '#ccc',
|
||||||
"detail_url": detail_url,
|
"detail_url": detail_url,
|
||||||
"bulb_types": obj.get('bulb_types') or [],
|
"bulb_types": obj.get('bulb_types') or [],
|
||||||
"element_type": get_element_type_label(asset_type or ''),
|
"element_type": element_type_label,
|
||||||
"thematic": "nature",
|
"thematic": "nature",
|
||||||
"is_location": False,
|
"is_location": False,
|
||||||
"structure_type": obj.get('structure_type', ''),
|
"structure_type": obj.get('structure_type', ''),
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ MAX_ASSET_FEATURES = 5000
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def parking_index(request):
|
def parking_index(request):
|
||||||
"""Page d'accueil de la thématique Stationnement & Mobilité."""
|
"""Page d'accueil de la thématique Stationnement."""
|
||||||
check_thematic_access(request.user, 'parking')
|
check_thematic_access(request.user, 'parking')
|
||||||
|
|
||||||
parking_thematic = Thematic.objects.filter(code='parking').first()
|
parking_thematic = Thematic.objects.filter(code='parking').first()
|
||||||
|
|
|
||||||
|
|
@ -62,9 +62,10 @@ def publiclighting_index(request):
|
||||||
"icon": next((t.icon for t in thematics if t.code == thematic), None),
|
"icon": next((t.icon for t in thematics if t.code == thematic), None),
|
||||||
}
|
}
|
||||||
|
|
||||||
municipalities = list(Municipality.objects.all())
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
municipalities = list(get_user_accessible_municipalities(request.user))
|
||||||
municipalities.sort(key=lambda m: m.name.lower())
|
municipalities.sort(key=lambda m: m.name.lower())
|
||||||
municipality_choices = [(m.id, m.name) for m in municipalities]
|
municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
|
||||||
|
|
||||||
categories = list(AssetCategory.objects.filter(thematic__code = 'publiclighting'))
|
categories = list(AssetCategory.objects.filter(thematic__code = 'publiclighting'))
|
||||||
categories.sort(key=lambda m: m.get_name().lower())
|
categories.sort(key=lambda m: m.get_name().lower())
|
||||||
|
|
|
||||||
|
|
@ -65,9 +65,10 @@ def roads_index(request):
|
||||||
"icon": next((t.icon for t in thematics if t.code == thematic), None),
|
"icon": next((t.icon for t in thematics if t.code == thematic), None),
|
||||||
}
|
}
|
||||||
|
|
||||||
municipalities = list(Municipality.objects.all())
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
municipalities = list(get_user_accessible_municipalities(request.user))
|
||||||
municipalities.sort(key=lambda m: m.name.lower())
|
municipalities.sort(key=lambda m: m.name.lower())
|
||||||
municipality_choices = [(m.id, m.name) for m in municipalities]
|
municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
|
||||||
|
|
||||||
categories = list(AssetCategory.objects.filter(thematic__code = 'roads'))
|
categories = list(AssetCategory.objects.filter(thematic__code = 'roads'))
|
||||||
categories.sort(key=lambda m: m.get_name().lower())
|
categories.sort(key=lambda m: m.get_name().lower())
|
||||||
|
|
@ -142,11 +143,26 @@ def roads_streets_geojson(request):
|
||||||
municipalities = request.GET.getlist('municipality')
|
municipalities = request.GET.getlist('municipality')
|
||||||
filter_geom = None
|
filter_geom = None
|
||||||
|
|
||||||
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
user = request.user
|
||||||
|
user_config = getattr(user, 'config', None)
|
||||||
|
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
|
||||||
|
is_external = (user_config is None) or (not user_config.is_intern)
|
||||||
|
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
|
||||||
|
|
||||||
# --------- Point d'entrée du queryset
|
# --------- Point d'entrée du queryset
|
||||||
qs = RoadStreet.objects.all()
|
qs = RoadStreet.objects.all()
|
||||||
|
|
||||||
# --------- Filtre par communes
|
# --------- Filtre par communes et restriction contractuelle
|
||||||
|
if not is_admin and must_limit:
|
||||||
|
accessible_munis = get_user_accessible_municipalities(user)
|
||||||
|
accessible_muni_ids = list(accessible_munis.values_list('id', flat=True))
|
||||||
if municipalities:
|
if municipalities:
|
||||||
|
valid_munis = [mid for mid in municipalities if (int(mid) if str(mid).isdigit() else mid) in accessible_muni_ids]
|
||||||
|
qs = qs.filter(municipality_id__in=valid_munis)
|
||||||
|
else:
|
||||||
|
qs = qs.filter(municipality_id__in=accessible_muni_ids)
|
||||||
|
elif municipalities:
|
||||||
qs = qs.filter(municipality_id__in=municipalities)
|
qs = qs.filter(municipality_id__in=municipalities)
|
||||||
|
|
||||||
# --------- Filtre géométrique
|
# --------- Filtre géométrique
|
||||||
|
|
@ -289,10 +305,11 @@ def roads_assets_geojson(request):
|
||||||
"geometry": geometry,
|
"geometry": geometry,
|
||||||
"properties": {
|
"properties": {
|
||||||
"code": obj.code,
|
"code": obj.code,
|
||||||
"name": obj.code,
|
"name": obj.get_name() if hasattr(obj, "get_name") else (getattr(obj, "name_fr", None) or obj.code),
|
||||||
"category": obj.category.get_name() if obj.category else None,
|
"category": obj.category.get_name() if obj.category else None,
|
||||||
"model": model.get_name() if model else None,
|
"model": model.get_name() if model else None,
|
||||||
"quality_level": obj.get_quality_level_display() if hasattr(obj, 'get_quality_level_display') else None,
|
"quality_level": obj.get_quality_level_display() if hasattr(obj, 'get_quality_level_display') else None,
|
||||||
|
"quality_level_value": getattr(obj, 'quality_level', None),
|
||||||
"status": obj.status,
|
"status": obj.status,
|
||||||
"validation_status": obj.validation_status,
|
"validation_status": obj.validation_status,
|
||||||
"lon": lon,
|
"lon": lon,
|
||||||
|
|
@ -350,7 +367,29 @@ def roads_assets_geojson(request):
|
||||||
drains_qs = drains_qs.filter(street__id__in=roads_location_ids)
|
drains_qs = drains_qs.filter(street__id__in=roads_location_ids)
|
||||||
concrete_block_sets_qs = concrete_block_sets_qs.none()
|
concrete_block_sets_qs = concrete_block_sets_qs.none()
|
||||||
|
|
||||||
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
user = request.user
|
||||||
|
user_config = getattr(user, 'config', None)
|
||||||
|
is_admin = getattr(user, 'is_superuser', False) or (user_config and (user_config.has_role('admin') or user_config.has_role('top_manager')))
|
||||||
|
is_external = (user_config is None) or (not user_config.is_intern)
|
||||||
|
must_limit = is_external or (user_config and user_config.limit_assets_to_contracts)
|
||||||
|
|
||||||
|
if not is_admin and must_limit:
|
||||||
|
accessible_munis = get_user_accessible_municipalities(user)
|
||||||
|
accessible_muni_ids = list(accessible_munis.values_list('id', flat=True))
|
||||||
if municipalities:
|
if municipalities:
|
||||||
|
valid_munis = [mid for mid in municipalities if (int(mid) if str(mid).isdigit() else mid) in accessible_muni_ids]
|
||||||
|
roadways_qs = roadways_qs.filter(street__municipality_id__in=valid_munis)
|
||||||
|
sidewalks_qs = sidewalks_qs.filter(street__municipality_id__in=valid_munis)
|
||||||
|
furnitures_qs = furnitures_qs.filter(street__municipality_id__in=valid_munis)
|
||||||
|
drains_qs = drains_qs.filter(street__municipality_id__in=valid_munis)
|
||||||
|
else:
|
||||||
|
roadways_qs = roadways_qs.filter(street__municipality_id__in=accessible_muni_ids)
|
||||||
|
sidewalks_qs = sidewalks_qs.filter(street__municipality_id__in=accessible_muni_ids)
|
||||||
|
furnitures_qs = furnitures_qs.filter(street__municipality_id__in=accessible_muni_ids)
|
||||||
|
drains_qs = drains_qs.filter(street__municipality_id__in=accessible_muni_ids)
|
||||||
|
concrete_block_sets_qs = concrete_block_sets_qs.none()
|
||||||
|
elif municipalities:
|
||||||
roadways_qs = roadways_qs.filter(street__municipality_id__in=municipalities)
|
roadways_qs = roadways_qs.filter(street__municipality_id__in=municipalities)
|
||||||
sidewalks_qs = sidewalks_qs.filter(street__municipality_id__in=municipalities)
|
sidewalks_qs = sidewalks_qs.filter(street__municipality_id__in=municipalities)
|
||||||
furnitures_qs = furnitures_qs.filter(street__municipality_id__in=municipalities)
|
furnitures_qs = furnitures_qs.filter(street__municipality_id__in=municipalities)
|
||||||
|
|
|
||||||
|
|
@ -70,9 +70,10 @@ def sign_index(request):
|
||||||
"icon": next((t.icon for t in thematics if t.code == thematic), None),
|
"icon": next((t.icon for t in thematics if t.code == thematic), None),
|
||||||
}
|
}
|
||||||
|
|
||||||
municipalities = list(Municipality.objects.all())
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
municipalities = list(get_user_accessible_municipalities(request.user))
|
||||||
municipalities.sort(key=lambda m: m.name.lower())
|
municipalities.sort(key=lambda m: m.name.lower())
|
||||||
municipality_choices = [(m.id, m.name) for m in municipalities]
|
municipality_choices = [(m.id, m.name) for m in municipalities] if len(municipalities) > 1 else []
|
||||||
|
|
||||||
categories = list(AssetCategory.objects.filter(thematic__code = 'sign'))
|
categories = list(AssetCategory.objects.filter(thematic__code = 'sign'))
|
||||||
categories.sort(key=lambda m: m.get_name().lower())
|
categories.sort(key=lambda m: m.get_name().lower())
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ def structures_index(request):
|
||||||
selected_locations_ids = draft_data.get('locations', [])
|
selected_locations_ids = draft_data.get('locations', [])
|
||||||
|
|
||||||
|
|
||||||
user_config, _ = UserConfig.objects.get_or_create(user=request.user)
|
user_config, _created = UserConfig.objects.get_or_create(user=request.user)
|
||||||
thematic_choices_qs = (
|
thematic_choices_qs = (
|
||||||
UserThematics.objects
|
UserThematics.objects
|
||||||
.filter(user_config=user_config, can_view_assets=True)
|
.filter(user_config=user_config, can_view_assets=True)
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from django.utils.translation import get_language, gettext as _
|
||||||
from django.utils.timezone import make_aware, get_current_timezone
|
from django.utils.timezone import make_aware, get_current_timezone
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import unicodedata
|
import unicodedata
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
|
@ -1922,6 +1923,54 @@ def get_base_dxf_name(filename):
|
||||||
return f"{base_cleaned}{ext}".lower()
|
return f"{base_cleaned}{ext}".lower()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_dxf_file_path(intersection, filename):
|
||||||
|
if not filename:
|
||||||
|
return None
|
||||||
|
filename_clean = os.path.basename(filename)
|
||||||
|
|
||||||
|
# 1. Managed documents
|
||||||
|
try:
|
||||||
|
from documents.models import DocumentAttachment, DocumentVersion
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
ct = ContentType.objects.get_for_model(intersection)
|
||||||
|
doc_ids = DocumentAttachment.objects.filter(content_type=ct, object_id=intersection.id).values_list('document_id', flat=True)
|
||||||
|
versions = DocumentVersion.objects.filter(document_id__in=doc_ids, file__icontains=filename_clean)
|
||||||
|
for v in versions:
|
||||||
|
if v.file and os.path.exists(v.file.path):
|
||||||
|
return v.file.path
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 2. Database documents (legacy)
|
||||||
|
try:
|
||||||
|
for d in intersection.documents.all():
|
||||||
|
if os.path.basename(d.file.name) == filename_clean and os.path.exists(d.file.path):
|
||||||
|
return d.file.path
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Private media dxf_files
|
||||||
|
uploaded_dxf_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_files')
|
||||||
|
p = os.path.join(uploaded_dxf_dir, filename_clean)
|
||||||
|
if os.path.exists(p):
|
||||||
|
return p
|
||||||
|
|
||||||
|
# 4. External read-only files
|
||||||
|
read_only_dxf_dir = os.path.join(os.path.dirname(settings.BASE_DIR), 'external_files')
|
||||||
|
p = os.path.join(read_only_dxf_dir, filename_clean)
|
||||||
|
if os.path.exists(p):
|
||||||
|
return p
|
||||||
|
|
||||||
|
# 5. Dxf temp
|
||||||
|
dxf_temp_dir = os.path.join(settings.PRIVATE_MEDIA_ROOT, 'dxf_temp')
|
||||||
|
p = os.path.join(dxf_temp_dir, filename_clean)
|
||||||
|
if os.path.exists(p):
|
||||||
|
return p
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@check_thematic_access('trafficlights', edit_permission_required=True)
|
@check_thematic_access('trafficlights', edit_permission_required=True)
|
||||||
def import_dxf(request, intersection_id):
|
def import_dxf(request, intersection_id):
|
||||||
intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id)
|
intersection = get_object_or_404(TrafficLightIntersection, pk=intersection_id)
|
||||||
|
|
@ -3696,6 +3745,77 @@ def update_staged_pole_position(request, intersection_id, pole_id):
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def find_db_lantern_model(model_name):
|
||||||
|
"""
|
||||||
|
Finds the most suitable TrafficLightLanternModel from the database based on model_name.
|
||||||
|
Gracefully handles variants such as '2V200 cyclo-piéto + HP', '2V200 cyclo-piéton + HP',
|
||||||
|
'2V200 piéton+HP', tram, cyclist models, etc.
|
||||||
|
"""
|
||||||
|
if not model_name:
|
||||||
|
return TrafficLightLanternModel.objects.first()
|
||||||
|
m = TrafficLightLanternModel.objects.filter(name_fr__iexact=model_name).first()
|
||||||
|
if m:
|
||||||
|
return m
|
||||||
|
m_nl = TrafficLightLanternModel.objects.filter(name_nl__iexact=model_name).first()
|
||||||
|
if m_nl:
|
||||||
|
return m_nl
|
||||||
|
model_lower = str(model_name).lower()
|
||||||
|
if "cyclo" in model_lower:
|
||||||
|
qs = TrafficLightLanternModel.objects.filter(
|
||||||
|
Q(name_fr__icontains="cyclo") | Q(name_nl__icontains="cyclo") |
|
||||||
|
Q(name_fr__icontains="fietsers- en voetgangers") | Q(name_nl__icontains="fietsers- en voetgangers")
|
||||||
|
)
|
||||||
|
if "hp" in model_lower or "haut-parleur" in model_lower:
|
||||||
|
m = qs.filter(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
|
||||||
|
else:
|
||||||
|
m = qs.exclude(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
|
||||||
|
if m:
|
||||||
|
return m
|
||||||
|
if qs.exists():
|
||||||
|
return qs.first()
|
||||||
|
elif any(w in model_lower for w in ["piét", "piet", "voetganger"]):
|
||||||
|
qs = TrafficLightLanternModel.objects.filter(
|
||||||
|
Q(name_fr__icontains="piét") | Q(name_fr__icontains="piet") | Q(name_nl__icontains="voetganger")
|
||||||
|
).exclude(Q(name_fr__icontains="cyclo") | Q(name_nl__icontains="cyclo"))
|
||||||
|
if "hp" in model_lower or "haut-parleur" in model_lower:
|
||||||
|
m = qs.filter(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
|
||||||
|
else:
|
||||||
|
m = qs.exclude(Q(name_fr__icontains="hp") | Q(name_fr__icontains="haut-parleur") | Q(name_nl__icontains="hp")).first()
|
||||||
|
if m:
|
||||||
|
return m
|
||||||
|
elif "tram" in model_lower:
|
||||||
|
if "300" in model_lower:
|
||||||
|
m = TrafficLightLanternModel.objects.filter(name_fr__icontains="tram").filter(name_fr__icontains="300").first()
|
||||||
|
if m:
|
||||||
|
return m
|
||||||
|
m = TrafficLightLanternModel.objects.filter(name_fr__icontains="tram").first()
|
||||||
|
if m:
|
||||||
|
return m
|
||||||
|
elif any(w in model_lower for w in ["cycliste", "cycl", "fiets"]):
|
||||||
|
m = TrafficLightLanternModel.objects.filter(
|
||||||
|
Q(name_fr__icontains="cycl") | Q(name_nl__icontains="fiets")
|
||||||
|
).exclude(Q(name_fr__icontains="piét") | Q(name_fr__icontains="piet") | Q(name_nl__icontains="voetganger")).first()
|
||||||
|
if m:
|
||||||
|
return m
|
||||||
|
return TrafficLightLanternModel.objects.first()
|
||||||
|
|
||||||
|
|
||||||
|
def is_stib_detector(detector_obj, code=""):
|
||||||
|
"""
|
||||||
|
Checks if a detector is a STIB/tram detection loop (e.g. dT1, dT2, DT1),
|
||||||
|
which is provided/managed by STIB and not present in cross plans.
|
||||||
|
"""
|
||||||
|
name = (getattr(detector_obj, "name", None) or getattr(detector_obj, "name_fr", None) or "").strip()
|
||||||
|
code_str = (code or getattr(detector_obj, "code", "") or "").strip()
|
||||||
|
if re.match(r'^dt[\s_\d]*$', name, re.IGNORECASE) or name.lower().startswith("dt"):
|
||||||
|
return True
|
||||||
|
if re.search(r'\bdt\d+\b', name, re.IGNORECASE) or "stib" in name.lower():
|
||||||
|
return True
|
||||||
|
if re.search(r'(?:^|_)dt\d*(?:_|$)', code_str, re.IGNORECASE) or "stib" in code_str.lower():
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
@check_thematic_access('trafficlights', edit_permission_required=True)
|
@check_thematic_access('trafficlights', edit_permission_required=True)
|
||||||
def import_cross_plan(request, intersection_id):
|
def import_cross_plan(request, intersection_id):
|
||||||
from django.shortcuts import render, get_object_or_404, redirect
|
from django.shortcuts import render, get_object_or_404, redirect
|
||||||
|
|
@ -3704,7 +3824,7 @@ def import_cross_plan(request, intersection_id):
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
import json
|
import json
|
||||||
from assets.utils.cross_plan_parser import parse_cross_plan
|
from assets.utils.cross_plan_parser import parse_cross_plan, match_pole_lanterns
|
||||||
from assets.models.trafficlights import (
|
from assets.models.trafficlights import (
|
||||||
TrafficLightIntersection, TrafficLightPole, TrafficLightLantern,
|
TrafficLightIntersection, TrafficLightPole, TrafficLightLantern,
|
||||||
TrafficLightDetector, TrafficLightCable, TrafficLightPoleModel,
|
TrafficLightDetector, TrafficLightCable, TrafficLightPoleModel,
|
||||||
|
|
@ -4068,6 +4188,7 @@ def import_cross_plan(request, intersection_id):
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": ep.id,
|
"id": ep.id,
|
||||||
"code": ep.code,
|
"code": ep.code,
|
||||||
|
"short_code": ep.code.split('_')[-1] if '_' in ep.code else ep.code,
|
||||||
"model": ep.model.code if ep.model else ""
|
"model": ep.model.code if ep.model else ""
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -4078,43 +4199,142 @@ def import_cross_plan(request, intersection_id):
|
||||||
|
|
||||||
deduced = merged_deduced
|
deduced = merged_deduced
|
||||||
|
|
||||||
|
# ── RESOLVE ACTIVE DXF & SNAP POINTS ──
|
||||||
|
active_dxf_file = request.POST.get('selected_dxf') or (dxf_files[0] if dxf_files else "")
|
||||||
|
active_dxf_poles = []
|
||||||
|
dxf_poles_by_label = {}
|
||||||
|
if active_dxf_file:
|
||||||
|
dxf_path = resolve_dxf_file_path(intersection, active_dxf_file)
|
||||||
|
if dxf_path:
|
||||||
|
try:
|
||||||
|
active_dxf_poles = parse_dxf_poles(dxf_path)
|
||||||
|
for dp_dxf in active_dxf_poles:
|
||||||
|
lbl = (dp_dxf.get('label') or "").strip().upper()
|
||||||
|
if lbl:
|
||||||
|
dxf_poles_by_label[lbl] = dp_dxf
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not parse DXF poles from %s: %s", dxf_path, e)
|
||||||
|
|
||||||
# ── 1. POLES COMPARISON ──
|
# ── 1. POLES COMPARISON ──
|
||||||
existing_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')}
|
existing_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')}
|
||||||
compared_poles = []
|
compared_poles = []
|
||||||
|
from pyproj import Transformer
|
||||||
|
transformer_3812_to_4326 = Transformer.from_crs(3812, 4326, always_xy=True)
|
||||||
|
transformer_4326_to_3812 = Transformer.from_crs(4326, 3812, always_xy=True)
|
||||||
|
|
||||||
for dp in deduced["poles"]:
|
for dp in deduced["poles"]:
|
||||||
code = dp["code"]
|
code = dp["code"]
|
||||||
|
short_code = dp["short_code"]
|
||||||
model_name = dp["model"]
|
model_name = dp["model"]
|
||||||
db_model = TrafficLightPoleModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightPoleModel.objects.first()
|
db_model = TrafficLightPoleModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightPoleModel.objects.first()
|
||||||
|
|
||||||
|
dxf_match = dxf_poles_by_label.get(short_code.strip().upper())
|
||||||
|
dxf_lon = dxf_match['lon'] if dxf_match else None
|
||||||
|
dxf_lat = dxf_match['lat'] if dxf_match else None
|
||||||
|
dxf_x_3812 = dxf_match['x_3812'] if dxf_match else None
|
||||||
|
dxf_y_3812 = dxf_match['y_3812'] if dxf_match else None
|
||||||
|
|
||||||
if code in existing_poles:
|
if code in existing_poles:
|
||||||
ep = existing_poles[code]
|
ep = existing_poles[code]
|
||||||
# Since cross plans do not specify pole physical models (height, color), keep existing DB model
|
ep_lon = ep.lon
|
||||||
status = 'identical'
|
ep_lat = ep.lat
|
||||||
|
ep_x_3812 = ep.geom.x if ep.geom else None
|
||||||
|
ep_y_3812 = ep.geom.y if ep.geom else None
|
||||||
|
|
||||||
|
if ep.geom and (ep_lon is None or ep_lat is None):
|
||||||
|
try:
|
||||||
|
ep_lon, ep_lat = transformer_3812_to_4326.transform(ep_x_3812, ep_y_3812)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif ep.geom is None and ep_lon is not None and ep_lat is not None:
|
||||||
|
try:
|
||||||
|
ep_x_3812, ep_y_3812 = transformer_4326_to_3812.transform(ep_lon, ep_lat)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
details_diff = []
|
||||||
|
geom_modified = False
|
||||||
|
distance_diff = None
|
||||||
|
|
||||||
|
if dxf_match:
|
||||||
|
if ep_x_3812 is not None and ep_y_3812 is not None:
|
||||||
|
import math
|
||||||
|
dist = math.sqrt((ep_x_3812 - dxf_x_3812)**2 + (ep_y_3812 - dxf_y_3812)**2)
|
||||||
|
if dist >= 0.05:
|
||||||
|
geom_modified = True
|
||||||
|
distance_diff = round(dist, 1)
|
||||||
|
details_diff.append(_("Position modifiée (DXF) : décalage de %(dist).1f m") % {'dist': dist})
|
||||||
|
else:
|
||||||
|
geom_modified = True
|
||||||
|
details_diff.append(_("Nouvelle position détectée (DXF)"))
|
||||||
|
|
||||||
|
is_model_diff = (ep.model_id != db_model.id) if (ep.model and db_model) else False
|
||||||
|
if is_model_diff:
|
||||||
|
details_diff.append(_("Modèle : %(db_m)s → %(new_m)s") % {'db_m': ep.model.name_fr if ep.model else 'Aucun', 'new_m': model_name})
|
||||||
|
|
||||||
|
status = 'modified' if (geom_modified or is_model_diff) else 'identical'
|
||||||
compared_poles.append({
|
compared_poles.append({
|
||||||
'code': code,
|
'code': code,
|
||||||
'short_code': dp['short_code'],
|
'short_code': short_code,
|
||||||
'name': ep.name_fr or dp.get('name', dp['short_code']),
|
'name': ep.name_fr or dp.get('name', short_code),
|
||||||
'status': status,
|
'status': status,
|
||||||
'deduced_model_name': ep.model.name_fr if ep.model else model_name,
|
'deduced_model_name': model_name,
|
||||||
'db_model_name': ep.model.name_fr if ep.model else '',
|
'db_model_name': ep.model.name_fr if ep.model else '',
|
||||||
'db_id': ep.id,
|
'db_id': ep.id,
|
||||||
'details_diff': []
|
'details_diff': details_diff,
|
||||||
|
'geom_modified': geom_modified,
|
||||||
|
'distance_diff': distance_diff,
|
||||||
|
'db_lon': ep_lon,
|
||||||
|
'db_lat': ep_lat,
|
||||||
|
'db_x_3812': ep_x_3812,
|
||||||
|
'db_y_3812': ep_y_3812,
|
||||||
|
'new_lon': dxf_lon if dxf_match else ep_lon,
|
||||||
|
'new_lat': dxf_lat if dxf_match else ep_lat,
|
||||||
|
'new_x_3812': dxf_x_3812 if dxf_match else ep_x_3812,
|
||||||
|
'new_y_3812': dxf_y_3812 if dxf_match else ep_y_3812,
|
||||||
})
|
})
|
||||||
# remove from processed
|
# remove from processed
|
||||||
existing_poles.pop(code)
|
existing_poles.pop(code)
|
||||||
else:
|
else:
|
||||||
|
details_diff = []
|
||||||
|
if dxf_match:
|
||||||
|
details_diff.append(_("Positionné via DXF"))
|
||||||
compared_poles.append({
|
compared_poles.append({
|
||||||
'code': code,
|
'code': code,
|
||||||
'short_code': dp['short_code'],
|
'short_code': short_code,
|
||||||
'name': dp.get('name', dp['short_code']),
|
'name': dp.get('name', short_code),
|
||||||
'status': 'new',
|
'status': 'new',
|
||||||
'deduced_model_name': model_name,
|
'deduced_model_name': model_name,
|
||||||
'db_model_name': '',
|
'db_model_name': '',
|
||||||
'db_id': None,
|
'db_id': None,
|
||||||
'details_diff': []
|
'details_diff': details_diff,
|
||||||
|
'geom_modified': False,
|
||||||
|
'distance_diff': None,
|
||||||
|
'db_lon': None,
|
||||||
|
'db_lat': None,
|
||||||
|
'db_x_3812': None,
|
||||||
|
'db_y_3812': None,
|
||||||
|
'new_lon': dxf_lon,
|
||||||
|
'new_lat': dxf_lat,
|
||||||
|
'new_x_3812': dxf_x_3812,
|
||||||
|
'new_y_3812': dxf_y_3812,
|
||||||
})
|
})
|
||||||
# Add remaining existing active poles (orphans)
|
# Add remaining existing active poles (orphans)
|
||||||
for code, ep in existing_poles.items():
|
for code, ep in existing_poles.items():
|
||||||
|
ep_lon = ep.lon
|
||||||
|
ep_lat = ep.lat
|
||||||
|
ep_x_3812 = ep.geom.x if ep.geom else None
|
||||||
|
ep_y_3812 = ep.geom.y if ep.geom else None
|
||||||
|
if ep.geom and (ep_lon is None or ep_lat is None):
|
||||||
|
try:
|
||||||
|
ep_lon, ep_lat = transformer_3812_to_4326.transform(ep_x_3812, ep_y_3812)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif ep.geom is None and ep_lon is not None and ep_lat is not None:
|
||||||
|
try:
|
||||||
|
ep_x_3812, ep_y_3812 = transformer_4326_to_3812.transform(ep_lon, ep_lat)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
compared_poles.append({
|
compared_poles.append({
|
||||||
'code': code,
|
'code': code,
|
||||||
'short_code': code.split('_')[-1] if '_' in code else code,
|
'short_code': code.split('_')[-1] if '_' in code else code,
|
||||||
|
|
@ -4123,21 +4343,65 @@ def import_cross_plan(request, intersection_id):
|
||||||
'deduced_model_name': '',
|
'deduced_model_name': '',
|
||||||
'db_model_name': ep.model.name_fr if ep.model else '',
|
'db_model_name': ep.model.name_fr if ep.model else '',
|
||||||
'db_id': ep.id,
|
'db_id': ep.id,
|
||||||
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
|
'details_diff': [_("Absent du fichier Excel (présent en DB)")],
|
||||||
|
'geom_modified': False,
|
||||||
|
'distance_diff': None,
|
||||||
|
'db_lon': ep_lon,
|
||||||
|
'db_lat': ep_lat,
|
||||||
|
'db_x_3812': ep_x_3812,
|
||||||
|
'db_y_3812': ep_y_3812,
|
||||||
|
'new_lon': ep_lon,
|
||||||
|
'new_lat': ep_lat,
|
||||||
|
'new_x_3812': ep_x_3812,
|
||||||
|
'new_y_3812': ep_y_3812,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
# ── 2. CABLES COMPARISON ──
|
# ── 2. CABLES COMPARISON ──
|
||||||
existing_cables = {c.code: c for c in TrafficLightCable.objects.filter(intersection=intersection, status='active')}
|
existing_cables = {c.code: c for c in TrafficLightCable.objects.filter(intersection=intersection, status='active')}
|
||||||
compared_cables = []
|
compared_cables = []
|
||||||
|
|
||||||
|
# Track poles that moved or are newly placed to see if connected cable paths changed
|
||||||
|
modified_pole_codes = {
|
||||||
|
p['code'] for p in compared_poles
|
||||||
|
if p.get('geom_modified') or p.get('status') == 'new'
|
||||||
|
}
|
||||||
|
modified_pole_short_codes = {
|
||||||
|
p['short_code'] for p in compared_poles
|
||||||
|
if p.get('geom_modified') or p.get('status') == 'new'
|
||||||
|
}
|
||||||
|
|
||||||
for dc in deduced["cables"]:
|
for dc in deduced["cables"]:
|
||||||
code = dc["code"]
|
code = dc["code"]
|
||||||
model_name = dc["model_name"]
|
model_name = dc["model_name"]
|
||||||
db_model = TrafficLightCableModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightCableModel.objects.first()
|
db_model = TrafficLightCableModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightCableModel.objects.first()
|
||||||
|
|
||||||
|
connected = dc.get("connected_poles", [])
|
||||||
|
has_connected_moved = any(
|
||||||
|
p_code in modified_pole_codes or p_code.split('_')[-1] in modified_pole_short_codes
|
||||||
|
for p_code in connected
|
||||||
|
)
|
||||||
|
|
||||||
if code in existing_cables:
|
if code in existing_cables:
|
||||||
ec = existing_cables[code]
|
ec = existing_cables[code]
|
||||||
is_diff = ec.model_id != db_model.id
|
has_geom = ec.geom is not None
|
||||||
status = 'modified' if is_diff else 'identical'
|
is_diff = (ec.model_id != db_model.id) if (ec.model and db_model) else False
|
||||||
|
details_diff = []
|
||||||
|
if is_diff:
|
||||||
|
details_diff.append(_("Modèle : %(db_m)s → %(new_m)s") % {
|
||||||
|
'db_m': ec.model.name_fr if ec.model else 'Aucun',
|
||||||
|
'new_m': model_name
|
||||||
|
})
|
||||||
|
|
||||||
|
geom_modified = False
|
||||||
|
if has_connected_moved:
|
||||||
|
geom_modified = True
|
||||||
|
details_diff.append(_("Position modifiée : déplacement de poteaux connectés"))
|
||||||
|
elif not has_geom and len(connected) >= 2:
|
||||||
|
geom_modified = True
|
||||||
|
details_diff.append(_("Nouveau tracé déduit des poteaux"))
|
||||||
|
|
||||||
|
status = 'modified' if (is_diff or geom_modified) else 'identical'
|
||||||
compared_cables.append({
|
compared_cables.append({
|
||||||
'code': code,
|
'code': code,
|
||||||
'name': ec.name_fr or dc['name'],
|
'name': ec.name_fr or dc['name'],
|
||||||
|
|
@ -4145,11 +4409,17 @@ def import_cross_plan(request, intersection_id):
|
||||||
'deduced_model_name': model_name,
|
'deduced_model_name': model_name,
|
||||||
'db_model_name': ec.model.name_fr if ec.model else '',
|
'db_model_name': ec.model.name_fr if ec.model else '',
|
||||||
'db_id': ec.id,
|
'db_id': ec.id,
|
||||||
'details_diff': [f"Modèle : {ec.model.name_fr if ec.model else 'Aucun'} → {model_name}"] if is_diff else [],
|
'details_diff': details_diff,
|
||||||
'connected_poles': dc.get("connected_poles", [])
|
'connected_poles': connected,
|
||||||
|
'has_db_geom': has_geom,
|
||||||
|
'geom_modified': geom_modified,
|
||||||
|
'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
|
||||||
})
|
})
|
||||||
existing_cables.pop(code)
|
existing_cables.pop(code)
|
||||||
else:
|
else:
|
||||||
|
details_diff = []
|
||||||
|
if len(connected) >= 2:
|
||||||
|
details_diff.append(_("Tracé déduit des poteaux"))
|
||||||
compared_cables.append({
|
compared_cables.append({
|
||||||
'code': code,
|
'code': code,
|
||||||
'name': dc['name'],
|
'name': dc['name'],
|
||||||
|
|
@ -4157,10 +4427,14 @@ def import_cross_plan(request, intersection_id):
|
||||||
'deduced_model_name': model_name,
|
'deduced_model_name': model_name,
|
||||||
'db_model_name': '',
|
'db_model_name': '',
|
||||||
'db_id': None,
|
'db_id': None,
|
||||||
'details_diff': [],
|
'details_diff': details_diff,
|
||||||
'connected_poles': dc.get("connected_poles", [])
|
'connected_poles': connected,
|
||||||
|
'has_db_geom': False,
|
||||||
|
'geom_modified': False,
|
||||||
|
'db_geom_length': None,
|
||||||
})
|
})
|
||||||
for code, ec in existing_cables.items():
|
for code, ec in existing_cables.items():
|
||||||
|
has_geom = ec.geom is not None
|
||||||
compared_cables.append({
|
compared_cables.append({
|
||||||
'code': code,
|
'code': code,
|
||||||
'name': ec.name_fr or code,
|
'name': ec.name_fr or code,
|
||||||
|
|
@ -4168,49 +4442,80 @@ def import_cross_plan(request, intersection_id):
|
||||||
'deduced_model_name': '',
|
'deduced_model_name': '',
|
||||||
'db_model_name': ec.model.name_fr if ec.model else '',
|
'db_model_name': ec.model.name_fr if ec.model else '',
|
||||||
'db_id': ec.id,
|
'db_id': ec.id,
|
||||||
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
|
'details_diff': [_("Absent du fichier Excel (présent en DB)")],
|
||||||
|
'has_db_geom': has_geom,
|
||||||
|
'geom_modified': False,
|
||||||
|
'db_geom_length': round(ec.geom.length, 1) if has_geom else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ── 3. LANTERNS COMPARISON ──
|
# ── 3. LANTERNS COMPARISON ──
|
||||||
existing_lanterns = {l.code: l for l in TrafficLightLantern.objects.filter(pole__intersection=intersection, status='active')}
|
all_db_lanterns = list(TrafficLightLantern.objects.filter(pole__intersection=intersection, status='active'))
|
||||||
compared_lanterns = []
|
|
||||||
|
from collections import defaultdict
|
||||||
|
deduced_by_pole = defaultdict(list)
|
||||||
|
pole_order = []
|
||||||
for dl in deduced["lanterns"]:
|
for dl in deduced["lanterns"]:
|
||||||
|
p_code = dl["pole_code"]
|
||||||
|
if p_code not in deduced_by_pole:
|
||||||
|
pole_order.append(p_code)
|
||||||
|
deduced_by_pole[p_code].append(dl)
|
||||||
|
|
||||||
|
db_by_pole = defaultdict(list)
|
||||||
|
for el in all_db_lanterns:
|
||||||
|
p_code = el.pole.code if el.pole else ""
|
||||||
|
db_by_pole[p_code].append(el)
|
||||||
|
|
||||||
|
def find_db_pole_key(target_pole_code, db_dict):
|
||||||
|
if target_pole_code in db_dict:
|
||||||
|
return target_pole_code
|
||||||
|
target_short = target_pole_code.split('_')[-1]
|
||||||
|
for k in db_dict:
|
||||||
|
if k.split('_')[-1] == target_short:
|
||||||
|
return k
|
||||||
|
return None
|
||||||
|
|
||||||
|
compared_lanterns = []
|
||||||
|
for p_code in pole_order:
|
||||||
|
pole_dls = deduced_by_pole[p_code]
|
||||||
|
db_key = find_db_pole_key(p_code, db_by_pole)
|
||||||
|
pole_els = db_by_pole.pop(db_key, []) if db_key else []
|
||||||
|
|
||||||
|
matched_pairs, unmatched_dls, unmatched_els = match_pole_lanterns(pole_dls, pole_els)
|
||||||
|
|
||||||
|
for dl, el in matched_pairs:
|
||||||
code = dl["code"]
|
code = dl["code"]
|
||||||
base_code = code.rsplit('_', 1)[0] if '_' in code else code
|
|
||||||
model_name = dl["model_name"]
|
model_name = dl["model_name"]
|
||||||
db_model = TrafficLightLanternModel.objects.filter(name_fr__iexact=model_name).first() or TrafficLightLanternModel.objects.first()
|
db_model = find_db_lantern_model(model_name)
|
||||||
|
|
||||||
# Try exact match, then try prefix match (without the phase suffix)
|
|
||||||
match_key = None
|
|
||||||
if code in existing_lanterns:
|
|
||||||
match_key = code
|
|
||||||
elif base_code in existing_lanterns:
|
|
||||||
match_key = base_code
|
|
||||||
|
|
||||||
if match_key:
|
|
||||||
el = existing_lanterns[match_key]
|
|
||||||
is_rename = (el.code != code)
|
is_rename = (el.code != code)
|
||||||
is_model_diff = el.model_id != db_model.id
|
is_model_diff = el.model_id != (db_model.id if db_model else None)
|
||||||
status = 'modified' if (is_rename or is_model_diff) else 'identical'
|
status = 'modified' if (is_rename or is_model_diff) else 'identical'
|
||||||
|
|
||||||
details = []
|
details = []
|
||||||
if is_rename:
|
if is_rename:
|
||||||
details.append(f"Code : {el.code} → {code}")
|
details.append(f"Code : {el.code} → {code}")
|
||||||
if is_model_diff:
|
if is_model_diff:
|
||||||
details.append(f"Modèle : {el.model.name_fr if el.model else 'Aucun'} → {model_name}")
|
details.append(f"Modèle : {el.model.name_fr if el.model else 'Aucun'} → {db_model.name_fr if db_model else model_name}")
|
||||||
|
|
||||||
|
if is_model_diff and el.name_fr and el.model and el.name_fr.strip().lower() == el.model.name_fr.strip().lower():
|
||||||
|
display_name = dl['name']
|
||||||
|
else:
|
||||||
|
display_name = el.name_fr or dl['name']
|
||||||
|
|
||||||
compared_lanterns.append({
|
compared_lanterns.append({
|
||||||
'code': code,
|
'code': code,
|
||||||
'name': el.name_fr or dl['name'],
|
'name': display_name,
|
||||||
'status': status,
|
'status': status,
|
||||||
'deduced_model_name': model_name,
|
'deduced_model_name': db_model.name_fr if db_model else model_name,
|
||||||
'db_model_name': el.model.name_fr if el.model else '',
|
'db_model_name': el.model.name_fr if el.model else '',
|
||||||
'db_id': el.id,
|
'db_id': el.id,
|
||||||
'pole_code': dl['pole_code'],
|
'pole_code': dl['pole_code'],
|
||||||
'details_diff': details
|
'details_diff': details
|
||||||
})
|
})
|
||||||
existing_lanterns.pop(match_key)
|
|
||||||
else:
|
for dl in unmatched_dls:
|
||||||
|
code = dl["code"]
|
||||||
|
model_name = dl["model_name"]
|
||||||
compared_lanterns.append({
|
compared_lanterns.append({
|
||||||
'code': code,
|
'code': code,
|
||||||
'name': dl['name'],
|
'name': dl['name'],
|
||||||
|
|
@ -4221,15 +4526,30 @@ def import_cross_plan(request, intersection_id):
|
||||||
'pole_code': dl['pole_code'],
|
'pole_code': dl['pole_code'],
|
||||||
'details_diff': []
|
'details_diff': []
|
||||||
})
|
})
|
||||||
for code, el in existing_lanterns.items():
|
|
||||||
|
for el in unmatched_els:
|
||||||
compared_lanterns.append({
|
compared_lanterns.append({
|
||||||
'code': code,
|
'code': el.code,
|
||||||
'name': el.name_fr or code,
|
'name': el.name_fr or el.code,
|
||||||
'status': 'db_only',
|
'status': 'db_only',
|
||||||
'deduced_model_name': '',
|
'deduced_model_name': '',
|
||||||
'db_model_name': el.model.name_fr if el.model else '',
|
'db_model_name': el.model.name_fr if el.model else '',
|
||||||
'db_id': el.id,
|
'db_id': el.id,
|
||||||
'pole_code': el.pole.code,
|
'pole_code': el.pole.code if el.pole else p_code,
|
||||||
|
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Remaining DB lanterns on poles not present in the plan at all
|
||||||
|
for p_key, remaining_els in db_by_pole.items():
|
||||||
|
for el in remaining_els:
|
||||||
|
compared_lanterns.append({
|
||||||
|
'code': el.code,
|
||||||
|
'name': el.name_fr or el.code,
|
||||||
|
'status': 'db_only',
|
||||||
|
'deduced_model_name': '',
|
||||||
|
'db_model_name': el.model.name_fr if el.model else '',
|
||||||
|
'db_id': el.id,
|
||||||
|
'pole_code': el.pole.code if el.pole else p_key,
|
||||||
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
|
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -4284,6 +4604,9 @@ def import_cross_plan(request, intersection_id):
|
||||||
'details_diff': []
|
'details_diff': []
|
||||||
})
|
})
|
||||||
for code, ed in existing_detectors.items():
|
for code, ed in existing_detectors.items():
|
||||||
|
is_stib = is_stib_detector(ed, code)
|
||||||
|
action_default = 'keep' if is_stib else 'archive'
|
||||||
|
diff_text = _("Détection STIB / Tram (fournie par la STIB, conservée par défaut)") if is_stib else _("Absent du fichier Excel (présent en DB)")
|
||||||
compared_detectors.append({
|
compared_detectors.append({
|
||||||
'code': code,
|
'code': code,
|
||||||
'name': ed.name or code,
|
'name': ed.name or code,
|
||||||
|
|
@ -4292,7 +4615,9 @@ def import_cross_plan(request, intersection_id):
|
||||||
'db_model_name': ed.model.name_fr if ed.model else '',
|
'db_model_name': ed.model.name_fr if ed.model else '',
|
||||||
'db_id': ed.id,
|
'db_id': ed.id,
|
||||||
'pole_code': ed.pole.code if ed.pole else '',
|
'pole_code': ed.pole.code if ed.pole else '',
|
||||||
'details_diff': [_("Absent du fichier Excel (présent en DB)")]
|
'details_diff': [diff_text],
|
||||||
|
'action_default': action_default,
|
||||||
|
'is_stib': is_stib,
|
||||||
})
|
})
|
||||||
|
|
||||||
# ── 5. OTHER ASSOCIATED ASSETS (NOT IN CROSS PLAN) ──
|
# ── 5. OTHER ASSOCIATED ASSETS (NOT IN CROSS PLAN) ──
|
||||||
|
|
@ -4526,8 +4851,8 @@ def import_cross_plan(request, intersection_id):
|
||||||
'cable_models': cable_models,
|
'cable_models': cable_models,
|
||||||
'step': 'preview',
|
'step': 'preview',
|
||||||
'thematic_code': 'trafficlights',
|
'thematic_code': 'trafficlights',
|
||||||
'dxf_file': "",
|
'dxf_file': active_dxf_file,
|
||||||
'dxf_poles': [],
|
'dxf_poles': active_dxf_poles,
|
||||||
'existing_poles_geojson': existing_poles_geojson,
|
'existing_poles_geojson': existing_poles_geojson,
|
||||||
'dxf_files': dxf_files,
|
'dxf_files': dxf_files,
|
||||||
'georeferenced_documents_json': georeferenced_documents_json,
|
'georeferenced_documents_json': georeferenced_documents_json,
|
||||||
|
|
@ -4584,7 +4909,7 @@ def integrate_cross_plan(request, intersection_id):
|
||||||
return m
|
return m
|
||||||
|
|
||||||
def resolve_lantern_model(name_fr):
|
def resolve_lantern_model(name_fr):
|
||||||
m = TrafficLightLanternModel.objects.filter(name_fr__iexact=name_fr).first()
|
m = find_db_lantern_model(name_fr)
|
||||||
if not m:
|
if not m:
|
||||||
m = TrafficLightLanternModel.objects.first()
|
m = TrafficLightLanternModel.objects.first()
|
||||||
if not m:
|
if not m:
|
||||||
|
|
@ -4621,6 +4946,40 @@ def integrate_cross_plan(request, intersection_id):
|
||||||
)
|
)
|
||||||
return m
|
return m
|
||||||
|
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from documents.models import DocumentAttachment, ManagedDocument
|
||||||
|
|
||||||
|
dxf_files = []
|
||||||
|
ct = ContentType.objects.get_for_model(intersection)
|
||||||
|
attachments = DocumentAttachment.objects.filter(content_type=ct, object_id=intersection.id)
|
||||||
|
managed_dxf_docs = ManagedDocument.objects.filter(
|
||||||
|
id__in=attachments.values_list('document_id', flat=True),
|
||||||
|
is_archived=False
|
||||||
|
).select_related('current_version')
|
||||||
|
for doc in managed_dxf_docs:
|
||||||
|
if doc.current_version and doc.current_version.file:
|
||||||
|
fn = os.path.basename(doc.current_version.file.name)
|
||||||
|
if fn.lower().endswith('.dxf') and fn not in dxf_files:
|
||||||
|
dxf_files.append(fn)
|
||||||
|
for doc in intersection.documents.all():
|
||||||
|
if doc.file.name.lower().endswith('.dxf'):
|
||||||
|
fn = os.path.basename(doc.file.name)
|
||||||
|
if fn not in dxf_files:
|
||||||
|
dxf_files.append(fn)
|
||||||
|
|
||||||
|
active_dxf_file = request.POST.get('selected_dxf') or (dxf_files[0] if dxf_files else "")
|
||||||
|
dxf_poles_by_label = {}
|
||||||
|
if active_dxf_file:
|
||||||
|
dxf_path = resolve_dxf_file_path(intersection, active_dxf_file)
|
||||||
|
if dxf_path:
|
||||||
|
try:
|
||||||
|
for dp_dxf in parse_dxf_poles(dxf_path):
|
||||||
|
lbl = (dp_dxf.get('label') or "").strip().upper()
|
||||||
|
if lbl:
|
||||||
|
dxf_poles_by_label[lbl] = dp_dxf
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def update_pole_geometry(pole, code):
|
def update_pole_geometry(pole, code):
|
||||||
def clean_float(val):
|
def clean_float(val):
|
||||||
if not val:
|
if not val:
|
||||||
|
|
@ -4635,9 +4994,22 @@ def integrate_cross_plan(request, intersection_id):
|
||||||
x_3812 = clean_float(request.POST.get(f"x_3812_pole_{code}"))
|
x_3812 = clean_float(request.POST.get(f"x_3812_pole_{code}"))
|
||||||
y_3812 = clean_float(request.POST.get(f"y_3812_pole_{code}"))
|
y_3812 = clean_float(request.POST.get(f"y_3812_pole_{code}"))
|
||||||
|
|
||||||
|
if x_3812 is None and lon is None:
|
||||||
|
short_code = code.split('_')[-1] if '_' in code else code
|
||||||
|
dxf_match = dxf_poles_by_label.get(short_code.strip().upper())
|
||||||
|
if dxf_match:
|
||||||
|
lon = dxf_match.get('lon')
|
||||||
|
lat = dxf_match.get('lat')
|
||||||
|
x_3812 = dxf_match.get('x_3812')
|
||||||
|
y_3812 = dxf_match.get('y_3812')
|
||||||
|
|
||||||
if x_3812 is not None and y_3812 is not None and x_3812 != 0.0 and y_3812 != 0.0:
|
if x_3812 is not None and y_3812 is not None and x_3812 != 0.0 and y_3812 != 0.0:
|
||||||
from django.contrib.gis.geos import Point as DjangoPoint
|
from django.contrib.gis.geos import Point as DjangoPoint
|
||||||
|
from pyproj import Transformer
|
||||||
pole.geom = DjangoPoint(x_3812, y_3812, srid=3812)
|
pole.geom = DjangoPoint(x_3812, y_3812, srid=3812)
|
||||||
|
if lon is None or lat is None:
|
||||||
|
transformer = Transformer.from_crs(3812, 4326, always_xy=True)
|
||||||
|
lon, lat = transformer.transform(x_3812, y_3812)
|
||||||
pole.lon = lon
|
pole.lon = lon
|
||||||
pole.lat = lat
|
pole.lat = lat
|
||||||
elif lon is not None and lat is not None:
|
elif lon is not None and lat is not None:
|
||||||
|
|
@ -4762,7 +5134,194 @@ def integrate_cross_plan(request, intersection_id):
|
||||||
|
|
||||||
messages.success(request, "Tous les anciens assets ont été archivés et les nouveaux ont été créés sur base du plan de croix.")
|
messages.success(request, "Tous les anciens assets ont été archivés et les nouveaux ont été créés sur base du plan de croix.")
|
||||||
|
|
||||||
# B. SELECTIVE OR INDIVIDUAL INTEGRATION
|
# B. GLOBAL UPDATE ALL (Preserve existing + Update in-place + Create new)
|
||||||
|
elif global_action == 'update_all':
|
||||||
|
# --- 1. Poles ---
|
||||||
|
created_poles = {}
|
||||||
|
for dp in deduced["poles"]:
|
||||||
|
code = dp["code"]
|
||||||
|
short_code = dp.get('short_code', code.split('_')[-1])
|
||||||
|
existing = TrafficLightPole.objects.filter(intersection=intersection, code=code, status='active').first()
|
||||||
|
if not existing:
|
||||||
|
db_id = request.POST.get(f"db_id_pole_{code}")
|
||||||
|
if db_id:
|
||||||
|
existing = TrafficLightPole.objects.filter(pk=db_id, status='active').first()
|
||||||
|
if not existing:
|
||||||
|
existing = TrafficLightPole.objects.filter(intersection=intersection, code__endswith=f"_{short_code}", status='active').first()
|
||||||
|
|
||||||
|
selected_model_id = request.POST.get(f"model_pole_{code}")
|
||||||
|
db_model = None
|
||||||
|
if selected_model_id:
|
||||||
|
db_model = TrafficLightPoleModel.objects.filter(pk=selected_model_id).first()
|
||||||
|
if not db_model:
|
||||||
|
db_model = resolve_pole_model(dp["model"])
|
||||||
|
|
||||||
|
custom_name = request.POST.get(f"name_pole_{code}", dp.get('name', short_code))
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
existing.code = code
|
||||||
|
existing.name_fr = custom_name
|
||||||
|
existing.name_nl = custom_name
|
||||||
|
if db_model:
|
||||||
|
existing.model = db_model
|
||||||
|
existing.updated_by = request.user
|
||||||
|
update_pole_geometry(existing, code)
|
||||||
|
existing.save()
|
||||||
|
created_poles[code] = existing
|
||||||
|
else:
|
||||||
|
new_pole = TrafficLightPole(
|
||||||
|
intersection=intersection,
|
||||||
|
code=code,
|
||||||
|
name_fr=custom_name,
|
||||||
|
name_nl=custom_name,
|
||||||
|
model=db_model,
|
||||||
|
status='active',
|
||||||
|
validation_status='validated',
|
||||||
|
updated_by=request.user
|
||||||
|
)
|
||||||
|
update_pole_geometry(new_pole, code)
|
||||||
|
new_pole.save()
|
||||||
|
created_poles[code] = new_pole
|
||||||
|
|
||||||
|
# Map active poles for lanterns and detectors parenting
|
||||||
|
active_poles = {p.code: p for p in TrafficLightPole.objects.filter(intersection=intersection, status='active')}
|
||||||
|
for c, p in created_poles.items():
|
||||||
|
active_poles[c] = p
|
||||||
|
|
||||||
|
# --- 2. Cables ---
|
||||||
|
for dc in deduced["cables"]:
|
||||||
|
code = dc["code"]
|
||||||
|
existing = TrafficLightCable.objects.filter(intersection=intersection, code=code, status='active').first()
|
||||||
|
if not existing:
|
||||||
|
db_id = request.POST.get(f"db_id_cable_{code}")
|
||||||
|
if db_id:
|
||||||
|
existing = TrafficLightCable.objects.filter(pk=db_id, status='active').first()
|
||||||
|
|
||||||
|
selected_model_id = request.POST.get(f"model_cable_{code}")
|
||||||
|
db_model = None
|
||||||
|
if selected_model_id:
|
||||||
|
db_model = TrafficLightCableModel.objects.filter(pk=selected_model_id).first()
|
||||||
|
if not db_model:
|
||||||
|
db_model = resolve_cable_model(dc["model_name"])
|
||||||
|
|
||||||
|
custom_name = request.POST.get(f"name_cable_{code}", dc["name"])
|
||||||
|
if existing:
|
||||||
|
existing.code = code
|
||||||
|
existing.name_fr = custom_name
|
||||||
|
existing.name_nl = custom_name
|
||||||
|
if db_model:
|
||||||
|
existing.model = db_model
|
||||||
|
existing.updated_by = request.user
|
||||||
|
update_cable_geometry(existing, code)
|
||||||
|
existing.save()
|
||||||
|
else:
|
||||||
|
new_cable = TrafficLightCable(
|
||||||
|
intersection=intersection,
|
||||||
|
code=code,
|
||||||
|
name_fr=custom_name,
|
||||||
|
name_nl=custom_name,
|
||||||
|
model=db_model,
|
||||||
|
status='active',
|
||||||
|
validation_status='validated',
|
||||||
|
updated_by=request.user
|
||||||
|
)
|
||||||
|
update_cable_geometry(new_cable, code)
|
||||||
|
new_cable.save()
|
||||||
|
|
||||||
|
# --- 3. Lanterns ---
|
||||||
|
for dl in deduced["lanterns"]:
|
||||||
|
code = dl["code"]
|
||||||
|
pole = active_poles.get(dl["pole_code"])
|
||||||
|
db_id = request.POST.get(f"db_id_lantern_{code}")
|
||||||
|
existing = None
|
||||||
|
if db_id:
|
||||||
|
existing = TrafficLightLantern.objects.filter(pk=db_id, status='active').first()
|
||||||
|
if not existing:
|
||||||
|
existing = TrafficLightLantern.objects.filter(pole__intersection=intersection, code=code, status='active').first()
|
||||||
|
|
||||||
|
selected_model_id = request.POST.get(f"model_lantern_{code}")
|
||||||
|
db_model = None
|
||||||
|
if selected_model_id:
|
||||||
|
db_model = TrafficLightLanternModel.objects.filter(pk=selected_model_id).first()
|
||||||
|
if not db_model:
|
||||||
|
db_model = resolve_lantern_model(dl["model_name"])
|
||||||
|
|
||||||
|
custom_name = request.POST.get(f"name_lantern_{code}", dl["name"])
|
||||||
|
if existing:
|
||||||
|
existing.code = code
|
||||||
|
if pole:
|
||||||
|
existing.pole = pole
|
||||||
|
if db_model:
|
||||||
|
existing.model = db_model
|
||||||
|
existing.name_fr = custom_name
|
||||||
|
existing.name_nl = custom_name
|
||||||
|
existing.updated_by = request.user
|
||||||
|
existing.save()
|
||||||
|
elif pole:
|
||||||
|
TrafficLightLantern.objects.create(
|
||||||
|
pole=pole,
|
||||||
|
code=code,
|
||||||
|
name_fr=custom_name,
|
||||||
|
name_nl=custom_name,
|
||||||
|
model=db_model,
|
||||||
|
status='active',
|
||||||
|
validation_status='validated',
|
||||||
|
updated_by=request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- 4. Detectors ---
|
||||||
|
for dd in deduced["detectors"]:
|
||||||
|
code = dd["code"]
|
||||||
|
pole = active_poles.get(dd["pole_code"])
|
||||||
|
existing = TrafficLightDetector.objects.filter(intersection=intersection, code=code, status='active').first()
|
||||||
|
if not existing:
|
||||||
|
db_id = request.POST.get(f"db_id_detector_{code}")
|
||||||
|
if db_id:
|
||||||
|
existing = TrafficLightDetector.objects.filter(pk=db_id, status='active').first()
|
||||||
|
if not existing:
|
||||||
|
parts = code.split('_', 2)
|
||||||
|
if len(parts) == 3:
|
||||||
|
old_key = f"{parts[0]}-{parts[2]}"
|
||||||
|
existing = TrafficLightDetector.objects.filter(intersection=intersection, code=old_key, status='active').first()
|
||||||
|
|
||||||
|
selected_model_id = request.POST.get(f"model_detector_{code}")
|
||||||
|
db_model = None
|
||||||
|
if selected_model_id:
|
||||||
|
db_model = TrafficLightDetectorModel.objects.filter(pk=selected_model_id).first()
|
||||||
|
if not db_model:
|
||||||
|
db_model = resolve_detector_model(dd["model_name"])
|
||||||
|
|
||||||
|
custom_name = request.POST.get(f"name_detector_{code}", dd["name"])
|
||||||
|
if existing:
|
||||||
|
existing.code = code
|
||||||
|
if pole:
|
||||||
|
existing.pole = pole
|
||||||
|
if db_model:
|
||||||
|
existing.model = db_model
|
||||||
|
existing.name = custom_name
|
||||||
|
existing.name_fr = custom_name
|
||||||
|
existing.name_nl = custom_name
|
||||||
|
existing.phases = dd.get("phases", "")
|
||||||
|
existing.updated_by = request.user
|
||||||
|
existing.save()
|
||||||
|
else:
|
||||||
|
TrafficLightDetector.objects.create(
|
||||||
|
intersection=intersection,
|
||||||
|
pole=pole,
|
||||||
|
code=code,
|
||||||
|
name=custom_name,
|
||||||
|
name_fr=custom_name,
|
||||||
|
name_nl=custom_name,
|
||||||
|
model=db_model,
|
||||||
|
phases=dd.get("phases", ""),
|
||||||
|
status='active',
|
||||||
|
validation_status='validated',
|
||||||
|
updated_by=request.user
|
||||||
|
)
|
||||||
|
|
||||||
|
messages.success(request, "Les assets existants ont été mis à jour et les nouveaux ont été créés sans archiver l'existant.")
|
||||||
|
|
||||||
|
# C. SELECTIVE OR INDIVIDUAL INTEGRATION
|
||||||
else:
|
else:
|
||||||
# We iterate over each asset category, checking the user choices from the form post parameters
|
# We iterate over each asset category, checking the user choices from the form post parameters
|
||||||
|
|
||||||
|
|
@ -4848,12 +5407,8 @@ def integrate_cross_plan(request, intersection_id):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# 'keep'
|
# 'keep' (Ne rien faire)
|
||||||
if existing:
|
if existing:
|
||||||
existing.name_fr = custom_name
|
|
||||||
existing.name_nl = custom_name
|
|
||||||
update_pole_geometry(existing, code)
|
|
||||||
existing.save()
|
|
||||||
created_poles[code] = existing
|
created_poles[code] = existing
|
||||||
|
|
||||||
# Handle poles present in DB only (orphans)
|
# Handle poles present in DB only (orphans)
|
||||||
|
|
@ -4938,12 +5493,9 @@ def integrate_cross_plan(request, intersection_id):
|
||||||
existing.replaced_by = new_cable
|
existing.replaced_by = new_cable
|
||||||
existing.save()
|
existing.save()
|
||||||
else:
|
else:
|
||||||
# 'keep'
|
# 'keep' (Ne rien faire)
|
||||||
if existing:
|
if existing:
|
||||||
existing.name_fr = custom_name
|
pass
|
||||||
existing.name_nl = custom_name
|
|
||||||
update_cable_geometry(existing, code)
|
|
||||||
existing.save()
|
|
||||||
|
|
||||||
for key in request.POST.keys():
|
for key in request.POST.keys():
|
||||||
if key.startswith("action_cable_db_"):
|
if key.startswith("action_cable_db_"):
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,8 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.dropdown-item-checkbox .form-check-input:checked {
|
.dropdown-item-checkbox .form-check-input:checked {
|
||||||
background-color: var(--brand-color);
|
background-color: #0d6efd;
|
||||||
border-color: var(--brand-color);
|
border-color: #0d6efd;
|
||||||
}
|
}
|
||||||
.dropdown-item-checkbox .form-check-label {
|
.dropdown-item-checkbox .form-check-label {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
}
|
}
|
||||||
.dropdown-item-checkbox .form-check-input:checked + .form-check-label {
|
.dropdown-item-checkbox .form-check-input:checked + .form-check-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--brand-color) !important;
|
color: #0f172a !important;
|
||||||
}
|
}
|
||||||
th.sortable:hover {
|
th.sortable:hover {
|
||||||
background-color: rgba(47, 62, 139, 0.08) !important;
|
background-color: rgba(47, 62, 139, 0.08) !important;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.contrib.gis import admin as gis_admin
|
||||||
from django.urls import path, reverse
|
from django.urls import path, reverse
|
||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
|
|
@ -8,6 +9,8 @@ from django.utils import timezone
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
from django.db.models import Case, When, IntegerField, Value
|
from django.db.models import Case, When, IntegerField, Value
|
||||||
from .models import (
|
from .models import (
|
||||||
|
MapBackground,
|
||||||
|
Territory,
|
||||||
UserConfig, UserContractAccess, Thematic, Role, UserThematics,
|
UserConfig, UserContractAccess, Thematic, Role, UserThematics,
|
||||||
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
|
AppView, RequestLog, UserWarehouseAccess, UserApprovalConfig, UserWarehouseLocationAccess,
|
||||||
UserThematicStatusPermission, UserContractStatusPermission,
|
UserThematicStatusPermission, UserContractStatusPermission,
|
||||||
|
|
@ -23,6 +26,16 @@ from contracts.models import UserCompanyTeamAccess, CompanyMember, CompanyTeam
|
||||||
from .admin_scheduled_tasks import ScheduledCommandAdmin, CommandExecutionLogAdmin
|
from .admin_scheduled_tasks import ScheduledCommandAdmin, CommandExecutionLogAdmin
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Territory)
|
||||||
|
class TerritoryAdmin(gis_admin.GISModelAdmin):
|
||||||
|
list_display = ("name_fr", "name_nl", "code", "is_default", "is_active", "updated_at")
|
||||||
|
list_filter = ("is_default", "is_active")
|
||||||
|
search_fields = ("name_fr", "name_nl", "code")
|
||||||
|
list_editable = ("is_default", "is_active")
|
||||||
|
ordering = ("-is_default", "name_fr")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Thematic)
|
@admin.register(Thematic)
|
||||||
class ThematicAdmin(admin.ModelAdmin):
|
class ThematicAdmin(admin.ModelAdmin):
|
||||||
list_display = ("name_fr", "name_nl", "code", "icon", "color", "uses_occupations", "include_in_daily_report", "allow_planning_without_contract")
|
list_display = ("name_fr", "name_nl", "code", "icon", "color", "uses_occupations", "include_in_daily_report", "allow_planning_without_contract")
|
||||||
|
|
@ -1012,3 +1025,31 @@ class UserAssetTypeAccessAdmin(admin.ModelAdmin):
|
||||||
url = f"/assets/permissions/type/{obj.content_type_id}/"
|
url = f"/assets/permissions/type/{obj.content_type_id}/"
|
||||||
return format_html('<a href="{}" target="_blank">Page du type</a>', url)
|
return format_html('<a href="{}" target="_blank">Page du type</a>', url)
|
||||||
type_page_link.short_description = "Page dédiée"
|
type_page_link.short_description = "Page dédiée"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(MapBackground)
|
||||||
|
class MapBackgroundAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("name_fr", "name_nl", "region_badge", "category_badge", "service_type", "order", "is_active", "is_default")
|
||||||
|
list_filter = ("region", "category", "service_type", "is_active", "is_default")
|
||||||
|
list_editable = ("order", "is_active", "is_default")
|
||||||
|
search_fields = ("name_fr", "name_nl", "identifier", "attribution", "url")
|
||||||
|
ordering = ("order", "name_fr")
|
||||||
|
|
||||||
|
@admin.display(description="Région")
|
||||||
|
def region_badge(self, obj):
|
||||||
|
colors = {
|
||||||
|
'WAL': '#dc2626',
|
||||||
|
'BRU': '#2563eb',
|
||||||
|
'VLG': '#d97706',
|
||||||
|
'ALL': '#4b5563',
|
||||||
|
}
|
||||||
|
bg = colors.get(obj.region, '#4b5563')
|
||||||
|
return format_html(
|
||||||
|
'<span style="background-color: {}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600;">{}</span>',
|
||||||
|
bg,
|
||||||
|
obj.get_region_display()
|
||||||
|
)
|
||||||
|
|
||||||
|
@admin.display(description="Catégorie")
|
||||||
|
def category_badge(self, obj):
|
||||||
|
return obj.get_category_display()
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ class CommonConfig(AppConfig):
|
||||||
from .middleware import set_login_time
|
from .middleware import set_login_time
|
||||||
user_logged_in.connect(set_login_time)
|
user_logged_in.connect(set_login_time)
|
||||||
|
|
||||||
|
# Connecter les signaux de common (ex: invalidation de cache)
|
||||||
|
import common.signals # noqa
|
||||||
|
|
||||||
# Charger les extensions conditionnelles
|
# Charger les extensions conditionnelles
|
||||||
try:
|
try:
|
||||||
from common import bm_extensions
|
from common import bm_extensions
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,12 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
import os
|
import os
|
||||||
from typing import Iterable, List
|
from typing import Iterable, List
|
||||||
|
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from django.core.cache import cache
|
||||||
from django.urls import resolve
|
from django.urls import resolve
|
||||||
|
|
||||||
from .models import UserConfig, AppView
|
from .models import UserConfig, AppView
|
||||||
|
|
@ -11,6 +14,7 @@ from .models import UserConfig, AppView
|
||||||
|
|
||||||
SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids"
|
SESSION_ALLOWED_IDS_KEY = "account_switch_allowed_user_ids"
|
||||||
SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id"
|
SESSION_ORIGIN_ID_KEY = "account_switch_origin_user_id"
|
||||||
|
SESSION_MAP_BOUNDS_KEY = "user_map_bounds"
|
||||||
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
@ -55,6 +59,7 @@ def current_app_view(request):
|
||||||
|
|
||||||
Only 'sign' and 'documents' have their own dedicated views.
|
Only 'sign' and 'documents' have their own dedicated views.
|
||||||
All other apps (assets, interventions, controls, contracts, etc.) use 'asset_management' view.
|
All other apps (assets, interventions, controls, contracts, etc.) use 'asset_management' view.
|
||||||
|
Results are cached to avoid hitting the database on every page render.
|
||||||
"""
|
"""
|
||||||
current_view = None
|
current_view = None
|
||||||
|
|
||||||
|
|
@ -67,6 +72,11 @@ def current_app_view(request):
|
||||||
namespace = resolved.namespace # e.g., 'sign', 'home', 'assets'
|
namespace = resolved.namespace # e.g., 'sign', 'home', 'assets'
|
||||||
|
|
||||||
if namespace:
|
if namespace:
|
||||||
|
cache_key = f"appview_for_{namespace}"
|
||||||
|
cached_view = cache.get(cache_key)
|
||||||
|
if cached_view is not None:
|
||||||
|
current_view = None if cached_view is False else cached_view
|
||||||
|
else:
|
||||||
if namespace in DEDICATED_VIEW_APPS:
|
if namespace in DEDICATED_VIEW_APPS:
|
||||||
# These apps have their own dedicated view
|
# These apps have their own dedicated view
|
||||||
current_view = AppView.objects.filter(url_name__startswith=f"{namespace}:").first()
|
current_view = AppView.objects.filter(url_name__startswith=f"{namespace}:").first()
|
||||||
|
|
@ -74,6 +84,8 @@ def current_app_view(request):
|
||||||
# All other apps (home, assets, interventions, controls, contracts, etc.)
|
# All other apps (home, assets, interventions, controls, contracts, etc.)
|
||||||
# should show 'asset_management' view
|
# should show 'asset_management' view
|
||||||
current_view = AppView.objects.filter(code='asset_management').first()
|
current_view = AppView.objects.filter(code='asset_management').first()
|
||||||
|
|
||||||
|
cache.set(cache_key, current_view if current_view is not None else False, 3600)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
@ -137,22 +149,264 @@ def linked_accounts(request):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_user_map_bounds(user_id: int) -> None:
|
||||||
|
"""
|
||||||
|
Invalidates the cached map center/zoom/extent for a given user.
|
||||||
|
Called when UserConfig or UserContractAccess changes.
|
||||||
|
"""
|
||||||
|
if user_id:
|
||||||
|
cache.delete(f"user_map_bounds_{user_id}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_map_backgrounds(request=None) -> list:
|
||||||
|
"""
|
||||||
|
Returns list of active basemap layers from MapBackground model, cached.
|
||||||
|
Fallback to defaults if table does not exist or is empty.
|
||||||
|
Prioritizes basemaps matching user contract municipality region (WAL, BRU, VLG).
|
||||||
|
"""
|
||||||
|
cached = cache.get("active_map_backgrounds")
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
backgrounds = []
|
||||||
|
try:
|
||||||
|
from .models import MapBackground
|
||||||
|
from django.utils.translation import get_language
|
||||||
|
lang = get_language() or 'fr'
|
||||||
|
qs = list(MapBackground.objects.filter(is_active=True).order_by('order', 'id'))
|
||||||
|
|
||||||
|
# Detect regional priority if user has an assigned municipality
|
||||||
|
preferred_region = 'ALL'
|
||||||
|
if request and hasattr(request, 'user') and request.user.is_authenticated:
|
||||||
|
try:
|
||||||
|
user_config = getattr(request.user, 'config', None)
|
||||||
|
if user_config and user_config.default_contract:
|
||||||
|
muni = user_config.default_contract.municipalities.exclude(geom=None).first()
|
||||||
|
if muni and muni.niscode:
|
||||||
|
code = str(muni.niscode).strip()
|
||||||
|
if code.startswith('21'):
|
||||||
|
preferred_region = 'BRU'
|
||||||
|
elif code[:2] in ('25', '51', '52', '53', '55', '56', '57', '61', '62', '63', '81', '82', '83', '84', '85', '91', '92', '93') or code[0] in ('5', '6', '8', '9'):
|
||||||
|
preferred_region = 'WAL'
|
||||||
|
elif code[0] in ('1', '3', '4', '7') or code[:2] in ('23', '24'):
|
||||||
|
preferred_region = 'VLG'
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if preferred_region != 'ALL':
|
||||||
|
# Stable sort: matching region first, then remaining
|
||||||
|
qs.sort(key=lambda x: (0 if x.region == preferred_region else (1 if x.region == 'ALL' else 2), x.order))
|
||||||
|
|
||||||
|
for bg in qs:
|
||||||
|
backgrounds.append({
|
||||||
|
'id': bg.identifier,
|
||||||
|
'name': bg.name_nl if lang == 'nl' and bg.name_nl else bg.name_fr,
|
||||||
|
'name_fr': bg.name_fr,
|
||||||
|
'name_nl': bg.name_nl,
|
||||||
|
'service_type': bg.service_type,
|
||||||
|
'url': bg.url,
|
||||||
|
'layers': bg.layers,
|
||||||
|
'format': bg.format,
|
||||||
|
'attribution': bg.attribution,
|
||||||
|
'region': bg.region,
|
||||||
|
'category': bg.category,
|
||||||
|
'tileSize': bg.tile_size,
|
||||||
|
'minZoom': bg.min_zoom,
|
||||||
|
'maxZoom': bg.max_zoom,
|
||||||
|
'is_default': bg.is_default,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not backgrounds:
|
||||||
|
backgrounds = [
|
||||||
|
{
|
||||||
|
'id': 'ortho_wallonie',
|
||||||
|
'name': 'Ortho Wallonie (SPW)',
|
||||||
|
'name_fr': 'Ortho Wallonie (SPW)',
|
||||||
|
'name_nl': 'Ortho Wallonië (SPW)',
|
||||||
|
'service_type': 'wms',
|
||||||
|
'url': 'https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer?service=WMS&version=1.3.0&request=GetMap&layers=0&styles=&format=image/jpeg&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256',
|
||||||
|
'layers': '0',
|
||||||
|
'format': 'image/jpeg',
|
||||||
|
'attribution': '© SPW - Orthophoto Wallonie',
|
||||||
|
'region': 'WAL',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tileSize': 256,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'urbis_ortho',
|
||||||
|
'name': 'Urbis Ortho (Bruxelles)',
|
||||||
|
'name_fr': 'Urbis Ortho (Bruxelles)',
|
||||||
|
'name_nl': 'Urbis Ortho (Brussel)',
|
||||||
|
'service_type': 'wms',
|
||||||
|
'url': getattr(settings, 'MAP_ORTHO_WMS_URL', None) or 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}',
|
||||||
|
'layers': 'Ortho',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': getattr(settings, 'MAP_ORTHO_WMS_ATTRIBUTION', None) or '© Brussels UrbIS',
|
||||||
|
'region': 'BRU',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tileSize': 256,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'ortho_vlaanderen',
|
||||||
|
'name': 'Ortho Vlaanderen',
|
||||||
|
'name_fr': 'Ortho Vlaanderen',
|
||||||
|
'name_nl': 'Ortho Vlaanderen',
|
||||||
|
'service_type': 'wmts',
|
||||||
|
'url': 'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}',
|
||||||
|
'layers': 'omwrgb23vl',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© Digitaal Vlaanderen',
|
||||||
|
'region': 'VLG',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tileSize': 256,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'id': 'plan_wallonie_spw',
|
||||||
|
'name': 'Plan de base Wallonie (SPW)',
|
||||||
|
'name_fr': 'Plan de base Wallonie (SPW)',
|
||||||
|
'name_nl': 'Basiskaart Wallonië (SPW)',
|
||||||
|
'service_type': 'arcgis_rest',
|
||||||
|
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
|
||||||
|
'layers': '',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© SPW - Fond de plan Wallonie',
|
||||||
|
'region': 'WAL',
|
||||||
|
'category': 'plan',
|
||||||
|
'tileSize': 256,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
cache.set("active_map_backgrounds", backgrounds, 3600)
|
||||||
|
return backgrounds
|
||||||
|
|
||||||
def organization_info(request):
|
def organization_info(request):
|
||||||
"""
|
"""
|
||||||
Context processor that exposes organization details from settings.
|
Context processor that exposes organization details from settings.
|
||||||
|
Dynamically adapts map_default_center, map_default_zoom and map_default_extent
|
||||||
|
when a default Territory is defined, or when the authenticated user has an
|
||||||
|
assigned contract associated with a municipality.
|
||||||
|
Caches calculated bounds in Django cache to avoid heavy GIS and SQL processing on every page.
|
||||||
"""
|
"""
|
||||||
|
center = [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)]
|
||||||
|
zoom = getattr(settings, 'MAP_DEFAULT_ZOOM', 11)
|
||||||
|
extent = getattr(settings, 'MAP_DEFAULT_EXTENT', None)
|
||||||
|
|
||||||
|
# 1. Si l'utilisateur est authentifié et a ses bornes en cache, les utiliser directement (0 requête)
|
||||||
|
user_cached = False
|
||||||
|
if hasattr(request, 'user') and request.user.is_authenticated:
|
||||||
|
cache_key = f"user_map_bounds_{request.user.pk}"
|
||||||
|
cached_bounds = cache.get(cache_key)
|
||||||
|
if cached_bounds is not None:
|
||||||
|
center = cached_bounds.get('center', center)
|
||||||
|
zoom = cached_bounds.get('zoom', zoom)
|
||||||
|
extent = cached_bounds.get('extent', extent)
|
||||||
|
user_cached = True
|
||||||
|
|
||||||
|
# 2. Si non présent dans le cache utilisateur et aucun extent dans settings, chercher le Territoire par défaut
|
||||||
|
if not user_cached and extent is None:
|
||||||
|
cached_ter_bounds = cache.get("default_territory_bounds")
|
||||||
|
if cached_ter_bounds is not None:
|
||||||
|
if cached_ter_bounds:
|
||||||
|
center = cached_ter_bounds.get('center', center)
|
||||||
|
zoom = cached_ter_bounds.get('zoom', zoom)
|
||||||
|
extent = cached_ter_bounds.get('extent', extent)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
from .models import Territory
|
||||||
|
def_ter = Territory.objects.filter(is_active=True, is_default=True, geom__isnull=False).first()
|
||||||
|
if def_ter:
|
||||||
|
g = def_ter.geom.transform(4326, clone=True) if def_ter.geom.srid != 4326 else def_ter.geom
|
||||||
|
min_lon, min_lat, max_lon, max_lat = g.extent
|
||||||
|
delta_lon = (max_lon - min_lon) * 0.05
|
||||||
|
delta_lat = (max_lat - min_lat) * 0.05
|
||||||
|
center = [round((min_lon + max_lon) / 2.0, 5), round((min_lat + max_lat) / 2.0, 5)]
|
||||||
|
extent = [
|
||||||
|
[round(min_lon - delta_lon, 5), round(min_lat - delta_lat, 5)],
|
||||||
|
[round(max_lon + delta_lon, 5), round(max_lat + delta_lat, 5)]
|
||||||
|
]
|
||||||
|
span = max(max_lon - min_lon, max_lat - min_lat)
|
||||||
|
if span < 0.08:
|
||||||
|
zoom = 14
|
||||||
|
elif span < 0.2:
|
||||||
|
zoom = 13
|
||||||
|
elif span < 0.5:
|
||||||
|
zoom = 12
|
||||||
|
else:
|
||||||
|
zoom = 11
|
||||||
|
cache.set("default_territory_bounds", {'center': center, 'zoom': zoom, 'extent': extent}, 86400)
|
||||||
|
else:
|
||||||
|
cache.set("default_territory_bounds", {}, 86400)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 3. Si utilisateur authentifié sans cache, calculer et mettre en cache ses bornes
|
||||||
|
if not user_cached and hasattr(request, 'user') and request.user.is_authenticated:
|
||||||
|
cache_key = f"user_map_bounds_{request.user.pk}"
|
||||||
|
try:
|
||||||
|
user_config = getattr(request.user, 'config', None)
|
||||||
|
if user_config:
|
||||||
|
contracts_to_check = []
|
||||||
|
if user_config.default_contract and user_config.default_contract.municipalities.exists():
|
||||||
|
contracts_to_check.append(user_config.default_contract)
|
||||||
|
else:
|
||||||
|
contracts_to_check = list(user_config.contracts.prefetch_related('municipalities').filter(municipalities__isnull=False).distinct())
|
||||||
|
|
||||||
|
geoms_4326 = []
|
||||||
|
for c in contracts_to_check:
|
||||||
|
for m in c.municipalities.exclude(geom=None):
|
||||||
|
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
|
||||||
|
geoms_4326.append(g)
|
||||||
|
|
||||||
|
if geoms_4326:
|
||||||
|
min_lon = min(g.extent[0] for g in geoms_4326)
|
||||||
|
min_lat = min(g.extent[1] for g in geoms_4326)
|
||||||
|
max_lon = max(g.extent[2] for g in geoms_4326)
|
||||||
|
max_lat = max(g.extent[3] for g in geoms_4326)
|
||||||
|
|
||||||
|
delta_lon = (max_lon - min_lon) * 0.05
|
||||||
|
delta_lat = (max_lat - min_lat) * 0.05
|
||||||
|
|
||||||
|
center = [round((min_lon + max_lon) / 2.0, 5), round((min_lat + max_lat) / 2.0, 5)]
|
||||||
|
extent = [
|
||||||
|
[round(min_lon - delta_lon, 5), round(min_lat - delta_lat, 5)],
|
||||||
|
[round(max_lon + delta_lon, 5), round(max_lat + delta_lat, 5)]
|
||||||
|
]
|
||||||
|
|
||||||
|
span = max(max_lon - min_lon, max_lat - min_lat)
|
||||||
|
if span < 0.08:
|
||||||
|
zoom = 14
|
||||||
|
elif span < 0.2:
|
||||||
|
zoom = 13
|
||||||
|
elif span < 0.5:
|
||||||
|
zoom = 12
|
||||||
|
else:
|
||||||
|
zoom = 11
|
||||||
|
|
||||||
|
cache.set(cache_key, {
|
||||||
|
'center': center,
|
||||||
|
'zoom': zoom,
|
||||||
|
'extent': extent,
|
||||||
|
}, 86400)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'product_name': getattr(settings, 'PRODUCT_NAME', 'Loko'),
|
'product_name': getattr(settings, 'PRODUCT_NAME', 'Loko'),
|
||||||
'organization_name': getattr(settings, 'ORGANIZATION_NAME', 'Loko'),
|
'organization_name': getattr(settings, 'ORGANIZATION_NAME', 'Loko'),
|
||||||
'organization_logo_header': getattr(settings, 'ORGANIZATION_LOGO_HEADER', 'common/android-192.png'),
|
'organization_logo_header': getattr(settings, 'ORGANIZATION_LOGO_HEADER', 'common/android-192.png'),
|
||||||
'organization_primary_color': getattr(settings, 'ORGANIZATION_PRIMARY_COLOR_HEX', '#2f3e8b'),
|
'organization_primary_color': getattr(settings, 'ORGANIZATION_PRIMARY_COLOR_HEX', '#2f3e8b'),
|
||||||
'map_default_center': [getattr(settings, 'MAP_DEFAULT_CENTER_LON', 4.3572), getattr(settings, 'MAP_DEFAULT_CENTER_LAT', 50.8477)],
|
'map_default_center': center,
|
||||||
'map_default_zoom': getattr(settings, 'MAP_DEFAULT_ZOOM', 11),
|
'map_default_zoom': zoom,
|
||||||
'map_default_extent': getattr(settings, 'MAP_DEFAULT_EXTENT', None),
|
'map_default_extent': extent,
|
||||||
'map_overlay_wms_url': getattr(settings, 'MAP_OVERLAY_WMS_URL', ''),
|
'map_overlay_wms_url': getattr(settings, 'MAP_OVERLAY_WMS_URL', ''),
|
||||||
'map_ortho_wms_url': getattr(settings, 'MAP_ORTHO_WMS_URL', ''),
|
'map_ortho_wms_url': getattr(settings, 'MAP_ORTHO_WMS_URL', ''),
|
||||||
'map_ortho_wms_name': getattr(settings, 'MAP_ORTHO_WMS_NAME', ''),
|
'map_ortho_wms_name': getattr(settings, 'MAP_ORTHO_WMS_NAME', ''),
|
||||||
'map_ortho_wms_attribution': getattr(settings, 'MAP_ORTHO_WMS_ATTRIBUTION', ''),
|
'map_ortho_wms_attribution': getattr(settings, 'MAP_ORTHO_WMS_ATTRIBUTION', ''),
|
||||||
|
'map_backgrounds': get_active_map_backgrounds(request),
|
||||||
|
'map_backgrounds_json': json.dumps(get_active_map_backgrounds(request)),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
828
loko/common/fixtures/asset_categories.json
Normal file
828
loko/common/fixtures/asset_categories.json
Normal file
|
|
@ -0,0 +1,828 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"code": "TL_ARMOIRE",
|
||||||
|
"name_fr": "Armoire Tricolore",
|
||||||
|
"name_nl": "Armoire Tricolore",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightelectricalcabinet"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_BORNIER",
|
||||||
|
"name_fr": "Bornier Tricolore",
|
||||||
|
"name_nl": "Bornier Tricolore",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightaccessory"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_CABLE",
|
||||||
|
"name_fr": "Cable de Carrefour",
|
||||||
|
"name_nl": "Cable de Carrefour",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightcable"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_CARREFOUR",
|
||||||
|
"name_fr": "Carrefour Tricolore",
|
||||||
|
"name_nl": "Carrefour Tricolore",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_CONE",
|
||||||
|
"name_fr": "Cône Balisage",
|
||||||
|
"name_nl": "Cône Balisage",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_DETEC_OTHER",
|
||||||
|
"name_fr": "Détecteurs autres",
|
||||||
|
"name_nl": "Andere detectoren",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightdetector"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_DETEC_BP",
|
||||||
|
"name_fr": "Détection Bouton Poussoir",
|
||||||
|
"name_nl": "Détection Bouton Poussoir",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightdetector"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_DETEC_RADAR",
|
||||||
|
"name_fr": "Détection Radar",
|
||||||
|
"name_nl": "Détection Radar",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightdetector"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_DIVERS",
|
||||||
|
"name_fr": "Equipement divers",
|
||||||
|
"name_nl": "Equipement divers",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightaccessory"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_LANELIGHT",
|
||||||
|
"name_fr": "Lanelight Balisage",
|
||||||
|
"name_nl": "Lanelight Balisage",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_LANTERNE",
|
||||||
|
"name_fr": "Lanterne Tricolore",
|
||||||
|
"name_nl": "Lanterne Tricolore",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightlantern"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_NETWERK",
|
||||||
|
"name_fr": "Réseau",
|
||||||
|
"name_nl": "Réseau",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_PANNEAU",
|
||||||
|
"name_fr": "Panneau signalisation",
|
||||||
|
"name_nl": "Panneau signalisation",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_POTEAU",
|
||||||
|
"name_fr": "Poteau Tricolore",
|
||||||
|
"name_nl": "Poteau Tricolore",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightpole"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_RADAR_PREVENTIF",
|
||||||
|
"name_fr": "Radar Préventif",
|
||||||
|
"name_nl": "Radar Préventif",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightradar"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_REGELAAR",
|
||||||
|
"name_fr": "Contrôleur de trafic",
|
||||||
|
"name_nl": "Traffic regelaar",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightcontroller"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_SPANNING",
|
||||||
|
"name_fr": "Transformateur électrique",
|
||||||
|
"name_nl": "Transformateur électrique",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LI_poles",
|
||||||
|
"name_fr": "Poteaux d'éclairage",
|
||||||
|
"name_nl": "Verlichtingsmasten",
|
||||||
|
"thematic_code": "publiclighting",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"publiclightingpole"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_BAL",
|
||||||
|
"name_fr": "Balisage",
|
||||||
|
"name_nl": "Balisage",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_BAR",
|
||||||
|
"name_fr": "Barrière",
|
||||||
|
"name_nl": "Barrière",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_BDI",
|
||||||
|
"name_fr": "Detection et protection incendie",
|
||||||
|
"name_nl": "Detection et protection incendie",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_BTS",
|
||||||
|
"name_fr": "Basse tension",
|
||||||
|
"name_nl": "Basse tension",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_CAB",
|
||||||
|
"name_fr": "Cables",
|
||||||
|
"name_nl": "Cables",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_CAC",
|
||||||
|
"name_fr": "Contrôle d'accès",
|
||||||
|
"name_nl": "Contrôle d'accès",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_CPT",
|
||||||
|
"name_fr": "Comptage vehicules",
|
||||||
|
"name_nl": "Comptage vehicules",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_CTV",
|
||||||
|
"name_fr": "Videosurveillance",
|
||||||
|
"name_nl": "Videosurveillance",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_ECL",
|
||||||
|
"name_fr": "Eclairage",
|
||||||
|
"name_nl": "Eclairage",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_GAB",
|
||||||
|
"name_fr": "Detection hors gabarit",
|
||||||
|
"name_nl": "Detection hors gabarit",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_GTC",
|
||||||
|
"name_fr": "Telegestion",
|
||||||
|
"name_nl": "Telegestion",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_HTS",
|
||||||
|
"name_fr": "Haute tension",
|
||||||
|
"name_nl": "Haute tension",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_LOC",
|
||||||
|
"name_fr": "Locaux techniques",
|
||||||
|
"name_nl": "Locaux techniques",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_MET",
|
||||||
|
"name_fr": "Métalleries",
|
||||||
|
"name_nl": "Métalleries",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_NBK",
|
||||||
|
"name_fr": "Groupes no break",
|
||||||
|
"name_nl": "Groupes no break",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_PMP",
|
||||||
|
"name_fr": "Pompage",
|
||||||
|
"name_nl": "Pompage",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_PMV",
|
||||||
|
"name_fr": "Panneaux à Messages Variables",
|
||||||
|
"name_nl": "Panneaux à Messages Variables",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_RAD",
|
||||||
|
"name_fr": "Retransmission radio",
|
||||||
|
"name_nl": "Retransmission radio",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_SIG",
|
||||||
|
"name_fr": "Signalisation routiere",
|
||||||
|
"name_nl": "Signalisation routiere",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_SON",
|
||||||
|
"name_fr": "Sonorisation",
|
||||||
|
"name_nl": "Sonorisation",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_TEL",
|
||||||
|
"name_fr": "Telephonie",
|
||||||
|
"name_nl": "Telephonie",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_VEN",
|
||||||
|
"name_fr": "Ventilation",
|
||||||
|
"name_nl": "Ventilation",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CC_BUILDING",
|
||||||
|
"name_fr": "Bâtiment",
|
||||||
|
"name_nl": "Gebouw",
|
||||||
|
"thematic_code": "controlcenters",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CC_MAINTENANCE",
|
||||||
|
"name_fr": "Maintenance",
|
||||||
|
"name_nl": "Onderhoud",
|
||||||
|
"thematic_code": "controlcenters",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CC_OFFICE_EQMNT",
|
||||||
|
"name_fr": "Bureautique",
|
||||||
|
"name_nl": "Bureautique",
|
||||||
|
"thematic_code": "controlcenters",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CC_SYSTEM",
|
||||||
|
"name_fr": "Système",
|
||||||
|
"name_nl": "Systeem",
|
||||||
|
"thematic_code": "controlcenters",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CC_TELEPHONY",
|
||||||
|
"name_fr": "Téléphonie",
|
||||||
|
"name_nl": "Telefonie",
|
||||||
|
"thematic_code": "controlcenters",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CC_VIDEO",
|
||||||
|
"name_fr": "Vidéo",
|
||||||
|
"name_nl": "Video",
|
||||||
|
"thematic_code": "controlcenters",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LI_lightfixtures",
|
||||||
|
"name_fr": "Luminaires",
|
||||||
|
"name_nl": "Verlichtingsarmaturen",
|
||||||
|
"thematic_code": "publiclighting",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"publiclightinglightfixture"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LI_electrical_cabinets",
|
||||||
|
"name_fr": "Armoires électriques",
|
||||||
|
"name_nl": "Elektrische kasten",
|
||||||
|
"thematic_code": "publiclighting",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"publiclightingelectricalcabinet"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LI_cables",
|
||||||
|
"name_fr": "Câbles",
|
||||||
|
"name_nl": "Kabels",
|
||||||
|
"thematic_code": "publiclighting",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ITS_CAMERA",
|
||||||
|
"name_fr": "Caméras",
|
||||||
|
"name_nl": "Cameras",
|
||||||
|
"thematic_code": "its",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ITS_COUNTING",
|
||||||
|
"name_fr": "Comptages",
|
||||||
|
"name_nl": "Tellingen",
|
||||||
|
"thematic_code": "its",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ITS_LIDAR",
|
||||||
|
"name_fr": "Lidar",
|
||||||
|
"name_nl": "Lidar",
|
||||||
|
"thematic_code": "its",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ITS_NETWORK",
|
||||||
|
"name_fr": "Réseau",
|
||||||
|
"name_nl": "Netwerk",
|
||||||
|
"thematic_code": "its",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ITS_PMV",
|
||||||
|
"name_fr": "PMV",
|
||||||
|
"name_nl": "PMV",
|
||||||
|
"thematic_code": "its",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ITS_SYSTEM",
|
||||||
|
"name_fr": "Systèmes",
|
||||||
|
"name_nl": "Systemen",
|
||||||
|
"thematic_code": "its",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NAT_GREEN_AREAS",
|
||||||
|
"name_fr": "Espaces verts",
|
||||||
|
"name_nl": "Groene ruimtes",
|
||||||
|
"thematic_code": "nature",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"naturegreensurface"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NAT_TREES",
|
||||||
|
"name_fr": "Arbres",
|
||||||
|
"name_nl": "Bomen",
|
||||||
|
"thematic_code": "nature",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"naturetree"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RD_ROADWAY",
|
||||||
|
"name_fr": "Chaussée",
|
||||||
|
"name_nl": "Rijbaan",
|
||||||
|
"thematic_code": "roads",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RD_SIDEWALK",
|
||||||
|
"name_fr": "Trottoir",
|
||||||
|
"name_nl": "Voetpad",
|
||||||
|
"thematic_code": "roads",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RD_CYCLEROAD",
|
||||||
|
"name_fr": "Piste cyclable",
|
||||||
|
"name_nl": "Fietspad",
|
||||||
|
"thematic_code": "roads",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_ROADWAY",
|
||||||
|
"name_fr": "Chaussée en tunnel",
|
||||||
|
"name_nl": "Tunnelweg",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_CIVIL_ENGINEERING",
|
||||||
|
"name_fr": "Génie civil",
|
||||||
|
"name_nl": "Burgerlijke bouwkunde",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TU_HYDRANT",
|
||||||
|
"name_fr": "Hydrant",
|
||||||
|
"name_nl": "Hydrant",
|
||||||
|
"thematic_code": "structures",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"structuregeoasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "LI_CONE",
|
||||||
|
"name_fr": "Cône",
|
||||||
|
"name_nl": "Kegel",
|
||||||
|
"thematic_code": "publiclighting",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ART_FONTAIN",
|
||||||
|
"name_fr": "Fontaines",
|
||||||
|
"name_nl": "Fonteinen",
|
||||||
|
"thematic_code": "artworks",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"artworkfountain"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ART_MONUMENT",
|
||||||
|
"name_fr": "Monument",
|
||||||
|
"name_nl": "Monument",
|
||||||
|
"thematic_code": "artworks",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"artworkmonument"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RD_DRAIN",
|
||||||
|
"name_fr": "Avaloir",
|
||||||
|
"name_nl": "Rioolput",
|
||||||
|
"thematic_code": "roads",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RD_MANHOLE",
|
||||||
|
"name_fr": "Taque",
|
||||||
|
"name_nl": "Riooldeksel",
|
||||||
|
"thematic_code": "roads",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SI_POLE",
|
||||||
|
"name_fr": "Poteau",
|
||||||
|
"name_nl": "Paal",
|
||||||
|
"thematic_code": "sign",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SI_PANEL",
|
||||||
|
"name_fr": "Panneau",
|
||||||
|
"name_nl": "Paneel",
|
||||||
|
"thematic_code": "sign",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SI_BARRIER",
|
||||||
|
"name_fr": "Barrière",
|
||||||
|
"name_nl": "Barrière",
|
||||||
|
"thematic_code": "sign",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SI_MARKING",
|
||||||
|
"name_fr": "Marquage",
|
||||||
|
"name_nl": "Markering",
|
||||||
|
"thematic_code": "sign",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "RD_STREET_FURNITURE",
|
||||||
|
"name_fr": "Mobilier urbain",
|
||||||
|
"name_nl": "Straatmeubilair",
|
||||||
|
"thematic_code": "roads",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_SIBELGA_METER",
|
||||||
|
"name_fr": "Compteur Sibelga",
|
||||||
|
"name_nl": "Sibelga-meter",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "SI_TAXI",
|
||||||
|
"name_fr": "Emplacement Taxi",
|
||||||
|
"name_nl": "Taxistandplaats",
|
||||||
|
"thematic_code": "sign",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_DETEC_VIDEO",
|
||||||
|
"name_fr": "Détection Vidéo",
|
||||||
|
"name_nl": "Videodetectie",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightdetector"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ITS_RADARS",
|
||||||
|
"name_fr": "Radars",
|
||||||
|
"name_nl": "Radars",
|
||||||
|
"thematic_code": "its",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NAT_FURNITURES",
|
||||||
|
"name_fr": "Mobilier (Bacs à fleurs, ...)",
|
||||||
|
"name_nl": "Meubilair (bloembakken, ...)",
|
||||||
|
"thematic_code": "nature",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"naturebench",
|
||||||
|
"natureshrub"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL_MATERIALS",
|
||||||
|
"name_fr": "Matériaux",
|
||||||
|
"name_nl": "Materialen",
|
||||||
|
"thematic_code": "clean",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL_DRAIN",
|
||||||
|
"name_fr": "Avaloir",
|
||||||
|
"name_nl": "Rioolput",
|
||||||
|
"thematic_code": "clean",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL_OTHER",
|
||||||
|
"name_fr": "Autre",
|
||||||
|
"name_nl": "Andere",
|
||||||
|
"thematic_code": "clean",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL_ROADWAY",
|
||||||
|
"name_fr": "Chaussée",
|
||||||
|
"name_nl": "Rijbaan",
|
||||||
|
"thematic_code": "clean",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL_BUBBLE",
|
||||||
|
"name_fr": "Bulle",
|
||||||
|
"name_nl": "Bubbel",
|
||||||
|
"thematic_code": "clean",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"cleanglasscontainer"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL_BASKET",
|
||||||
|
"name_fr": "Corbeille",
|
||||||
|
"name_nl": "Prullenbak",
|
||||||
|
"thematic_code": "clean",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"cleanlitterbin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "ITS_POWER_SUPPLY",
|
||||||
|
"name_fr": "Alimentation électrique",
|
||||||
|
"name_nl": "Elektrische voeding",
|
||||||
|
"thematic_code": "its",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL_SIDEWALK",
|
||||||
|
"name_fr": "Trottoir",
|
||||||
|
"name_nl": "Voetpad",
|
||||||
|
"thematic_code": "clean",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "CL_BIKELANE",
|
||||||
|
"name_fr": "Piste cyclable",
|
||||||
|
"name_nl": "Fietspad",
|
||||||
|
"thematic_code": "clean",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "NAT_RWI",
|
||||||
|
"name_fr": "Gestion intégrée des eaux pluviales (GIEP)",
|
||||||
|
"name_nl": "Geïntegreerd regenwaterbeheer (GRWB)",
|
||||||
|
"thematic_code": "water",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"naturerwiasset"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_RADAR_POLE",
|
||||||
|
"name_fr": "Poteau radar",
|
||||||
|
"name_nl": "Radarpaal",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightradarpole"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "parking_spot",
|
||||||
|
"name_fr": "Emplacements de stationnement",
|
||||||
|
"name_nl": "Parkeerplaatsen",
|
||||||
|
"thematic_code": "parking",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "bike_parking_spot",
|
||||||
|
"name_fr": "Emplacement vélos",
|
||||||
|
"name_nl": "Fietsenstalling",
|
||||||
|
"thematic_code": "parking",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"parkinglocation",
|
||||||
|
"parkingspot"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "TL_PROGRAMMING",
|
||||||
|
"name_fr": "Programmation",
|
||||||
|
"name_nl": "Programmatie",
|
||||||
|
"thematic_code": "trafficlights",
|
||||||
|
"parent_code": null,
|
||||||
|
"allowed_models": [
|
||||||
|
"trafficlightprogramming"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -121,9 +121,9 @@
|
||||||
"pk": 11,
|
"pk": 11,
|
||||||
"fields": {
|
"fields": {
|
||||||
"code": "dpv_micro_projects_security",
|
"code": "dpv_micro_projects_security",
|
||||||
"name_fr": "DPV Micro-projets Sécurité",
|
"name_fr": "Sécurité",
|
||||||
"name_nl": "DPV Micro-projecten Beveiliging",
|
"name_nl": "Veiligheid",
|
||||||
"description": "",
|
"description": "Gestion de la sécurité publique et des équipements associés",
|
||||||
"icon": "bi-exclamation-diamond",
|
"icon": "bi-exclamation-diamond",
|
||||||
"color": "#000000",
|
"color": "#000000",
|
||||||
"uses_occupations": false
|
"uses_occupations": false
|
||||||
|
|
@ -142,6 +142,32 @@
|
||||||
"uses_occupations": false
|
"uses_occupations": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"model": "common.thematic",
|
||||||
|
"pk": 13,
|
||||||
|
"fields": {
|
||||||
|
"code": "parking",
|
||||||
|
"name_fr": "Stationnement",
|
||||||
|
"name_nl": "Parkeren",
|
||||||
|
"description": "Thématique dédiée aux emplacements de stationnement et zones de mobilité (vélos, trottinettes, PMR, livraison)",
|
||||||
|
"icon": "bi-p-square",
|
||||||
|
"color": "#0d6efd",
|
||||||
|
"uses_occupations": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"model": "common.thematic",
|
||||||
|
"pk": 14,
|
||||||
|
"fields": {
|
||||||
|
"code": "clean",
|
||||||
|
"name_fr": "Propreté",
|
||||||
|
"name_nl": "Netheid",
|
||||||
|
"description": "Gestion de la propreté publique, corbeilles et conteneurs",
|
||||||
|
"icon": "bi-trash3",
|
||||||
|
"color": "#198754",
|
||||||
|
"uses_occupations": false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"model": "common.role",
|
"model": "common.role",
|
||||||
"pk": 1,
|
"pk": 1,
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -133,3 +133,11 @@ msgstr "@"
|
||||||
#: loko/common/static/common/jquery-ui.min.js:6
|
#: loko/common/static/common/jquery-ui.min.js:6
|
||||||
msgid "!"
|
msgid "!"
|
||||||
msgstr "!"
|
msgstr "!"
|
||||||
|
|
||||||
|
#: loko/common/static/common/address_search_control.js:148
|
||||||
|
msgid "Rechercher une adresse…"
|
||||||
|
msgstr "Search an address…"
|
||||||
|
|
||||||
|
msgid "Rechercher une adresse..."
|
||||||
|
msgstr "Search an address..."
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -133,3 +133,11 @@ msgstr "@"
|
||||||
#: loko/common/static/common/jquery-ui.min.js:6
|
#: loko/common/static/common/jquery-ui.min.js:6
|
||||||
msgid "!"
|
msgid "!"
|
||||||
msgstr "!"
|
msgstr "!"
|
||||||
|
|
||||||
|
#: loko/common/static/common/address_search_control.js:148
|
||||||
|
msgid "Rechercher une adresse…"
|
||||||
|
msgstr "Rechercher une adresse…"
|
||||||
|
|
||||||
|
msgid "Rechercher une adresse..."
|
||||||
|
msgstr "Rechercher une adresse..."
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -135,3 +135,11 @@ msgstr "@"
|
||||||
#: loko/common/static/common/jquery-ui.min.js:6
|
#: loko/common/static/common/jquery-ui.min.js:6
|
||||||
msgid "!"
|
msgid "!"
|
||||||
msgstr "!"
|
msgstr "!"
|
||||||
|
|
||||||
|
#: loko/common/static/common/address_search_control.js:148
|
||||||
|
msgid "Rechercher une adresse…"
|
||||||
|
msgstr "Zoek een adres…"
|
||||||
|
|
||||||
|
msgid "Rechercher une adresse..."
|
||||||
|
msgstr "Zoek een adres..."
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
# Generated by Django 6.0.7
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def remove_obsolete_schools_appview(apps, schema_editor):
|
||||||
|
AppView = apps.get_model('common', 'AppView')
|
||||||
|
AppView.objects.filter(code='schools').delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0014_userconfig_inspection_list_filters_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(remove_obsolete_schools_appview, reverse_code=migrations.RunPython.noop),
|
||||||
|
]
|
||||||
145
loko/common/migrations/0016_mapbackground.py
Normal file
145
loko/common/migrations/0016_mapbackground.py
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
# Generated manually for dynamic MapBackgrounds
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def seed_map_backgrounds(apps, schema_editor):
|
||||||
|
MapBackground = apps.get_model('common', 'MapBackground')
|
||||||
|
initial_data = [
|
||||||
|
{
|
||||||
|
'identifier': 'ortho_wallonie',
|
||||||
|
'name_fr': 'Ortho Wallonie (SPW)',
|
||||||
|
'name_nl': 'Ortho Wallonië (SPW)',
|
||||||
|
'service_type': 'wms',
|
||||||
|
'url': 'https://geoservices.wallonie.be/arcgis/services/IMAGERIE/ORTHO_LAST/MapServer/WMSServer?service=WMS&version=1.3.0&request=GetMap&layers=0&styles=&format=image/jpeg&crs=EPSG:3857&bbox={bbox-epsg-3857}&width=256&height=256',
|
||||||
|
'layers': '0',
|
||||||
|
'format': 'image/jpeg',
|
||||||
|
'attribution': '© SPW - Orthophoto Wallonie',
|
||||||
|
'region': 'WAL',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tile_size': 256,
|
||||||
|
'min_zoom': 0,
|
||||||
|
'max_zoom': 21,
|
||||||
|
'order': 10,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'identifier': 'urbis_ortho',
|
||||||
|
'name_fr': 'Urbis Ortho (Bruxelles)',
|
||||||
|
'name_nl': 'Urbis Ortho (Brussel)',
|
||||||
|
'service_type': 'wms',
|
||||||
|
'url': 'https://geoservices-urbis.irisnet.be/geoserver/urbisgrid/wms?service=WMS&version=1.3.0&request=GetMap&layers=Ortho&styles=&format=image/png&transparent=true&width=256&height=256&crs=EPSG:3857&bbox={bbox-epsg-3857}',
|
||||||
|
'layers': 'Ortho',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© Brussels UrbIS',
|
||||||
|
'region': 'BRU',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tile_size': 256,
|
||||||
|
'min_zoom': 0,
|
||||||
|
'max_zoom': 21,
|
||||||
|
'order': 20,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'identifier': 'ortho_vlaanderen',
|
||||||
|
'name_fr': 'Ortho Vlaanderen',
|
||||||
|
'name_nl': 'Ortho Vlaanderen',
|
||||||
|
'service_type': 'wmts',
|
||||||
|
'url': 'https://geo.api.vlaanderen.be/OMW/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=omwrgb23vl&STYLE=&FORMAT=image/png&TILEMATRIXSET=GoogleMapsVL&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}',
|
||||||
|
'layers': 'omwrgb23vl',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© Digitaal Vlaanderen',
|
||||||
|
'region': 'VLG',
|
||||||
|
'category': 'ortho',
|
||||||
|
'tile_size': 256,
|
||||||
|
'min_zoom': 0,
|
||||||
|
'max_zoom': 21,
|
||||||
|
'order': 30,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'identifier': 'plan_wallonie_spw',
|
||||||
|
'name_fr': 'Plan de base Wallonie (SPW)',
|
||||||
|
'name_nl': 'Basiskaart Wallonië (SPW)',
|
||||||
|
'service_type': 'arcgis_rest',
|
||||||
|
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
|
||||||
|
'layers': '',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© SPW - Fond de plan Wallonie',
|
||||||
|
'region': 'WAL',
|
||||||
|
'category': 'plan',
|
||||||
|
'tile_size': 256,
|
||||||
|
'min_zoom': 0,
|
||||||
|
'max_zoom': 21,
|
||||||
|
'order': 40,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'identifier': 'plan_leger_wallonie_spw',
|
||||||
|
'name_fr': 'Plan léger Wallonie (SPW)',
|
||||||
|
'name_nl': 'Lichte basiskaart Wallonië (SPW)',
|
||||||
|
'service_type': 'arcgis_rest',
|
||||||
|
'url': 'https://geoservices.wallonie.be/arcgis/rest/services/DONNEES_BASE/FDC_SPW_LEGER/MapServer/export?bbox={bbox-epsg-3857}&bboxSR=3857&imageSR=3857&size=256,256&format=png&f=image',
|
||||||
|
'layers': '',
|
||||||
|
'format': 'image/png',
|
||||||
|
'attribution': '© SPW - Fond de plan léger',
|
||||||
|
'region': 'WAL',
|
||||||
|
'category': 'plan',
|
||||||
|
'tile_size': 256,
|
||||||
|
'min_zoom': 0,
|
||||||
|
'max_zoom': 21,
|
||||||
|
'order': 50,
|
||||||
|
'is_active': True,
|
||||||
|
'is_default': False,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for item in initial_data:
|
||||||
|
MapBackground.objects.get_or_create(identifier=item['identifier'], defaults=item)
|
||||||
|
|
||||||
|
|
||||||
|
def rollback_seed(apps, schema_editor):
|
||||||
|
MapBackground = apps.get_model('common', 'MapBackground')
|
||||||
|
MapBackground.objects.filter(identifier__in=[
|
||||||
|
'ortho_wallonie', 'urbis_ortho', 'ortho_vlaanderen', 'plan_wallonie_spw', 'plan_leger_wallonie_spw'
|
||||||
|
]).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0015_remove_obsolete_schools_appview'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='MapBackground',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('identifier', models.SlugField(help_text='Ex: urbis_ortho, ortho_vlaanderen, ortho_wallonie', unique=True, verbose_name='Identifiant unique')),
|
||||||
|
('name_fr', models.CharField(max_length=150, verbose_name='Nom FR')),
|
||||||
|
('name_nl', models.CharField(max_length=150, verbose_name='Nom NL')),
|
||||||
|
('service_type', models.CharField(choices=[('wms', 'WMS (Web Map Service)'), ('wmts', 'WMTS (Web Map Tile Service)'), ('xyz', 'XYZ / Tuiles standard'), ('arcgis_rest', 'ArcGIS REST Export')], default='wms', max_length=20, verbose_name='Type de service')),
|
||||||
|
('url', models.TextField(help_text='Supporte {bbox-epsg-3857} pour WMS/Export, et {z}/{x}/{y} pour WMTS/XYZ.', verbose_name='URL du flux')),
|
||||||
|
('layers', models.CharField(blank=True, help_text='Nom de couche WMS si applicable (ex: Ortho, 0, ORTHO_LAST)', max_length=255, verbose_name='Couche(s)')),
|
||||||
|
('format', models.CharField(default='image/jpeg', help_text='ex: image/jpeg, image/png', max_length=50, verbose_name="Format d'image")),
|
||||||
|
('attribution', models.CharField(blank=True, help_text='ex: © SPW - Orthophoto Wallonie', max_length=255, verbose_name='Attribution / Copyright')),
|
||||||
|
('region', models.CharField(choices=[('ALL', 'Toutes régions (National / Global)'), ('BRU', 'Bruxelles (StreetUp)'), ('WAL', 'Wallonie'), ('VLG', 'Flandre')], default='ALL', max_length=10, verbose_name='Région')),
|
||||||
|
('category', models.CharField(choices=[('ortho', 'Orthophoto / Vue aérienne'), ('plan', 'Plan de base / Topographique'), ('cadastre', 'Cadastre / Parcellaire'), ('other', 'Autre')], default='ortho', max_length=20, verbose_name='Catégorie')),
|
||||||
|
('tile_size', models.PositiveIntegerField(default=256, verbose_name='Taille des tuiles (px)')),
|
||||||
|
('min_zoom', models.PositiveIntegerField(default=0, verbose_name='Zoom minimum')),
|
||||||
|
('max_zoom', models.PositiveIntegerField(default=21, verbose_name='Zoom maximum')),
|
||||||
|
('order', models.PositiveIntegerField(default=0, verbose_name="Ordre d'affichage")),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='Actif')),
|
||||||
|
('is_default', models.BooleanField(default=False, verbose_name='Sélectionné par défaut')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Fond de plan',
|
||||||
|
'verbose_name_plural': 'Fonds de plan',
|
||||||
|
'ordering': ['order', 'name_fr'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_map_backgrounds, rollback_seed),
|
||||||
|
]
|
||||||
34
loko/common/migrations/0017_territory.py
Normal file
34
loko/common/migrations/0017_territory.py
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-09-21 08:03
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0016_mapbackground'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Territory',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')),
|
||||||
|
('name_nl', models.CharField(blank=True, max_length=255, verbose_name='Nom (NL)')),
|
||||||
|
('code', models.CharField(blank=True, max_length=50, null=True, unique=True, verbose_name='Code')),
|
||||||
|
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
|
||||||
|
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Géométrie')),
|
||||||
|
('is_default', models.BooleanField(default=False, help_text='Si coché, ce territoire servira de zone de référence par défaut pour les cartes et masques du territoire.', verbose_name='Territoire par défaut')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='Actif')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Créé le')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Modifié le')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Territoire',
|
||||||
|
'verbose_name_plural': 'Territoires',
|
||||||
|
'ordering': ['-is_default', 'name_fr'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -85,6 +85,17 @@ class AppView(models.Model):
|
||||||
return self.name_nl
|
return self.name_nl
|
||||||
return self.name_fr
|
return self.name_fr
|
||||||
|
|
||||||
|
@property
|
||||||
|
def url(self):
|
||||||
|
"""Renvoie l'URL résolue de la vue ou None si la route n'est pas enregistrée."""
|
||||||
|
if not self.url_name:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from django.urls import reverse
|
||||||
|
return reverse(self.url_name)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
|
@ -1310,5 +1321,131 @@ class RequestLog(models.Model):
|
||||||
return log_entry
|
return log_entry
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class MapBackground(models.Model):
|
||||||
|
SERVICE_TYPE_CHOICES = [
|
||||||
|
('wms', _('WMS (Web Map Service)')),
|
||||||
|
('wmts', _('WMTS (Web Map Tile Service)')),
|
||||||
|
('xyz', _('XYZ / Tuiles standard')),
|
||||||
|
('arcgis_rest', _('ArcGIS REST Export')),
|
||||||
|
]
|
||||||
|
|
||||||
|
REGION_CHOICES = [
|
||||||
|
('ALL', _('Toutes régions (National / Global)')),
|
||||||
|
('BRU', _('Bruxelles (StreetUp)')),
|
||||||
|
('WAL', _('Wallonie')),
|
||||||
|
('VLG', _('Flandre')),
|
||||||
|
]
|
||||||
|
|
||||||
|
CATEGORY_CHOICES = [
|
||||||
|
('ortho', _('Orthophoto / Vue aérienne')),
|
||||||
|
('plan', _('Plan de base / Topographique')),
|
||||||
|
('cadastre', _('Cadastre / Parcellaire')),
|
||||||
|
('other', _('Autre')),
|
||||||
|
]
|
||||||
|
|
||||||
|
identifier = models.SlugField(max_length=50, unique=True, verbose_name=_("Identifiant unique"), help_text=_("Ex: urbis_ortho, ortho_vlaanderen, ortho_wallonie"))
|
||||||
|
name_fr = models.CharField(max_length=150, verbose_name=_("Nom FR"))
|
||||||
|
name_nl = models.CharField(max_length=150, verbose_name=_("Nom NL"))
|
||||||
|
service_type = models.CharField(max_length=20, choices=SERVICE_TYPE_CHOICES, default='wms', verbose_name=_("Type de service"))
|
||||||
|
url = models.TextField(verbose_name=_("URL du flux"), help_text=_("Supporte {bbox-epsg-3857} pour WMS/Export, et {z}/{x}/{y} pour WMTS/XYZ."))
|
||||||
|
layers = models.CharField(max_length=255, blank=True, verbose_name=_("Couche(s)"), help_text=_("Nom de couche WMS si applicable (ex: Ortho, 0, ORTHO_LAST)"))
|
||||||
|
format = models.CharField(max_length=50, default="image/jpeg", verbose_name=_("Format d'image"), help_text=_("ex: image/jpeg, image/png"))
|
||||||
|
attribution = models.CharField(max_length=255, blank=True, verbose_name=_("Attribution / Copyright"), help_text=_("ex: © SPW - Orthophoto Wallonie"))
|
||||||
|
region = models.CharField(max_length=10, choices=REGION_CHOICES, default='ALL', verbose_name=_("Région"))
|
||||||
|
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='ortho', verbose_name=_("Catégorie"))
|
||||||
|
tile_size = models.PositiveIntegerField(default=256, verbose_name=_("Taille des tuiles (px)"))
|
||||||
|
min_zoom = models.PositiveIntegerField(default=0, verbose_name=_("Zoom minimum"))
|
||||||
|
max_zoom = models.PositiveIntegerField(default=21, verbose_name=_("Zoom maximum"))
|
||||||
|
order = models.PositiveIntegerField(default=0, verbose_name=_("Ordre d'affichage"))
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name=_("Actif"))
|
||||||
|
is_default = models.BooleanField(default=False, verbose_name=_("Sélectionné par défaut"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
lang = get_language()
|
||||||
|
if lang == 'nl':
|
||||||
|
return self.name_nl or self.name_fr
|
||||||
|
return self.name_fr or self.name_nl
|
||||||
|
|
||||||
|
objects = LanguageManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Fond de plan")
|
||||||
|
verbose_name_plural = _("Fonds de plan")
|
||||||
|
ordering = ['order', 'name_fr']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} ({self.get_region_display()})"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
from django.core.cache import cache
|
||||||
|
cache.delete("active_map_backgrounds")
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
from django.core.cache import cache
|
||||||
|
cache.delete("active_map_backgrounds")
|
||||||
|
|
||||||
|
|
||||||
|
class Territory(models.Model):
|
||||||
|
"""
|
||||||
|
Définit une zone territoriale administrative ou contractuelle (ex: Région de Bruxelles-Capitale).
|
||||||
|
Permet d'illuminer directement un polygone global sans jointure complexe de communes,
|
||||||
|
évitant ainsi les artefacts visuels de lignes intérieures.
|
||||||
|
"""
|
||||||
|
name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)"))
|
||||||
|
name_nl = models.CharField(max_length=255, blank=True, verbose_name=_("Nom (NL)"))
|
||||||
|
code = models.CharField(max_length=50, blank=True, null=True, unique=True, verbose_name=_("Code"))
|
||||||
|
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
|
||||||
|
geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie"))
|
||||||
|
is_default = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("Territoire par défaut"),
|
||||||
|
help_text=_("Si coché, ce territoire servira de zone de référence par défaut pour les cartes et masques du territoire.")
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name=_("Actif"))
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Créé le"))
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Modifié le"))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
lang = get_language()
|
||||||
|
if lang == 'nl':
|
||||||
|
return self.name_nl or self.name_fr
|
||||||
|
return self.name_fr or self.name_nl
|
||||||
|
|
||||||
|
objects = LanguageManager()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _("Territoire")
|
||||||
|
verbose_name_plural = _("Territoires")
|
||||||
|
ordering = ['-is_default', 'name_fr']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
code_str = f" [{self.code}]" if self.code else ""
|
||||||
|
return f"{self.name}{code_str}"
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if self.is_default:
|
||||||
|
Territory.objects.filter(is_default=True).exclude(pk=self.pk).update(is_default=False)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
self._clear_cache()
|
||||||
|
|
||||||
|
def delete(self, *args, **kwargs):
|
||||||
|
super().delete(*args, **kwargs)
|
||||||
|
self._clear_cache()
|
||||||
|
|
||||||
|
def _clear_cache(self):
|
||||||
|
from django.core.cache import cache
|
||||||
|
cache.delete(f"territory_mask_geojson_territory_{self.id}")
|
||||||
|
cache.delete(f"territory_mask_geojson_default_{self.id}")
|
||||||
|
cache.delete("territory_mask_geojson_default")
|
||||||
|
cache.delete("default_territory_bounds")
|
||||||
|
|
||||||
|
|
||||||
# Import des modèles de tâches planifiées
|
# Import des modèles de tâches planifiées
|
||||||
from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog
|
from .models_scheduled_tasks import ScheduledCommand, CommandExecutionLog
|
||||||
|
|
||||||
|
|
|
||||||
37
loko/common/signals.py
Normal file
37
loko/common/signals.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import logging
|
||||||
|
from django.db.models.signals import post_save, post_delete
|
||||||
|
from django.dispatch import receiver
|
||||||
|
|
||||||
|
from .models import UserConfig, UserContractAccess
|
||||||
|
from .context_processors import invalidate_user_map_bounds
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=UserConfig)
|
||||||
|
@receiver(post_delete, sender=UserConfig)
|
||||||
|
def invalidate_map_bounds_on_userconfig_change(sender, instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Invalidates the cached map center/zoom/extent when UserConfig is modified or deleted
|
||||||
|
(e.g., when default_contract changes).
|
||||||
|
"""
|
||||||
|
if instance.user_id:
|
||||||
|
invalidate_user_map_bounds(instance.user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=UserContractAccess)
|
||||||
|
@receiver(post_delete, sender=UserContractAccess)
|
||||||
|
def invalidate_map_bounds_on_contract_access_change(sender, instance, **kwargs):
|
||||||
|
"""
|
||||||
|
Invalidates the cached map center/zoom/extent when user contract accesses are added or removed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user_id = instance.user_config.user_id if instance.user_config else None
|
||||||
|
if user_id:
|
||||||
|
invalidate_user_map_bounds(user_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Failed to invalidate map bounds for UserContractAccess %s: %s",
|
||||||
|
getattr(instance, 'pk', None),
|
||||||
|
e
|
||||||
|
)
|
||||||
|
|
@ -144,8 +144,9 @@
|
||||||
*/
|
*/
|
||||||
constructor(opts = {}) {
|
constructor(opts = {}) {
|
||||||
super();
|
super();
|
||||||
|
const defaultPlaceholder = (typeof gettext !== 'undefined' ? gettext('Rechercher une adresse…') : 'Rechercher une adresse…');
|
||||||
this._opts = Object.assign({
|
this._opts = Object.assign({
|
||||||
placeholder : 'Rechercher une adresse…',
|
placeholder : defaultPlaceholder,
|
||||||
minChars : 2,
|
minChars : 2,
|
||||||
debounce : 250,
|
debounce : 250,
|
||||||
markerColor : '#0d6efd',
|
markerColor : '#0d6efd',
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -419,3 +419,148 @@ class ScheduledCommandResolveOutputFileTests(TestCase):
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
from django.test import RequestFactory
|
||||||
|
from django.contrib.sessions.middleware import SessionMiddleware
|
||||||
|
from django.core.cache import cache
|
||||||
|
from common.context_processors import (
|
||||||
|
current_app_view,
|
||||||
|
organization_info,
|
||||||
|
)
|
||||||
|
from common.models import AppView
|
||||||
|
|
||||||
|
|
||||||
|
class ContextProcessorsCachingTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
cache.clear()
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.user = User.objects.create_user(username="context_user", password="password123")
|
||||||
|
self.config = UserConfig.objects.create(user=self.user)
|
||||||
|
|
||||||
|
def _add_session_to_request(self, request):
|
||||||
|
middleware = SessionMiddleware(lambda req: None)
|
||||||
|
middleware.process_request(request)
|
||||||
|
request.session.save()
|
||||||
|
|
||||||
|
def test_current_app_view_caches_result(self):
|
||||||
|
AppView.objects.create(code="asset_management", name_fr="Gestion des actifs", name_nl="Activa beheer", url_name="home:index")
|
||||||
|
request = self.factory.get("/")
|
||||||
|
|
||||||
|
with self.assertNumQueries(1):
|
||||||
|
ctx1 = current_app_view(request)
|
||||||
|
self.assertIsNotNone(ctx1["current_view"])
|
||||||
|
|
||||||
|
# Second call should use cache and perform 0 database queries
|
||||||
|
with self.assertNumQueries(0):
|
||||||
|
ctx2 = current_app_view(request)
|
||||||
|
self.assertEqual(ctx1["current_view"], ctx2["current_view"])
|
||||||
|
|
||||||
|
def test_organization_info_caches_in_django_cache(self):
|
||||||
|
request = self.factory.get("/")
|
||||||
|
self._add_session_to_request(request)
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
cache_key = f"user_map_bounds_{self.user.pk}"
|
||||||
|
self.assertIsNone(cache.get(cache_key))
|
||||||
|
|
||||||
|
ctx1 = organization_info(request)
|
||||||
|
self.assertIsNotNone(cache.get(cache_key))
|
||||||
|
|
||||||
|
# Subsequent call with cache populated performs 0 queries
|
||||||
|
with self.assertNumQueries(0):
|
||||||
|
ctx2 = organization_info(request)
|
||||||
|
self.assertEqual(ctx1["map_default_center"], ctx2["map_default_center"])
|
||||||
|
self.assertEqual(ctx1["map_default_zoom"], ctx2["map_default_zoom"])
|
||||||
|
self.assertEqual(ctx1["map_default_extent"], ctx2["map_default_extent"])
|
||||||
|
|
||||||
|
def test_user_config_save_invalidates_cache(self):
|
||||||
|
request = self.factory.get("/")
|
||||||
|
self._add_session_to_request(request)
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
cache_key = f"user_map_bounds_{self.user.pk}"
|
||||||
|
organization_info(request)
|
||||||
|
self.assertIsNotNone(cache.get(cache_key))
|
||||||
|
|
||||||
|
# Modifying and saving UserConfig should invalidate the cached map bounds via signal
|
||||||
|
self.config.save()
|
||||||
|
self.assertIsNone(cache.get(cache_key))
|
||||||
|
|
||||||
|
def test_user_contract_access_invalidates_cache(self):
|
||||||
|
from datetime import date
|
||||||
|
from contracts.models import Contract, Company
|
||||||
|
from common.models import UserContractAccess
|
||||||
|
|
||||||
|
company = Company.objects.create(name="Company Test")
|
||||||
|
contract = Contract.objects.create(
|
||||||
|
company=company,
|
||||||
|
contract_number="CTR-TEST-CACHE",
|
||||||
|
start_date=date(2026, 1, 1),
|
||||||
|
end_date=date(2026, 12, 31),
|
||||||
|
)
|
||||||
|
request = self.factory.get("/")
|
||||||
|
self._add_session_to_request(request)
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
cache_key = f"user_map_bounds_{self.user.pk}"
|
||||||
|
organization_info(request)
|
||||||
|
self.assertIsNotNone(cache.get(cache_key))
|
||||||
|
|
||||||
|
# Creating UserContractAccess invalidates cache
|
||||||
|
access = UserContractAccess.objects.create(user_config=self.config, contract=contract)
|
||||||
|
self.assertIsNone(cache.get(cache_key))
|
||||||
|
|
||||||
|
# Re-populate cache
|
||||||
|
organization_info(request)
|
||||||
|
self.assertIsNotNone(cache.get(cache_key))
|
||||||
|
|
||||||
|
# Deleting UserContractAccess invalidates cache
|
||||||
|
access.delete()
|
||||||
|
self.assertIsNone(cache.get(cache_key))
|
||||||
|
|
||||||
|
|
||||||
|
class TerritoryTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
from django.contrib.gis.geos import Polygon, MultiPolygon
|
||||||
|
from common.models import Territory
|
||||||
|
# Create a simple square polygon in EPSG:3812
|
||||||
|
poly = Polygon(((650000, 670000), (660000, 670000), (660000, 680000), (650000, 680000), (650000, 670000)))
|
||||||
|
self.geom = MultiPolygon(poly, srid=3812)
|
||||||
|
self.territory = Territory.objects.create(
|
||||||
|
name_fr="Territoire Test",
|
||||||
|
name_nl="Test Gebied",
|
||||||
|
code="TEST",
|
||||||
|
geom=self.geom,
|
||||||
|
is_default=True,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_single_default_territory_enforced(self):
|
||||||
|
from common.models import Territory
|
||||||
|
t2 = Territory.objects.create(
|
||||||
|
name_fr="Territoire 2",
|
||||||
|
geom=self.geom,
|
||||||
|
is_default=True,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
self.territory.refresh_from_db()
|
||||||
|
self.assertFalse(self.territory.is_default)
|
||||||
|
self.assertTrue(t2.is_default)
|
||||||
|
|
||||||
|
def test_get_territory_mask_geojson_uses_default_territory(self):
|
||||||
|
import json
|
||||||
|
from django.test import RequestFactory
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
from common.views import get_territory_mask_geojson
|
||||||
|
|
||||||
|
factory = RequestFactory()
|
||||||
|
req = factory.get("/common/territory-mask-geojson/")
|
||||||
|
req.user = AnonymousUser()
|
||||||
|
res = get_territory_mask_geojson(req)
|
||||||
|
self.assertEqual(res.status_code, 200)
|
||||||
|
data = json.loads(res.content.decode())
|
||||||
|
self.assertEqual(len(data["features"]), 2)
|
||||||
|
boundary_feature = next(f for f in data["features"] if f["id"] == "territory_boundary")
|
||||||
|
self.assertEqual(boundary_feature["properties"]["name"], "Territoire Test")
|
||||||
|
self.assertEqual(boundary_feature["geometry"]["type"], "MultiPolygon")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ app_name = "common"
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("address/autocomplete-street/", views.autocomplete_street, name="autocomplete_street"),
|
path("address/autocomplete-street/", views.autocomplete_street, name="autocomplete_street"),
|
||||||
path("address/get-nearest-address/", views.get_nearest_address, name="get_nearest_address"),
|
path("address/get-nearest-address/", views.get_nearest_address, name="get_nearest_address"),
|
||||||
|
path("territory-mask-geojson/", views.get_territory_mask_geojson, name="get_territory_mask_geojson"),
|
||||||
path("get-regional-roads-geojson/", views.get_regional_roads_geojson, name="get_regional_roads_geojson"),
|
path("get-regional-roads-geojson/", views.get_regional_roads_geojson, name="get_regional_roads_geojson"),
|
||||||
path("get-thematic-categories/", views.get_categories_for_thematics, name="get_thematic_categories"),
|
path("get-thematic-categories/", views.get_categories_for_thematics, name="get_thematic_categories"),
|
||||||
path("account-info", views.account_info, name='account_info'),
|
path("account-info", views.account_info, name='account_info'),
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
from django.core.cache import cache
|
||||||
from django.http import JsonResponse, FileResponse, Http404
|
from django.http import JsonResponse, FileResponse, Http404
|
||||||
from django.shortcuts import render, redirect
|
from django.shortcuts import render, redirect
|
||||||
from django.views.decorators.http import require_GET, require_POST
|
from django.views.decorators.http import require_GET, require_POST
|
||||||
|
|
@ -5,7 +6,7 @@ from django.contrib.auth.decorators import login_required
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.gis.db.models.functions import Distance
|
from django.contrib.gis.db.models.functions import Distance
|
||||||
from django.contrib.gis.geos import Point
|
from django.contrib.gis.geos import Point, Polygon
|
||||||
from django.contrib.auth import login as auth_login
|
from django.contrib.auth import login as auth_login
|
||||||
from django.contrib.auth.decorators import login_not_required
|
from django.contrib.auth.decorators import login_not_required
|
||||||
from django.contrib.auth.forms import PasswordChangeForm
|
from django.contrib.auth.forms import PasswordChangeForm
|
||||||
|
|
@ -22,6 +23,7 @@ from urllib.parse import urlencode
|
||||||
from common.context_processors import (
|
from common.context_processors import (
|
||||||
SESSION_ALLOWED_IDS_KEY,
|
SESSION_ALLOWED_IDS_KEY,
|
||||||
SESSION_ORIGIN_ID_KEY,
|
SESSION_ORIGIN_ID_KEY,
|
||||||
|
SESSION_MAP_BOUNDS_KEY,
|
||||||
compute_initial_allowed_ids,
|
compute_initial_allowed_ids,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -148,6 +150,168 @@ def get_nearest_address(request):
|
||||||
|
|
||||||
@login_not_required
|
@login_not_required
|
||||||
@require_GET
|
@require_GET
|
||||||
|
def get_territory_mask_geojson(request):
|
||||||
|
"""
|
||||||
|
Renvoie le masque cartographique inversé (calque grisé transparent en dehors du territoire)
|
||||||
|
et le tracé du contour pour mettre en évidence le territoire à gérer.
|
||||||
|
Supporte un modèle Territory par défaut (contour direct sans jointure de communes),
|
||||||
|
ou une liste de communes (filtrage par contrat/utilisateur).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
user = request.user
|
||||||
|
muni_params = request.GET.getlist('municipality')
|
||||||
|
territory_param = request.GET.get('territory_id') or request.GET.get('territory')
|
||||||
|
|
||||||
|
def _build_mask_geojson(target_geom, territory_name, cache_key):
|
||||||
|
cached = cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return JsonResponse(cached)
|
||||||
|
|
||||||
|
g = target_geom.transform(4326, clone=True) if target_geom.srid != 4326 else target_geom
|
||||||
|
try:
|
||||||
|
num_points = sum(len(ring) for poly in g for ring in poly)
|
||||||
|
except Exception:
|
||||||
|
num_points = 0
|
||||||
|
simp_geom = g.simplify(0.00003, preserve_topology=True) if num_points > 800 else g
|
||||||
|
|
||||||
|
world = Polygon.from_bbox((-180, -85, 180, 85))
|
||||||
|
try:
|
||||||
|
mask = world.difference(simp_geom)
|
||||||
|
except Exception:
|
||||||
|
mask = world.difference(simp_geom.buffer(0))
|
||||||
|
|
||||||
|
features = [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"id": "territory_mask",
|
||||||
|
"properties": {
|
||||||
|
"layer": "mask",
|
||||||
|
"type": "mask",
|
||||||
|
"name": territory_name
|
||||||
|
},
|
||||||
|
"geometry": json.loads(mask.geojson)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"id": "territory_boundary",
|
||||||
|
"properties": {
|
||||||
|
"layer": "boundary",
|
||||||
|
"type": "boundary",
|
||||||
|
"name": territory_name
|
||||||
|
},
|
||||||
|
"geometry": json.loads(simp_geom.geojson)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
result = {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": features
|
||||||
|
}
|
||||||
|
cache.set(cache_key, result, 86400)
|
||||||
|
return JsonResponse(result)
|
||||||
|
|
||||||
|
from common.models import Municipality, Territory
|
||||||
|
from contracts.models import Contract
|
||||||
|
from assets.permissions import get_user_accessible_municipalities
|
||||||
|
|
||||||
|
# 1. Si un territoire explicite est demandé par identifiant ou code
|
||||||
|
if territory_param:
|
||||||
|
t_filter = Q(code=territory_param)
|
||||||
|
if str(territory_param).isdigit():
|
||||||
|
t_filter |= Q(id=int(territory_param))
|
||||||
|
explicit_territory = Territory.objects.filter(t_filter, is_active=True, geom__isnull=False).first()
|
||||||
|
if explicit_territory:
|
||||||
|
return _build_mask_geojson(
|
||||||
|
explicit_territory.geom,
|
||||||
|
explicit_territory.name,
|
||||||
|
f"territory_mask_geojson_territory_{explicit_territory.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
muni_ids = []
|
||||||
|
if muni_params:
|
||||||
|
for p in muni_params:
|
||||||
|
if str(p).isdigit():
|
||||||
|
muni_ids.append(int(p))
|
||||||
|
|
||||||
|
default_territory = Territory.objects.filter(is_active=True, is_default=True, geom__isnull=False).first()
|
||||||
|
total_munis_count = Municipality.objects.filter(geom__isnull=False).count()
|
||||||
|
|
||||||
|
if not muni_ids:
|
||||||
|
# Vérifier si l'utilisateur est restreint à un sous-ensemble strict de communes
|
||||||
|
user_config = getattr(user, 'config', None) if user and user.is_authenticated else None
|
||||||
|
user_restricted_muni_ids = []
|
||||||
|
|
||||||
|
if user and user.is_authenticated and not user.is_superuser:
|
||||||
|
if user_config and user_config.default_contract and user_config.default_contract.municipalities.exists():
|
||||||
|
c_munis = list(user_config.default_contract.municipalities.values_list('id', flat=True))
|
||||||
|
if len(c_munis) < total_munis_count:
|
||||||
|
user_restricted_muni_ids = c_munis
|
||||||
|
elif user_config and user_config.contracts.filter(municipalities__isnull=False).exists():
|
||||||
|
c_munis = list(Municipality.objects.filter(contracts__in=user_config.contracts.all()).values_list('id', flat=True).distinct())
|
||||||
|
if len(c_munis) < total_munis_count:
|
||||||
|
user_restricted_muni_ids = c_munis
|
||||||
|
elif Contract.objects.filter(municipalities__isnull=False).count() == 1:
|
||||||
|
c_munis = list(Contract.objects.filter(municipalities__isnull=False).first().municipalities.values_list('id', flat=True))
|
||||||
|
if len(c_munis) < total_munis_count:
|
||||||
|
user_restricted_muni_ids = c_munis
|
||||||
|
else:
|
||||||
|
accessible = list(get_user_accessible_municipalities(user).values_list('id', flat=True))
|
||||||
|
if accessible and len(accessible) < total_munis_count:
|
||||||
|
user_restricted_muni_ids = accessible
|
||||||
|
|
||||||
|
if user_restricted_muni_ids:
|
||||||
|
muni_ids = user_restricted_muni_ids
|
||||||
|
elif default_territory:
|
||||||
|
return _build_mask_geojson(
|
||||||
|
default_territory.geom,
|
||||||
|
default_territory.name,
|
||||||
|
f"territory_mask_geojson_default_{default_territory.id}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
muni_ids = list(Municipality.objects.filter(geom__isnull=False).values_list('id', flat=True))
|
||||||
|
|
||||||
|
if default_territory and total_munis_count > 0 and len(set(muni_ids)) >= total_munis_count:
|
||||||
|
return _build_mask_geojson(
|
||||||
|
default_territory.geom,
|
||||||
|
default_territory.name,
|
||||||
|
f"territory_mask_geojson_default_{default_territory.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not muni_ids:
|
||||||
|
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||||||
|
|
||||||
|
|
||||||
|
muni_ids = sorted(list(set(muni_ids)))
|
||||||
|
cache_key = f"territory_mask_geojson_{'_'.join(map(str, muni_ids))}"
|
||||||
|
cached_data = cache.get(cache_key)
|
||||||
|
if cached_data is not None:
|
||||||
|
return JsonResponse(cached_data)
|
||||||
|
|
||||||
|
munis = Municipality.objects.filter(id__in=muni_ids, geom__isnull=False)
|
||||||
|
if not munis.exists():
|
||||||
|
empty_res = {"type": "FeatureCollection", "features": []}
|
||||||
|
return JsonResponse(empty_res)
|
||||||
|
|
||||||
|
combined_geom = None
|
||||||
|
names = []
|
||||||
|
for m in munis:
|
||||||
|
names.append(m.name)
|
||||||
|
g = m.geom.transform(4326, clone=True) if m.geom.srid != 4326 else m.geom
|
||||||
|
g_simp = g.simplify(0.00003, preserve_topology=True)
|
||||||
|
if combined_geom is None:
|
||||||
|
combined_geom = g_simp
|
||||||
|
else:
|
||||||
|
combined_geom = combined_geom.union(g_simp)
|
||||||
|
|
||||||
|
if not combined_geom:
|
||||||
|
return JsonResponse({"type": "FeatureCollection", "features": []})
|
||||||
|
|
||||||
|
return _build_mask_geojson(combined_geom, ", ".join(names), cache_key)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def get_regional_roads_geojson(request):
|
def get_regional_roads_geojson(request):
|
||||||
file_path = os.path.join(settings.BASE_DIR, 'common', 'static', 'geojson', 'regional_roads_simplified_4326.geojson')
|
file_path = os.path.join(settings.BASE_DIR, 'common', 'static', 'geojson', 'regional_roads_simplified_4326.geojson')
|
||||||
|
|
||||||
|
|
@ -287,6 +451,7 @@ def switch_account(request):
|
||||||
|
|
||||||
session[SESSION_ALLOWED_IDS_KEY] = allowed_ids
|
session[SESSION_ALLOWED_IDS_KEY] = allowed_ids
|
||||||
session[SESSION_ORIGIN_ID_KEY] = origin_user_id
|
session[SESSION_ORIGIN_ID_KEY] = origin_user_id
|
||||||
|
session.pop(SESSION_MAP_BOUNDS_KEY, None)
|
||||||
|
|
||||||
return redirect(origin_next or reverse("home:index"))
|
return redirect(origin_next or reverse("home:index"))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -126,17 +126,17 @@ class CompanyAdmin(admin.ModelAdmin):
|
||||||
|
|
||||||
@admin.register(Contract)
|
@admin.register(Contract)
|
||||||
class ContractAdmin(admin.ModelAdmin):
|
class ContractAdmin(admin.ModelAdmin):
|
||||||
list_display = ('contract_number', 'description', 'company', 'is_blocked', 'is_active', 'start_date', 'end_date', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'thematics_list')
|
list_display = ('contract_number', 'description', 'company', 'municipalities_list', 'is_blocked', 'is_active', 'start_date', 'end_date', 'contact_phone', 'send_email_notifications', 'send_daily_summary', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'thematics_list')
|
||||||
search_fields = ('contract_number', 'company__name', 'description', 'contact_phone')
|
search_fields = ('contract_number', 'company__name', 'description', 'contact_phone')
|
||||||
list_filter = ('is_blocked', 'is_active', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'send_daily_summary')
|
list_filter = ('is_blocked', 'is_active', 'municipalities', 'thematics', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'allow_team_members_edit', 'auto_assign_control_team', 'send_daily_summary')
|
||||||
filter_horizontal = ('thematics',)
|
filter_horizontal = ('thematics', 'municipalities')
|
||||||
|
|
||||||
def get_inlines(self, request, obj=None):
|
def get_inlines(self, request, obj=None):
|
||||||
return [_get_pause_reason_inline(), _get_symptom_autoclose_inline(), _get_operation_template_posts_inline()] + list(super().get_inlines(request, obj))
|
return [_get_pause_reason_inline(), _get_symptom_autoclose_inline(), _get_operation_template_posts_inline()] + list(super().get_inlines(request, obj))
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, {
|
(None, {
|
||||||
'fields': ('company', 'contract_number', 'description', 'start_date', 'end_date', 'is_active', 'committed_amount', 'internal_link', 'executive_officer', 'contact_phone', 'signatory_name', 'signatory_role', 'thematics'),
|
'fields': ('company', 'municipalities', 'contract_number', 'description', 'start_date', 'end_date', 'is_active', 'committed_amount', 'internal_link', 'executive_officer', 'contact_phone', 'signatory_name', 'signatory_role', 'thematics'),
|
||||||
}),
|
}),
|
||||||
('Blocage budgétaire / Suspension', {
|
('Blocage budgétaire / Suspension', {
|
||||||
'fields': ('is_blocked', 'blocked_reason', 'blocked_at'),
|
'fields': ('is_blocked', 'blocked_reason', 'blocked_at'),
|
||||||
|
|
@ -154,10 +154,20 @@ class ContractAdmin(admin.ModelAdmin):
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
('Options de workflow', {
|
('Options de workflow', {
|
||||||
'fields': ('needs_approval', 'needs_checking', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'warn_if_no_prestation_on_processed', 'allow_team_members_edit'),
|
'fields': ('needs_approval', 'approval_except_urgent', 'needs_checking', 'uses_occupations', 'occupations_required', 'uses_intervention_manager', 'warn_if_no_prestation_on_processed', 'allow_team_members_edit', 'auto_assign_control_team'),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
'all': ('contracts/css/contract_admin.css',)
|
||||||
|
}
|
||||||
|
js = ('contracts/js/contract_admin.js',)
|
||||||
|
|
||||||
|
def municipalities_list(self, obj):
|
||||||
|
return ", ".join([m.name for m in obj.municipalities.all()])
|
||||||
|
municipalities_list.short_description = 'Communes'
|
||||||
|
|
||||||
def thematics_list(self, obj):
|
def thematics_list(self, obj):
|
||||||
return ", ".join([thematic.name for thematic in obj.thematics.all()])
|
return ", ".join([thematic.name for thematic in obj.thematics.all()])
|
||||||
thematics_list.short_description = 'Thématiques'
|
thematics_list.short_description = 'Thématiques'
|
||||||
|
|
@ -258,18 +268,61 @@ class CompanyTeamMemberInline(admin.TabularInline):
|
||||||
verbose_name_plural = "Membres"
|
verbose_name_plural = "Membres"
|
||||||
|
|
||||||
|
|
||||||
|
from django.contrib.gis import admin as gis_admin
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyTeamAdminForm(forms.ModelForm):
|
||||||
|
operation_types = forms.MultipleChoiceField(
|
||||||
|
choices=CompanyTeam.OPERATION_TYPES,
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
required=False,
|
||||||
|
label=_("Types d'opérations gérées"),
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CompanyTeam
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
if self.instance and self.instance.pk:
|
||||||
|
self.initial['operation_types'] = self.instance.operation_types or []
|
||||||
|
|
||||||
|
def clean_operation_types(self):
|
||||||
|
return list(self.cleaned_data.get('operation_types') or [])
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CompanyTeam)
|
@admin.register(CompanyTeam)
|
||||||
class CompanyTeamAdmin(admin.ModelAdmin):
|
class CompanyTeamAdmin(gis_admin.GISModelAdmin):
|
||||||
list_display = ('name', 'company', 'thematics_list', 'members_count', 'can_generate_daily_report', 'photos_required', 'restrict_today_interventions_visibility', 'visibility_start_hour', 'restrict_to_assigned_interventions')
|
form = CompanyTeamAdminForm
|
||||||
|
list_display = (
|
||||||
|
'name', 'company', 'thematics_list', 'operation_types_list', 'has_geom',
|
||||||
|
'members_count', 'can_generate_daily_report', 'photos_required',
|
||||||
|
'restrict_today_interventions_visibility', 'visibility_start_hour',
|
||||||
|
'restrict_to_assigned_interventions'
|
||||||
|
)
|
||||||
search_fields = ('name', 'company__name')
|
search_fields = ('name', 'company__name')
|
||||||
list_filter = ('company', 'can_generate_daily_report', 'photos_required', 'restrict_today_interventions_visibility', 'restrict_to_assigned_interventions')
|
list_filter = (
|
||||||
filter_horizontal = ('thematics',)
|
'company', 'can_generate_daily_report', 'photos_required',
|
||||||
|
'restrict_today_interventions_visibility', 'restrict_to_assigned_interventions'
|
||||||
|
)
|
||||||
|
filter_horizontal = ('thematics', 'asset_categories')
|
||||||
inlines = [CompanyTeamMemberInline]
|
inlines = [CompanyTeamMemberInline]
|
||||||
|
|
||||||
def thematics_list(self, obj):
|
def thematics_list(self, obj):
|
||||||
return ", ".join([thematic.name for thematic in obj.thematics.all()])
|
return ", ".join([thematic.name for thematic in obj.thematics.all()])
|
||||||
thematics_list.short_description = 'Thématiques'
|
thematics_list.short_description = 'Thématiques'
|
||||||
|
|
||||||
|
def operation_types_list(self, obj):
|
||||||
|
return obj.get_operation_types_display() or "—"
|
||||||
|
operation_types_list.short_description = _("Opérations gérées")
|
||||||
|
|
||||||
|
def has_geom(self, obj):
|
||||||
|
return bool(obj.geom)
|
||||||
|
has_geom.boolean = True
|
||||||
|
has_geom.short_description = _("Zone définie")
|
||||||
|
|
||||||
def members_count(self, obj):
|
def members_count(self, obj):
|
||||||
return obj.members.count()
|
return obj.members.count()
|
||||||
members_count.short_description = 'Nb membres'
|
members_count.short_description = 'Nb membres'
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -2270,6 +2270,90 @@ msgstr "You do not have permission to generate documents for this order."
|
||||||
msgid "Déclaration de créance créée et sauvegardée."
|
msgid "Déclaration de créance créée et sauvegardée."
|
||||||
msgstr "Debt declaration created and saved."
|
msgstr "Debt declaration created and saved."
|
||||||
|
|
||||||
|
#: contracts/models.py:73
|
||||||
|
msgid "Inspections"
|
||||||
|
msgstr "Inspections"
|
||||||
|
|
||||||
|
#: contracts/models.py:75
|
||||||
|
msgid "Contrôles"
|
||||||
|
msgstr "Controls"
|
||||||
|
|
||||||
|
#: contracts/models.py:76
|
||||||
|
msgid "Observations"
|
||||||
|
msgstr "Observations"
|
||||||
|
|
||||||
|
#: contracts/models.py:93
|
||||||
|
msgid "Zone géographique"
|
||||||
|
msgstr "Geographic zone"
|
||||||
|
|
||||||
|
#: contracts/models.py:94
|
||||||
|
msgid "Emprise géographique multi-polygone pour l'attribution automatique."
|
||||||
|
msgstr "Multi-polygon geographic footprint for automatic assignment."
|
||||||
|
|
||||||
|
#: contracts/models.py:99 contracts/admin.py:274
|
||||||
|
msgid "Types d'opérations gérées"
|
||||||
|
msgstr "Managed operation types"
|
||||||
|
|
||||||
|
#: contracts/models.py:100
|
||||||
|
msgid "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)."
|
||||||
|
msgstr "Operation types supported by this team (e.g. Inspections, Interventions, Controls, Observations)."
|
||||||
|
|
||||||
|
#: contracts/admin.py:312
|
||||||
|
msgid "Opérations gérées"
|
||||||
|
msgstr "Managed operations"
|
||||||
|
|
||||||
|
#: contracts/admin.py:318
|
||||||
|
msgid "Zone définie"
|
||||||
|
msgstr "Defined zone"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:679
|
||||||
|
msgid "1 Mois"
|
||||||
|
msgstr "1 Month"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:684
|
||||||
|
msgid "Portée :"
|
||||||
|
msgstr "Scope:"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:685
|
||||||
|
msgid "Durée appliquée lors de l'assignation ou du retrait d'un membre"
|
||||||
|
msgstr "Duration applied when assigning or removing a member"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:686
|
||||||
|
msgid "Jour"
|
||||||
|
msgstr "Day"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:687
|
||||||
|
msgid "Semaine"
|
||||||
|
msgstr "Week"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:688
|
||||||
|
msgid "Mois"
|
||||||
|
msgstr "Month"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:887
|
||||||
|
msgid "Un mois complet"
|
||||||
|
msgstr "A full month"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:924
|
||||||
|
msgid "Mois source"
|
||||||
|
msgstr "Source month"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:928
|
||||||
|
msgid "Mois cible"
|
||||||
|
msgstr "Target month"
|
||||||
|
|
||||||
|
#: contracts/views_teams.py:648 contracts/views_teams.py:664
|
||||||
|
msgid "Impossible de modifier une période passée."
|
||||||
|
msgstr "Cannot modify a past period."
|
||||||
|
|
||||||
|
#: contracts/views_teams.py:790
|
||||||
|
msgid "Le mois cible ne peut pas être dans le passé."
|
||||||
|
msgstr "Target month cannot be in the past."
|
||||||
|
|
||||||
|
#: contracts/views_teams.py:792
|
||||||
|
msgid "La date cible ne peut pas être dans le passé."
|
||||||
|
msgstr "Target date cannot be in the past."
|
||||||
|
|
||||||
#~ msgid "Ce déplacement modifie l'équipe de"
|
#~ msgid "Ce déplacement modifie l'équipe de"
|
||||||
#~ msgstr "This move changes the team of"
|
#~ msgstr "This move changes the team of"
|
||||||
|
|
||||||
|
|
@ -2278,3 +2362,12 @@ msgstr "Debt declaration created and saved."
|
||||||
|
|
||||||
#~ msgid "Confirmer le changement immédiat"
|
#~ msgid "Confirmer le changement immédiat"
|
||||||
#~ msgstr "Confirm immediate change"
|
#~ msgstr "Confirm immediate change"
|
||||||
|
|
||||||
|
#: contracts/models.py:435
|
||||||
|
msgid "Assigner automatiquement une équipe de contrôle"
|
||||||
|
msgstr "Automatically assign a control team"
|
||||||
|
|
||||||
|
#: contracts/models.py:436
|
||||||
|
msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."
|
||||||
|
msgstr "If enabled, a control team is automatically assigned to corrective interventions for this contract."
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -2320,6 +2320,90 @@ msgstr ""
|
||||||
msgid "Déclaration de créance créée et sauvegardée."
|
msgid "Déclaration de créance créée et sauvegardée."
|
||||||
msgstr "Déclaration de créance créée et sauvegardée."
|
msgstr "Déclaration de créance créée et sauvegardée."
|
||||||
|
|
||||||
|
#: contracts/models.py:73
|
||||||
|
msgid "Inspections"
|
||||||
|
msgstr "Inspections"
|
||||||
|
|
||||||
|
#: contracts/models.py:75
|
||||||
|
msgid "Contrôles"
|
||||||
|
msgstr "Contrôles"
|
||||||
|
|
||||||
|
#: contracts/models.py:76
|
||||||
|
msgid "Observations"
|
||||||
|
msgstr "Observations"
|
||||||
|
|
||||||
|
#: contracts/models.py:93
|
||||||
|
msgid "Zone géographique"
|
||||||
|
msgstr "Zone géographique"
|
||||||
|
|
||||||
|
#: contracts/models.py:94
|
||||||
|
msgid "Emprise géographique multi-polygone pour l'attribution automatique."
|
||||||
|
msgstr "Emprise géographique multi-polygone pour l'attribution automatique."
|
||||||
|
|
||||||
|
#: contracts/models.py:99 contracts/admin.py:274
|
||||||
|
msgid "Types d'opérations gérées"
|
||||||
|
msgstr "Types d'opérations gérées"
|
||||||
|
|
||||||
|
#: contracts/models.py:100
|
||||||
|
msgid "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)."
|
||||||
|
msgstr "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)."
|
||||||
|
|
||||||
|
#: contracts/admin.py:312
|
||||||
|
msgid "Opérations gérées"
|
||||||
|
msgstr "Opérations gérées"
|
||||||
|
|
||||||
|
#: contracts/admin.py:318
|
||||||
|
msgid "Zone définie"
|
||||||
|
msgstr "Zone définie"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:679
|
||||||
|
msgid "1 Mois"
|
||||||
|
msgstr "1 Mois"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:684
|
||||||
|
msgid "Portée :"
|
||||||
|
msgstr "Portée :"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:685
|
||||||
|
msgid "Durée appliquée lors de l'assignation ou du retrait d'un membre"
|
||||||
|
msgstr "Durée appliquée lors de l'assignation ou du retrait d'un membre"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:686
|
||||||
|
msgid "Jour"
|
||||||
|
msgstr "Jour"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:687
|
||||||
|
msgid "Semaine"
|
||||||
|
msgstr "Semaine"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:688
|
||||||
|
msgid "Mois"
|
||||||
|
msgstr "Mois"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:887
|
||||||
|
msgid "Un mois complet"
|
||||||
|
msgstr "Un mois complet"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:924
|
||||||
|
msgid "Mois source"
|
||||||
|
msgstr "Mois source"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:928
|
||||||
|
msgid "Mois cible"
|
||||||
|
msgstr "Mois cible"
|
||||||
|
|
||||||
|
#: contracts/views_teams.py:648 contracts/views_teams.py:664
|
||||||
|
msgid "Impossible de modifier une période passée."
|
||||||
|
msgstr "Impossible de modifier une période passée."
|
||||||
|
|
||||||
|
#: contracts/views_teams.py:790
|
||||||
|
msgid "Le mois cible ne peut pas être dans le passé."
|
||||||
|
msgstr "Le mois cible ne peut pas être dans le passé."
|
||||||
|
|
||||||
|
#: contracts/views_teams.py:792
|
||||||
|
msgid "La date cible ne peut pas être dans le passé."
|
||||||
|
msgstr "La date cible ne peut pas être dans le passé."
|
||||||
|
|
||||||
#~ msgid "Ce déplacement modifie l'équipe de"
|
#~ msgid "Ce déplacement modifie l'équipe de"
|
||||||
#~ msgstr "Ce déplacement modifie l'équipe de"
|
#~ msgstr "Ce déplacement modifie l'équipe de"
|
||||||
|
|
||||||
|
|
@ -2328,3 +2412,12 @@ msgstr "Déclaration de créance créée et sauvegardée."
|
||||||
|
|
||||||
#~ msgid "Confirmer le changement immédiat"
|
#~ msgid "Confirmer le changement immédiat"
|
||||||
#~ msgstr "Confirmer le changement immédiat"
|
#~ msgstr "Confirmer le changement immédiat"
|
||||||
|
|
||||||
|
#: contracts/models.py:435
|
||||||
|
msgid "Assigner automatiquement une équipe de contrôle"
|
||||||
|
msgstr "Assigner automatiquement une équipe de contrôle"
|
||||||
|
|
||||||
|
#: contracts/models.py:436
|
||||||
|
msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."
|
||||||
|
msgstr "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -2276,6 +2276,90 @@ msgstr ""
|
||||||
msgid "Déclaration de créance créée et sauvegardée."
|
msgid "Déclaration de créance créée et sauvegardée."
|
||||||
msgstr "Schuldvordering aangemaakt en opgeslagen."
|
msgstr "Schuldvordering aangemaakt en opgeslagen."
|
||||||
|
|
||||||
|
#: contracts/models.py:73
|
||||||
|
msgid "Inspections"
|
||||||
|
msgstr "Inspecties"
|
||||||
|
|
||||||
|
#: contracts/models.py:75
|
||||||
|
msgid "Contrôles"
|
||||||
|
msgstr "Controles"
|
||||||
|
|
||||||
|
#: contracts/models.py:76
|
||||||
|
msgid "Observations"
|
||||||
|
msgstr "Observaties"
|
||||||
|
|
||||||
|
#: contracts/models.py:93
|
||||||
|
msgid "Zone géographique"
|
||||||
|
msgstr "Geografische zone"
|
||||||
|
|
||||||
|
#: contracts/models.py:94
|
||||||
|
msgid "Emprise géographique multi-polygone pour l'attribution automatique."
|
||||||
|
msgstr "Geografische multi-polygoondekking voor automatische toewijzing."
|
||||||
|
|
||||||
|
#: contracts/models.py:99 contracts/admin.py:274
|
||||||
|
msgid "Types d'opérations gérées"
|
||||||
|
msgstr "Beheerde bewerkingstypes"
|
||||||
|
|
||||||
|
#: contracts/models.py:100
|
||||||
|
msgid "Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations)."
|
||||||
|
msgstr "Bewerkingstypes die door dit team worden ondersteund (bijv. Inspecties, Interventies, Controles, Observaties)."
|
||||||
|
|
||||||
|
#: contracts/admin.py:312
|
||||||
|
msgid "Opérations gérées"
|
||||||
|
msgstr "Beheerde bewerkingen"
|
||||||
|
|
||||||
|
#: contracts/admin.py:318
|
||||||
|
msgid "Zone définie"
|
||||||
|
msgstr "Gedefinieerde zone"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:679
|
||||||
|
msgid "1 Mois"
|
||||||
|
msgstr "1 Maand"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:684
|
||||||
|
msgid "Portée :"
|
||||||
|
msgstr "Bereik:"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:685
|
||||||
|
msgid "Durée appliquée lors de l'assignation ou du retrait d'un membre"
|
||||||
|
msgstr "Duur toegepast bij de toewijzing of verwijdering van een lid"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:686
|
||||||
|
msgid "Jour"
|
||||||
|
msgstr "Dag"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:687
|
||||||
|
msgid "Semaine"
|
||||||
|
msgstr "Week"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:688
|
||||||
|
msgid "Mois"
|
||||||
|
msgstr "Maand"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:887
|
||||||
|
msgid "Un mois complet"
|
||||||
|
msgstr "Een volledige maand"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:924
|
||||||
|
msgid "Mois source"
|
||||||
|
msgstr "Bronmaand"
|
||||||
|
|
||||||
|
#: contracts/templates/contracts/teams_index.html:928
|
||||||
|
msgid "Mois cible"
|
||||||
|
msgstr "Doelmaand"
|
||||||
|
|
||||||
|
#: contracts/views_teams.py:648 contracts/views_teams.py:664
|
||||||
|
msgid "Impossible de modifier une période passée."
|
||||||
|
msgstr "Kan een verstreken periode niet wijzigen."
|
||||||
|
|
||||||
|
#: contracts/views_teams.py:790
|
||||||
|
msgid "Le mois cible ne peut pas être dans le passé."
|
||||||
|
msgstr "De doelmaand kan niet in het verleden liggen."
|
||||||
|
|
||||||
|
#: contracts/views_teams.py:792
|
||||||
|
msgid "La date cible ne peut pas être dans le passé."
|
||||||
|
msgstr "De doeldatum kan niet in het verleden liggen."
|
||||||
|
|
||||||
#~ msgid "Ce déplacement modifie l'équipe de"
|
#~ msgid "Ce déplacement modifie l'équipe de"
|
||||||
#~ msgstr "Deze verplaatsing wijzigt het team van"
|
#~ msgstr "Deze verplaatsing wijzigt het team van"
|
||||||
|
|
||||||
|
|
@ -2284,3 +2368,12 @@ msgstr "Schuldvordering aangemaakt en opgeslagen."
|
||||||
|
|
||||||
#~ msgid "Confirmer le changement immédiat"
|
#~ msgid "Confirmer le changement immédiat"
|
||||||
#~ msgstr "Onmiddellijke wijziging bevestigen"
|
#~ msgstr "Onmiddellijke wijziging bevestigen"
|
||||||
|
|
||||||
|
#: contracts/models.py:435
|
||||||
|
msgid "Assigner automatiquement une équipe de contrôle"
|
||||||
|
msgstr "Automatisch een controleteam toewijzen"
|
||||||
|
|
||||||
|
#: contracts/models.py:436
|
||||||
|
msgid "Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."
|
||||||
|
msgstr "Indien geactiveerd wordt automatisch een controleteam toegewezen aan de correctieve interventies van dit contract."
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-09-17 12:19
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contracts', '0006_usercompanyteamaccess_can_tag'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contract',
|
||||||
|
name='approval_except_urgent',
|
||||||
|
field=models.BooleanField(default=True, help_text="Indique si la nécessité d'approbation est levée pour les interventions à priorité urgente.", verbose_name='Sauf si urgence'),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-09-19 08:32
|
||||||
|
|
||||||
|
import django.contrib.gis.db.models.fields
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contracts', '0007_contract_approval_except_urgent'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='companyteam',
|
||||||
|
name='geom',
|
||||||
|
field=django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, help_text="Emprise géographique multi-polygone pour l'attribution automatique.", null=True, srid=3812, verbose_name='Zone géographique'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='companyteam',
|
||||||
|
name='operation_types',
|
||||||
|
field=models.JSONField(blank=True, default=list, help_text="Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations).", verbose_name="Types d'opérations gérées"),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-09-19 11:56
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('contracts', '0008_companyteam_geom_companyteam_operation_types'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contract',
|
||||||
|
name='auto_assign_control_team',
|
||||||
|
field=models.BooleanField(default=False, help_text='Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat.', verbose_name='Assigner automatiquement une équipe de contrôle'),
|
||||||
|
),
|
||||||
|
]
|
||||||
20
loko/contracts/migrations/0010_contract_municipality.py
Normal file
20
loko/contracts/migrations/0010_contract_municipality.py
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
# Generated by Django 6.0.7 on 2026-09-19 13:49
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0015_remove_obsolete_schools_appview'),
|
||||||
|
('contracts', '0009_contract_auto_assign_control_team'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contract',
|
||||||
|
name='municipality',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='Commune couverte par ce contrat (permet le cadrage cartographique automatique).', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='contracts', to='common.municipality', verbose_name='Commune associée'),
|
||||||
|
),
|
||||||
|
]
|
||||||
46
loko/contracts/migrations/0011_contract_municipalities.py
Normal file
46
loko/contracts/migrations/0011_contract_municipalities.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Generated manually for multi-commune contracts
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def copy_municipality_to_municipalities(apps, schema_editor):
|
||||||
|
Contract = apps.get_model('contracts', 'Contract')
|
||||||
|
for contract in Contract.objects.all():
|
||||||
|
if getattr(contract, 'municipality_id', None):
|
||||||
|
contract.municipalities.add(contract.municipality_id)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_copy(apps, schema_editor):
|
||||||
|
Contract = apps.get_model('contracts', 'Contract')
|
||||||
|
for contract in Contract.objects.all():
|
||||||
|
first_m = contract.municipalities.first()
|
||||||
|
if first_m:
|
||||||
|
contract.municipality_id = first_m.id
|
||||||
|
contract.save(update_fields=['municipality_id'])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('common', '0015_remove_obsolete_schools_appview'),
|
||||||
|
('contracts', '0010_contract_municipality'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='contract',
|
||||||
|
name='municipalities',
|
||||||
|
field=models.ManyToManyField(
|
||||||
|
blank=True,
|
||||||
|
help_text='Communes couvertes par ce contrat (permet le filtrage et le cadrage cartographique).',
|
||||||
|
related_name='contracts',
|
||||||
|
to='common.municipality',
|
||||||
|
verbose_name='Communes associées',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(copy_municipality_to_municipalities, reverse_code=reverse_copy),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='contract',
|
||||||
|
name='municipality',
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -6,6 +6,8 @@ from django.utils.translation import gettext_lazy as _
|
||||||
from django.utils.translation import get_language
|
from django.utils.translation import get_language
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from django.contrib.gis.db import models as gis_models
|
||||||
|
|
||||||
from common.models import Thematic
|
from common.models import Thematic
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -54,12 +56,6 @@ class MaintainType(models.Model):
|
||||||
return self.name_nl or self.name_fr
|
return self.name_nl or self.name_fr
|
||||||
return self.name_fr
|
return self.name_fr
|
||||||
|
|
||||||
def get_name(self):
|
|
||||||
lang = get_language()
|
|
||||||
if lang and lang.startswith('nl'):
|
|
||||||
return self.name_nl or self.name_fr
|
|
||||||
return self.name_fr
|
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = _("Type de maintenance")
|
verbose_name = _("Type de maintenance")
|
||||||
verbose_name_plural = _("Types de maintenance")
|
verbose_name_plural = _("Types de maintenance")
|
||||||
|
|
@ -68,6 +64,18 @@ class MaintainType(models.Model):
|
||||||
|
|
||||||
class CompanyTeam(models.Model):
|
class CompanyTeam(models.Model):
|
||||||
"""Modèle représentant une équipe au sein d'une société ou d'un organisme"""
|
"""Modèle représentant une équipe au sein d'une société ou d'un organisme"""
|
||||||
|
|
||||||
|
OPERATION_INSPECTIONS = 'inspections'
|
||||||
|
OPERATION_INTERVENTIONS = 'interventions'
|
||||||
|
OPERATION_CONTROLS = 'controls'
|
||||||
|
OPERATION_OBSERVATIONS = 'observations'
|
||||||
|
OPERATION_TYPES = [
|
||||||
|
(OPERATION_INSPECTIONS, _('Inspections')),
|
||||||
|
(OPERATION_INTERVENTIONS, _('Interventions')),
|
||||||
|
(OPERATION_CONTROLS, _('Contrôles')),
|
||||||
|
(OPERATION_OBSERVATIONS, _('Observations')),
|
||||||
|
]
|
||||||
|
|
||||||
name = models.CharField(max_length=255, unique=True)
|
name = models.CharField(max_length=255, unique=True)
|
||||||
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name="teams")
|
company = models.ForeignKey(Company, on_delete=models.CASCADE, related_name="teams")
|
||||||
thematics = models.ManyToManyField('common.Thematic', blank=True, related_name="company_teams")
|
thematics = models.ManyToManyField('common.Thematic', blank=True, related_name="company_teams")
|
||||||
|
|
@ -78,6 +86,19 @@ class CompanyTeam(models.Model):
|
||||||
verbose_name=_("Catégories d'assets gérées"),
|
verbose_name=_("Catégories d'assets gérées"),
|
||||||
help_text=_("Catégories d'assets dont cette équipe est responsable."),
|
help_text=_("Catégories d'assets dont cette équipe est responsable."),
|
||||||
)
|
)
|
||||||
|
geom = gis_models.MultiPolygonField(
|
||||||
|
srid=3812,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Zone géographique"),
|
||||||
|
help_text=_("Emprise géographique multi-polygone pour l'attribution automatique.")
|
||||||
|
)
|
||||||
|
operation_types = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_("Types d'opérations gérées"),
|
||||||
|
help_text=_("Types d'opérations prises en charge par cette équipe (ex: Inspections, Interventions, Contrôles, Observations).")
|
||||||
|
)
|
||||||
is_assets_manager = models.BooleanField(
|
is_assets_manager = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_("Gestionnaire d'assets"),
|
verbose_name=_("Gestionnaire d'assets"),
|
||||||
|
|
@ -115,6 +136,17 @@ class CompanyTeam(models.Model):
|
||||||
help_text=_("Si activé, les membres de cette équipe ne voient que les interventions assignées à l'équipe ou directement à un membre."),
|
help_text=_("Si activé, les membres de cette équipe ne voient que les interventions assignées à l'équipe ou directement à un membre."),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def handles_operation(self, op_type: str) -> bool:
|
||||||
|
"""Indique si l'équipe prend en charge le type d'opération donné."""
|
||||||
|
if not self.operation_types:
|
||||||
|
return False
|
||||||
|
return op_type in self.operation_types
|
||||||
|
|
||||||
|
def get_operation_types_display(self) -> str:
|
||||||
|
"""Retourne la liste lisible des types d'opérations gérées."""
|
||||||
|
mapping = dict(self.OPERATION_TYPES)
|
||||||
|
return ", ".join(str(mapping.get(op, op)) for op in (self.operation_types or []))
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.name} ({self.company.name})"
|
return f"{self.name} ({self.company.name})"
|
||||||
|
|
||||||
|
|
@ -340,6 +372,18 @@ class Contract(models.Model):
|
||||||
verbose_name=_("Thématiques"),
|
verbose_name=_("Thématiques"),
|
||||||
help_text=_("Thématiques associées à ce contrat."),
|
help_text=_("Thématiques associées à ce contrat."),
|
||||||
)
|
)
|
||||||
|
municipalities = models.ManyToManyField(
|
||||||
|
'common.Municipality',
|
||||||
|
blank=True,
|
||||||
|
related_name="contracts",
|
||||||
|
verbose_name=_("Communes associées"),
|
||||||
|
help_text=_("Communes couvertes par ce contrat (permet le filtrage et le cadrage cartographique).")
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def municipality(self):
|
||||||
|
"""Retourne la première commune associée (compatibilité ascendante)."""
|
||||||
|
return self.municipalities.first()
|
||||||
is_active = models.BooleanField(
|
is_active = models.BooleanField(
|
||||||
default=True,
|
default=True,
|
||||||
verbose_name=_("Actif"),
|
verbose_name=_("Actif"),
|
||||||
|
|
@ -350,6 +394,11 @@ class Contract(models.Model):
|
||||||
verbose_name=_("Nécessite une approbation"),
|
verbose_name=_("Nécessite une approbation"),
|
||||||
help_text=_("Indique si les interventions liées à ce contrat nécessitent une approbation avant d'être transmises pour traitement."),
|
help_text=_("Indique si les interventions liées à ce contrat nécessitent une approbation avant d'être transmises pour traitement."),
|
||||||
)
|
)
|
||||||
|
approval_except_urgent = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
verbose_name=_("Sauf si urgence"),
|
||||||
|
help_text=_("Indique si la nécessité d'approbation est levée pour les interventions à priorité urgente."),
|
||||||
|
)
|
||||||
needs_checking = models.BooleanField(
|
needs_checking = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_("Nécessite une vérification"),
|
verbose_name=_("Nécessite une vérification"),
|
||||||
|
|
@ -395,6 +444,11 @@ class Contract(models.Model):
|
||||||
verbose_name=_("Modification inter-membres d'équipe autorisée"),
|
verbose_name=_("Modification inter-membres d'équipe autorisée"),
|
||||||
help_text=_("Si activé, les membres d'une même équipe peuvent modifier les interventions assignées à d'autres membres de leur équipe, y compris à partir du statut 'Pris en charge' ou au-delà."),
|
help_text=_("Si activé, les membres d'une même équipe peuvent modifier les interventions assignées à d'autres membres de leur équipe, y compris à partir du statut 'Pris en charge' ou au-delà."),
|
||||||
)
|
)
|
||||||
|
auto_assign_control_team = models.BooleanField(
|
||||||
|
default=False,
|
||||||
|
verbose_name=_("Assigner automatiquement une équipe de contrôle"),
|
||||||
|
help_text=_("Si activé, une équipe de contrôle est automatiquement assignée aux interventions correctives de ce contrat."),
|
||||||
|
)
|
||||||
is_blocked = models.BooleanField(
|
is_blocked = models.BooleanField(
|
||||||
default=False,
|
default=False,
|
||||||
verbose_name=_("Marché bloqué / gelé"),
|
verbose_name=_("Marché bloqué / gelé"),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
Service de gestion temporelle et de planification des équipes.
|
Service de gestion temporelle et de planification des équipes.
|
||||||
Gère les appartenances continues, l'historique d'équipe, les absences journalières et la duplication.
|
Gère les appartenances continues, l'historique d'équipe, les absences journalières et la duplication.
|
||||||
"""
|
"""
|
||||||
|
import calendar
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import timedelta, date
|
from datetime import timedelta, date
|
||||||
from typing import Optional, List, Dict, Set, Any
|
from typing import Optional, List, Dict, Set, Any
|
||||||
|
|
@ -182,6 +183,51 @@ def set_member_team_schedule(
|
||||||
return new_history
|
return new_history
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def set_member_team_period_schedule(
|
||||||
|
member: CompanyMember,
|
||||||
|
team_id: Optional[int],
|
||||||
|
start_date: date,
|
||||||
|
end_date: date,
|
||||||
|
action: str = 'move',
|
||||||
|
unassign_team_id: Optional[int] = None,
|
||||||
|
changed_by=None,
|
||||||
|
weekdays_only: bool = False,
|
||||||
|
) -> int:
|
||||||
|
"""
|
||||||
|
Affecte ou désaffecte un membre sur une plage de dates continue [start_date, end_date].
|
||||||
|
Permet de définir des semaines complètes ou des mois entiers en une seule opération.
|
||||||
|
Ne modifie pas les jours passés (< aujourd'hui).
|
||||||
|
Retourne le nombre de jours mis à jour.
|
||||||
|
"""
|
||||||
|
today = timezone.now().date()
|
||||||
|
num_days = (end_date - start_date).days + 1
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for i in range(num_days):
|
||||||
|
d = start_date + timedelta(days=i)
|
||||||
|
if d < today:
|
||||||
|
continue
|
||||||
|
if weekdays_only and d.weekday() >= 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
set_member_team_schedule(
|
||||||
|
member=member,
|
||||||
|
team_id=team_id,
|
||||||
|
target_date=d,
|
||||||
|
changed_by=changed_by,
|
||||||
|
sync_current=False,
|
||||||
|
action=action,
|
||||||
|
unassign_team_id=unassign_team_id,
|
||||||
|
)
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
if start_date <= today <= end_date:
|
||||||
|
sync_member_current_teams(member, today)
|
||||||
|
|
||||||
|
return updated_count
|
||||||
|
|
||||||
|
|
||||||
def _sync_teams_if_needed(member: CompanyMember, target_date: date, today: date, sync_current: bool):
|
def _sync_teams_if_needed(member: CompanyMember, target_date: date, today: date, sync_current: bool):
|
||||||
"""Synchronise member.teams si la date modifiée impacte la date d'aujourd'hui."""
|
"""Synchronise member.teams si la date modifiée impacte la date d'aujourd'hui."""
|
||||||
if sync_current and target_date <= today:
|
if sync_current and target_date <= today:
|
||||||
|
|
@ -486,3 +532,40 @@ def duplicate_week_schedule(
|
||||||
total_absences += res['absences_count']
|
total_absences += res['absences_count']
|
||||||
|
|
||||||
return {'assigned_count': total_assigned, 'absences_count': total_absences}
|
return {'assigned_count': total_assigned, 'absences_count': total_absences}
|
||||||
|
|
||||||
|
|
||||||
|
@transaction.atomic
|
||||||
|
def duplicate_month_schedule(
|
||||||
|
source_year: int,
|
||||||
|
source_month: int,
|
||||||
|
target_year: int,
|
||||||
|
target_month: int,
|
||||||
|
allowed_team_ids: Optional[Set[int]] = None,
|
||||||
|
copy_absences: bool = False,
|
||||||
|
changed_by=None,
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Duplique la configuration des équipes d'un mois source vers un mois cible (jour par jour).
|
||||||
|
Gère automatiquement les longueurs de mois différentes (28 à 31 jours).
|
||||||
|
"""
|
||||||
|
_, src_days = calendar.monthrange(source_year, source_month)
|
||||||
|
_, tgt_days = calendar.monthrange(target_year, target_month)
|
||||||
|
days_to_copy = min(src_days, tgt_days)
|
||||||
|
|
||||||
|
total_assigned = 0
|
||||||
|
total_absences = 0
|
||||||
|
|
||||||
|
for day_num in range(1, days_to_copy + 1):
|
||||||
|
s_day = date(source_year, source_month, day_num)
|
||||||
|
t_day = date(target_year, target_month, day_num)
|
||||||
|
res = duplicate_day_schedule(
|
||||||
|
source_date=s_day,
|
||||||
|
target_date=t_day,
|
||||||
|
allowed_team_ids=allowed_team_ids,
|
||||||
|
copy_absences=copy_absences,
|
||||||
|
changed_by=changed_by,
|
||||||
|
)
|
||||||
|
total_assigned += res['assigned_count']
|
||||||
|
total_absences += res['absences_count']
|
||||||
|
|
||||||
|
return {'assigned_count': total_assigned, 'absences_count': total_absences}
|
||||||
|
|
|
||||||
16
loko/contracts/static/contracts/css/contract_admin.css
Normal file
16
loko/contracts/static/contracts/css/contract_admin.css
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/* Indentation et mise en valeur ergonomique de l'option "Sauf si urgence" */
|
||||||
|
.form-row.field-approval_except_urgent {
|
||||||
|
margin-left: 32px !important;
|
||||||
|
padding-left: 16px !important;
|
||||||
|
border-left: 3px solid #79aec8 !important;
|
||||||
|
background-color: rgba(121, 174, 200, 0.04);
|
||||||
|
transition: opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row.field-approval_except_urgent .checkbox-row {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row.field-approval_except_urgent label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
19
loko/contracts/static/contracts/js/contract_admin.js
Normal file
19
loko/contracts/static/contracts/js/contract_admin.js
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const needsApprovalCheckbox = document.getElementById('id_needs_approval');
|
||||||
|
const exceptUrgentRow = document.querySelector('.form-row.field-approval_except_urgent');
|
||||||
|
|
||||||
|
if (needsApprovalCheckbox && exceptUrgentRow) {
|
||||||
|
function syncExceptUrgentVisibility() {
|
||||||
|
if (needsApprovalCheckbox.checked) {
|
||||||
|
exceptUrgentRow.style.opacity = '1';
|
||||||
|
exceptUrgentRow.style.pointerEvents = 'auto';
|
||||||
|
} else {
|
||||||
|
exceptUrgentRow.style.opacity = '0.45';
|
||||||
|
exceptUrgentRow.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
needsApprovalCheckbox.addEventListener('change', syncExceptUrgentVisibility);
|
||||||
|
syncExceptUrgentVisibility();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -138,29 +138,79 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Onglet 2 : Planification / Calendrier des équipes --- */
|
/* --- Onglet 2 : Planification / Calendrier des équipes --- */
|
||||||
|
body.tab-planning-active #content-wrapper,
|
||||||
|
#content-wrapper:has(#tab-planning.active) {
|
||||||
|
overflow-y: hidden !important;
|
||||||
|
padding-bottom: 2px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
max-height: calc(100vh - 65px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.teams-header-row {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-bottom: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#teamsTabContent {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-pane.active {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tab-current-state.active {
|
||||||
|
overflow-y: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#tab-planning.active {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.planning-toolbar {
|
.planning-toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 10px 14px;
|
padding: 8px 12px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 8px;
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.planning-layout {
|
.planning-layout {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
align-items: flex-start;
|
align-items: stretch;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.planning-matrix-wrapper {
|
.planning-matrix-wrapper {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: calc(100vh - 170px);
|
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
|
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
@ -168,19 +218,19 @@
|
||||||
.planning-sidebar-wrapper {
|
.planning-sidebar-wrapper {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
flex: 0 0 300px;
|
flex: 0 0 300px;
|
||||||
|
height: 100%;
|
||||||
background: #ffffff;
|
background: #ffffff;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
|
box-shadow: 0 2px 6px rgba(0,0,0,0.04);
|
||||||
position: sticky;
|
|
||||||
top: 20px;
|
|
||||||
max-height: calc(100vh - 170px);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.planning-sidebar-header {
|
.planning-sidebar-header {
|
||||||
padding: 12px 14px;
|
flex-shrink: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
border-bottom: 1px solid #f0f0f0;
|
border-bottom: 1px solid #f0f0f0;
|
||||||
background: #fdfdfd;
|
background: #fdfdfd;
|
||||||
border-top-left-radius: 8px;
|
border-top-left-radius: 8px;
|
||||||
|
|
@ -188,9 +238,10 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.planning-sidebar-body {
|
.planning-sidebar-body {
|
||||||
padding: 10px 14px;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Table planning */
|
/* Table planning */
|
||||||
|
|
@ -446,10 +497,10 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid py-3">
|
<div class="container-fluid px-3 pt-2 pb-0 teams-container">
|
||||||
|
|
||||||
<!-- En-tête avec titre & onglets -->
|
<!-- En-tête avec titre & onglets -->
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
|
<div class="d-flex justify-content-between align-items-center mb-2 flex-wrap gap-2 teams-header-row">
|
||||||
<div>
|
<div>
|
||||||
<h4 class="mb-0">
|
<h4 class="mb-0">
|
||||||
<i class="bi bi-people-fill me-2 text-primary"></i>{% trans "Gestion des équipes" %}
|
<i class="bi bi-people-fill me-2 text-primary"></i>{% trans "Gestion des équipes" %}
|
||||||
|
|
@ -672,10 +723,24 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sélecteur de mode de vue -->
|
<!-- Sélecteur de mode de vue -->
|
||||||
|
<div class="d-flex align-items-center gap-1">
|
||||||
|
<span class="text-muted small text-nowrap"><i class="bi bi-eye me-1"></i>{% trans "Vue :" %}</span>
|
||||||
<div class="btn-group btn-group-sm" role="group" id="planning-viewmode-group">
|
<div class="btn-group btn-group-sm" role="group" id="planning-viewmode-group">
|
||||||
<button type="button" class="btn btn-outline-primary" data-mode="work_week">{% trans "5j (Ouvré)" %}</button>
|
<button type="button" class="btn btn-outline-primary" data-mode="work_week">{% trans "5j (Ouvré)" %}</button>
|
||||||
<button type="button" class="btn btn-primary" data-mode="week">{% trans "1 Semaine (7j)" %}</button>
|
<button type="button" class="btn btn-primary" data-mode="week">{% trans "1 Semaine (7j)" %}</button>
|
||||||
<button type="button" class="btn btn-outline-primary" data-mode="two_weeks">{% trans "2 Semaines" %}</button>
|
<button type="button" class="btn btn-outline-primary" data-mode="two_weeks">{% trans "2 Semaines" %}</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary" data-mode="month">{% trans "1 Mois" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sélecteur global de portée d'assignation -->
|
||||||
|
<div class="d-flex align-items-center gap-1 ms-1 p-1 px-2 rounded" style="background: #eef2ff; border: 1px solid #c7d2fe;">
|
||||||
|
<span class="fw-semibold small text-primary-emphasis text-nowrap" title="{% trans "Période sur laquelle un membre est affecté ou retiré lors d'un glisser-déposer" %}"><i class="bi bi-calendar-check me-1 text-primary"></i>{% trans "Affecter par :" %}</span>
|
||||||
|
<div class="btn-group btn-group-sm" role="group" id="planning-assign-scope-group" title="{% trans "Durée appliquée lors de l'assignation ou du retrait d'un membre" %}">
|
||||||
|
<button type="button" class="btn btn-primary" data-scope="day">{% trans "Jour" %}</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary" data-scope="week">{% trans "Semaine" %}</button>
|
||||||
|
<button type="button" class="btn btn-outline-primary" data-scope="month">{% trans "Mois" %}</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -830,6 +895,9 @@
|
||||||
{% trans "Vous tentez de l'assigner à l'équipe" %} : <strong id="multiTeamTargetTeamName"></strong>.
|
{% trans "Vous tentez de l'assigner à l'équipe" %} : <strong id="multiTeamTargetTeamName"></strong>.
|
||||||
{% trans "Souhaitez-vous déplacer ce membre ou l'affecter aux deux équipes ?" %}
|
{% trans "Souhaitez-vous déplacer ce membre ou l'affecter aux deux équipes ?" %}
|
||||||
</p>
|
</p>
|
||||||
|
<div class="mt-2 p-2 bg-light rounded small border">
|
||||||
|
<i class="bi bi-clock-history me-1 text-primary"></i>{% trans "Portée d'affectation :" %} <strong id="multiTeamScopeDisplay">Jour</strong>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-top-0 pt-0 d-flex justify-content-between">
|
<div class="modal-footer border-top-0 pt-0 d-flex justify-content-between">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-bs-dismiss="modal">
|
||||||
|
|
@ -862,7 +930,7 @@
|
||||||
<!-- Mode de duplication -->
|
<!-- Mode de duplication -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label fw-semibold">{% trans "Que souhaitez-vous dupliquer ?" %}</label>
|
<label class="form-label fw-semibold">{% trans "Que souhaitez-vous dupliquer ?" %}</label>
|
||||||
<div class="d-flex gap-3">
|
<div class="d-flex gap-3 flex-wrap">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
<input class="form-check-input" type="radio" name="dupMode" id="dupModeDay" value="day" checked>
|
<input class="form-check-input" type="radio" name="dupMode" id="dupModeDay" value="day" checked>
|
||||||
<label class="form-check-label" for="dupModeDay">{% trans "Une journée" %}</label>
|
<label class="form-check-label" for="dupModeDay">{% trans "Une journée" %}</label>
|
||||||
|
|
@ -871,6 +939,10 @@
|
||||||
<input class="form-check-input" type="radio" name="dupMode" id="dupModeWeek" value="week">
|
<input class="form-check-input" type="radio" name="dupMode" id="dupModeWeek" value="week">
|
||||||
<label class="form-check-label" for="dupModeWeek">{% trans "Une semaine complète" %}</label>
|
<label class="form-check-label" for="dupModeWeek">{% trans "Une semaine complète" %}</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="dupMode" id="dupModeMonth" value="month">
|
||||||
|
<label class="form-check-label" for="dupModeMonth">{% trans "Un mois complet" %}</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -879,11 +951,11 @@
|
||||||
<div class="row g-2 mb-3">
|
<div class="row g-2 mb-3">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="form-label small fw-semibold">{% trans "Jour source" %}</label>
|
<label class="form-label small fw-semibold">{% trans "Jour source" %}</label>
|
||||||
<input type="text" class="form-control form-control-sm" id="dupSourceDate">
|
<input type="date" class="form-control form-control-sm" id="dupSourceDate">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="form-label small fw-semibold">{% trans "Jour cible" %}</label>
|
<label class="form-label small fw-semibold">{% trans "Jour cible" %}</label>
|
||||||
<input type="text" class="form-control form-control-sm" id="dupTargetDate">
|
<input type="date" class="form-control form-control-sm" id="dupTargetDate">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -893,11 +965,25 @@
|
||||||
<div class="row g-2 mb-3">
|
<div class="row g-2 mb-3">
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="form-label small fw-semibold">{% trans "Semaine source (Lundi)" %}</label>
|
<label class="form-label small fw-semibold">{% trans "Semaine source (Lundi)" %}</label>
|
||||||
<input type="text" class="form-control form-control-sm" id="dupSourceWeek">
|
<input type="date" class="form-control form-control-sm" id="dupSourceWeek">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="form-label small fw-semibold">{% trans "Semaine cible (Lundi)" %}</label>
|
<label class="form-label small fw-semibold">{% trans "Semaine cible (Lundi)" %}</label>
|
||||||
<input type="text" class="form-control form-control-sm" id="dupTargetWeek">
|
<input type="date" class="form-control form-control-sm" id="dupTargetWeek">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Section Mois -->
|
||||||
|
<div id="dupSectionMonth" class="d-none">
|
||||||
|
<div class="row g-2 mb-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label small fw-semibold">{% trans "Mois source" %}</label>
|
||||||
|
<input type="month" class="form-control form-control-sm" id="dupSourceMonth">
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<label class="form-label small fw-semibold">{% trans "Mois cible" %}</label>
|
||||||
|
<input type="month" class="form-control form-control-sm" id="dupTargetMonth">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1565,13 +1651,14 @@ const initialDate = (function() {
|
||||||
|
|
||||||
const initialViewMode = (function() {
|
const initialViewMode = (function() {
|
||||||
const saved = getStoredSetting('view_mode', 'week');
|
const saved = getStoredSetting('view_mode', 'week');
|
||||||
if (['work_week', 'week', 'two_weeks'].includes(saved)) return saved;
|
if (['work_week', 'week', 'two_weeks', 'month'].includes(saved)) return saved;
|
||||||
return 'week';
|
return 'week';
|
||||||
})();
|
})();
|
||||||
|
|
||||||
let _planningState = {
|
let _planningState = {
|
||||||
anchorDate: initialDate,
|
anchorDate: initialDate,
|
||||||
viewMode: initialViewMode,
|
viewMode: initialViewMode,
|
||||||
|
assignScope: getStoredSetting('assign_scope', 'day'),
|
||||||
companyId: getStoredSetting('company_id', ''),
|
companyId: getStoredSetting('company_id', ''),
|
||||||
data: null,
|
data: null,
|
||||||
members: [],
|
members: [],
|
||||||
|
|
@ -1584,6 +1671,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
$('#planning-viewmode-group button').removeClass('btn-primary').addClass('btn-outline-primary');
|
$('#planning-viewmode-group button').removeClass('btn-primary').addClass('btn-outline-primary');
|
||||||
$(`#planning-viewmode-group button[data-mode="${_planningState.viewMode}"]`).removeClass('btn-outline-primary').addClass('btn-primary');
|
$(`#planning-viewmode-group button[data-mode="${_planningState.viewMode}"]`).removeClass('btn-outline-primary').addClass('btn-primary');
|
||||||
|
|
||||||
|
// Restaurer le sélecteur global de portée d'assignation
|
||||||
|
$('#planning-assign-scope-group button').removeClass('btn-primary').addClass('btn-outline-primary');
|
||||||
|
$(`#planning-assign-scope-group button[data-scope="${_planningState.assignScope}"]`).removeClass('btn-outline-primary').addClass('btn-primary');
|
||||||
|
|
||||||
|
// Gestion du sélecteur de portée d'assignation
|
||||||
|
$('#planning-assign-scope-group button').on('click', function() {
|
||||||
|
$('#planning-assign-scope-group button').removeClass('btn-primary').addClass('btn-outline-primary');
|
||||||
|
$(this).removeClass('btn-outline-primary').addClass('btn-primary');
|
||||||
|
_planningState.assignScope = $(this).data('scope');
|
||||||
|
setStoredSetting('assign_scope', _planningState.assignScope);
|
||||||
|
});
|
||||||
|
|
||||||
// Restaurer les filtres de la sidebar
|
// Restaurer les filtres de la sidebar
|
||||||
const savedSearch = getStoredSetting('search', '');
|
const savedSearch = getStoredSetting('search', '');
|
||||||
if (savedSearch) $('#planning-member-search').val(savedSearch);
|
if (savedSearch) $('#planning-member-search').val(savedSearch);
|
||||||
|
|
@ -1621,7 +1720,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadPlanningData();
|
loadPlanningData();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Changement de mode de vue (5j / 7j / 14j)
|
// Changement de mode de vue (5j / 7j / 14j / mois)
|
||||||
$('#planning-viewmode-group button').on('click', function() {
|
$('#planning-viewmode-group button').on('click', function() {
|
||||||
$('#planning-viewmode-group button').removeClass('btn-primary').addClass('btn-outline-primary');
|
$('#planning-viewmode-group button').removeClass('btn-primary').addClass('btn-outline-primary');
|
||||||
$(this).removeClass('btn-outline-primary').addClass('btn-primary');
|
$(this).removeClass('btn-outline-primary').addClass('btn-primary');
|
||||||
|
|
@ -1651,17 +1750,24 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
filterSidebarMembers();
|
filterSidebarMembers();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mémorisation de l'onglet actif
|
// Gestion du basculement d'onglets
|
||||||
$('.teams-nav-tabs .nav-link').on('shown.bs.tab', function(e) {
|
function updatePlanningTabClass() {
|
||||||
const activeTabId = $(e.target).attr('id');
|
if ($('#tab-planning-btn').hasClass('active')) {
|
||||||
setStoredSetting('active_tab', activeTabId);
|
$('body').addClass('tab-planning-active');
|
||||||
});
|
} else {
|
||||||
|
$('body').removeClass('tab-planning-active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Chargement automatique lors de l'accès à l'onglet Planification
|
|
||||||
let planningLoadedOnce = false;
|
let planningLoadedOnce = false;
|
||||||
$('#tab-planning-btn').on('shown.bs.tab', function() {
|
$('button[data-bs-toggle="pill"]').on('shown.bs.tab', function(e) {
|
||||||
|
const tabId = $(e.target).attr('id');
|
||||||
|
setStoredSetting('active_tab', tabId);
|
||||||
|
updatePlanningTabClass();
|
||||||
|
if (tabId === 'tab-planning-btn') {
|
||||||
if (!planningLoadedOnce) {
|
if (!planningLoadedOnce) {
|
||||||
planningLoadedOnce = true;
|
planningLoadedOnce = true;
|
||||||
|
}
|
||||||
loadPlanningData();
|
loadPlanningData();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -1674,16 +1780,14 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
bootstrap.Tab.getOrCreateInstance(tabEl).show();
|
bootstrap.Tab.getOrCreateInstance(tabEl).show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
updatePlanningTabClass();
|
||||||
|
|
||||||
// Mode duplication modal switch
|
// Mode duplication modal switch
|
||||||
$('input[name="dupMode"]').on('change', function() {
|
$('input[name="dupMode"]').on('change', function() {
|
||||||
if ($(this).val() === 'week') {
|
const val = $(this).val();
|
||||||
$('#dupSectionDay').addClass('d-none');
|
$('#dupSectionDay').toggleClass('d-none', val !== 'day');
|
||||||
$('#dupSectionWeek').removeClass('d-none');
|
$('#dupSectionWeek').toggleClass('d-none', val !== 'week');
|
||||||
} else {
|
$('#dupSectionMonth').toggleClass('d-none', val !== 'month');
|
||||||
$('#dupSectionDay').removeClass('d-none');
|
|
||||||
$('#dupSectionWeek').addClass('d-none');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
$('#dupSourceDate').on('change', function() {
|
$('#dupSourceDate').on('change', function() {
|
||||||
|
|
@ -1721,8 +1825,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
|
||||||
function navigatePlanningDate(direction) {
|
function navigatePlanningDate(direction) {
|
||||||
const d = new Date(_planningState.anchorDate);
|
const d = new Date(_planningState.anchorDate);
|
||||||
|
if (_planningState.viewMode === 'month') {
|
||||||
|
d.setDate(1);
|
||||||
|
d.setMonth(d.getMonth() + direction);
|
||||||
|
} else {
|
||||||
const step = _planningState.viewMode === 'two_weeks' ? 14 : 7;
|
const step = _planningState.viewMode === 'two_weeks' ? 14 : 7;
|
||||||
d.setDate(d.getDate() + (direction * step));
|
d.setDate(d.getDate() + (direction * step));
|
||||||
|
}
|
||||||
_planningState.anchorDate = d.toISOString().split('T')[0];
|
_planningState.anchorDate = d.toISOString().split('T')[0];
|
||||||
setStoredSetting('anchor_date', _planningState.anchorDate);
|
setStoredSetting('anchor_date', _planningState.anchorDate);
|
||||||
_planningState.flatpickrInstance.setDate(_planningState.anchorDate);
|
_planningState.flatpickrInstance.setDate(_planningState.anchorDate);
|
||||||
|
|
@ -1982,6 +2091,7 @@ function executePlanningAssign(memberId, targetTeamId, targetDate, action) {
|
||||||
team_id: parseInt(targetTeamId),
|
team_id: parseInt(targetTeamId),
|
||||||
date: targetDate,
|
date: targetDate,
|
||||||
action: action || 'move',
|
action: action || 'move',
|
||||||
|
scope: _planningState.assignScope || 'day',
|
||||||
},
|
},
|
||||||
function(resp) {
|
function(resp) {
|
||||||
loadPlanningData();
|
loadPlanningData();
|
||||||
|
|
@ -2072,6 +2182,11 @@ function initPlanningDragAndDrop() {
|
||||||
targetDate: targetDate
|
targetDate: targetDate
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let scopeLabel = "{% trans 'Jour'|escapejs %}";
|
||||||
|
if (_planningState.assignScope === 'week') scopeLabel = "{% trans 'Semaine complète'|escapejs %}";
|
||||||
|
if (_planningState.assignScope === 'month') scopeLabel = "{% trans 'Mois complet'|escapejs %}";
|
||||||
|
$('#multiTeamScopeDisplay').text(scopeLabel);
|
||||||
|
|
||||||
const modalEl = document.getElementById('planningMultiTeamConfirmModal');
|
const modalEl = document.getElementById('planningMultiTeamConfirmModal');
|
||||||
const modal = new bootstrap.Modal(modalEl);
|
const modal = new bootstrap.Modal(modalEl);
|
||||||
modal.show();
|
modal.show();
|
||||||
|
|
@ -2171,6 +2286,7 @@ function unassignMemberFromDate(memberId, dateStr, teamId) {
|
||||||
team_id: null,
|
team_id: null,
|
||||||
date: dateStr,
|
date: dateStr,
|
||||||
unassign_team_id: teamId ? parseInt(teamId) : null,
|
unassign_team_id: teamId ? parseInt(teamId) : null,
|
||||||
|
scope: _planningState.assignScope || 'day',
|
||||||
},
|
},
|
||||||
function(resp) {
|
function(resp) {
|
||||||
loadPlanningData();
|
loadPlanningData();
|
||||||
|
|
@ -2273,6 +2389,15 @@ function openDuplicateModal() {
|
||||||
const nextWeekDay = String(dWeek.getDate()).padStart(2, '0');
|
const nextWeekDay = String(dWeek.getDate()).padStart(2, '0');
|
||||||
$('#dupTargetWeek').val(`${nextWeekYear}-${nextWeekMonth}-${nextWeekDay}`);
|
$('#dupTargetWeek').val(`${nextWeekYear}-${nextWeekMonth}-${nextWeekDay}`);
|
||||||
|
|
||||||
|
// Initialiser les mois source et cible
|
||||||
|
const srcMonthVal = `${parts[0]}-${parts[1]}`;
|
||||||
|
$('#dupSourceMonth').val(srcMonthVal);
|
||||||
|
const dMonth = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, 1);
|
||||||
|
dMonth.setMonth(dMonth.getMonth() + 1);
|
||||||
|
const nextMonthYear = dMonth.getFullYear();
|
||||||
|
const nextMonthVal = `${nextMonthYear}-${String(dMonth.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
$('#dupTargetMonth').val(nextMonthVal);
|
||||||
|
|
||||||
$('#dupModalAlert').addClass('d-none');
|
$('#dupModalAlert').addClass('d-none');
|
||||||
new bootstrap.Modal(document.getElementById('duplicatePlanningModal')).show();
|
new bootstrap.Modal(document.getElementById('duplicatePlanningModal')).show();
|
||||||
}
|
}
|
||||||
|
|
@ -2307,8 +2432,17 @@ function quickDuplicateToNextDay(sourceDateStr) {
|
||||||
|
|
||||||
function submitDuplicatePlanning() {
|
function submitDuplicatePlanning() {
|
||||||
const mode = $('input[name="dupMode"]:checked').val();
|
const mode = $('input[name="dupMode"]:checked').val();
|
||||||
const sourceDate = mode === 'week' ? $('#dupSourceWeek').val() : $('#dupSourceDate').val();
|
let sourceDate, targetDate;
|
||||||
const targetDate = mode === 'week' ? $('#dupTargetWeek').val() : $('#dupTargetDate').val();
|
if (mode === 'month') {
|
||||||
|
sourceDate = $('#dupSourceMonth').val();
|
||||||
|
targetDate = $('#dupTargetMonth').val();
|
||||||
|
} else if (mode === 'week') {
|
||||||
|
sourceDate = $('#dupSourceWeek').val();
|
||||||
|
targetDate = $('#dupTargetWeek').val();
|
||||||
|
} else {
|
||||||
|
sourceDate = $('#dupSourceDate').val();
|
||||||
|
targetDate = $('#dupTargetDate').val();
|
||||||
|
}
|
||||||
const copyAbsences = $('#dupCopyAbsences').is(':checked');
|
const copyAbsences = $('#dupCopyAbsences').is(':checked');
|
||||||
|
|
||||||
if (!sourceDate || !targetDate) {
|
if (!sourceDate || !targetDate) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,8 @@ from contracts.services_teams import (
|
||||||
toggle_member_absence,
|
toggle_member_absence,
|
||||||
duplicate_day_schedule,
|
duplicate_day_schedule,
|
||||||
duplicate_week_schedule,
|
duplicate_week_schedule,
|
||||||
|
duplicate_month_schedule,
|
||||||
|
set_member_team_period_schedule,
|
||||||
sync_member_current_teams,
|
sync_member_current_teams,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -176,6 +178,41 @@ class TeamPlanningServicesTests(TestCase):
|
||||||
self.assertIn(self.team_b, teams_day2)
|
self.assertIn(self.team_b, teams_day2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_member_team_period_schedule(self):
|
||||||
|
"""Tester l'affectation sur une période continue."""
|
||||||
|
start_d = date(2026, 10, 5) # Lundi
|
||||||
|
end_d = date(2026, 10, 11) # Dimanche
|
||||||
|
count = set_member_team_period_schedule(
|
||||||
|
member=self.member_alice,
|
||||||
|
team_id=self.team_a.id,
|
||||||
|
start_date=start_d,
|
||||||
|
end_date=end_d,
|
||||||
|
)
|
||||||
|
self.assertEqual(count, 7)
|
||||||
|
for day_offset in range(7):
|
||||||
|
d = start_d + timedelta(days=day_offset)
|
||||||
|
self.assertEqual(get_member_team_for_date(self.member_alice, d), self.team_a)
|
||||||
|
|
||||||
|
def test_duplicate_month_schedule(self):
|
||||||
|
"""Tester la duplication d'un mois entier vers un autre mois."""
|
||||||
|
# Configurer Alice dans Team A tous les jours d'octobre 2026 (31 jours)
|
||||||
|
for day in range(1, 32):
|
||||||
|
d = date(2026, 10, day)
|
||||||
|
set_member_team_schedule(self.member_alice, self.team_a.id, d)
|
||||||
|
|
||||||
|
# Dupliquer vers novembre 2026 (30 jours)
|
||||||
|
res = duplicate_month_schedule(
|
||||||
|
source_year=2026,
|
||||||
|
source_month=10,
|
||||||
|
target_year=2026,
|
||||||
|
target_month=11,
|
||||||
|
)
|
||||||
|
self.assertEqual(res['assigned_count'], 30)
|
||||||
|
|
||||||
|
# Vérifier que le 15 novembre Alice est bien dans Team A
|
||||||
|
self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 11, 15)), self.team_a)
|
||||||
|
|
||||||
|
|
||||||
class TeamPlanningAjaxViewsTests(TestCase):
|
class TeamPlanningAjaxViewsTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
from common.models import UserConfig, Role
|
from common.models import UserConfig, Role
|
||||||
|
|
@ -255,8 +292,12 @@ class TeamPlanningAjaxViewsTests(TestCase):
|
||||||
|
|
||||||
def test_planning_duplicate_friday_to_monday_ajax(self):
|
def test_planning_duplicate_friday_to_monday_ajax(self):
|
||||||
"""Vérifie la duplication d'un vendredi vers le lundi suivant via l'endpoint AJAX."""
|
"""Vérifie la duplication d'un vendredi vers le lundi suivant via l'endpoint AJAX."""
|
||||||
friday = date(2026, 9, 11)
|
today = timezone.now().date()
|
||||||
monday = date(2026, 9, 14)
|
days_ahead_to_friday = (4 - today.weekday()) % 7
|
||||||
|
if days_ahead_to_friday == 0:
|
||||||
|
days_ahead_to_friday = 7
|
||||||
|
friday = today + timedelta(days=days_ahead_to_friday)
|
||||||
|
monday = friday + timedelta(days=3)
|
||||||
set_member_team_schedule(self.member_alice, self.team_b.id, friday)
|
set_member_team_schedule(self.member_alice, self.team_b.id, friday)
|
||||||
toggle_member_absence(self.member_alice, friday, is_absent=True, reason='conge', comment='Weekend prolonge')
|
toggle_member_absence(self.member_alice, friday, is_absent=True, reason='conge', comment='Weekend prolonge')
|
||||||
|
|
||||||
|
|
@ -346,7 +387,8 @@ class TeamPlanningAjaxViewsTests(TestCase):
|
||||||
'target_date': past_date,
|
'target_date': past_date,
|
||||||
}), content_type='application/json')
|
}), content_type='application/json')
|
||||||
self.assertEqual(resp_dup.status_code, 400)
|
self.assertEqual(resp_dup.status_code, 400)
|
||||||
self.assertIn('passé', resp_dup.json().get('error', '').lower())
|
error_msg = resp_dup.json().get('error', '').lower()
|
||||||
|
self.assertTrue('passé' in error_msg or 'past' in error_msg)
|
||||||
|
|
||||||
def test_reassign_future_member_to_same_team_as_previous_day(self):
|
def test_reassign_future_member_to_same_team_as_previous_day(self):
|
||||||
"""
|
"""
|
||||||
|
|
@ -559,5 +601,58 @@ class TeamPlanningAjaxViewsTests(TestCase):
|
||||||
modal_member_ids = [m['id'] for m in resp_modal.json()['current_members']]
|
modal_member_ids = [m['id'] for m in resp_modal.json()['current_members']]
|
||||||
self.assertIn(member_olivier.id, modal_member_ids)
|
self.assertIn(member_olivier.id, modal_member_ids)
|
||||||
|
|
||||||
|
def test_planning_data_ajax_month_view(self):
|
||||||
|
"""Tester le retour du mode 'month' dans team_planning_data_ajax."""
|
||||||
|
url = reverse('contracts:team_planning_data_ajax')
|
||||||
|
response = self.client.get(url, {'date': '2026-09-15', 'view_mode': 'month'})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(len(data['days']), 30) # Septembre compte 30 jours
|
||||||
|
self.assertEqual(data['days'][0]['date'], '2026-09-01')
|
||||||
|
self.assertEqual(data['days'][-1]['date'], '2026-09-30')
|
||||||
|
|
||||||
|
def test_planning_assign_member_ajax_week_scope(self):
|
||||||
|
"""Tester l'assignation avec portée 'week' via l'API AJAX."""
|
||||||
|
# Prendre un lundi dans le futur
|
||||||
|
today = timezone.now().date()
|
||||||
|
next_monday = today + timedelta(days=(7 - today.weekday()))
|
||||||
|
url = reverse('contracts:team_planning_assign_member_ajax')
|
||||||
|
payload = {
|
||||||
|
'member_id': self.member_alice.id,
|
||||||
|
'team_id': self.team_a.id,
|
||||||
|
'date': next_monday.isoformat(),
|
||||||
|
'scope': 'week',
|
||||||
|
}
|
||||||
|
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.json()['success'])
|
||||||
|
self.assertEqual(response.json()['scope'], 'week')
|
||||||
|
self.assertEqual(response.json()['updated_count'], 7)
|
||||||
|
|
||||||
|
# Vérifier que le mercredi et le dimanche de cette semaine ont bien Alice
|
||||||
|
self.assertEqual(get_member_team_for_date(self.member_alice, next_monday + timedelta(days=2)), self.team_a)
|
||||||
|
self.assertEqual(get_member_team_for_date(self.member_alice, next_monday + timedelta(days=6)), self.team_a)
|
||||||
|
|
||||||
|
def test_planning_duplicate_ajax_month_mode(self):
|
||||||
|
"""Tester la duplication de mois via l'API AJAX."""
|
||||||
|
# Créer des affectations pour octobre 2026
|
||||||
|
oct_1 = date(2026, 10, 1)
|
||||||
|
set_member_team_schedule(self.member_alice, self.team_b.id, oct_1)
|
||||||
|
|
||||||
|
url = reverse('contracts:team_planning_duplicate_ajax')
|
||||||
|
payload = {
|
||||||
|
'mode': 'month',
|
||||||
|
'source_date': '2026-10',
|
||||||
|
'target_date': '2026-11',
|
||||||
|
'copy_absences': False,
|
||||||
|
}
|
||||||
|
response = self.client.post(url, data=json.dumps(payload), content_type='application/json')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
data = response.json()
|
||||||
|
self.assertTrue(data['success'])
|
||||||
|
self.assertEqual(data['mode'], 'month')
|
||||||
|
self.assertGreaterEqual(data['assigned_count'], 1)
|
||||||
|
self.assertEqual(get_member_team_for_date(self.member_alice, date(2026, 11, 1)), self.team_b)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import calendar
|
||||||
import json
|
import json
|
||||||
from datetime import date, timedelta, datetime
|
from datetime import date, timedelta, datetime
|
||||||
|
|
||||||
|
|
@ -21,12 +22,14 @@ from contracts.permissions import (
|
||||||
)
|
)
|
||||||
from contracts.services_teams import (
|
from contracts.services_teams import (
|
||||||
set_member_team_schedule,
|
set_member_team_schedule,
|
||||||
|
set_member_team_period_schedule,
|
||||||
get_member_team_for_date,
|
get_member_team_for_date,
|
||||||
get_teams_composition_for_period,
|
get_teams_composition_for_period,
|
||||||
get_member_absences_for_period,
|
get_member_absences_for_period,
|
||||||
toggle_member_absence,
|
toggle_member_absence,
|
||||||
duplicate_day_schedule,
|
duplicate_day_schedule,
|
||||||
duplicate_week_schedule,
|
duplicate_week_schedule,
|
||||||
|
duplicate_month_schedule,
|
||||||
sync_member_current_teams,
|
sync_member_current_teams,
|
||||||
sync_all_members_current_teams,
|
sync_all_members_current_teams,
|
||||||
)
|
)
|
||||||
|
|
@ -456,6 +459,11 @@ def team_planning_data_ajax(request):
|
||||||
num_days = 14
|
num_days = 14
|
||||||
start_date = monday
|
start_date = monday
|
||||||
end_date = monday + timedelta(days=13)
|
end_date = monday + timedelta(days=13)
|
||||||
|
elif view_mode == 'month':
|
||||||
|
_, days_in_month = calendar.monthrange(anchor_date.year, anchor_date.month)
|
||||||
|
num_days = days_in_month
|
||||||
|
start_date = date(anchor_date.year, anchor_date.month, 1)
|
||||||
|
end_date = date(anchor_date.year, anchor_date.month, days_in_month)
|
||||||
else: # 'week' (default)
|
else: # 'week' (default)
|
||||||
num_days = 7
|
num_days = 7
|
||||||
start_date = monday
|
start_date = monday
|
||||||
|
|
@ -602,6 +610,7 @@ def team_planning_assign_member_ajax(request):
|
||||||
member_id = data.get("member_id")
|
member_id = data.get("member_id")
|
||||||
raw_team_id = data.get("team_id")
|
raw_team_id = data.get("team_id")
|
||||||
date_str = data.get("date")
|
date_str = data.get("date")
|
||||||
|
scope = data.get("scope", "day") # 'day', 'week', 'work_week', 'month'
|
||||||
|
|
||||||
if not member_id or not date_str:
|
if not member_id or not date_str:
|
||||||
return JsonResponse({"error": "Paramètres member_id et date requis."}, status=400)
|
return JsonResponse({"error": "Paramètres member_id et date requis."}, status=400)
|
||||||
|
|
@ -611,7 +620,7 @@ def team_planning_assign_member_ajax(request):
|
||||||
return JsonResponse({"error": "Date invalide."}, status=400)
|
return JsonResponse({"error": "Date invalide."}, status=400)
|
||||||
|
|
||||||
today = timezone.now().date()
|
today = timezone.now().date()
|
||||||
if target_date < today:
|
if scope == "day" and target_date < today:
|
||||||
return JsonResponse({"error": "Impossible de modifier la planification d'un jour passé."}, status=400)
|
return JsonResponse({"error": "Impossible de modifier la planification d'un jour passé."}, status=400)
|
||||||
|
|
||||||
member = get_object_or_404(CompanyMember, id=member_id)
|
member = get_object_or_404(CompanyMember, id=member_id)
|
||||||
|
|
@ -631,7 +640,38 @@ def team_planning_assign_member_ajax(request):
|
||||||
raw_unassign_team_id = data.get("unassign_team_id")
|
raw_unassign_team_id = data.get("unassign_team_id")
|
||||||
unassign_team_id = int(raw_unassign_team_id) if raw_unassign_team_id is not None else None
|
unassign_team_id = int(raw_unassign_team_id) if raw_unassign_team_id is not None else None
|
||||||
|
|
||||||
# Effectuer la planification
|
# Effectuer la planification selon la portée choisie
|
||||||
|
if scope in ("week", "work_week"):
|
||||||
|
monday = target_date - timedelta(days=target_date.weekday())
|
||||||
|
week_end = monday + timedelta(days=4 if scope == "work_week" else 6)
|
||||||
|
if week_end < today:
|
||||||
|
return JsonResponse({"error": _("Impossible de modifier une période passée.")}, status=400)
|
||||||
|
updated_count = set_member_team_period_schedule(
|
||||||
|
member=member,
|
||||||
|
team_id=team_id,
|
||||||
|
start_date=monday,
|
||||||
|
end_date=week_end,
|
||||||
|
action=action,
|
||||||
|
unassign_team_id=unassign_team_id,
|
||||||
|
changed_by=request.user,
|
||||||
|
weekdays_only=(scope == "work_week"),
|
||||||
|
)
|
||||||
|
elif scope == "month":
|
||||||
|
_, days_in_month = calendar.monthrange(target_date.year, target_date.month)
|
||||||
|
month_start = date(target_date.year, target_date.month, 1)
|
||||||
|
month_end = date(target_date.year, target_date.month, days_in_month)
|
||||||
|
if month_end < today:
|
||||||
|
return JsonResponse({"error": _("Impossible de modifier une période passée.")}, status=400)
|
||||||
|
updated_count = set_member_team_period_schedule(
|
||||||
|
member=member,
|
||||||
|
team_id=team_id,
|
||||||
|
start_date=month_start,
|
||||||
|
end_date=month_end,
|
||||||
|
action=action,
|
||||||
|
unassign_team_id=unassign_team_id,
|
||||||
|
changed_by=request.user,
|
||||||
|
)
|
||||||
|
else:
|
||||||
set_member_team_schedule(
|
set_member_team_schedule(
|
||||||
member=member,
|
member=member,
|
||||||
team_id=team_id,
|
team_id=team_id,
|
||||||
|
|
@ -641,6 +681,7 @@ def team_planning_assign_member_ajax(request):
|
||||||
action=action,
|
action=action,
|
||||||
unassign_team_id=unassign_team_id,
|
unassign_team_id=unassign_team_id,
|
||||||
)
|
)
|
||||||
|
updated_count = 1
|
||||||
|
|
||||||
return JsonResponse({
|
return JsonResponse({
|
||||||
"success": True,
|
"success": True,
|
||||||
|
|
@ -648,6 +689,8 @@ def team_planning_assign_member_ajax(request):
|
||||||
"member_name": member.name,
|
"member_name": member.name,
|
||||||
"team_id": team_id,
|
"team_id": team_id,
|
||||||
"date": target_date.isoformat(),
|
"date": target_date.isoformat(),
|
||||||
|
"scope": scope,
|
||||||
|
"updated_count": updated_count,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -728,6 +771,13 @@ def team_planning_duplicate_ajax(request):
|
||||||
if not source_date_str or not target_date_str:
|
if not source_date_str or not target_date_str:
|
||||||
return JsonResponse({"error": "Dates source et cible requises."}, status=400)
|
return JsonResponse({"error": "Dates source et cible requises."}, status=400)
|
||||||
|
|
||||||
|
# Si mode == 'month' et dates au format 'YYYY-MM', normaliser avec le 1er du mois
|
||||||
|
if mode == "month":
|
||||||
|
if len(source_date_str) == 7:
|
||||||
|
source_date_str = f"{source_date_str}-01"
|
||||||
|
if len(target_date_str) == 7:
|
||||||
|
target_date_str = f"{target_date_str}-01"
|
||||||
|
|
||||||
source_date = parse_date(source_date_str)
|
source_date = parse_date(source_date_str)
|
||||||
target_date = parse_date(target_date_str)
|
target_date = parse_date(target_date_str)
|
||||||
|
|
||||||
|
|
@ -735,8 +785,11 @@ def team_planning_duplicate_ajax(request):
|
||||||
return JsonResponse({"error": "Format de date invalide."}, status=400)
|
return JsonResponse({"error": "Format de date invalide."}, status=400)
|
||||||
|
|
||||||
today = timezone.now().date()
|
today = timezone.now().date()
|
||||||
if target_date < today:
|
if mode == "month":
|
||||||
return JsonResponse({"error": "La date cible ne peut pas être dans le passé."}, status=400)
|
if (target_date.year, target_date.month) < (today.year, today.month):
|
||||||
|
return JsonResponse({"error": _("Le mois cible ne peut pas être dans le passé.")}, status=400)
|
||||||
|
elif target_date < today:
|
||||||
|
return JsonResponse({"error": _("La date cible ne peut pas être dans le passé.")}, status=400)
|
||||||
|
|
||||||
# Récupérer les équipes que l'utilisateur a le droit d'éditer
|
# Récupérer les équipes que l'utilisateur a le droit d'éditer
|
||||||
viewable = get_viewable_teams_for_user(request.user)
|
viewable = get_viewable_teams_for_user(request.user)
|
||||||
|
|
@ -752,7 +805,17 @@ def team_planning_duplicate_ajax(request):
|
||||||
else:
|
else:
|
||||||
allowed_team_ids = set(viewable.values_list('id', flat=True))
|
allowed_team_ids = set(viewable.values_list('id', flat=True))
|
||||||
|
|
||||||
if mode == "week":
|
if mode == "month":
|
||||||
|
res = duplicate_month_schedule(
|
||||||
|
source_year=source_date.year,
|
||||||
|
source_month=source_date.month,
|
||||||
|
target_year=target_date.year,
|
||||||
|
target_month=target_date.month,
|
||||||
|
allowed_team_ids=allowed_team_ids,
|
||||||
|
copy_absences=copy_absences,
|
||||||
|
changed_by=request.user,
|
||||||
|
)
|
||||||
|
elif mode == "week":
|
||||||
# S'assurer de pointer sur les lundis
|
# S'assurer de pointer sur les lundis
|
||||||
source_monday = source_date - timedelta(days=source_date.weekday())
|
source_monday = source_date - timedelta(days=source_date.weekday())
|
||||||
target_monday = target_date - timedelta(days=target_date.weekday())
|
target_monday = target_date - timedelta(days=target_date.weekday())
|
||||||
|
|
|
||||||
|
|
@ -435,7 +435,7 @@
|
||||||
<i class="bi {{ entry.status|status_icon }}"></i>
|
<i class="bi {{ entry.status|status_icon }}"></i>
|
||||||
{{ entry.get_status_display }}
|
{{ entry.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }})</span>
|
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }})</span>
|
||||||
</div>
|
</div>
|
||||||
{% if entry.description %}
|
{% if entry.description %}
|
||||||
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ entry.description }}</div>
|
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ entry.description }}</div>
|
||||||
|
|
@ -486,7 +486,7 @@
|
||||||
<i class="bi {{ remark.status|status_icon }}"></i>
|
<i class="bi {{ remark.status|status_icon }}"></i>
|
||||||
{{ remark.get_status_display }}
|
{{ remark.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
<span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:remark.updated_by.username }})</span>
|
<span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:"-" }})</span>
|
||||||
</div>
|
</div>
|
||||||
{% if remark.description %}
|
{% if remark.description %}
|
||||||
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ remark.description }}</div>
|
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ remark.description }}</div>
|
||||||
|
|
|
||||||
|
|
@ -467,7 +467,7 @@
|
||||||
<!-- ✅ Infos sur l’utilisateur et la date (dernière mise à jour) -->
|
<!-- ✅ Infos sur l’utilisateur et la date (dernière mise à jour) -->
|
||||||
<p class="small text-muted mb-2">
|
<p class="small text-muted mb-2">
|
||||||
{% load short_name %}
|
{% load short_name %}
|
||||||
{% translate 'Mise à jour par' %} <span id="updated-by-{{ remark.id }}">{{ remark.updated_by|short_name|default:remark.updated_by.username }}</span>
|
{% translate 'Mise à jour par' %} <span id="updated-by-{{ remark.id }}">{{ remark.updated_by|short_name|default:"-" }}</span>
|
||||||
{% translate 'le' %} <span id="updated-at-{{ remark.id }}">{{ remark.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}</span>
|
{% translate 'le' %} <span id="updated-at-{{ remark.id }}">{{ remark.updated_at|timezone:"Europe/Paris"|date:"d/m/Y H:i" }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
@ -483,7 +483,7 @@
|
||||||
<i class="bi {{ entry.status|status_icon }}"></i>
|
<i class="bi {{ entry.status|status_icon }}"></i>
|
||||||
{{ entry.get_status_display }}
|
{{ entry.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }})</span>
|
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }})</span>
|
||||||
</div>
|
</div>
|
||||||
{% if entry.description %}
|
{% if entry.description %}
|
||||||
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ entry.description }}</div>
|
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ entry.description }}</div>
|
||||||
|
|
@ -534,7 +534,7 @@
|
||||||
<i class="bi {{ remark.status|status_icon }}"></i>
|
<i class="bi {{ remark.status|status_icon }}"></i>
|
||||||
{{ remark.get_status_display }}
|
{{ remark.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
<span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:remark.updated_by.username }})</span>
|
<span class="small text-muted">({% translate 'par' %} {{ remark.updated_by|short_name|default:"-" }})</span>
|
||||||
</div>
|
</div>
|
||||||
{% if remark.description %}
|
{% if remark.description %}
|
||||||
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ remark.description }}</div>
|
<div class="mt-1 text-dark" style="white-space: pre-wrap; font-size: 0.95rem;">{{ remark.description }}</div>
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@
|
||||||
<i class="bi {{ entry.status|status_icon }}"></i>
|
<i class="bi {{ entry.status|status_icon }}"></i>
|
||||||
{{ entry.get_status_display }}
|
{{ entry.get_status_display }}
|
||||||
</span>
|
</span>
|
||||||
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:entry.created_by.username }})</span>
|
<span class="small text-muted">({% translate 'par' %} {{ entry.created_by|short_name|default:"-" }})</span>
|
||||||
</div>
|
</div>
|
||||||
{% if entry.description %}
|
{% if entry.description %}
|
||||||
<div class="small text-dark" style="white-space: pre-wrap;">{{ entry.description }}</div>
|
<div class="small text-dark" style="white-space: pre-wrap;">{{ entry.description }}</div>
|
||||||
|
|
|
||||||
|
|
@ -731,9 +731,21 @@ class DocumentFolderAttachment(models.Model):
|
||||||
def duplicate_file(field):
|
def duplicate_file(field):
|
||||||
if not field:
|
if not field:
|
||||||
return None
|
return None
|
||||||
|
try:
|
||||||
file_name = os.path.basename(field.name)
|
file_name = os.path.basename(field.name)
|
||||||
with field.open("rb") as f:
|
with field.open("rb") as f:
|
||||||
return ContentFile(f.read(), name=file_name)
|
return ContentFile(f.read(), name=file_name)
|
||||||
|
except Exception:
|
||||||
|
# Fallback si le fichier historique est présent dans MEDIA_ROOT avant migration vers private storage
|
||||||
|
try:
|
||||||
|
if hasattr(settings, "MEDIA_ROOT") and field.name:
|
||||||
|
alt_path = os.path.join(settings.MEDIA_ROOT, field.name)
|
||||||
|
if os.path.exists(alt_path):
|
||||||
|
with open(alt_path, "rb") as f:
|
||||||
|
return ContentFile(f.read(), name=os.path.basename(field.name))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def ensure_document_for_legacy(instance, old_file_name=None):
|
def ensure_document_for_legacy(instance, old_file_name=None):
|
||||||
|
|
@ -757,6 +769,16 @@ def ensure_document_for_legacy(instance, old_file_name=None):
|
||||||
context_object = instance.get_document_context()
|
context_object = instance.get_document_context()
|
||||||
title = instance.get_document_title()
|
title = instance.get_document_title()
|
||||||
|
|
||||||
|
file_changed = bool(old_file_name and old_file_name != instance.file.name)
|
||||||
|
should_create_version = (managed is None) or not managed.versions.exists() or file_changed
|
||||||
|
|
||||||
|
if should_create_version:
|
||||||
|
new_file = duplicate_file(instance.file)
|
||||||
|
if not new_file:
|
||||||
|
# Ne pas créer de ManagedDocument orphelin si le fichier physique est inaccessible
|
||||||
|
return
|
||||||
|
|
||||||
|
with transaction.atomic():
|
||||||
if managed is None:
|
if managed is None:
|
||||||
managed = ManagedDocument.objects.create(
|
managed = ManagedDocument.objects.create(
|
||||||
title=title,
|
title=title,
|
||||||
|
|
@ -784,11 +806,6 @@ def ensure_document_for_legacy(instance, old_file_name=None):
|
||||||
)
|
)
|
||||||
managed.types.add(doc_type)
|
managed.types.add(doc_type)
|
||||||
|
|
||||||
file_changed = bool(old_file_name and old_file_name != instance.file.name)
|
|
||||||
should_create_version = not managed.versions.exists() or file_changed
|
|
||||||
|
|
||||||
if should_create_version:
|
|
||||||
new_file = duplicate_file(instance.file)
|
|
||||||
thumbnail_file = duplicate_file(getattr(instance, "thumbnail", None))
|
thumbnail_file = duplicate_file(getattr(instance, "thumbnail", None))
|
||||||
|
|
||||||
version = DocumentVersion(
|
version = DocumentVersion(
|
||||||
|
|
@ -802,6 +819,7 @@ def ensure_document_for_legacy(instance, old_file_name=None):
|
||||||
validated_at=timezone.now(),
|
validated_at=timezone.now(),
|
||||||
)
|
)
|
||||||
version.save()
|
version.save()
|
||||||
|
managed.touch(version)
|
||||||
|
|
||||||
elif managed.current_version is None:
|
elif managed.current_version is None:
|
||||||
managed.touch(managed.versions.order_by("-version_number").first())
|
managed.touch(managed.versions.order_by("-version_number").first())
|
||||||
|
|
|
||||||
|
|
@ -331,6 +331,7 @@ class DocumentManagementService:
|
||||||
"message": _("New version added to existing document."),
|
"message": _("New version added to existing document."),
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
|
with transaction.atomic():
|
||||||
# Déterminer la visibilité
|
# Déterminer la visibilité
|
||||||
if visibility is None:
|
if visibility is None:
|
||||||
if content_object and getattr(getattr(content_object, '_meta', None), 'model_name', None) == 'project':
|
if content_object and getattr(getattr(content_object, '_meta', None), 'model_name', None) == 'project':
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}"
|
<a href="{% url 'documents:list' %}?folder={{ folder.slug }}"
|
||||||
class="nav-link flex-grow-1 d-flex align-items-center p-2 {% if active_slug == folder.slug %}active{% endif %} {% if folder.slug in trail_slugs and active_slug != folder.slug %} fw-semibold{% endif %}" style="color: var(--brand-color);">
|
class="nav-link flex-grow-1 d-flex align-items-center p-2 {% if active_slug == folder.slug %}active{% endif %} {% if folder.slug in trail_slugs and active_slug != folder.slug %} fw-semibold{% endif %}" >
|
||||||
<i class="bi bi-folder{% if active_slug == folder.slug %}-fill{% endif %}"></i>
|
<i class="bi bi-folder{% if active_slug == folder.slug %}-fill{% endif %}"></i>
|
||||||
<span class="ms-2 text d-none d-md-inline">{{ folder.name }}</span>
|
<span class="ms-2 text d-none d-md-inline">{{ folder.name }}</span>
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -656,10 +656,11 @@ msgstr ""
|
||||||
msgid "En pause"
|
msgid "En pause"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:36
|
||||||
#: home/templates/home/widgets/external_interventions.html:84
|
#: home/templates/home/widgets/external_interventions.html:84
|
||||||
#: home/templates/home/widgets/external_interventions.html:85
|
#: home/templates/home/widgets/external_interventions.html:85
|
||||||
msgid "À corriger"
|
msgid "À corriger"
|
||||||
msgstr ""
|
msgstr "To be corrected"
|
||||||
|
|
||||||
#: home/templates/home/widgets/external_interventions.html:94
|
#: home/templates/home/widgets/external_interventions.html:94
|
||||||
msgid "10 dernières interventions"
|
msgid "10 dernières interventions"
|
||||||
|
|
@ -1111,3 +1112,28 @@ msgstr "Loaned items"
|
||||||
#: home/templates/home/widgets/stock_summary.html:235
|
#: home/templates/home/widgets/stock_summary.html:235
|
||||||
msgid "Aucun article en prêt en cours"
|
msgid "Aucun article en prêt en cours"
|
||||||
msgstr "No active loaned items"
|
msgstr "No active loaned items"
|
||||||
|
|
||||||
|
#: home/models.py:38
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:11
|
||||||
|
msgid "Raccourcis contrôleur"
|
||||||
|
msgstr "Controller shortcuts"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:15
|
||||||
|
msgid "Interventions à contrôler"
|
||||||
|
msgstr "Interventions to control"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:18
|
||||||
|
msgid "en attente de contrôle"
|
||||||
|
msgstr "pending control"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:43
|
||||||
|
msgid "Validées"
|
||||||
|
msgstr "Validated"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:64
|
||||||
|
msgid "Aucune intervention à contrôler"
|
||||||
|
msgstr "No interventions to control"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:72
|
||||||
|
msgid "Voir toutes les interventions à contrôler"
|
||||||
|
msgstr "View all interventions to control"
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -665,10 +665,11 @@ msgstr ""
|
||||||
msgid "En pause"
|
msgid "En pause"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:36
|
||||||
#: home/templates/home/widgets/external_interventions.html:84
|
#: home/templates/home/widgets/external_interventions.html:84
|
||||||
#: home/templates/home/widgets/external_interventions.html:85
|
#: home/templates/home/widgets/external_interventions.html:85
|
||||||
msgid "À corriger"
|
msgid "À corriger"
|
||||||
msgstr ""
|
msgstr "À corriger"
|
||||||
|
|
||||||
#: home/templates/home/widgets/external_interventions.html:94
|
#: home/templates/home/widgets/external_interventions.html:94
|
||||||
msgid "10 dernières interventions"
|
msgid "10 dernières interventions"
|
||||||
|
|
@ -1120,3 +1121,28 @@ msgstr "Articles en prêt"
|
||||||
#: home/templates/home/widgets/stock_summary.html:235
|
#: home/templates/home/widgets/stock_summary.html:235
|
||||||
msgid "Aucun article en prêt en cours"
|
msgid "Aucun article en prêt en cours"
|
||||||
msgstr "Aucun article en prêt en cours"
|
msgstr "Aucun article en prêt en cours"
|
||||||
|
|
||||||
|
#: home/models.py:38
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:11
|
||||||
|
msgid "Raccourcis contrôleur"
|
||||||
|
msgstr "Raccourcis contrôleur"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:15
|
||||||
|
msgid "Interventions à contrôler"
|
||||||
|
msgstr "Interventions à contrôler"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:18
|
||||||
|
msgid "en attente de contrôle"
|
||||||
|
msgstr "en attente de contrôle"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:43
|
||||||
|
msgid "Validées"
|
||||||
|
msgstr "Validées"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:64
|
||||||
|
msgid "Aucune intervention à contrôler"
|
||||||
|
msgstr "Aucune intervention à contrôler"
|
||||||
|
|
||||||
|
#: home/templates/home/widgets/controller_shortcuts.html:72
|
||||||
|
msgid "Voir toutes les interventions à contrôler"
|
||||||
|
msgstr "Voir toutes les interventions à contrôler"
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue