Compare commits

..

No commits in common. "main" and "v1.0.0" have entirely different histories.
main ... v1.0.0

2369 changed files with 116994 additions and 432259 deletions

View file

@ -25,10 +25,10 @@ venv_old/
*.sqlite3
# Django media & static
loko/media/
loko/private_media/
loko/static/
loko/django.log
streetup/media/
streetup/private_media/
streetup/static/
streetup/django.log
*.log
# VS Code / Editor config

105
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,105 @@
name: Build and Deploy StreetUp
on:
workflow_dispatch:
inputs:
environment:
description: 'Environnement de déploiement (test ou production)'
required: true
default: 'test'
type: choice
options:
- test
- production
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Log in to the Container Registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=test,enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'test' }}
type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'production' }}
type=raw,value=test,enable=${{ github.event_name != 'workflow_dispatch' && github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.event_name != 'workflow_dispatch' && startsWith(github.ref, 'refs/tags/v') }}
type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build and push Docker image
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Determine Target Environment
id: target
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
ENV_CHOICE="${{ github.event.inputs.environment }}"
else
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
ENV_CHOICE="production"
else
ENV_CHOICE="test"
fi
fi
if [[ "$ENV_CHOICE" == "production" ]]; then
echo "env=production" >> $GITHUB_OUTPUT
echo "host=${{ secrets.SSH_HOST_PROD }}" >> $GITHUB_OUTPUT
echo "path=/home/kdeterme/streetup-prod" >> $GITHUB_OUTPUT
else
echo "env=test" >> $GITHUB_OUTPUT
echo "host=${{ secrets.SSH_HOST_TEST }}" >> $GITHUB_OUTPUT
echo "path=/home/kdeterme/streetup-test" >> $GITHUB_OUTPUT
fi
- name: Deploy to Server via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ steps.target.outputs.host }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd ${{ steps.target.outputs.path }}
# Pull de la nouvelle image
docker compose pull
# Relance des conteneurs
docker compose up -d
# Exécution des migrations de base de données
docker compose exec -T web python manage.py migrate --noinput
# Collecte des fichiers statiques
docker compose exec -T web python manage.py collectstatic --noinput --clear

22
.gitignore vendored
View file

@ -7,9 +7,8 @@ node_modules/
.well-known/
*.log
*.md
!README.md
!loko/common/help_docs/
!loko/common/help_docs/**/*.md
!streetup/common/help_docs/
!streetup/common/help_docs/**/*.md
media/
private_media/
@ -25,26 +24,19 @@ __pycache__/
*$py.class
loko/.env
streetup/.env
/scripts/
!loko/scripts/
!loko/scripts/**
scripts/
carl_export/
carl_export.*
loko/static/
streetup/static/
deploy.sh
generated_passwords.txt
loko/generated_passwords.txt
streetup/generated_passwords.txt
scratch/
Caddyfile
Caddyfile.prod
.vscode/
# AI Training & ML Artifacts
runs/
*.pt
loko/sign/ai/training/
labels_signpanels/

View file

@ -26,7 +26,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Création de l'environnement conda avec les packages cartographiques et poppler
# gdal, geos, proj et poppler sont installés depuis conda-forge
RUN mamba create -n loko-env -y -c conda-forge \
RUN mamba create -n streetup-env -y -c conda-forge \
python=3.12 \
gdal \
geos \
@ -35,19 +35,19 @@ RUN mamba create -n loko-env -y -c conda-forge \
&& mamba clean -afy
# Activation par défaut de l'environnement Conda dans le conteneur
ENV PATH=/opt/conda/envs/loko-env/bin:$PATH
ENV CONDA_DEFAULT_ENV=loko-env
ENV PATH=/opt/conda/envs/streetup-env/bin:$PATH
ENV CONDA_DEFAULT_ENV=streetup-env
# Configuration explicite des variables GIS pour GeoDjango
ENV GDAL_LIBRARY_PATH=/opt/conda/envs/loko-env/lib/libgdal.so
ENV GEOS_LIBRARY_PATH=/opt/conda/envs/loko-env/lib/libgeos_c.so
ENV PROJ_DATA=/opt/conda/envs/loko-env/share/proj
ENV GDAL_LIBRARY_PATH=/opt/conda/envs/streetup-env/lib/libgdal.so
ENV GEOS_LIBRARY_PATH=/opt/conda/envs/streetup-env/lib/libgeos_c.so
ENV PROJ_DATA=/opt/conda/envs/streetup-env/share/proj
# Argument de build pour activer l'installation des librairies IA (ultralytics, OpenCV...)
# Passer INSTALL_AI_LIBS=true dans le docker-compose pour les inclure
ARG INSTALL_AI_LIBS=false
# Copie et installation des dépendances Python (mis en cache tant que requirements/ ne change pas)
# Copie et installation des dépendances Python
COPY requirements/ /app/requirements/
RUN pip install --no-cache-dir -r requirements/base.txt && \
if [ "$INSTALL_AI_LIBS" = "true" ]; then \
@ -55,60 +55,50 @@ RUN pip install --no-cache-dir -r requirements/base.txt && \
fi
# 1. Copie des répertoires volumineux et statiques (très bonne mise en cache)
COPY loko/scripts/ /app/scripts/
COPY loko/common/ /app/common/
COPY loko/home/ /app/home/
COPY streetup/road_detection/detection_models/ /app/road_detection/detection_models/
COPY streetup/scripts/ /app/scripts/
COPY streetup/common/ /app/common/
COPY streetup/home/ /app/home/
# 2. Copie des modules de l'application qui changent rarement
COPY loko/locale/ /app/locale/
COPY loko/templates/ /app/templates/
COPY loko/analytics/ /app/analytics/
COPY loko/api/ /app/api/
COPY loko/authorizations/ /app/authorizations/
COPY loko/contracts/ /app/contracts/
COPY loko/documents/ /app/documents/
COPY loko/maps/ /app/maps/
COPY loko/notifications/ /app/notifications/
COPY loko/projects/ /app/projects/
COPY loko/reporting/ /app/reporting/
COPY loko/bim/ /app/bim/
COPY loko/sign/ /app/sign/
COPY loko/stock/ /app/stock/
COPY streetup/locale/ /app/locale/
COPY streetup/templates/ /app/templates/
COPY streetup/analytics/ /app/analytics/
COPY streetup/api/ /app/api/
COPY streetup/authorizations/ /app/authorizations/
COPY streetup/contracts/ /app/contracts/
COPY streetup/documents/ /app/documents/
COPY streetup/maps/ /app/maps/
COPY streetup/notifications/ /app/notifications/
COPY streetup/projects/ /app/projects/
COPY streetup/reporting/ /app/reporting/
COPY streetup/schools/ /app/schools/
COPY streetup/sign/ /app/sign/
COPY streetup/stock/ /app/stock/
# 3. Copie des modules actifs ou modifiés fréquemment
COPY loko/loko/ /app/loko/
COPY loko/mobile/ /app/mobile/
COPY loko/observations/ /app/observations/
COPY loko/controls/ /app/controls/
COPY loko/interventions/ /app/interventions/
COPY loko/inspections/ /app/inspections/
COPY loko/assets/ /app/assets/
COPY loko/panoramax/ /app/panoramax/
COPY streetup/streetup/ /app/streetup/
COPY streetup/mobile/ /app/mobile/
COPY streetup/observations/ /app/observations/
COPY streetup/controls/ /app/controls/
COPY streetup/interventions/ /app/interventions/
COPY streetup/assets/ /app/assets/
# 4. Copie des fichiers de code de road_detection (sans réimporter les modèles de détection)
COPY loko/road_detection/__init__.py /app/road_detection/
COPY loko/road_detection/admin.py /app/road_detection/
COPY loko/road_detection/apps.py /app/road_detection/
COPY loko/road_detection/detection.py /app/road_detection/
COPY loko/road_detection/forms.py /app/road_detection/
COPY loko/road_detection/models.py /app/road_detection/
COPY loko/road_detection/urls.py /app/road_detection/
COPY loko/road_detection/views.py /app/road_detection/
COPY loko/road_detection/migrations/ /app/road_detection/migrations/
COPY loko/road_detection/templates/ /app/road_detection/templates/
COPY streetup/road_detection/__init__.py /app/road_detection/
COPY streetup/road_detection/admin.py /app/road_detection/
COPY streetup/road_detection/apps.py /app/road_detection/
COPY streetup/road_detection/detection.py /app/road_detection/
COPY streetup/road_detection/forms.py /app/road_detection/
COPY streetup/road_detection/models.py /app/road_detection/
COPY streetup/road_detection/urls.py /app/road_detection/
COPY streetup/road_detection/views.py /app/road_detection/
COPY streetup/road_detection/migrations/ /app/road_detection/migrations/
COPY streetup/road_detection/templates/ /app/road_detection/templates/
# 5. Copie des fichiers Python et de configuration restants à la racine
COPY loko/*.py /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}
COPY streetup/*.py /app/
COPY streetup/generated_passwords.txt* /app/
# Exposer le port par défaut
EXPOSE 8000

View file

@ -1,70 +0,0 @@
# Loko — Asset Management pour l'espace public
<p align="center">
<img src="loko/common/static/common/loko_logo.svg" alt="Loko Logo" width="120" height="120">
</p>
<p align="center">
<strong>Loko</strong> est une plateforme open source de gestion de patrimoine d'infrastructures publiques (<em>Public Infrastructure Asset Management</em>), produite et maintenue par <strong><a href="https://www.valogeo.be" target="_blank">Valogeo</a></strong>.
</p>
---
## 🌟 Présentation
**Loko** permet aux administrations publiques, collectivités territoriales, gestionnaires d'infrastructures et prestataires de maintenance d'inventorier, de cartographier, de planifier la maintenance et de valoriser l'ensemble des équipements et réseaux présents sur l'espace public.
Le projet est publié sous licence libre **EUPL (European Union Public Licence)**.
Les grandes lignes de la licence EUPL :
* **Liberté d'utilisation, de modification et de redistribution** : Vous êtes libre d'utiliser le logiciel pour tout usage (y compris commercial), d'accéder au code source, de le modifier et de le redistribuer.
* **Réciprocité (Copyleft)** : Tout code dérivé ou modifié qui est redistribué doit rester sous licence EUPL (ou sous une licence compatible), afin de garantir la pérennité du code ouvert.
* **Compatibilité inter-licences** : La licence comporte une clause expresse de compatibilité avec d'autres licences copyleft majeures (GPL v2/v3, AGPL v3, MPL, CeCILL, EPL, etc.), facilitant la réutilisation de composants open source.
* **Cadre juridique européen & multilinguisme** : Conçue par la Commission européenne, elle possède une valeur juridique identique dans les 24 langues officielles de l'Union européenne.
* **Absence de garantie** : Le logiciel est fourni "en l'état", sans garantie explicite ou implicite de la part des contributeurs.
Le projet est propulsé par **Django** et s'appuie sur une base de données **PostgreSQL avec l'extension PostGIS** pour toute la dimension géospatiale et cartographique.
---
## 🚦 Thématiques Gérées
Loko permet de modéliser et de localiser précisément les équipements liés à plusieurs domaines de la voirie :
* **Bâtiments** : Bâtiments publics, gestion des salles, etc.
* **Espaces extérieurs** : Plaines de jeux, terrains de sport extérieurs, etc.
* **Éclairage public** : Rues d'éclairage, poteaux, luminaires et caractéristiques d'éclairage.
* **Signalisation routière** : Panneaux de signalisation (types de panneaux, poteaux supports, etc.).
* **Ouvrages d'art** : Suivi des tunnels, ponts, viaducs et des équipements associés à ces structures.
* **Espaces verts / Nature** : Zones naturelles, arbres isolés, surfaces végétalisées.
* **Systèmes de transport intelligents (ITS)** : Capteurs de trafic, caméras, panneaux à messages variables.
* **Chaussées, trottoirs et mobilier urbain** : Description géométrique des rues, des trottoirs et du mobilier.
* **Patrimoine artistique** : Statues, monuments historiques, fontaines publiques.
* **Centres de contrôle** : Supervision et gestion des équipements centralisés.
---
## 🛠️ Modules Fonctionnels Majeurs
L'application est structurée en plusieurs modules Django spécialisés :
1. **Gestion des Actifs (`assets`)** : Modélisation physique, classification et géolocalisation de tous les équipements sur l'espace public.
2. **Maintenance & Interventions (`interventions`)** : Suivi du cycle de vie des interventions (correctives, préventives et amélioratives). Planification des tournées, relevés de mesures sur le terrain et fiches réflexes pour les techniciens.
3. **Contrats & Commandes (`contracts`)** : Gestion des entreprises partenaires, des contrats de maintenance, des bordereaux de prix (postes), des devis et des commandes de travaux.
4. **Projets (`projects`)** : Regroupement et planification d'interventions sous forme de projets globaux structurés en phases.
5. **Observations Terrain (`observations`)** : Permet aux agents ou utilisateurs de remonter des anomalies ou des signalements directement depuis le terrain avec des photos et des commentaires géolocalisés.
6. **Contrôles de Chantier (`controls`)** : Formulaires de contrôle qualité, rapports de visites de chantier et listes de vérification (checklists).
7. **Gestion Documentaire (`documents`)** : Système de partage de documents et dossiers avec historique des versions, workflows de validation et gestion des droits d'accès.
8. **Gestion des Stocks (`stock`)** : Gestion des entrepôts, des approvisionnements, des mouvements de pièces détachées et des alertes de seuil critique.
9. **API REST (`api`) & Mobile (`mobile`)** : Points d'accès pour l'intégration de systèmes tiers et interface optimisée pour les terminaux mobiles utilisés par les agents sur le terrain.
---
## ⚙️ Architecture Technique
* **Backend** : Python 3, Django, Django REST Framework
* **Base de données** : PostgreSQL + PostGIS (données géographiques et spatiales)
* **Conteneurisation** : Environnement complet basé sur Docker et Docker Compose

View file

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documentation Loko</title>
<title>Documentation StreetUp</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
@ -112,13 +112,13 @@
</head>
<body>
<div class="container">
<h1>Documentation de l'application Loko</h1></p><p><h2>Table des matières</h2></p><p>1. <a href="#vue-densemble">Vue d'ensemble</a>
<h1>Documentation de l'application StreetUp</h1></p><p><h2>Table des matières</h2></p><p>1. <a href="#vue-densemble">Vue d'ensemble</a>
2. <a href="#architecture-de-lapplication">Architecture de l'application</a>
3. <a href="#modules-django">Modules Django</a>
4. <a href="#schéma-des-modèles-et-relations">Schéma des modèles et relations</a>
5. <a href="#détail-des-modèles-par-module">Détail des modèles par module</a>
6. <a href="#flux-de-données">Flux de données</a>
7. <a href="#diagrammes">Diagrammes</a></p><p>---</p><p><h2>Vue d'ensemble</h2></p><p><strong>Loko</strong> est un système complet de gestion des actifs de voirie (Street Asset Management System). L'application permet de gérer, suivre et maintenir différents types d'infrastructures de rue telles que :</p><p><ul>
7. <a href="#diagrammes">Diagrammes</a></p><p>---</p><p><h2>Vue d'ensemble</h2></p><p><strong>StreetUp</strong> est un système complet de gestion des actifs de voirie (Street Asset Management System). L'application permet de gérer, suivre et maintenir différents types d'infrastructures de rue telles que :</p><p><ul>
<li><strong>Feux de signalisation</strong> (Traffic Lights)</li>
<li><strong>Éclairage public</strong> (Public Lighting)</li>
<li><strong>Panneaux de signalisation</strong> (Road Signs)</li>
@ -128,8 +128,8 @@
<li><strong>Patrimoine artistique</strong> (Artworks - monuments, fontaines)</li>
<li><strong>ITS - Systèmes de transport intelligents</strong> (Intelligent Transport Systems)</li>
<li><strong>Chaussées, trottoirs et mobilier urbain</strong> (Roads)</li>
</ul></p><p>L'application est construite avec <strong>Django</strong> et utilise <strong>PostGIS</strong> pour la gestion des données géospatiales.</p><p>---</p><p><h2>Architecture de l'application</h2></p><p><pre><code class="">loko/
├── loko/ # Configuration principale Django
</ul></p><p>L'application est construite avec <strong>Django</strong> et utilise <strong>PostGIS</strong> pour la gestion des données géospatiales.</p><p>---</p><p><h2>Architecture de l'application</h2></p><p><pre><code class="">streetup/
├── streetup/ # Configuration principale Django
│ ├── settings.py # Paramètres de l'application
│ ├── urls.py # Routes principales
│ └── wsgi.py # Point d'entrée WSGI
@ -697,7 +697,7 @@
│ContractPost │◄──────┘
└─────────────┘
</code></pre></p><p>---</p><p><h2>Diagrammes</h2></p><p><h3>Architecture globale</h3></p><p><pre><code class="">┌─────────────────────────────────────────────────────────────────────────────────────┐
│ LOKO │
│ STREETUP │
│ Système de gestion des actifs de voirie │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
@ -847,7 +847,7 @@
</ul></p><p><h3>API</h3></p><p><ul>
<li>Module <code>api/</code> pour les endpoints REST</li>
<li>Support de l'interface mobile</li>
</ul></p><p>---</p><p><h2>Conclusion</h2></p><p>Loko est une application Django complète et modulaire qui permet de gérer l'ensemble du cycle de vie des actifs de voirie. La structure en modules permet une maintenance facile et une évolution progressive des fonctionnalités.</p><p>Les principaux points forts de l'architecture sont :</p><p>1. <strong>Modularité</strong> : Chaque domaine métier est isolé dans son propre module Django
</ul></p><p>---</p><p><h2>Conclusion</h2></p><p>StreetUp est une application Django complète et modulaire qui permet de gérer l'ensemble du cycle de vie des actifs de voirie. La structure en modules permet une maintenance facile et une évolution progressive des fonctionnalités.</p><p>Les principaux points forts de l'architecture sont :</p><p>1. <strong>Modularité</strong> : Chaque domaine métier est isolé dans son propre module Django
2. <strong>Extensibilité</strong> : Les classes abstraites permettent d'ajouter facilement de nouveaux types d'actifs
3. <strong>Traçabilité</strong> : Historique complet des interventions et des modifications
4. <strong>Géolocalisation</strong> : Support natif des données géospatiales avec PostGIS

View file

@ -0,0 +1,135 @@
services:
panoramax_db:
image: postgis/postgis:16-3.4
container_name: panoramax_db
restart: unless-stopped
environment:
POSTGRES_DB: panoramax
POSTGRES_USER: panoramax_user
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ${SRV_DIR:-/srv/panoramax}/db_data:/var/lib/postgresql/data
networks:
- panoramax_network
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U panoramax_user -d panoramax" ]
interval: 10s
timeout: 5s
retries: 5
panoramax_api:
image: panoramax/api:latest
container_name: panoramax_api
restart: unless-stopped
depends_on:
panoramax_db:
condition: service_healthy
env_file:
- .env
environment:
- DB_HOST=panoramax_db
- DB_PORT=5432
- DB_NAME=panoramax
- DB_USERNAME=panoramax_user
- DB_PASSWORD=${DB_PASSWORD}
# Désactive le traitement interne pour déléguer au picture-worker
- PICTURE_PROCESS_THREADS_LIMIT=0
# Permet à Gunicorn de faire confiance aux en-têtes de proxy (comme X-Forwarded-Proto) envoyés par Caddy
- GUNICORN_CMD_ARGS=--forwarded-allow-ips=*
# Visibilité par défaut publique pour les métadonnées (images protégées au niveau du proxy)
- API_DEFAULT_PICTURE_VISIBILITY=anyone
# ports:
# Optionnel : Exposer localement pour le débug si besoin (décommenter si nécessaire)
# - "127.0.0.1:5001:5000"
# - "5000:5000"
networks:
- panoramax_network
- streetup_network
deploy:
resources:
limits:
cpus: '2.0'
memory: 2G
panoramax_worker:
image: panoramax/api:latest
restart: unless-stopped
command: picture-worker
depends_on:
panoramax_db:
condition: service_healthy
panoramax_api:
condition: service_started
panoramax_blur:
condition: service_healthy
env_file:
- .env
environment:
- DB_HOST=panoramax_db
- DB_PORT=5432
- DB_NAME=panoramax
- DB_USERNAME=panoramax_user
- DB_PASSWORD=${DB_PASSWORD}
# Visibilité par défaut publique pour les métadonnées (images protégées au niveau du proxy)
- API_DEFAULT_PICTURE_VISIBILITY=anyone
networks:
- panoramax_network
deploy:
replicas: 4
resources:
limits:
cpus: '2.0'
memory: 4G
panoramax_blur:
image: panoramax/sgblur:latest
container_name: panoramax_blur
restart: unless-stopped
environment:
- WEB_CONCURRENCY=4 # Limite le nombre de workers concurrents pour préserver le CPU
- DETECT_URL=http://panoramax_detect:8001
depends_on:
panoramax_detect:
condition: service_healthy
networks:
- panoramax_network
deploy:
resources:
limits:
cpus: '2.0'
memory: 1G
healthcheck:
test: python -c "import requests; requests.get('http://localhost:8000/').raise_for_status()"
interval: 10s
timeout: 5s
retries: 5
start_period: 2s
panoramax_detect:
image: panoramax/sgblur:latest
container_name: panoramax_detect
entrypoint: ["uvicorn", "src.detect.detect_api:app", "--host", "0.0.0.0", "--port", "8001", "--workers", "4"]
restart: unless-stopped
environment:
- OMP_NUM_THREADS=1 # 1 seul thread PyTorch par processus pour éviter la surcharge de synchronisation
- MKL_NUM_THREADS=1 # 1 seul thread MKL par processus
- MKL_DYNAMIC=FALSE # Désactive la mise à l'échelle dynamique des threads de MKL
networks:
- panoramax_network
deploy:
resources:
limits:
cpus: '4.0'
memory: 6G
healthcheck:
test: python -c "import requests; requests.get('http://localhost:8001/').raise_for_status()"
interval: 10s
timeout: 5s
retries: 5
start_period: 2s
networks:
panoramax_network:
driver: bridge
streetup_network:
external: true

102
docker-compose.prod.yml Normal file
View file

@ -0,0 +1,102 @@
services:
db:
image: postgis/postgis:18-3.6
container_name: ${CONTAINER_PREFIX}_db
restart: unless-stopped
ports:
- "127.0.0.1:${DB_HOST_PORT:-5438}:5432" # Conserve l'accès local sécurisé pour l'hôte (port personnalisable)
environment:
POSTGRES_DB: streetup
POSTGRES_USER: streetup_admin
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- ${SRV_DIR:-/srv/streetup}/db_data:/var/lib/postgresql
networks:
- streetup_network
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U streetup_admin -d streetup" ]
interval: 10s
timeout: 5s
retries: 5
web:
# Utilise l'image avec le tag approprié (latest en prod, test en environnement de test)
image: ghcr.io/kdeterme/streetup:${IMAGE_TAG:-latest}
container_name: ${CONTAINER_PREFIX}_web
restart: unless-stopped
command: /docker-entrypoint.sh
networks:
- streetup_network
volumes:
- ${SRV_DIR:-/srv/streetup}/media:/app/media
- ${SRV_DIR:-/srv/streetup}/private_media:/app/private_media
- ${SRV_DIR:-/srv/streetup}/static:/app/static
- ${SRV_DIR:-/srv/streetup}/backups:/app/backups
- ${BACKUP_HOST_SSH_KEY:-${HOME}/.ssh/id_ed25519}:/app/backup_key:ro
env_file:
- .env
environment:
- DB_HOST=db
- DB_PORT=5432
- GDAL_LIBRARY_PATH=/opt/conda/envs/streetup-env/lib/libgdal.so
- GEOS_LIBRARY_PATH=/opt/conda/envs/streetup-env/lib/libgeos_c.so
- PROJ_DATA=/opt/conda/envs/streetup-env/share/proj
- LOG_FILE_PATH=/app/django.log
depends_on:
db:
condition: service_healthy
rclone-backup:
image: rclone/rclone:latest
container_name: ${CONTAINER_PREFIX}_backup_rclone
restart: unless-stopped
volumes:
- ${SRV_DIR:-/srv/streetup}/media:/data/media:ro
- ${SRV_DIR:-/srv/streetup}/private_media:/data/private_media:ro
- ./rclone:/config/rclone
environment:
- BACKUP_INTERVAL=86400
- DESTINATION=gdrive:streetup_backup
entrypoint: >
sh -c '
echo "=== Démarrage du daemon de sauvegarde Rclone ===";
while true; do
echo "=== Début de la synchronisation : $$(date) ===";
rclone sync /data/media $${DESTINATION}/media --verbose;
rclone sync /data/private_media $${DESTINATION}/private_media --verbose;
echo "=== Fin de la synchronisation : $$(date) ===";
echo "Sommeil de $${BACKUP_INTERVAL} secondes...";
sleep $${BACKUP_INTERVAL};
done
'
caddy:
image: caddy:latest
container_name: ${CONTAINER_PREFIX}_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- ${CONTAINER_PREFIX}_caddy_data:/data
- ${CONTAINER_PREFIX}_caddy_config:/config
- ${SRV_DIR:-/srv/streetup}/static:/srv/static:ro
- ${SRV_DIR:-/srv/streetup}/media:/srv/media:ro
- /srv/streetup-test/static:/srv/static_test:ro
- /srv/streetup-test/media:/srv/media_test:ro
depends_on:
- web
networks:
- streetup_network
volumes:
streetup_prod_caddy_data:
streetup_prod_caddy_config:
streetup_test_caddy_data:
streetup_test_caddy_config:
networks:
streetup_network:
external: true

View file

@ -1,21 +1,19 @@
services:
db:
image: postgis/postgis:18-3.6
container_name: loko_db
container_name: streetup_db
restart: unless-stopped
ports:
- "5436:5432" # Mappé sur le port 5436 de l'hôte
env_file:
- ./loko/.env
- "5434:5432" # Mappé sur le port 5434 de l'hôte pour éviter tout conflit avec PostgreSQL local
environment:
POSTGRES_DB: ${DB_NAME:-loko}
POSTGRES_USER: ${DB_USER:-admin}
POSTGRES_PASSWORD: ${DB_PASSWORD:?La variable DB_PASSWORD est obligatoire dans le fichier .env}
POSTGRES_DB: streetup
POSTGRES_USER: admin
POSTGRES_PASSWORD: replace_by_your_pwd
volumes:
- loko_db_data:/var/lib/postgresql
- ./loko/scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
- streetup_db_data:/var/lib/postgresql
- ./streetup/scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U ${DB_USER:-admin} -d ${DB_NAME:-loko}" ]
test: [ "CMD-SHELL", "pg_isready -U admin -d streetup" ]
interval: 10s
timeout: 5s
retries: 5
@ -27,25 +25,25 @@ services:
args:
# Mettre "true" pour installer ultralytics, OpenCV et PyTorch dans l'image
INSTALL_AI_LIBS: "false"
container_name: loko_web
container_name: streetup_web
restart: unless-stopped
command: python manage.py runserver 0.0.0.0:8000
ports:
- "8003:8000" # Mappé sur le port 8003 de l'hôte
- "8000:8000"
volumes:
- ./loko:/app # Volume mount pour le hot-reload local
- loko_media:/app/media
- loko_private_media:/app/private_media
- ./streetup:/app # Volume mount pour le hot-reload local
- streetup_media:/app/media
- streetup_private_media:/app/private_media
env_file:
- ./loko/.env # Chargement du fichier d'environnement local
- ./streetup/.env # Chargement du fichier d'environnement local
environment:
# Surcharges d'environnement internes au conteneur pour cibler la base du conteneur "db"
- DB_HOST=db
- DB_PORT=5432
# Surcharges des chemins GIS internes au conteneur pour écraser ceux de l'hôte issus du .env
- GDAL_LIBRARY_PATH=/opt/conda/envs/loko-env/lib/libgdal.so
- GEOS_LIBRARY_PATH=/opt/conda/envs/loko-env/lib/libgeos_c.so
- PROJ_DATA=/opt/conda/envs/loko-env/share/proj
- GDAL_LIBRARY_PATH=/opt/conda/envs/streetup-env/lib/libgdal.so
- GEOS_LIBRARY_PATH=/opt/conda/envs/streetup-env/lib/libgeos_c.so
- PROJ_DATA=/opt/conda/envs/streetup-env/share/proj
# Correction du chemin des logs pour rester dans le conteneur
- LOG_FILE_PATH=/app/django.log
depends_on:
@ -54,20 +52,22 @@ services:
caddy:
image: caddy:latest
container_name: loko_caddy
container_name: streetup_caddy
restart: unless-stopped
ports:
- "8083:80" # Mappé sur le port 8083 de l'hôte
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- loko_caddy_data:/data
- loko_caddy_config:/config
- streetup_caddy_data:/data
- streetup_caddy_config:/config
depends_on:
- web
volumes:
loko_db_data:
loko_media:
loko_private_media:
loko_caddy_data:
loko_caddy_config:
streetup_db_data:
streetup_media:
streetup_private_media:
streetup_caddy_data:
streetup_caddy_config:

View file

@ -4,7 +4,7 @@
# "command: python manage.py runserver ..." dans docker-compose.yml.
set -e
exec gunicorn loko.wsgi:application \
exec gunicorn streetup.wsgi:application \
--bind 0.0.0.0:8000 \
--workers "${GUNICORN_WORKERS:-4}" \
--threads "${GUNICORN_THREADS:-2}" \

View file

@ -1,59 +0,0 @@
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())

View file

@ -1,558 +0,0 @@
from django.contrib.gis.geos import GEOSGeometry, Polygon, MultiPolygon
from django import forms
from assets.models import TrafficLightIntersectionDocument, PublicLightingStreetDocument, StructureDocument, ControlCenterDocument
from assets.models import SignStreetDocument, RoadStreetDocument, ITSLocationDocument
from assets.models import TrafficLightIntersection
from assets.models.its import ITSLocation
from assets.models.buildings import PublicBuilding
from assets.models.trafficlights import TL_INTERSECTION_STATUS_CHOICES, TL_VOLTAGE_CHOICES
class MultipleFileInput(forms.ClearableFileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", MultipleFileInput())
super().__init__(*args, **kwargs)
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
result = [single_file_clean(d, initial) for d in data]
else:
result = [single_file_clean(data, initial)]
return result
class IntersectionDocumentForm(forms.ModelForm):
documents = MultipleFileField(required=True, label="Documents")
class Meta:
model = TrafficLightIntersectionDocument
fields = ['documents']
def __init__(self, *args, **kwargs):
super(IntersectionDocumentForm, self).__init__(*args, **kwargs)
self.fields['documents'].widget.attrs.update({'class': 'form-control'})
class StreetDocumentForm(forms.ModelForm):
documents = MultipleFileField(required=True, label="Documents")
class Meta:
model = PublicLightingStreetDocument
fields = ['documents']
def __init__(self, *args, **kwargs):
super(StreetDocumentForm, self).__init__(*args, **kwargs)
self.fields['documents'].widget.attrs.update({'class': 'form-control'})
class StructureDocumentForm(forms.ModelForm):
documents = MultipleFileField(required=True, label="Documents")
class Meta:
model = StructureDocument
fields = ['documents']
def __init__(self, *args, **kwargs):
super(StructureDocumentForm, self).__init__(*args, **kwargs)
self.fields['documents'].widget.attrs.update({'class': 'form-control'})
class ControlCenterDocumentForm(forms.ModelForm):
documents = MultipleFileField(required=True, label="Documents")
class Meta:
model = ControlCenterDocument
fields = ['documents']
def __init__(self, *args, **kwargs):
super(ControlCenterDocumentForm, self).__init__(*args, **kwargs)
self.fields['documents'].widget.attrs.update({'class': 'form-control'})
class SignStreetDocumentForm(forms.ModelForm):
documents = MultipleFileField(required=True, label="Documents")
class Meta:
model = SignStreetDocument
fields = ['documents']
def __init__(self, *args, **kwargs):
super(SignStreetDocumentForm, self).__init__(*args, **kwargs)
self.fields['documents'].widget.attrs.update({'class': 'form-control'})
class RoadStreetDocumentForm(forms.ModelForm):
documents = MultipleFileField(required=True, label="Documents")
class Meta:
model = RoadStreetDocument
fields = ['documents']
def __init__(self, *args, **kwargs):
super(RoadStreetDocumentForm, self).__init__(*args, **kwargs)
self.fields['documents'].widget.attrs.update({'class': 'form-control'})
class ITSLocationDocumentForm(forms.ModelForm):
documents = MultipleFileField(required=True, label="Documents")
class Meta:
model = ITSLocationDocument
fields = ['documents']
def __init__(self, *args, **kwargs):
super(ITSLocationDocumentForm, self).__init__(*args, **kwargs)
self.fields['documents'].widget.attrs.update({'class': 'form-control'})
# ---------------------------------------------------------------------------
# Generic Location Forms (for create / edit location functionality)
# ---------------------------------------------------------------------------
class TrafficLightIntersectionForm(forms.ModelForm):
"""Form to create or edit a TrafficLightIntersection (localisation Feux)."""
# Not a model field — computed from the linked controller.
# Presented as a 3-way choice (Yes / No / Unknown).
connected_to_control_center = forms.ChoiceField(
choices=[('', '—'), ('true', 'Oui'), ('false', 'Non')],
required=False,
label='Connexion à la centrale',
widget=forms.Select(attrs={'class': 'form-select'}),
)
class Meta:
model = TrafficLightIntersection
fields = [
'code', 'status',
'name_fr', 'name_nl',
'voltage_type', 'is_radar',
'municipality', 'master',
'current_index', 'begin_time',
'lat', 'lon',
]
widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}),
'name_fr': forms.TextInput(attrs={'class': 'form-control'}),
'name_nl': forms.TextInput(attrs={'class': 'form-control'}),
'status': forms.Select(attrs={'class': 'form-select'}),
'voltage_type': forms.Select(attrs={'class': 'form-select'}),
'is_radar': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'municipality': forms.Select(attrs={'class': 'form-select'}),
'master': forms.Select(attrs={'class': 'form-select'}),
'current_index': forms.TextInput(attrs={'class': 'form-control'}),
'begin_time': forms.DateTimeInput(attrs={'class': 'form-control', 'type': 'datetime-local'}, format='%Y-%m-%dT%H:%M'),
'lat': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'lon': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
}
labels = {
'code': 'Code',
'name_fr': 'Nom (FR)',
'name_nl': 'Naam (NL)',
'status': 'Statut',
'voltage_type': 'Voltage',
'is_radar': 'Est un radar ?',
'municipality': 'Commune',
'master': 'Carrefour principal',
'current_index': 'Index',
'begin_time': 'Date de mise en service',
'lat': 'Latitude',
'lon': 'Longitude',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['municipality'].required = False
self.fields['master'].required = False
self.fields['current_index'].required = False
self.fields['begin_time'].required = False
self.fields['lat'].required = False
self.fields['lon'].required = False
self.fields['name_fr'].required = False
self.fields['name_nl'].required = False
# Exclude current instance from master choices to prevent self-referencing loops
if self.instance and self.instance.pk:
self.fields['master'].queryset = TrafficLightIntersection.objects.exclude(pk=self.instance.pk).order_by('code')
else:
self.fields['master'].queryset = TrafficLightIntersection.objects.all().order_by('code')
# Pre-populate connected_to_control_center from the computed property
if self.instance and self.instance.pk:
val = self.instance.is_connected_to_control_center
if val is True:
self.initial['connected_to_control_center'] = 'true'
elif val is False:
self.initial['connected_to_control_center'] = 'false'
else:
self.initial['connected_to_control_center'] = ''
# Enforce display order
from collections import OrderedDict
desired_order = [
'code', 'status',
'name_fr', 'name_nl',
'voltage_type', 'is_radar', 'connected_to_control_center',
'municipality', 'master',
'current_index', 'begin_time',
'lat', 'lon',
]
self.fields = OrderedDict(
(k, self.fields[k]) for k in desired_order if k in self.fields
)
def save(self, commit=True):
instance = super().save(commit=commit)
# Propagate connected_to_control_center to the latest active controller
raw = self.cleaned_data.get('connected_to_control_center', '')
if raw != '' and instance.pk:
controller = instance.controllers.filter(status='active').order_by('-installation_date').first()
if controller is not None:
controller.connected_to_control_center = (raw == 'true')
controller.save(update_fields=['connected_to_control_center'])
return instance
class ITSLocationForm(forms.ModelForm):
"""Form to create or edit an ITSLocation."""
# Hidden field: WGS84 GeoJSON polygon drawn by the user on the map.
# Accepted values: a GeoJSON Polygon or MultiPolygon string, or empty.
geom_geojson = forms.CharField(required=False, widget=forms.HiddenInput())
class Meta:
model = ITSLocation
fields = [
'code', 'name_fr', 'name_nl',
'is_main_location', 'parent_location',
]
widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}),
'name_fr': forms.TextInput(attrs={'class': 'form-control'}),
'name_nl': forms.TextInput(attrs={'class': 'form-control'}),
'is_main_location': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'parent_location': forms.Select(attrs={'class': 'form-select'}),
}
labels = {
'code': 'Code',
'name_fr': 'Nom (FR)',
'name_nl': 'Naam (NL)',
'is_main_location': 'Localisation principale',
'parent_location': 'Localisation parente',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['name_fr'].required = False
self.fields['name_nl'].required = False
self.fields['parent_location'].required = False
if self.instance and self.instance.pk:
self.fields['parent_location'].queryset = ITSLocation.objects.exclude(pk=self.instance.pk).order_by('code')
# Pre-populate geom_geojson with the existing geometry in WGS84
if self.instance.geom:
geom_wgs84 = self.instance.geom.transform(4326, clone=True)
self.initial['geom_geojson'] = geom_wgs84.geojson
else:
self.fields['parent_location'].queryset = ITSLocation.objects.all().order_by('code')
# Registry: maps location_type slug → form class + thematic code
# Used by the generic location_editing views.
LOCATION_FORM_REGISTRY = {
'trafficlightintersection': {
'form_class': TrafficLightIntersectionForm,
'thematic_code': 'trafficlights',
'label': 'Intersection',
},
'itslocation': {
'form_class': ITSLocationForm,
'thematic_code': 'its',
'label': 'Localisation ITS',
},
}
from assets.models.buildings import PublicBuilding, BuildingAsset
class PublicBuildingForm(forms.ModelForm):
"""Formulaire de création et d'édition d'une Localisation de Bâtiment Public."""
class Meta:
model = PublicBuilding
fields = [
'code', 'name_fr', 'name_nl', 'parent_location', 'location_type', 'is_main_location',
'address', 'description', 'owner', 'manager', 'occupant_entity',
'usage_type', 'building_status',
'construction_year', 'floors_count', 'basement_count',
'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number',
'room_number', 'ceiling_height_m', 'is_heated', 'fire_compartment', 'floor_type', 'floor_plan',
'is_erp', 'erp_category', 'erp_type', 'max_capacity', 'pmr_accessible',
'dpe_rating', 'ges_rating', 'has_asbestos_diag', 'has_lead_diag',
'lat', 'lon',
]
widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}),
'name_fr': forms.TextInput(attrs={'class': 'form-control'}),
'name_nl': forms.TextInput(attrs={'class': 'form-control'}),
'parent_location': forms.Select(attrs={'class': 'form-select'}),
'location_type': forms.Select(attrs={'class': 'form-select'}),
'is_main_location': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'address': forms.TextInput(attrs={'class': 'form-control'}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'owner': forms.TextInput(attrs={'class': 'form-control'}),
'manager': forms.Select(attrs={'class': 'form-select'}),
'occupant_entity': forms.TextInput(attrs={'class': 'form-control'}),
'usage_type': forms.Select(attrs={'class': 'form-select'}),
'building_status': forms.Select(attrs={'class': 'form-select'}),
'construction_year': forms.NumberInput(attrs={'class': 'form-control'}),
'floors_count': forms.NumberInput(attrs={'class': 'form-control'}),
'basement_count': forms.NumberInput(attrs={'class': 'form-control'}),
'total_surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'usable_surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'level_number': forms.NumberInput(attrs={'class': 'form-control'}),
'room_number': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: B-204, RDC-01'}),
'ceiling_height_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'floor_plan': forms.FileInput(attrs={'class': 'form-control', 'accept': '.png,.jpg,.jpeg,.svg,.pdf,.webp'}),
'is_heated': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'fire_compartment': forms.TextInput(attrs={'class': 'form-control'}),
'floor_type': forms.Select(attrs={'class': 'form-select'}),
'is_erp': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'erp_category': forms.Select(attrs={'class': 'form-select'}),
'erp_type': forms.Select(attrs={'class': 'form-select'}),
'max_capacity': forms.NumberInput(attrs={'class': 'form-control'}),
'pmr_accessible': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'dpe_rating': forms.Select(attrs={'class': 'form-select'}),
'ges_rating': forms.Select(attrs={'class': 'form-select'}),
'has_asbestos_diag': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'has_lead_diag': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'lat': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
'lon': forms.NumberInput(attrs={'class': 'form-control', 'step': 'any'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name in [
'name_fr', 'name_nl', 'parent_location', 'location_type', 'address', 'description',
'owner', 'manager', 'occupant_entity', 'construction_year', 'floors_count',
'basement_count', 'total_surface_m2', 'usable_surface_m2', 'surface_m2', 'level_number',
'room_number', 'ceiling_height_m', 'fire_compartment', 'floor_type',
'erp_category', 'erp_type', 'max_capacity', 'dpe_rating', 'ges_rating', 'lat', 'lon'
]:
if field_name in self.fields:
self.fields[field_name].required = False
class BuildingAssetForm(forms.ModelForm):
"""Formulaire pour les assets/équipements physiques d'un bâtiment."""
class Meta:
model = BuildingAsset
fields = [
'code', 'name_fr', 'name_nl', 'building', 'location', 'parent_asset',
'asset_type', 'category', 'status', 'obsolescence',
'dimensions', 'surface_m2', 'material',
'power_kw', 'energy_source', 'refrigerant_type', 'refrigerant_charge_kg',
'glazing_type', 'fire_rating', 'opening_type',
'installation_date', 'serial_number',
'maintenance_contract_ref', 'contractor_name',
'last_control_date', 'next_control_date', 'regulatory_periodicity_months',
'replacement_cost_estimate', 'note'
]
widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}),
'name_fr': forms.TextInput(attrs={'class': 'form-control'}),
'name_nl': forms.TextInput(attrs={'class': 'form-control'}),
'building': forms.Select(attrs={'class': 'form-select'}),
'location': forms.Select(attrs={'class': 'form-select'}),
'parent_asset': forms.Select(attrs={'class': 'form-select'}),
'asset_type': forms.Select(attrs={'class': 'form-select'}),
'category': forms.Select(attrs={'class': 'form-select'}),
'status': forms.Select(attrs={'class': 'form-select'}),
'obsolescence': forms.Select(attrs={'class': 'form-select'}),
'dimensions': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: 120x140 cm, 350 m²'}),
'surface_m2': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'material': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: Aluminium, PVC, Zinc, Acier'}),
'power_kw': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.1'}),
'energy_source': forms.Select(attrs={'class': 'form-select'}),
'refrigerant_type': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: R32, R410A'}),
'refrigerant_charge_kg': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'glazing_type': forms.Select(attrs={'class': 'form-select'}),
'fire_rating': forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'ex: EI30, EI60, Coupe-feu 1h'}),
'opening_type': forms.Select(attrs={'class': 'form-select'}),
'installation_date': forms.DateTimeInput(attrs={'class': 'form-control', 'type': 'datetime-local'}),
'serial_number': forms.TextInput(attrs={'class': 'form-control'}),
'maintenance_contract_ref': forms.TextInput(attrs={'class': 'form-control'}),
'contractor_name': forms.TextInput(attrs={'class': 'form-control'}),
'last_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'next_control_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'regulatory_periodicity_months': forms.NumberInput(attrs={'class': 'form-control'}),
'replacement_cost_estimate': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'note': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for field_name in [
'name_fr', 'name_nl', 'location', 'parent_asset', 'category',
'dimensions', 'surface_m2', 'material', 'power_kw', 'energy_source',
'refrigerant_type', 'refrigerant_charge_kg', 'glazing_type', 'fire_rating', 'opening_type',
'installation_date', 'serial_number', 'maintenance_contract_ref', 'contractor_name',
'last_control_date', 'next_control_date', 'regulatory_periodicity_months',
'replacement_cost_estimate', 'note'
]:
if field_name in self.fields:
self.fields[field_name].required = False
from common.models import Thematic
from assets.models import AssetCategory
t = Thematic.objects.filter(code='publicbuildings').first()
if t and 'category' in self.fields:
self.fields['category'].queryset = AssetCategory.objects.filter(thematic=t)
class StructureExceptionalTransportForm(forms.ModelForm):
"""Formulaire d'édition des paramètres de transport exceptionnel sur un ouvrage."""
class Meta:
from assets.models.structures import StructureExceptionalTransport
model = StructureExceptionalTransport
fields = [
'passage_status', 'fod_vnr', 'national_id', 'bridge_class', 'is_railway_metro_crossing', 'number_of_spans',
'max_authorized_weight_tonnes', 'min_guaranteed_capacity_tonnes', 'max_calculated_weight_tonnes',
'max_axle_load_tonnes', 'min_axle_spacing_m',
'clearance_height_left_m', 'clearance_height_center_m', 'clearance_height_right_m', 'usable_width_m',
'max_speed_kmh', 'is_braking_forbidden', 'is_isolated_crossing_only', 'safety_distance_m',
'traffic_interruption_required', 'lane_positioning', 'is_escort_required',
'prescriptions_fr', 'prescriptions_nl',
'last_study_date', 'last_study_ref',
]
widgets = {
'passage_status': forms.Select(attrs={'class': 'form-select'}),
'fod_vnr': forms.NumberInput(attrs={'class': 'form-control'}),
'national_id': forms.TextInput(attrs={'class': 'form-control'}),
'bridge_class': forms.TextInput(attrs={'class': 'form-control'}),
'is_railway_metro_crossing': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'number_of_spans': forms.NumberInput(attrs={'class': 'form-control'}),
'max_authorized_weight_tonnes': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'min_guaranteed_capacity_tonnes': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'max_calculated_weight_tonnes': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'max_axle_load_tonnes': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'min_axle_spacing_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'clearance_height_left_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'clearance_height_center_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'clearance_height_right_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'usable_width_m': forms.NumberInput(attrs={'class': 'form-control', 'step': '0.01'}),
'max_speed_kmh': forms.NumberInput(attrs={'class': 'form-control'}),
'is_braking_forbidden': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'is_isolated_crossing_only': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'safety_distance_m': forms.NumberInput(attrs={'class': 'form-control'}),
'traffic_interruption_required': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'lane_positioning': forms.Select(attrs={'class': 'form-select'}),
'is_escort_required': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
'prescriptions_fr': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'prescriptions_nl': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'last_study_date': forms.DateInput(attrs={'class': 'form-control', 'type': 'date'}),
'last_study_ref': forms.TextInput(attrs={'class': 'form-control'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
for name, field in self.fields.items():
if not isinstance(field.widget, forms.CheckboxInput):
field.required = False
# ─── Management Zones Forms ───────────────────────────────────────────────────
from assets.models import ManagementZone, ManagementZoneType, ManagementZoneDocument
class ManagementZoneForm(forms.ModelForm):
"""Formulaire de création et d'édition d'une Zone de gestion."""
geom_geojson = forms.CharField(required=False, widget=forms.HiddenInput())
class Meta:
model = ManagementZone
fields = [
'code', 'name_fr', 'name_nl', 'thematic', 'zone_type', 'color',
'target_frequency_days', 'seasonality_notes', 'instructions', 'description', 'is_active'
]
widgets = {
'code': forms.TextInput(attrs={'class': 'form-control'}),
'name_fr': forms.TextInput(attrs={'class': 'form-control'}),
'name_nl': forms.TextInput(attrs={'class': 'form-control'}),
'thematic': forms.Select(attrs={'class': 'form-select'}),
'zone_type': forms.Select(attrs={'class': 'form-select'}),
'color': forms.TextInput(attrs={'class': 'form-control form-control-color', 'type': 'color'}),
'target_frequency_days': forms.NumberInput(attrs={'class': 'form-control', 'min': 1}),
'seasonality_notes': forms.TextInput(attrs={'class': 'form-control', 'placeholder': "Ex: Tonte d'avril à octobre; fauche tardive en automne"}),
'instructions': forms.Textarea(attrs={'class': 'form-control', 'rows': 4, 'placeholder': "Consignes d'exploitation, précautions tramway, biodiversité..."}),
'description': forms.Textarea(attrs={'class': 'form-control', 'rows': 3}),
'is_active': forms.CheckboxInput(attrs={'class': 'form-check-input'}),
}
def __init__(self, *args, user=None, **kwargs):
super().__init__(*args, **kwargs)
for field in ['name_nl', 'zone_type', 'color', 'target_frequency_days', 'seasonality_notes', 'instructions', 'description']:
if field in self.fields:
self.fields[field].required = False
if not self.instance.pk and not self.initial.get('color'):
self.initial['color'] = '#198754'
if self.instance and self.instance.pk and self.instance.geom:
geom_wgs84 = self.instance.geom.transform(4326, clone=True)
self.initial['geom_geojson'] = geom_wgs84.geojson
if user:
from assets.permissions import get_editable_thematics_for_management_zones
editable_thematics = get_editable_thematics_for_management_zones(user)
self.fields['thematic'].queryset = editable_thematics
if not self.instance.pk and editable_thematics.count() == 1:
self.fields['thematic'].initial = editable_thematics.first()
thematic_id = None
if self.data.get('thematic'):
thematic_id = self.data.get('thematic')
elif self.instance.pk and self.instance.thematic_id:
thematic_id = self.instance.thematic_id
elif self.initial.get('thematic'):
thematic_id = self.initial.get('thematic')
if hasattr(thematic_id, 'pk'):
thematic_id = thematic_id.pk
if thematic_id:
self.fields['zone_type'].queryset = ManagementZoneType.objects.filter(thematic_id=thematic_id)
else:
self.fields['zone_type'].queryset = ManagementZoneType.objects.all()
def save(self, commit=True):
zone = super().save(commit=False)
geojson_str = (self.cleaned_data.get('geom_geojson') or '').strip()
if geojson_str:
try:
geom = GEOSGeometry(geojson_str, srid=4326)
if isinstance(geom, Polygon):
geom = MultiPolygon(geom, srid=4326)
geom.transform(3812)
zone.geom = geom
except Exception:
pass
elif 'geom_geojson' in self.cleaned_data and not geojson_str:
zone.geom = None
if commit:
zone.save()
return zone
class ManagementZoneDocumentForm(forms.ModelForm):
documents = MultipleFileField(required=True, label="Documents")
class Meta:
model = ManagementZoneDocument
fields = ['documents']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['documents'].widget.attrs.update({'class': 'form-control'})

View file

@ -1,746 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-17 11:57+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: loko/assets/static/assets/asset_add_modal.js:51
#: loko/assets/static/assets/asset_add_modal.js:139
#: loko/assets/static/assets/asset_add_modal.js:190
#: loko/assets/static/assets/asset_add_modal.js:615
#: loko/assets/static/assets/unified_index.js:118
#: loko/assets/static/assets/unified_index.js:224
#: loko/assets/static/assets/unified_index.js:266
msgid "Sélectionner d'abord une catégorie..."
msgstr "Select a category first..."
#: loko/assets/static/assets/asset_add_modal.js:59
#: loko/assets/static/assets/asset_add_modal.js:86
#: loko/assets/static/assets/asset_add_modal.js:101
#: loko/assets/static/assets/asset_add_modal.js:608
msgid "Sélectionner un type..."
msgstr "Select a type..."
#: loko/assets/static/assets/asset_add_modal.js:186
#: loko/assets/static/assets/asset_location_edit.js:59
#: loko/assets/static/assets/unified_index.js:119
#: loko/assets/static/assets/unified_index.js:172
#: loko/assets/static/assets/unified_index.js:228
msgid "Chargement..."
msgstr "Loading..."
#: loko/assets/static/assets/asset_add_modal.js:199
#: loko/assets/static/assets/unified_index.js:234
msgid "Sélectionner un modèle..."
msgstr "Select a model..."
#: loko/assets/static/assets/asset_add_modal.js:223
msgid "Aucun modèle disponible pour ce type"
msgstr "No model available for this type"
#: loko/assets/static/assets/asset_add_modal.js:226
#: loko/assets/static/assets/unified_index.js:247
msgid "Aucun modèle disponible"
msgstr "No model available"
#: loko/assets/static/assets/asset_add_modal.js:231
#: loko/assets/static/assets/asset_location_edit.js:102
#: loko/assets/static/assets/unified_index.js:138
#: loko/assets/static/assets/unified_index.js:209
#: loko/assets/static/assets/unified_index.js:251
msgid "Erreur de chargement"
msgstr "Loading error"
#: loko/assets/static/assets/asset_add_modal.js:239
#: loko/assets/static/assets/unified_index.js:924
#: loko/assets/static/assets/unified_index.js:1011
msgid "Création..."
msgstr "Creating..."
#: loko/assets/static/assets/asset_add_modal.js:319
#: loko/assets/static/assets/asset_add_modal.js:523
msgid "Erreur lors de la création"
msgstr "Error during creation"
#: loko/assets/static/assets/asset_add_modal.js:329
#: loko/assets/static/assets/asset_add_modal.js:532
msgid "Erreur de connexion"
msgstr "Connection error"
#: loko/assets/static/assets/asset_add_modal.js:394
msgid "Sélection de template d'opérations"
msgstr "Operation template selection"
#: loko/assets/static/assets/asset_add_modal.js:396
msgid "Fermer"
msgstr "Close"
#: loko/assets/static/assets/asset_add_modal.js:399
msgid ""
"Choisissez le(s) template(s) d'opérations à appliquer à cet équipement."
msgstr "Choose the operation template(s) to apply to this asset."
#: loko/assets/static/assets/asset_add_modal.js:401
msgid "Choisir le(s) template(s) d'opérations :"
msgstr "Choose operation template(s):"
#: loko/assets/static/assets/asset_add_modal.js:415
msgid "Tout sélectionner"
msgstr "Select all"
#: loko/assets/static/assets/asset_add_modal.js:418
msgid "Tout désélectionner"
msgstr "Deselect all"
#: loko/assets/static/assets/asset_add_modal.js:426
msgid "Ignorer et continuer"
msgstr "Ignore and continue"
#: loko/assets/static/assets/asset_add_modal.js:429
msgid "Créer les opérations"
msgstr "Create operations"
#: loko/assets/static/assets/asset_add_modal.js:490
msgid "Création en cours..."
msgstr "Creating..."
#: loko/assets/static/assets/asset_location_edit.js:37
msgid "Saisissez au moins 2 caractères pour rechercher."
msgstr "Enter at least 2 characters to search."
#: loko/assets/static/assets/asset_location_edit.js:76
#: loko/assets/static/assets/unified_index.js:181
msgid "Sélectionner une localisation..."
msgstr "Select location..."
#: loko/assets/static/assets/asset_location_edit.js:87
msgid "Résultats limités. Affinez la recherche pour trouver plus facilement."
msgstr "Limited results. Refine your search to find more easily."
#: loko/assets/static/assets/asset_location_edit.js:92
#: loko/assets/static/assets/unified_index.js:200
msgid "Aucune localisation disponible"
msgstr "No location available"
#: loko/assets/static/assets/asset_location_edit.js:94
msgid "Aucun résultat pour cette recherche."
msgstr "No results for this search."
#: loko/assets/static/assets/asset_location_edit.js:104
msgid "Erreur lors du chargement des localisations"
msgstr "Error during loading locations"
#: loko/assets/static/assets/asset_location_edit.js:117
msgid "Veuillez sélectionner une localisation"
msgstr "Please select a location"
#: loko/assets/static/assets/asset_location_edit.js:124
msgid "Mise à jour..."
msgstr "Updating..."
#: loko/assets/static/assets/asset_location_edit.js:149
msgid "Localisation mise à jour"
msgstr "Location updated"
#: loko/assets/static/assets/asset_location_edit.js:154
#: loko/assets/static/assets/asset_location_edit.js:161
msgid "Erreur lors de la mise à jour"
msgstr "Error during updating"
#: loko/assets/static/assets/assets_index.js:568
#: loko/assets/static/assets/assets_index.js:592
#: loko/assets/static/assets/unified_index.js:506
msgid "Code"
msgstr "Code"
#: loko/assets/static/assets/assets_index.js:594
#: loko/assets/static/assets/green-surfaces-comments.js:956
#: loko/assets/static/assets/unified_index.js:518
msgid "Statut"
msgstr "Status"
#: loko/assets/static/assets/assets_index.js:777
msgid "%(count)s localisation sélectionnée"
msgid_plural "%(count)s localisations sélectionnées"
msgstr[0] "%(count)s location selected"
msgstr[1] "%(count)s locations selected"
#: loko/assets/static/assets/assets_index.js:789
msgid "%(count)s asset sélectionné"
msgid_plural "%(count)s assets sélectionnés"
msgstr[0] "%(count)s asset selected"
msgstr[1] "%(count)s assets selected"
#: loko/assets/static/assets/assets_index.js:880
msgid "Sélectionner l'asset"
msgstr "Select asset"
#: loko/assets/static/assets/assets_index.js:886
msgid "Désélectionner l'asset"
msgstr "Deselect asset"
#: loko/assets/static/assets/assets_index.js:997
#: loko/assets/static/assets/assets_index.js:1068
msgid "Erreur lors de la récupération des données."
msgstr "Error while retrieving data."
#: loko/assets/static/assets/assets_index.js:1100
msgid ""
"Sélectionnez des localisations ou lancez une recherche pour afficher les "
"assets."
msgstr ""
#: loko/assets/static/assets/assets_index.js:1102
msgid "Aucun asset ne correspond aux critères de recherche."
msgstr "No asset matches the search criteria."
#: loko/assets/static/assets/assets_index.js:1309
#: loko/assets/static/assets/unified_index.js:2980
#: loko/assets/static/assets/unified_index.js:3743
#: loko/assets/static/assets/unified_index.js:3811
msgid "Sans catégorie"
msgstr "Uncategorized"
#: loko/assets/static/assets/assets_index.js:1330
#: loko/assets/static/assets/unified_index.js:3837
msgid "%(count)s élément pris en compte"
msgid_plural "%(count)s éléments pris en compte"
msgstr[0] "%(count)s item included"
msgstr[1] "%(count)s items included"
#: loko/assets/static/assets/assets_index.js:1338
#: loko/assets/static/assets/unified_index.js:3842
msgid "(sélection)"
msgstr "(selection)"
#: loko/assets/static/assets/assets_index.js:1339
#: loko/assets/static/assets/unified_index.js:3842
msgid "(table)"
msgstr "(table)"
#: loko/assets/static/assets/assets_index.js:1350
#: loko/assets/static/assets/unified_index.js:3847
msgid "Aucune donnée disponible"
msgstr "No data available"
#: loko/assets/static/assets/assets_index.js:1383
#: loko/assets/static/assets/unified_index.js:3878
msgid "au total"
msgstr "in total"
#: loko/assets/static/assets/assets_index.js:1389
#: loko/assets/static/assets/unified_index.js:516
#: loko/assets/static/assets/unified_index.js:3884
msgid "Modèle"
msgstr "Model"
#: loko/assets/static/assets/assets_index.js:1390
#: loko/assets/static/assets/unified_index.js:3885
msgid "Nombre"
msgstr "Number"
#: loko/assets/static/assets/assets_index.js:1391
#: loko/assets/static/assets/unified_index.js:3886
msgid "Longueur (m)"
msgstr "Length (m)"
#: loko/assets/static/assets/assets_index.js:1392
#: loko/assets/static/assets/unified_index.js:3887
msgid "Surface (m²)"
msgstr "Area (m²)"
#: loko/assets/static/assets/assets_index.js:1396
#: loko/assets/static/assets/unified_index.js:3890
msgid "Aucun modèle"
msgstr "No model"
#: loko/assets/static/assets/assets_index.js:1400
#: loko/assets/static/assets/unified_index.js:3893
msgid "Total catégorie"
msgstr "Category total"
#: loko/assets/static/assets/green-surfaces-comments.js:476
#: loko/assets/static/assets/green-surfaces-comments.js:905
msgid "Nouveau commentaire"
msgstr "New comment"
#: loko/assets/static/assets/green-surfaces-comments.js:483
msgid "Textes rapides"
msgstr "Quick texts"
#: loko/assets/static/assets/green-surfaces-comments.js:485
#: loko/assets/static/assets/green-surfaces-comments.js:486
#: loko/assets/static/assets/green-surfaces-comments.js:959
msgid "Mémo"
msgstr "Memo"
#: loko/assets/static/assets/green-surfaces-comments.js:488
#: loko/assets/static/assets/green-surfaces-comments.js:489
msgid "Point d'attention"
msgstr "Point of attention"
#: loko/assets/static/assets/green-surfaces-comments.js:491
#: loko/assets/static/assets/green-surfaces-comments.js:492
#: loko/assets/static/assets/green-surfaces-comments.js:965
msgid "À faire"
msgstr "To do"
#: loko/assets/static/assets/green-surfaces-comments.js:494
#: loko/assets/static/assets/green-surfaces-comments.js:495
#: loko/assets/static/assets/green-surfaces-comments.js:970
msgid "OK"
msgstr "Okay"
#: loko/assets/static/assets/green-surfaces-comments.js:497
#: loko/assets/static/assets/green-surfaces-comments.js:498
#: loko/assets/static/assets/green-surfaces-comments.js:973
msgid "Problème"
msgstr "Problem"
#: loko/assets/static/assets/green-surfaces-comments.js:504
#: loko/assets/static/assets/green-surfaces-comments.js:980
msgid "Commentaire"
msgstr "Comment"
#: loko/assets/static/assets/green-surfaces-comments.js:505
msgid "Votre commentaire..."
msgstr "Your comment..."
#: loko/assets/static/assets/green-surfaces-comments.js:509
#: loko/assets/static/assets/green-surfaces-comments.js:993
msgid "Photos"
msgstr "Photos"
#: loko/assets/static/assets/green-surfaces-comments.js:512
msgid "Prendre des photos (mode continu)"
msgstr "Take photos (continuous mode)"
#: loko/assets/static/assets/green-surfaces-comments.js:515
msgid "Sélectionner des fichiers"
msgstr "Select files"
#: loko/assets/static/assets/green-surfaces-comments.js:524
msgid "Position"
msgstr "Position"
#: loko/assets/static/assets/green-surfaces-comments.js:528
msgid "Position actuelle sera utilisée"
msgstr "Current position will be used"
#: loko/assets/static/assets/green-surfaces-comments.js:531
#: loko/assets/static/assets/green-surfaces-comments.js:988
msgid "Repositionner"
msgstr "Reposition"
#: loko/assets/static/assets/green-surfaces-comments.js:539
#: loko/assets/static/assets/green-surfaces-comments.js:1011
msgid "Annuler"
msgstr "Cancel"
#: loko/assets/static/assets/green-surfaces-comments.js:541
#: loko/assets/static/assets/green-surfaces-comments.js:1008
msgid "Enregistrer"
msgstr "Save"
#: loko/assets/static/assets/green-surfaces-comments.js:573
msgid "Caméra"
msgstr "Camera"
#: loko/assets/static/assets/green-surfaces-comments.js:588
#: loko/assets/static/assets/green-surfaces-comments.js:1130
msgid "Terminer"
msgstr "Finish"
#: loko/assets/static/assets/green-surfaces-comments.js:591
#: loko/assets/static/assets/green-surfaces-comments.js:1127
msgid "Capturer"
msgstr "Capture"
#: loko/assets/static/assets/green-surfaces-comments.js:962
msgid "Attention"
msgstr "Warning"
#: loko/assets/static/assets/green-surfaces-comments.js:980
msgid "(optionnel)"
msgstr "(optional)"
#: loko/assets/static/assets/green-surfaces-comments.js:981
msgid "Entrez un commentaire optionnel..."
msgstr "Enter an optional comment..."
#: loko/assets/static/assets/green-surfaces-comments.js:985
msgid "Localisation"
msgstr "Location"
#: loko/assets/static/assets/green-surfaces-comments.js:986
msgid "Calcul en cours..."
msgstr "Calculating..."
#: loko/assets/static/assets/green-surfaces-comments.js:996
msgid "Appareil photo"
msgstr "Camera"
#: loko/assets/static/assets/green-surfaces-comments.js:999
msgid "Sélectionner fichiers"
msgstr "Select files"
#: loko/assets/static/assets/unified_index.js:125
#: loko/assets/static/assets/unified_index.js:262
msgid "Sélectionner une catégorie..."
msgstr "Select a category..."
#: loko/assets/static/assets/unified_index.js:134
msgid "Aucune catégorie disponible"
msgstr "No category available"
#: loko/assets/static/assets/unified_index.js:174
msgid "Sélectionnez la localisation liée à ce nouvel asset."
msgstr "Select the location linked to this new asset."
#: loko/assets/static/assets/unified_index.js:204
msgid "Aucune localisation disponible pour créer cet asset."
msgstr "No location available to create this asset."
#: loko/assets/static/assets/unified_index.js:213
msgid "Impossible de charger les localisations pour cette thématique."
msgstr "Unable to load locations for this topic."
#: loko/assets/static/assets/unified_index.js:298
msgid "Créer un asset"
msgstr "Create an asset"
#: loko/assets/static/assets/unified_index.js:312
msgid "Le code est généré automatiquement."
msgstr "The code is generated automatically."
#: loko/assets/static/assets/unified_index.js:449
#: loko/assets/static/assets/unified_index.js:454
msgid "Erreur lors du chargement du formulaire."
msgstr "Error while loading the form."
#: loko/assets/static/assets/unified_index.js:471
msgid "Créer une localisation"
msgstr "Create a location"
#: loko/assets/static/assets/unified_index.js:472
#: loko/assets/static/assets/unified_index.js:513
msgid "Type"
msgstr "Type"
#: loko/assets/static/assets/unified_index.js:496
msgid "Zoom"
msgstr "Zoom"
#: loko/assets/static/assets/unified_index.js:512
msgid "Nom"
msgstr "Name"
#: loko/assets/static/assets/unified_index.js:514
msgid "Thématique"
msgstr "Thematic"
#: loko/assets/static/assets/unified_index.js:515
msgid "Catégorie"
msgstr "Category"
#: loko/assets/static/assets/unified_index.js:522
msgid "Statut de validation"
msgstr ""
#: loko/assets/static/assets/unified_index.js:525
msgid "À valider"
msgstr ""
#: loko/assets/static/assets/unified_index.js:526
msgid "Validé"
msgstr ""
#: loko/assets/static/assets/unified_index.js:527
msgid "À compléter"
msgstr ""
#: loko/assets/static/assets/unified_index.js:528
msgid "Rejeté"
msgstr ""
#: loko/assets/static/assets/unified_index.js:841
#: loko/assets/static/assets/unified_index.js:842
msgid "Sélectionner une thématique..."
msgstr "Select a topic..."
#: loko/assets/static/assets/unified_index.js:841
#: loko/assets/static/assets/unified_index.js:842
msgid "Aucune thématique disponible"
msgstr "No topic available"
#: loko/assets/static/assets/unified_index.js:849
msgid "Vous n'avez pas de droits de création"
msgstr "You do not have creation rights"
#: loko/assets/static/assets/unified_index.js:867
msgid "Sélectionnez une thématique pour créer un asset."
msgstr "Select a topic to create an asset."
#: loko/assets/static/assets/unified_index.js:888
msgid "Sélectionnez une thématique pour créer une localisation."
msgstr "Select a topic to create a location."
#: loko/assets/static/assets/unified_index.js:916
msgid "Sélectionnez une localisation avant de créer cet asset."
msgstr "Select a location before creating this asset."
#: loko/assets/static/assets/unified_index.js:947
msgid "Type d'asset introuvable pour ce modèle."
msgstr "Asset type not found for this model."
#: loko/assets/static/assets/unified_index.js:977
msgid "Asset créé."
msgstr "Asset created."
#: loko/assets/static/assets/unified_index.js:988
#: loko/assets/static/assets/unified_index.js:1035
msgid "Erreur lors de la création."
msgstr "Error during creation."
#: loko/assets/static/assets/unified_index.js:992
#: loko/assets/static/assets/unified_index.js:1039
msgid "Erreur de connexion."
msgstr "Connection error."
#: loko/assets/static/assets/unified_index.js:1028
msgid "Localisation créée."
msgstr "Location created."
#: loko/assets/static/assets/unified_index.js:1499
#: loko/assets/static/assets/unified_index.js:1579
#: loko/assets/static/assets/unified_index.js:1639
msgid "Veuillez sélectionner au moins un asset."
msgstr "Please select at least one asset."
#: loko/assets/static/assets/unified_index.js:1608
#: loko/assets/static/assets/unified_index.js:1615
#: loko/assets/static/assets/unified_index.js:1668
#: loko/assets/static/assets/unified_index.js:1675
msgid "Une erreur est survenue."
msgstr ""
#: loko/assets/static/assets/unified_index.js:1700
msgid ""
"Trop de données à afficher. Ajoutez des filtres ou réduisez le périmètre "
"pour charger le tableau et la carte."
msgstr ""
#: loko/assets/static/assets/unified_index.js:1955
#: loko/assets/static/assets/unified_index.js:1986
msgid "Voir dans le tableau"
msgstr "View in table"
#: loko/assets/static/assets/unified_index.js:1995
msgid "autre(s) asset(s)"
msgstr "other asset(s)"
#: loko/assets/static/assets/unified_index.js:1999
msgid "autre(s) localisation(s)"
msgstr "other location(s)"
#: loko/assets/static/assets/unified_index.js:2004
msgid "Assets trouvés"
msgstr "Assets found"
#: loko/assets/static/assets/unified_index.js:2012
msgid "Localisations trouvées"
msgstr "Locations found"
#: loko/assets/static/assets/unified_index.js:2600
msgid "Vue initiale (Bruxelles)"
msgstr "Initial view (Brussels)"
#: loko/assets/static/assets/unified_index.js:2758
msgid "sélectionné(s)"
msgstr "selected"
#: loko/assets/static/assets/unified_index.js:3302
msgid "Aucun type sélectionné"
msgstr "No type selected"
#: loko/assets/static/assets/unified_index.js:3306
msgid "Aucun type disponible."
msgstr "No type available."
#: loko/assets/static/assets/unified_index.js:3447
#: loko/assets/static/assets/unified_index.js:3617
#: loko/assets/static/assets/unified_index.js:3675
msgid "Sélectionnez d'abord une ou plusieurs thématiques."
msgstr "Select one or more topics first."
#: loko/assets/static/assets/unified_index.js:3517
msgid "Tous"
msgstr "All"
#: loko/assets/static/assets/unified_index.js:3682
msgid "Sélectionnez d'abord une ou plusieurs catégories."
msgstr "Select one or more categories first."
#: loko/assets/static/assets/unified_index.js:3698
msgid "Chargement des modèles..."
msgstr "Loading models..."
#: loko/assets/static/assets/unified_index.js:3709
msgid "Aucun modèle disponible pour les catégories sélectionnées."
msgstr "No model available for the selected categories."
#: loko/assets/static/assets/unified_index.js:3720
msgid "Afficher les modèles"
msgstr "Show models"
#: loko/assets/static/assets/unified_index.js:3721
msgid "Replier les modèles"
msgstr "Collapse models"
#: loko/assets/static/assets/unified_index.js:3726
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
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."
msgid "Zones de gestion"
msgstr "Management zones"
msgid "Aucune zone de gestion définie pour cette thématique."
msgstr "No management zone defined for this thematic."
msgid "zones"
msgstr "zones"
msgid "Afficher / masquer toutes les zones"
msgstr "Show / hide all zones"
msgid "Opacité"
msgstr "Opacity"
msgid "Fréquence cible :"
msgstr "Target frequency:"
msgid "jours"
msgstr "days"
msgid "Consignes & points d'attention"
msgstr "Instructions & points of attention"
msgid "Centrer sur la zone"
msgstr "Center on zone"

File diff suppressed because it is too large Load diff

View file

@ -1,751 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-17 11:57+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#: loko/assets/static/assets/asset_add_modal.js:51
#: loko/assets/static/assets/asset_add_modal.js:139
#: loko/assets/static/assets/asset_add_modal.js:190
#: loko/assets/static/assets/asset_add_modal.js:615
#: loko/assets/static/assets/unified_index.js:118
#: loko/assets/static/assets/unified_index.js:224
#: loko/assets/static/assets/unified_index.js:266
msgid "Sélectionner d'abord une catégorie..."
msgstr "Sélectionner d'abord une catégorie..."
#: loko/assets/static/assets/asset_add_modal.js:59
#: loko/assets/static/assets/asset_add_modal.js:86
#: loko/assets/static/assets/asset_add_modal.js:101
#: loko/assets/static/assets/asset_add_modal.js:608
msgid "Sélectionner un type..."
msgstr "Sélectionner un type..."
#: loko/assets/static/assets/asset_add_modal.js:186
#: loko/assets/static/assets/asset_location_edit.js:59
#: loko/assets/static/assets/unified_index.js:119
#: loko/assets/static/assets/unified_index.js:172
#: loko/assets/static/assets/unified_index.js:228
msgid "Chargement..."
msgstr "Chargement..."
#: loko/assets/static/assets/asset_add_modal.js:199
#: loko/assets/static/assets/unified_index.js:234
msgid "Sélectionner un modèle..."
msgstr "Sélectionner un modèle..."
#: loko/assets/static/assets/asset_add_modal.js:223
msgid "Aucun modèle disponible pour ce type"
msgstr "Aucun modèle disponible pour ce type"
#: loko/assets/static/assets/asset_add_modal.js:226
#: loko/assets/static/assets/unified_index.js:247
msgid "Aucun modèle disponible"
msgstr "Aucun modèle disponible"
#: loko/assets/static/assets/asset_add_modal.js:231
#: loko/assets/static/assets/asset_location_edit.js:102
#: loko/assets/static/assets/unified_index.js:138
#: loko/assets/static/assets/unified_index.js:209
#: loko/assets/static/assets/unified_index.js:251
msgid "Erreur de chargement"
msgstr "Erreur de chargement"
#: loko/assets/static/assets/asset_add_modal.js:239
#: loko/assets/static/assets/unified_index.js:924
#: loko/assets/static/assets/unified_index.js:1011
msgid "Création..."
msgstr "Création..."
#: loko/assets/static/assets/asset_add_modal.js:319
#: loko/assets/static/assets/asset_add_modal.js:523
msgid "Erreur lors de la création"
msgstr "Erreur lors de la création"
#: loko/assets/static/assets/asset_add_modal.js:329
#: loko/assets/static/assets/asset_add_modal.js:532
msgid "Erreur de connexion"
msgstr "Erreur de connexion"
#: loko/assets/static/assets/asset_add_modal.js:394
msgid "Sélection de template d'opérations"
msgstr "Sélection de template d'opérations"
#: loko/assets/static/assets/asset_add_modal.js:396
msgid "Fermer"
msgstr "Fermer"
#: loko/assets/static/assets/asset_add_modal.js:399
msgid ""
"Choisissez le(s) template(s) d'opérations à appliquer à cet équipement."
msgstr ""
"Choisissez le(s) template(s) d'opérations à appliquer à cet équipement."
#: loko/assets/static/assets/asset_add_modal.js:401
msgid "Choisir le(s) template(s) d'opérations :"
msgstr "Choisir le(s) template(s) d'opérations :"
#: loko/assets/static/assets/asset_add_modal.js:415
msgid "Tout sélectionner"
msgstr "Tout sélectionner"
#: loko/assets/static/assets/asset_add_modal.js:418
msgid "Tout désélectionner"
msgstr "Tout désélectionner"
#: loko/assets/static/assets/asset_add_modal.js:426
msgid "Ignorer et continuer"
msgstr "Ignorer et continuer"
#: loko/assets/static/assets/asset_add_modal.js:429
msgid "Créer les opérations"
msgstr "Créer les opérations"
#: loko/assets/static/assets/asset_add_modal.js:490
msgid "Création en cours..."
msgstr "Création en cours..."
#: loko/assets/static/assets/asset_location_edit.js:37
msgid "Saisissez au moins 2 caractères pour rechercher."
msgstr "Saisissez au moins 2 caractères pour rechercher."
#: loko/assets/static/assets/asset_location_edit.js:76
#: loko/assets/static/assets/unified_index.js:181
msgid "Sélectionner une localisation..."
msgstr "Sélectionner une localisation..."
#: loko/assets/static/assets/asset_location_edit.js:87
msgid "Résultats limités. Affinez la recherche pour trouver plus facilement."
msgstr "Résultats limités. Affinez la recherche pour trouver plus facilement."
#: loko/assets/static/assets/asset_location_edit.js:92
#: loko/assets/static/assets/unified_index.js:200
msgid "Aucune localisation disponible"
msgstr "Aucune localisation disponible"
#: loko/assets/static/assets/asset_location_edit.js:94
msgid "Aucun résultat pour cette recherche."
msgstr "Aucun résultat pour cette recherche."
#: loko/assets/static/assets/asset_location_edit.js:104
msgid "Erreur lors du chargement des localisations"
msgstr "Erreur lors du chargement des localisations"
#: loko/assets/static/assets/asset_location_edit.js:117
msgid "Veuillez sélectionner une localisation"
msgstr "Veuillez sélectionner une localisation"
#: loko/assets/static/assets/asset_location_edit.js:124
msgid "Mise à jour..."
msgstr "Mise à jour..."
#: loko/assets/static/assets/asset_location_edit.js:149
msgid "Localisation mise à jour"
msgstr "Localisation mise à jour"
#: loko/assets/static/assets/asset_location_edit.js:154
#: loko/assets/static/assets/asset_location_edit.js:161
msgid "Erreur lors de la mise à jour"
msgstr "Erreur lors de la mise à jour"
#: loko/assets/static/assets/assets_index.js:568
#: loko/assets/static/assets/assets_index.js:592
#: loko/assets/static/assets/unified_index.js:506
msgid "Code"
msgstr "Code"
#: loko/assets/static/assets/assets_index.js:594
#: loko/assets/static/assets/green-surfaces-comments.js:956
#: loko/assets/static/assets/unified_index.js:518
msgid "Statut"
msgstr "Statut"
#: loko/assets/static/assets/assets_index.js:777
msgid "%(count)s localisation sélectionnée"
msgid_plural "%(count)s localisations sélectionnées"
msgstr[0] "%(count)s localisation sélectionnée"
msgstr[1] "%(count)s localisations sélectionnées"
#: loko/assets/static/assets/assets_index.js:789
msgid "%(count)s asset sélectionné"
msgid_plural "%(count)s assets sélectionnés"
msgstr[0] "%(count)s asset sélectionné"
msgstr[1] "%(count)s assets sélectionnés"
#: loko/assets/static/assets/assets_index.js:880
msgid "Sélectionner l'asset"
msgstr "Sélectionner l'asset"
#: loko/assets/static/assets/assets_index.js:886
msgid "Désélectionner l'asset"
msgstr "Désélectionner l'asset"
#: loko/assets/static/assets/assets_index.js:997
#: loko/assets/static/assets/assets_index.js:1068
msgid "Erreur lors de la récupération des données."
msgstr "Erreur lors de la récupération des données."
#: loko/assets/static/assets/assets_index.js:1100
msgid ""
"Sélectionnez des localisations ou lancez une recherche pour afficher les "
"assets."
msgstr ""
"Sélectionnez des localisations ou lancez une recherche pour afficher les "
"assets."
#: loko/assets/static/assets/assets_index.js:1102
msgid "Aucun asset ne correspond aux critères de recherche."
msgstr "Aucun asset ne correspond aux critères de recherche."
#: loko/assets/static/assets/assets_index.js:1309
#: loko/assets/static/assets/unified_index.js:2980
#: loko/assets/static/assets/unified_index.js:3743
#: loko/assets/static/assets/unified_index.js:3811
msgid "Sans catégorie"
msgstr "Sans catégorie"
#: loko/assets/static/assets/assets_index.js:1330
#: loko/assets/static/assets/unified_index.js:3837
msgid "%(count)s élément pris en compte"
msgid_plural "%(count)s éléments pris en compte"
msgstr[0] "%(count)s élément pris en compte"
msgstr[1] "%(count)s éléments pris en compte"
#: loko/assets/static/assets/assets_index.js:1338
#: loko/assets/static/assets/unified_index.js:3842
msgid "(sélection)"
msgstr "(sélection)"
#: loko/assets/static/assets/assets_index.js:1339
#: loko/assets/static/assets/unified_index.js:3842
msgid "(table)"
msgstr "(table)"
#: loko/assets/static/assets/assets_index.js:1350
#: loko/assets/static/assets/unified_index.js:3847
msgid "Aucune donnée disponible"
msgstr "Aucune donnée disponible"
#: loko/assets/static/assets/assets_index.js:1383
#: loko/assets/static/assets/unified_index.js:3878
msgid "au total"
msgstr "au total"
#: loko/assets/static/assets/assets_index.js:1389
#: loko/assets/static/assets/unified_index.js:516
#: loko/assets/static/assets/unified_index.js:3884
msgid "Modèle"
msgstr "Modèle"
#: loko/assets/static/assets/assets_index.js:1390
#: loko/assets/static/assets/unified_index.js:3885
msgid "Nombre"
msgstr "Nombre"
#: loko/assets/static/assets/assets_index.js:1391
#: loko/assets/static/assets/unified_index.js:3886
msgid "Longueur (m)"
msgstr "Longueur (m)"
#: loko/assets/static/assets/assets_index.js:1392
#: loko/assets/static/assets/unified_index.js:3887
msgid "Surface (m²)"
msgstr "Surface (m²)"
#: loko/assets/static/assets/assets_index.js:1396
#: loko/assets/static/assets/unified_index.js:3890
msgid "Aucun modèle"
msgstr "Aucun modèle"
#: loko/assets/static/assets/assets_index.js:1400
#: loko/assets/static/assets/unified_index.js:3893
msgid "Total catégorie"
msgstr "Total catégorie"
#: loko/assets/static/assets/green-surfaces-comments.js:476
#: loko/assets/static/assets/green-surfaces-comments.js:905
msgid "Nouveau commentaire"
msgstr "Nouveau commentaire"
#: loko/assets/static/assets/green-surfaces-comments.js:483
msgid "Textes rapides"
msgstr "Textes rapides"
#: loko/assets/static/assets/green-surfaces-comments.js:485
#: loko/assets/static/assets/green-surfaces-comments.js:486
#: loko/assets/static/assets/green-surfaces-comments.js:959
msgid "Mémo"
msgstr "Mémo"
#: loko/assets/static/assets/green-surfaces-comments.js:488
#: loko/assets/static/assets/green-surfaces-comments.js:489
msgid "Point d'attention"
msgstr "Point d'attention"
#: loko/assets/static/assets/green-surfaces-comments.js:491
#: loko/assets/static/assets/green-surfaces-comments.js:492
#: loko/assets/static/assets/green-surfaces-comments.js:965
msgid "À faire"
msgstr "À faire"
#: loko/assets/static/assets/green-surfaces-comments.js:494
#: loko/assets/static/assets/green-surfaces-comments.js:495
#: loko/assets/static/assets/green-surfaces-comments.js:970
msgid "OK"
msgstr "OK"
#: loko/assets/static/assets/green-surfaces-comments.js:497
#: loko/assets/static/assets/green-surfaces-comments.js:498
#: loko/assets/static/assets/green-surfaces-comments.js:973
msgid "Problème"
msgstr "Problème"
#: loko/assets/static/assets/green-surfaces-comments.js:504
#: loko/assets/static/assets/green-surfaces-comments.js:980
msgid "Commentaire"
msgstr "Commentaire"
#: loko/assets/static/assets/green-surfaces-comments.js:505
msgid "Votre commentaire..."
msgstr "Votre commentaire..."
#: loko/assets/static/assets/green-surfaces-comments.js:509
#: loko/assets/static/assets/green-surfaces-comments.js:993
msgid "Photos"
msgstr "Photos"
#: loko/assets/static/assets/green-surfaces-comments.js:512
msgid "Prendre des photos (mode continu)"
msgstr "Prendre des photos (mode continu)"
#: loko/assets/static/assets/green-surfaces-comments.js:515
msgid "Sélectionner des fichiers"
msgstr "Sélectionner des fichiers"
#: loko/assets/static/assets/green-surfaces-comments.js:524
msgid "Position"
msgstr "Position"
#: loko/assets/static/assets/green-surfaces-comments.js:528
msgid "Position actuelle sera utilisée"
msgstr "Position actuelle sera utilisée"
#: loko/assets/static/assets/green-surfaces-comments.js:531
#: loko/assets/static/assets/green-surfaces-comments.js:988
msgid "Repositionner"
msgstr "Repositionner"
#: loko/assets/static/assets/green-surfaces-comments.js:539
#: loko/assets/static/assets/green-surfaces-comments.js:1011
msgid "Annuler"
msgstr "Annuler"
#: loko/assets/static/assets/green-surfaces-comments.js:541
#: loko/assets/static/assets/green-surfaces-comments.js:1008
msgid "Enregistrer"
msgstr "Enregistrer"
#: loko/assets/static/assets/green-surfaces-comments.js:573
msgid "Caméra"
msgstr "Caméra"
#: loko/assets/static/assets/green-surfaces-comments.js:588
#: loko/assets/static/assets/green-surfaces-comments.js:1130
msgid "Terminer"
msgstr "Terminer"
#: loko/assets/static/assets/green-surfaces-comments.js:591
#: loko/assets/static/assets/green-surfaces-comments.js:1127
msgid "Capturer"
msgstr "Capturer"
#: loko/assets/static/assets/green-surfaces-comments.js:962
msgid "Attention"
msgstr "Attention"
#: loko/assets/static/assets/green-surfaces-comments.js:980
msgid "(optionnel)"
msgstr "(optionnel)"
#: loko/assets/static/assets/green-surfaces-comments.js:981
msgid "Entrez un commentaire optionnel..."
msgstr "Entrez un commentaire optionnel..."
#: loko/assets/static/assets/green-surfaces-comments.js:985
msgid "Localisation"
msgstr "Localisation"
#: loko/assets/static/assets/green-surfaces-comments.js:986
msgid "Calcul en cours..."
msgstr "Calcul en cours..."
#: loko/assets/static/assets/green-surfaces-comments.js:996
msgid "Appareil photo"
msgstr "Appareil photo"
#: loko/assets/static/assets/green-surfaces-comments.js:999
msgid "Sélectionner fichiers"
msgstr "Sélectionner fichiers"
#: loko/assets/static/assets/unified_index.js:125
#: loko/assets/static/assets/unified_index.js:262
msgid "Sélectionner une catégorie..."
msgstr "Sélectionner une catégorie..."
#: loko/assets/static/assets/unified_index.js:134
msgid "Aucune catégorie disponible"
msgstr "Aucune catégorie disponible"
#: loko/assets/static/assets/unified_index.js:174
msgid "Sélectionnez la localisation liée à ce nouvel asset."
msgstr "Sélectionnez la localisation liée à ce nouvel asset."
#: loko/assets/static/assets/unified_index.js:204
msgid "Aucune localisation disponible pour créer cet asset."
msgstr "Aucune localisation disponible pour créer cet asset."
#: loko/assets/static/assets/unified_index.js:213
msgid "Impossible de charger les localisations pour cette thématique."
msgstr "Impossible de charger les localisations pour cette thématique."
#: loko/assets/static/assets/unified_index.js:298
msgid "Créer un asset"
msgstr "Créer un asset"
#: loko/assets/static/assets/unified_index.js:312
msgid "Le code est généré automatiquement."
msgstr "Le code est généré automatiquement."
#: loko/assets/static/assets/unified_index.js:449
#: loko/assets/static/assets/unified_index.js:454
msgid "Erreur lors du chargement du formulaire."
msgstr "Erreur lors du chargement du formulaire."
#: loko/assets/static/assets/unified_index.js:471
msgid "Créer une localisation"
msgstr "Créer une localisation"
#: loko/assets/static/assets/unified_index.js:472
#: loko/assets/static/assets/unified_index.js:513
msgid "Type"
msgstr "Type"
#: loko/assets/static/assets/unified_index.js:496
msgid "Zoom"
msgstr "Zoom"
#: loko/assets/static/assets/unified_index.js:512
msgid "Nom"
msgstr "Nom"
#: loko/assets/static/assets/unified_index.js:514
msgid "Thématique"
msgstr "Thématique"
#: loko/assets/static/assets/unified_index.js:515
msgid "Catégorie"
msgstr "Catégorie"
#: loko/assets/static/assets/unified_index.js:522
msgid "Statut de validation"
msgstr ""
#: loko/assets/static/assets/unified_index.js:525
msgid "À valider"
msgstr ""
#: loko/assets/static/assets/unified_index.js:526
msgid "Validé"
msgstr ""
#: loko/assets/static/assets/unified_index.js:527
msgid "À compléter"
msgstr ""
#: loko/assets/static/assets/unified_index.js:528
msgid "Rejeté"
msgstr ""
#: loko/assets/static/assets/unified_index.js:841
#: loko/assets/static/assets/unified_index.js:842
msgid "Sélectionner une thématique..."
msgstr "Sélectionner une thématique..."
#: loko/assets/static/assets/unified_index.js:841
#: loko/assets/static/assets/unified_index.js:842
msgid "Aucune thématique disponible"
msgstr "Aucune thématique disponible"
#: loko/assets/static/assets/unified_index.js:849
msgid "Vous n'avez pas de droits de création"
msgstr "Vous n'avez pas de droits de création"
#: loko/assets/static/assets/unified_index.js:867
msgid "Sélectionnez une thématique pour créer un asset."
msgstr "Sélectionnez une thématique pour créer un asset."
#: loko/assets/static/assets/unified_index.js:888
msgid "Sélectionnez une thématique pour créer une localisation."
msgstr "Sélectionnez une thématique pour créer une localisation."
#: loko/assets/static/assets/unified_index.js:916
msgid "Sélectionnez une localisation avant de créer cet asset."
msgstr "Sélectionnez une localisation avant de créer cet asset."
#: loko/assets/static/assets/unified_index.js:947
msgid "Type d'asset introuvable pour ce modèle."
msgstr "Type d'asset introuvable pour ce modèle."
#: loko/assets/static/assets/unified_index.js:977
msgid "Asset créé."
msgstr "Asset créé."
#: loko/assets/static/assets/unified_index.js:988
#: loko/assets/static/assets/unified_index.js:1035
msgid "Erreur lors de la création."
msgstr "Erreur lors de la création."
#: loko/assets/static/assets/unified_index.js:992
#: loko/assets/static/assets/unified_index.js:1039
msgid "Erreur de connexion."
msgstr "Erreur de connexion."
#: loko/assets/static/assets/unified_index.js:1028
msgid "Localisation créée."
msgstr "Localisation créée."
#: loko/assets/static/assets/unified_index.js:1499
#: loko/assets/static/assets/unified_index.js:1579
#: loko/assets/static/assets/unified_index.js:1639
msgid "Veuillez sélectionner au moins un asset."
msgstr "Veuillez sélectionner au moins un asset."
#: loko/assets/static/assets/unified_index.js:1608
#: loko/assets/static/assets/unified_index.js:1615
#: loko/assets/static/assets/unified_index.js:1668
#: loko/assets/static/assets/unified_index.js:1675
msgid "Une erreur est survenue."
msgstr ""
#: loko/assets/static/assets/unified_index.js:1700
msgid ""
"Trop de données à afficher. Ajoutez des filtres ou réduisez le périmètre "
"pour charger le tableau et la carte."
msgstr ""
"Trop de données à afficher. Ajoutez des filtres ou réduisez le périmètre "
"pour charger le tableau et la carte."
#: loko/assets/static/assets/unified_index.js:1955
#: loko/assets/static/assets/unified_index.js:1986
msgid "Voir dans le tableau"
msgstr "Voir dans le tableau"
#: loko/assets/static/assets/unified_index.js:1995
msgid "autre(s) asset(s)"
msgstr "autre(s) asset(s)"
#: loko/assets/static/assets/unified_index.js:1999
msgid "autre(s) localisation(s)"
msgstr "autre(s) localisation(s)"
#: loko/assets/static/assets/unified_index.js:2004
msgid "Assets trouvés"
msgstr "Assets trouvés"
#: loko/assets/static/assets/unified_index.js:2012
msgid "Localisations trouvées"
msgstr "Localisations trouvées"
#: loko/assets/static/assets/unified_index.js:2600
msgid "Vue initiale (Bruxelles)"
msgstr "Vue initiale (Bruxelles)"
#: loko/assets/static/assets/unified_index.js:2758
msgid "sélectionné(s)"
msgstr "sélectionné(s)"
#: loko/assets/static/assets/unified_index.js:3302
msgid "Aucun type sélectionné"
msgstr "Aucun type sélectionné"
#: loko/assets/static/assets/unified_index.js:3306
msgid "Aucun type disponible."
msgstr "Aucun type disponible."
#: loko/assets/static/assets/unified_index.js:3447
#: loko/assets/static/assets/unified_index.js:3617
#: loko/assets/static/assets/unified_index.js:3675
msgid "Sélectionnez d'abord une ou plusieurs thématiques."
msgstr "Sélectionnez d'abord une ou plusieurs thématiques."
#: loko/assets/static/assets/unified_index.js:3517
msgid "Tous"
msgstr "Tous"
#: loko/assets/static/assets/unified_index.js:3682
msgid "Sélectionnez d'abord une ou plusieurs catégories."
msgstr "Sélectionnez d'abord une ou plusieurs catégories."
#: loko/assets/static/assets/unified_index.js:3698
msgid "Chargement des modèles..."
msgstr "Chargement des modèles..."
#: loko/assets/static/assets/unified_index.js:3709
msgid "Aucun modèle disponible pour les catégories sélectionnées."
msgstr "Aucun modèle disponible pour les catégories sélectionnées."
#: loko/assets/static/assets/unified_index.js:3720
msgid "Afficher les modèles"
msgstr "Afficher les modèles"
#: loko/assets/static/assets/unified_index.js:3721
msgid "Replier les modèles"
msgstr "Replier les modèles"
#: loko/assets/static/assets/unified_index.js:3726
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."
#: 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."
msgid "Zones de gestion"
msgstr "Zones de gestion"
msgid "Aucune zone de gestion définie pour cette thématique."
msgstr "Aucune zone de gestion définie pour cette thématique."
msgid "zones"
msgstr "zones"
msgid "Afficher / masquer toutes les zones"
msgstr "Afficher / masquer toutes les zones"
msgid "Opacité"
msgstr "Opacité"
msgid "Fréquence cible :"
msgstr "Fréquence cible :"
msgid "jours"
msgstr "jours"
msgid "Consignes & points d'attention"
msgstr "Consignes & points d'attention"
msgid "Centrer sur la zone"
msgstr "Centrer sur la zone"

File diff suppressed because it is too large Load diff

View file

@ -1,748 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-08-17 11:57+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: loko/assets/static/assets/asset_add_modal.js:51
#: loko/assets/static/assets/asset_add_modal.js:139
#: loko/assets/static/assets/asset_add_modal.js:190
#: loko/assets/static/assets/asset_add_modal.js:615
#: loko/assets/static/assets/unified_index.js:118
#: loko/assets/static/assets/unified_index.js:224
#: loko/assets/static/assets/unified_index.js:266
msgid "Sélectionner d'abord une catégorie..."
msgstr "Selecteer eerst een categorie..."
#: loko/assets/static/assets/asset_add_modal.js:59
#: loko/assets/static/assets/asset_add_modal.js:86
#: loko/assets/static/assets/asset_add_modal.js:101
#: loko/assets/static/assets/asset_add_modal.js:608
msgid "Sélectionner un type..."
msgstr "Selecteer een type..."
#: loko/assets/static/assets/asset_add_modal.js:186
#: loko/assets/static/assets/asset_location_edit.js:59
#: loko/assets/static/assets/unified_index.js:119
#: loko/assets/static/assets/unified_index.js:172
#: loko/assets/static/assets/unified_index.js:228
msgid "Chargement..."
msgstr "Laden..."
#: loko/assets/static/assets/asset_add_modal.js:199
#: loko/assets/static/assets/unified_index.js:234
msgid "Sélectionner un modèle..."
msgstr "Selecteer een model..."
#: loko/assets/static/assets/asset_add_modal.js:223
msgid "Aucun modèle disponible pour ce type"
msgstr "Geen model beschikbaar voor dit type"
#: loko/assets/static/assets/asset_add_modal.js:226
#: loko/assets/static/assets/unified_index.js:247
msgid "Aucun modèle disponible"
msgstr "Geen model beschikbaar"
#: loko/assets/static/assets/asset_add_modal.js:231
#: loko/assets/static/assets/asset_location_edit.js:102
#: loko/assets/static/assets/unified_index.js:138
#: loko/assets/static/assets/unified_index.js:209
#: loko/assets/static/assets/unified_index.js:251
msgid "Erreur de chargement"
msgstr "Laadfout"
#: loko/assets/static/assets/asset_add_modal.js:239
#: loko/assets/static/assets/unified_index.js:924
#: loko/assets/static/assets/unified_index.js:1011
msgid "Création..."
msgstr "Bezig met aanmaken..."
#: loko/assets/static/assets/asset_add_modal.js:319
#: loko/assets/static/assets/asset_add_modal.js:523
msgid "Erreur lors de la création"
msgstr "Fout bij het aanmaken"
#: loko/assets/static/assets/asset_add_modal.js:329
#: loko/assets/static/assets/asset_add_modal.js:532
msgid "Erreur de connexion"
msgstr "Verbindingsfout"
#: loko/assets/static/assets/asset_add_modal.js:394
msgid "Sélection de template d'opérations"
msgstr "Selectie van bewerkingssjabloon"
#: loko/assets/static/assets/asset_add_modal.js:396
msgid "Fermer"
msgstr "Sluiten"
#: loko/assets/static/assets/asset_add_modal.js:399
msgid ""
"Choisissez le(s) template(s) d'opérations à appliquer à cet équipement."
msgstr ""
"Kies de bedieningssjabloon(en) die u op deze apparatuur wilt toepassen."
#: loko/assets/static/assets/asset_add_modal.js:401
msgid "Choisir le(s) template(s) d'opérations :"
msgstr "Kies de bewerkingssjabloon(s) :"
#: loko/assets/static/assets/asset_add_modal.js:415
msgid "Tout sélectionner"
msgstr "Alles selecteren"
#: loko/assets/static/assets/asset_add_modal.js:418
msgid "Tout désélectionner"
msgstr "Alles deselecteren"
#: loko/assets/static/assets/asset_add_modal.js:426
msgid "Ignorer et continuer"
msgstr "Negeren en doorgaan"
#: loko/assets/static/assets/asset_add_modal.js:429
msgid "Créer les opérations"
msgstr "Bewerkingen aanmaken"
#: loko/assets/static/assets/asset_add_modal.js:490
msgid "Création en cours..."
msgstr "Bezig met aanmaken..."
#: loko/assets/static/assets/asset_location_edit.js:37
msgid "Saisissez au moins 2 caractères pour rechercher."
msgstr "Typ ten minste 2 tekens om te zoeken."
#: loko/assets/static/assets/asset_location_edit.js:76
#: loko/assets/static/assets/unified_index.js:181
msgid "Sélectionner une localisation..."
msgstr "Selecteer een locatie..."
#: loko/assets/static/assets/asset_location_edit.js:87
msgid "Résultats limités. Affinez la recherche pour trouver plus facilement."
msgstr ""
"Beperkte resultaten. Verfijn de zoekopdracht om gemakkelijker te vinden."
#: loko/assets/static/assets/asset_location_edit.js:92
#: loko/assets/static/assets/unified_index.js:200
msgid "Aucune localisation disponible"
msgstr "Geen locatie beschikbaar"
#: loko/assets/static/assets/asset_location_edit.js:94
msgid "Aucun résultat pour cette recherche."
msgstr "Geen resultaten voor deze zoekopdracht."
#: loko/assets/static/assets/asset_location_edit.js:104
msgid "Erreur lors du chargement des localisations"
msgstr "Fout bij het laden van locaties"
#: loko/assets/static/assets/asset_location_edit.js:117
msgid "Veuillez sélectionner une localisation"
msgstr "Selecteer een locatie"
#: loko/assets/static/assets/asset_location_edit.js:124
msgid "Mise à jour..."
msgstr "Bezig met bijwerken..."
#: loko/assets/static/assets/asset_location_edit.js:149
msgid "Localisation mise à jour"
msgstr "Locatie bijgewerkt"
#: loko/assets/static/assets/asset_location_edit.js:154
#: loko/assets/static/assets/asset_location_edit.js:161
msgid "Erreur lors de la mise à jour"
msgstr "Fout bij het bijwerken"
#: loko/assets/static/assets/assets_index.js:568
#: loko/assets/static/assets/assets_index.js:592
#: loko/assets/static/assets/unified_index.js:506
msgid "Code"
msgstr "Code"
#: loko/assets/static/assets/assets_index.js:594
#: loko/assets/static/assets/green-surfaces-comments.js:956
#: loko/assets/static/assets/unified_index.js:518
msgid "Statut"
msgstr "Status"
#: loko/assets/static/assets/assets_index.js:777
msgid "%(count)s localisation sélectionnée"
msgid_plural "%(count)s localisations sélectionnées"
msgstr[0] "%(count)s locatie geselecteerd"
msgstr[1] "%(count)s locaties geselecteerd"
#: loko/assets/static/assets/assets_index.js:789
msgid "%(count)s asset sélectionné"
msgid_plural "%(count)s assets sélectionnés"
msgstr[0] "%(count)s asset geselecteerd"
msgstr[1] "%(count)s assets geselecteerd"
#: loko/assets/static/assets/assets_index.js:880
msgid "Sélectionner l'asset"
msgstr "Asset selecteren"
#: loko/assets/static/assets/assets_index.js:886
msgid "Désélectionner l'asset"
msgstr "Asset deselecteren"
#: loko/assets/static/assets/assets_index.js:997
#: loko/assets/static/assets/assets_index.js:1068
msgid "Erreur lors de la récupération des données."
msgstr "Fout bij het ophalen van de gegevens."
#: loko/assets/static/assets/assets_index.js:1100
msgid ""
"Sélectionnez des localisations ou lancez une recherche pour afficher les "
"assets."
msgstr ""
#: loko/assets/static/assets/assets_index.js:1102
msgid "Aucun asset ne correspond aux critères de recherche."
msgstr "Geen enkele asset voldoet aan de zoekcriteria."
#: loko/assets/static/assets/assets_index.js:1309
#: loko/assets/static/assets/unified_index.js:2980
#: loko/assets/static/assets/unified_index.js:3743
#: loko/assets/static/assets/unified_index.js:3811
msgid "Sans catégorie"
msgstr "Zonder categorie"
#: loko/assets/static/assets/assets_index.js:1330
#: loko/assets/static/assets/unified_index.js:3837
msgid "%(count)s élément pris en compte"
msgid_plural "%(count)s éléments pris en compte"
msgstr[0] "%(count)s item meegenomen"
msgstr[1] "%(count)s items meegenomen"
#: loko/assets/static/assets/assets_index.js:1338
#: loko/assets/static/assets/unified_index.js:3842
msgid "(sélection)"
msgstr "(selectie)"
#: loko/assets/static/assets/assets_index.js:1339
#: loko/assets/static/assets/unified_index.js:3842
msgid "(table)"
msgstr "(tabel)"
#: loko/assets/static/assets/assets_index.js:1350
#: loko/assets/static/assets/unified_index.js:3847
msgid "Aucune donnée disponible"
msgstr "Geen gegevens beschikbaar"
#: loko/assets/static/assets/assets_index.js:1383
#: loko/assets/static/assets/unified_index.js:3878
msgid "au total"
msgstr "in totaal"
#: loko/assets/static/assets/assets_index.js:1389
#: loko/assets/static/assets/unified_index.js:516
#: loko/assets/static/assets/unified_index.js:3884
msgid "Modèle"
msgstr "Model"
#: loko/assets/static/assets/assets_index.js:1390
#: loko/assets/static/assets/unified_index.js:3885
msgid "Nombre"
msgstr "Nummer"
#: loko/assets/static/assets/assets_index.js:1391
#: loko/assets/static/assets/unified_index.js:3886
msgid "Longueur (m)"
msgstr "Lengte (m)"
#: loko/assets/static/assets/assets_index.js:1392
#: loko/assets/static/assets/unified_index.js:3887
msgid "Surface (m²)"
msgstr "Oppervlakte (m²)"
#: loko/assets/static/assets/assets_index.js:1396
#: loko/assets/static/assets/unified_index.js:3890
msgid "Aucun modèle"
msgstr "Geen model"
#: loko/assets/static/assets/assets_index.js:1400
#: loko/assets/static/assets/unified_index.js:3893
msgid "Total catégorie"
msgstr "Totaal per categorie"
#: loko/assets/static/assets/green-surfaces-comments.js:476
#: loko/assets/static/assets/green-surfaces-comments.js:905
msgid "Nouveau commentaire"
msgstr "Nieuwe opmerking"
#: loko/assets/static/assets/green-surfaces-comments.js:483
msgid "Textes rapides"
msgstr "Snelle teksten"
#: loko/assets/static/assets/green-surfaces-comments.js:485
#: loko/assets/static/assets/green-surfaces-comments.js:486
#: loko/assets/static/assets/green-surfaces-comments.js:959
msgid "Mémo"
msgstr "Memo"
#: loko/assets/static/assets/green-surfaces-comments.js:488
#: loko/assets/static/assets/green-surfaces-comments.js:489
msgid "Point d'attention"
msgstr "Aandachtspunt"
#: loko/assets/static/assets/green-surfaces-comments.js:491
#: loko/assets/static/assets/green-surfaces-comments.js:492
#: loko/assets/static/assets/green-surfaces-comments.js:965
msgid "À faire"
msgstr "Te doen"
#: loko/assets/static/assets/green-surfaces-comments.js:494
#: loko/assets/static/assets/green-surfaces-comments.js:495
#: loko/assets/static/assets/green-surfaces-comments.js:970
msgid "OK"
msgstr "Oké"
#: loko/assets/static/assets/green-surfaces-comments.js:497
#: loko/assets/static/assets/green-surfaces-comments.js:498
#: loko/assets/static/assets/green-surfaces-comments.js:973
msgid "Problème"
msgstr "Probleem"
#: loko/assets/static/assets/green-surfaces-comments.js:504
#: loko/assets/static/assets/green-surfaces-comments.js:980
msgid "Commentaire"
msgstr "Opmerking"
#: loko/assets/static/assets/green-surfaces-comments.js:505
msgid "Votre commentaire..."
msgstr "Uw opmerking..."
#: loko/assets/static/assets/green-surfaces-comments.js:509
#: loko/assets/static/assets/green-surfaces-comments.js:993
msgid "Photos"
msgstr "Foto's"
#: loko/assets/static/assets/green-surfaces-comments.js:512
msgid "Prendre des photos (mode continu)"
msgstr "Foto's maken (continue modus)"
#: loko/assets/static/assets/green-surfaces-comments.js:515
msgid "Sélectionner des fichiers"
msgstr "Bestanden selecteren"
#: loko/assets/static/assets/green-surfaces-comments.js:524
msgid "Position"
msgstr "Positie"
#: loko/assets/static/assets/green-surfaces-comments.js:528
msgid "Position actuelle sera utilisée"
msgstr "Huidige positie zal worden gebruikt"
#: loko/assets/static/assets/green-surfaces-comments.js:531
#: loko/assets/static/assets/green-surfaces-comments.js:988
msgid "Repositionner"
msgstr "Opnieuw positioneren"
#: loko/assets/static/assets/green-surfaces-comments.js:539
#: loko/assets/static/assets/green-surfaces-comments.js:1011
msgid "Annuler"
msgstr "Annuleren"
#: loko/assets/static/assets/green-surfaces-comments.js:541
#: loko/assets/static/assets/green-surfaces-comments.js:1008
msgid "Enregistrer"
msgstr "Opslaan"
#: loko/assets/static/assets/green-surfaces-comments.js:573
msgid "Caméra"
msgstr "Camera"
#: loko/assets/static/assets/green-surfaces-comments.js:588
#: loko/assets/static/assets/green-surfaces-comments.js:1130
msgid "Terminer"
msgstr "Voltooien"
#: loko/assets/static/assets/green-surfaces-comments.js:591
#: loko/assets/static/assets/green-surfaces-comments.js:1127
msgid "Capturer"
msgstr "Opnemen"
#: loko/assets/static/assets/green-surfaces-comments.js:962
msgid "Attention"
msgstr "Let op"
#: loko/assets/static/assets/green-surfaces-comments.js:980
msgid "(optionnel)"
msgstr "(optioneel)"
#: loko/assets/static/assets/green-surfaces-comments.js:981
msgid "Entrez un commentaire optionnel..."
msgstr "Voer een optionele opmerking in..."
#: loko/assets/static/assets/green-surfaces-comments.js:985
msgid "Localisation"
msgstr "Locatie"
#: loko/assets/static/assets/green-surfaces-comments.js:986
msgid "Calcul en cours..."
msgstr "Bezig met berekenen..."
#: loko/assets/static/assets/green-surfaces-comments.js:996
msgid "Appareil photo"
msgstr "Camera"
#: loko/assets/static/assets/green-surfaces-comments.js:999
msgid "Sélectionner fichiers"
msgstr "Bestanden selecteren"
#: loko/assets/static/assets/unified_index.js:125
#: loko/assets/static/assets/unified_index.js:262
msgid "Sélectionner une catégorie..."
msgstr "Selecteer een categorie..."
#: loko/assets/static/assets/unified_index.js:134
msgid "Aucune catégorie disponible"
msgstr "Geen categorie beschikbaar"
#: loko/assets/static/assets/unified_index.js:174
msgid "Sélectionnez la localisation liée à ce nouvel asset."
msgstr "Selecteer de locatie gekoppeld aan deze nieuwe asset."
#: loko/assets/static/assets/unified_index.js:204
msgid "Aucune localisation disponible pour créer cet asset."
msgstr "Geen locatie beschikbaar om deze asset aan te maken."
#: loko/assets/static/assets/unified_index.js:213
msgid "Impossible de charger les localisations pour cette thématique."
msgstr "Kan locaties voor dit thema niet laden."
#: loko/assets/static/assets/unified_index.js:298
msgid "Créer un asset"
msgstr "Een asset aanmaken"
#: loko/assets/static/assets/unified_index.js:312
msgid "Le code est généré automatiquement."
msgstr "De code wordt automatisch gegenereerd."
#: loko/assets/static/assets/unified_index.js:449
#: loko/assets/static/assets/unified_index.js:454
msgid "Erreur lors du chargement du formulaire."
msgstr "Fout bij het laden van het formulier."
#: loko/assets/static/assets/unified_index.js:471
msgid "Créer une localisation"
msgstr "Een locatie aanmaken"
#: loko/assets/static/assets/unified_index.js:472
#: loko/assets/static/assets/unified_index.js:513
msgid "Type"
msgstr "Type"
#: loko/assets/static/assets/unified_index.js:496
msgid "Zoom"
msgstr "Zoom"
#: loko/assets/static/assets/unified_index.js:512
msgid "Nom"
msgstr "Naam"
#: loko/assets/static/assets/unified_index.js:514
msgid "Thématique"
msgstr "Thema"
#: loko/assets/static/assets/unified_index.js:515
msgid "Catégorie"
msgstr "Categorie"
#: loko/assets/static/assets/unified_index.js:522
msgid "Statut de validation"
msgstr ""
#: loko/assets/static/assets/unified_index.js:525
msgid "À valider"
msgstr ""
#: loko/assets/static/assets/unified_index.js:526
msgid "Validé"
msgstr ""
#: loko/assets/static/assets/unified_index.js:527
msgid "À compléter"
msgstr ""
#: loko/assets/static/assets/unified_index.js:528
msgid "Rejeté"
msgstr ""
#: loko/assets/static/assets/unified_index.js:841
#: loko/assets/static/assets/unified_index.js:842
msgid "Sélectionner une thématique..."
msgstr "Selecteer een thema..."
#: loko/assets/static/assets/unified_index.js:841
#: loko/assets/static/assets/unified_index.js:842
msgid "Aucune thématique disponible"
msgstr "Geen thema beschikbaar"
#: loko/assets/static/assets/unified_index.js:849
msgid "Vous n'avez pas de droits de création"
msgstr "U heeft geen rechten om aan te maken"
#: loko/assets/static/assets/unified_index.js:867
msgid "Sélectionnez une thématique pour créer un asset."
msgstr "Selecteer een thema om een asset aan te maken."
#: loko/assets/static/assets/unified_index.js:888
msgid "Sélectionnez une thématique pour créer une localisation."
msgstr "Selecteer een thema om een locatie aan te maken."
#: loko/assets/static/assets/unified_index.js:916
msgid "Sélectionnez une localisation avant de créer cet asset."
msgstr "Selecteer een locatie voordat u deze asset aanmaakt."
#: loko/assets/static/assets/unified_index.js:947
msgid "Type d'asset introuvable pour ce modèle."
msgstr "Assettype niet gevonden voor dit model."
#: loko/assets/static/assets/unified_index.js:977
msgid "Asset créé."
msgstr "Asset aangemaakt."
#: loko/assets/static/assets/unified_index.js:988
#: loko/assets/static/assets/unified_index.js:1035
msgid "Erreur lors de la création."
msgstr "Fout bij het aanmaken."
#: loko/assets/static/assets/unified_index.js:992
#: loko/assets/static/assets/unified_index.js:1039
msgid "Erreur de connexion."
msgstr "Verbindingsfout."
#: loko/assets/static/assets/unified_index.js:1028
msgid "Localisation créée."
msgstr "Locatie aangemaakt."
#: loko/assets/static/assets/unified_index.js:1499
#: loko/assets/static/assets/unified_index.js:1579
#: loko/assets/static/assets/unified_index.js:1639
msgid "Veuillez sélectionner au moins un asset."
msgstr "Selecteer ten minste één asset."
#: loko/assets/static/assets/unified_index.js:1608
#: loko/assets/static/assets/unified_index.js:1615
#: loko/assets/static/assets/unified_index.js:1668
#: loko/assets/static/assets/unified_index.js:1675
msgid "Une erreur est survenue."
msgstr ""
#: loko/assets/static/assets/unified_index.js:1700
msgid ""
"Trop de données à afficher. Ajoutez des filtres ou réduisez le périmètre "
"pour charger le tableau et la carte."
msgstr ""
#: loko/assets/static/assets/unified_index.js:1955
#: loko/assets/static/assets/unified_index.js:1986
msgid "Voir dans le tableau"
msgstr "Bekijk in de tabel"
#: loko/assets/static/assets/unified_index.js:1995
msgid "autre(s) asset(s)"
msgstr "andere asset(s)"
#: loko/assets/static/assets/unified_index.js:1999
msgid "autre(s) localisation(s)"
msgstr "andere locatie(s)"
#: loko/assets/static/assets/unified_index.js:2004
msgid "Assets trouvés"
msgstr "Gevonden assets"
#: loko/assets/static/assets/unified_index.js:2012
msgid "Localisations trouvées"
msgstr "Gevonden locaties"
#: loko/assets/static/assets/unified_index.js:2600
msgid "Vue initiale (Bruxelles)"
msgstr "Eerste weergave (Brussel)"
#: loko/assets/static/assets/unified_index.js:2758
msgid "sélectionné(s)"
msgstr "geselecteerd"
#: loko/assets/static/assets/unified_index.js:3302
msgid "Aucun type sélectionné"
msgstr "Geen type geselecteerd"
#: loko/assets/static/assets/unified_index.js:3306
msgid "Aucun type disponible."
msgstr "Geen type beschikbaar."
#: loko/assets/static/assets/unified_index.js:3447
#: loko/assets/static/assets/unified_index.js:3617
#: loko/assets/static/assets/unified_index.js:3675
msgid "Sélectionnez d'abord une ou plusieurs thématiques."
msgstr "Selecteer eerst een of meer thema's."
#: loko/assets/static/assets/unified_index.js:3517
msgid "Tous"
msgstr "Alle"
#: loko/assets/static/assets/unified_index.js:3682
msgid "Sélectionnez d'abord une ou plusieurs catégories."
msgstr "Selecteer eerst een of meer categorieën."
#: loko/assets/static/assets/unified_index.js:3698
msgid "Chargement des modèles..."
msgstr "Modellen laden..."
#: loko/assets/static/assets/unified_index.js:3709
msgid "Aucun modèle disponible pour les catégories sélectionnées."
msgstr "Geen model beschikbaar voor de geselecteerde categorieën."
#: loko/assets/static/assets/unified_index.js:3720
msgid "Afficher les modèles"
msgstr "Modellen tonen"
#: loko/assets/static/assets/unified_index.js:3721
msgid "Replier les modèles"
msgstr "Modellen inklappen"
#: loko/assets/static/assets/unified_index.js:3726
msgid "Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
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."
msgid "Zones de gestion"
msgstr "Beheerszones"
msgid "Aucune zone de gestion définie pour cette thématique."
msgstr "Geen beheerszone gedefinieerd voor dit thema."
msgid "zones"
msgstr "zones"
msgid "Afficher / masquer toutes les zones"
msgstr "Alle zones tonen / verbergen"
msgid "Opacité"
msgstr "Dekking"
msgid "Fréquence cible :"
msgstr "Doelfrequentie:"
msgid "jours"
msgstr "dagen"
msgid "Consignes & points d'attention"
msgstr "Instructies & aandachtspunten"
msgid "Centrer sur la zone"
msgstr "Centreren op zone"

View file

@ -1,220 +0,0 @@
import time
from datetime import datetime
from django.core.management.base import BaseCommand
from django.db import connection, transaction
from assets.models import NatureLocation, NatureTree
from common.models import Municipality
MUNICIPALITY_PREFIXES = {
'AND': 'Anderlecht',
'AUD': 'Auderghem',
'BRK': 'Berchem-Sainte-Agathe',
'BSA': 'Berchem-Sainte-Agathe',
'BXL': 'Bruxelles',
'BRU': 'Bruxelles',
'ETT': 'Etterbeek',
'EVE': 'Evere',
'FOR': 'Forest',
'GAN': 'Ganshoren',
'IXL': 'Ixelles',
'IXE': 'Ixelles',
'JET': 'Jette',
'KOE': 'Koekelberg',
'MOL': 'Molenbeek-Saint-Jean',
'STG': 'Saint-Gilles',
'STJ': 'Saint-Josse-ten-Noode',
'SCH': 'Schaerbeek',
'UCC': 'Uccle',
'WAT': 'Watermael-Boitsfort',
'WLU': 'Woluwe-Saint-Lambert',
'WSL': 'Woluwe-Saint-Lambert',
'WSP': 'Woluwe-Saint-Pierre',
}
class Command(BaseCommand):
help = "Rattache spatialement les arbres (NatureTree) aux tronçons des sections vertes (NatureLocation, ex: WAT_R22_008)."
def add_arguments(self, parser):
parser.add_argument(
'--max-distance',
type=float,
default=25.0,
help="Distance maximale en mètres pour le rattachement (défaut: 25.0m, 0 pour intersection stricte)"
)
parser.add_argument(
'--section-code',
type=str,
default=None,
help="Code d'un tronçon spécifique à cibler (ex: WAT_R22_008)"
)
parser.add_argument(
'--dry-run',
action='store_true',
help="Simuler le rattachement sans modifier la base de données"
)
parser.add_argument(
'--skip-sync-munis',
action='store_true',
help="Ne pas synchroniser la commune sur les sections vertes"
)
def log(self, msg):
self.stdout.write(f"[{datetime.now().strftime('%H:%M:%S')}] {msg}")
def handle(self, *args, **options):
max_dist = options['max_distance']
target_section = options['section_code']
dry_run = options['dry_run']
sync_munis = not options['skip_sync_munis']
self.log("=== RATTACHEMENT SPATIAL DES ARBRES AUX TRONÇONS DE SECTIONS VERTES ===")
self.log(f"Distance max : {max_dist} m {'(Intersection stricte)' if max_dist == 0 else ''}")
if target_section:
self.log(f"Section ciblée : {target_section}")
if dry_run:
self.log("[SIMULATION / DRY-RUN] Aucune modification ne sera enregistrée.")
# -----------------------------------------------------------------
# 1. Synchronisation des communes sur les sections vertes
# -----------------------------------------------------------------
if sync_munis and not dry_run:
self.log("1. Synchronisation des communes sur les sections vertes...")
munis_by_name = {m.name_fr.lower(): m for m in Municipality.objects.all()}
updated_munis = 0
with connection.cursor() as cursor:
# Intersections géométriques
cursor.execute("""
UPDATE assets_naturelocation l
SET municipality_id = m.id
FROM common_municipality m
WHERE l.code ~ '^[A-Z]{3}_'
AND l.municipality_id IS NULL
AND l.geom IS NOT NULL
AND ST_Intersects(ST_PointOnSurface(l.geom), m.geom);
""")
updated_munis += cursor.rowcount
# Complément par préfixe de code pour les cas frontaliers
locs_without_muni = NatureLocation.objects.filter(code__regex=r'^[A-Z]{3}_', municipality__isnull=True)
for loc in locs_without_muni:
pfx = loc.code[:3].upper()
muni_name = MUNICIPALITY_PREFIXES.get(pfx)
if muni_name and muni_name.lower() in munis_by_name:
loc.municipality = munis_by_name[muni_name.lower()]
loc.save(update_fields=['municipality'])
updated_munis += 1
self.log(f" Communes renseignées sur {updated_munis} sections vertes.")
# -----------------------------------------------------------------
# 2. Analyse et Rattachement des Arbres
# -----------------------------------------------------------------
t0 = time.time()
self.log("2. Analyse géospatiale du rattachement arbres -> sections vertes...")
section_filter_sql = ""
params = [max_dist]
if target_section:
section_filter_sql = "AND nearest.code = %s"
params.append(target_section)
count_query = f"""
WITH nearest AS (
SELECT
t.id AS tree_id,
nearest_loc.id AS loc_id,
nearest_loc.code AS loc_code,
nearest_loc.name_fr AS loc_name,
nearest_loc.dist
FROM assets_naturetree t
CROSS JOIN LATERAL (
SELECT l.id, l.code, l.name_fr, ST_Distance(t.geom, l.geom) AS dist
FROM assets_naturelocation l
WHERE l.code ~ '^[A-Z]{{3}}_' AND l.geom IS NOT NULL
ORDER BY t.geom <-> l.geom
LIMIT 1
) nearest_loc
WHERE t.geom IS NOT NULL AND t.lat != 0 AND t.lon != 0
AND nearest_loc.dist <= %s
)
SELECT
COUNT(*) AS total_matched,
COUNT(*) FILTER (WHERE dist = 0) AS inside,
COUNT(*) FILTER (WHERE dist > 0 AND dist <= 5) AS within_5m,
COUNT(*) FILTER (WHERE dist > 5 AND dist <= 15) AS within_15m,
COUNT(*) FILTER (WHERE dist > 15 AND dist <= 25) AS within_25m,
COUNT(*) FILTER (WHERE dist > 25) AS beyond_25m,
COUNT(DISTINCT loc_id) AS distinct_sections
FROM nearest
WHERE 1=1 {section_filter_sql};
"""
with connection.cursor() as cursor:
cursor.execute(count_query, params)
stats = cursor.fetchone()
total_matched = stats[0]
inside = stats[1]
w5m = stats[2]
w15m = stats[3]
w25m = stats[4]
b25m = stats[5]
distinct_sections = stats[6]
self.log(f" Arbres éligibles au rattachement : {total_matched}")
self.log(f" - Directement à l'intérieur (0m) : {inside}")
self.log(f" - Proximité immédiate (1 à 5m) : {w5m}")
self.log(f" - Bordure de voirie (5 à 15m) : {w15m}")
self.log(f" - Zone étendue (15 à 25m) : {w25m}")
if b25m:
self.log(f" - Au-delà (> 25m) : {b25m}")
self.log(f" Nombre de tronçons / sections concernés : {distinct_sections}")
if dry_run:
self.log("Simulation terminée avec succès (aucune écriture en base).")
return
# -----------------------------------------------------------------
# 3. Application de la mise à jour
# -----------------------------------------------------------------
self.log("3. Application de la mise à jour des liaisons (location_id)...")
update_query = f"""
WITH candidates AS (
SELECT
t.id AS tree_id,
nearest.id AS loc_id
FROM assets_naturetree t
CROSS JOIN LATERAL (
SELECT l.id, l.code, ST_Distance(t.geom, l.geom) AS dist
FROM assets_naturelocation l
WHERE l.code ~ '^[A-Z]{{3}}_' AND l.geom IS NOT NULL
ORDER BY t.geom <-> l.geom
LIMIT 1
) nearest
WHERE t.geom IS NOT NULL AND t.lat != 0 AND t.lon != 0
AND nearest.dist <= %s
{section_filter_sql}
)
UPDATE assets_naturetree t
SET location_id = c.loc_id
FROM candidates c
WHERE t.id = c.tree_id
AND (t.location_id IS DISTINCT FROM c.loc_id);
"""
with connection.cursor() as cursor:
cursor.execute(update_query, params)
updated_count = cursor.rowcount
t1 = time.time()
self.log(f"Mise à jour terminée : {updated_count} arbres rattachés en {t1 - t0:.2f} secondes.")
# Vérification ciblée (par exemple WAT_R22_008)
check_code = target_section or 'WAT_R22_008'
check_loc = NatureLocation.objects.filter(code=check_code).first()
if check_loc:
attached_cnt = check_loc.nature_trees.count()
self.log(f"Vérification sur {check_code} ({check_loc.name_fr}) : {attached_cnt} arbres rattachés.")

View file

@ -1,279 +0,0 @@
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db import connection, transaction
from django.utils import timezone
from django.contrib.contenttypes.models import ContentType
from assets.models import NatureTree
from interventions.models import Intervention, SourceCategory
class Command(BaseCommand):
help = (
"Annule les interventions arboricoles caduques ou périmées :\n"
"1. Interventions prescrites avant un diagnostic plus récent sur l'arbre (remplacées par ré-inspection).\n"
"2. Prescriptions actives dont la date limite est échue depuis plus de X mois (défaut : 12 mois).\n"
"Met à jour les événements de timeline et recalcule automatiquement les dates de prochaine inspection."
)
def add_arguments(self, parser):
parser.add_argument(
'--months',
type=int,
default=12,
help="Délai en mois après échéance au-delà duquel une prescription non exécutée est annulée (défaut : 12).",
)
parser.add_argument(
'--dry-run',
action='store_true',
help="Simuler l'opération et afficher les comptages sans modifier la base de données.",
)
parser.add_argument(
'--no-recalc-trees',
action='store_true',
help="Ne pas recalculer next_inspection_date sur les arbres après l'annulation.",
)
def handle(self, *args, **options):
months = options['months']
dry_run = options['dry_run']
recalc_trees = not options['no_recalc_trees']
now = timezone.now()
# Calcul de la date seuil d'échéance (ex: il y a 12 mois)
cutoff_date = (now - timedelta(days=int(months * 30.4375))).date()
self.stdout.write(self.style.MIGRATE_HEADING(
f"=== Nettoyage des interventions d'arbres caduques et échues ==="
))
self.stdout.write(f"Date de référence : {now.strftime('%d/%m/%Y %H:%M')}")
self.stdout.write(f"Seuil d'échéance ({months} mois) : interventions échues avant le {cutoff_date.strftime('%d/%m/%Y')}\n")
if dry_run:
self.stdout.write(self.style.WARNING("[MODE SIMULATION] Aucune modification ne sera enregistrée.\n"))
content_type_tree = ContentType.objects.get_for_model(NatureTree)
source_fulcrum = SourceCategory.objects.filter(name_fr="Fulcrum").first()
source_fulcrum_id = source_fulcrum.id if source_fulcrum else None
with connection.cursor() as cur:
# -------------------------------------------------------------
# COMPTAGE RÈGLE 1 : Diagnostic plus récent sur l'arbre
# -------------------------------------------------------------
cur.execute("""
SELECT COUNT(DISTINCT i.id)
FROM interventions_intervention i
JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
JOIN assets_naturetree t ON t.id = ia.object_id
WHERE ia.content_type_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND t.last_phytosanitary_date IS NOT NULL
AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
);
""", [content_type_tree.id])
count_rule_diag = cur.fetchone()[0]
# -------------------------------------------------------------
# COMPTAGE RÈGLE 2 : Échéance dépassée > X mois (sans être dans règle 1)
# -------------------------------------------------------------
cur.execute("""
SELECT COUNT(DISTINCT i.id)
FROM interventions_intervention i
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
WHERE i.status NOT IN ('finished', 'canceled')
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND i.expected_end_time IS NOT NULL
AND i.expected_end_time::date < %s
AND NOT (
t.last_phytosanitary_date IS NOT NULL
AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time::date < t.last_phytosanitary_date)
)
);
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
count_rule_expired = cur.fetchone()[0]
# Total combiné
cur.execute("""
SELECT COUNT(DISTINCT i.id)
FROM interventions_intervention i
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
WHERE i.status NOT IN ('finished', 'canceled')
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND (
(t.last_phytosanitary_date IS NOT NULL AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
))
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < %s)
);
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
total_to_cancel = cur.fetchone()[0]
self.stdout.write(f"1. Prescriptions caduques (arbre ré-inspecté ultérieurement) : {count_rule_diag}")
self.stdout.write(f"2. Prescriptions échues depuis plus de {months} mois sans nouveau diag : {count_rule_expired}")
self.stdout.write(self.style.MIGRATE_LABEL(f"Total des interventions à annuler : {total_to_cancel}\n"))
if total_to_cancel == 0:
self.stdout.write(self.style.SUCCESS("Aucune intervention à annuler. La base est déjà assainie."))
return
if dry_run:
self.stdout.write(self.style.WARNING(
f"[MODE SIMULATION] {total_to_cancel} interventions auraient été passées au statut 'canceled'.\n"
"Exécutez sans --dry-run pour appliquer."
))
return
# -------------------------------------------------------------
# EXÉCUTION RÉELLE EN TRANSACTION
# -------------------------------------------------------------
self.stdout.write("Application des annulations...")
with transaction.atomic():
with connection.cursor() as cur:
# 1a. Timeline events pour la règle 1 (diagnostic plus récent)
cur.execute("""
INSERT INTO interventions_interventiontimeline (
intervention_id, event_time, event_type, from_status, to_status,
event_description, field_changes
)
SELECT
i.id,
%s,
'status_change',
i.status,
'canceled',
'Intervention annulée automatiquement : prescription caduque suite au nouveau diagnostic de l''arbre du ' ||
TO_CHAR(t.last_phytosanitary_date, 'DD/MM/YYYY') || '.',
('{"status": ["' || i.status || '", "canceled"]}')::json
FROM interventions_intervention i
JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
JOIN assets_naturetree t ON t.id = ia.object_id
WHERE ia.content_type_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND t.last_phytosanitary_date IS NOT NULL
AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
)
AND NOT EXISTS (
SELECT 1 FROM interventions_interventiontimeline tl
WHERE tl.intervention_id = i.id
AND tl.to_status = 'canceled'
);
""", [now, content_type_tree.id])
tl_diag_count = cur.rowcount
# 1b. Timeline events pour la règle 2 (échues > X mois)
cur.execute("""
INSERT INTO interventions_interventiontimeline (
intervention_id, event_time, event_type, from_status, to_status,
event_description, field_changes
)
SELECT
i.id,
%s,
'status_change',
i.status,
'canceled',
'Intervention annulée automatiquement : prescription échue depuis plus de ' || %s || ' mois (' ||
TO_CHAR(i.expected_end_time, 'DD/MM/YYYY') || ') sans nouveau diagnostic.',
('{"status": ["' || i.status || '", "canceled"]}')::json
FROM interventions_intervention i
LEFT JOIN interventions_interventionasset ia ON ia.intervention_id = i.id
WHERE i.status NOT IN ('finished', 'canceled')
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND i.expected_end_time IS NOT NULL
AND i.expected_end_time::date < %s
AND NOT EXISTS (
SELECT 1 FROM interventions_interventiontimeline tl
WHERE tl.intervention_id = i.id
AND tl.to_status = 'canceled'
);
""", [now, months, content_type_tree.id, source_fulcrum_id, cutoff_date])
tl_expired_count = cur.rowcount
# 2. Mise à jour effective du statut vers 'canceled'
cur.execute("""
UPDATE interventions_intervention i
SET status = 'canceled',
status_order = 200,
cancellation_type = 'non_relevant'
FROM interventions_interventionasset ia
LEFT JOIN assets_naturetree t ON t.id = ia.object_id AND ia.content_type_id = %s
WHERE ia.intervention_id = i.id
AND (ia.content_type_id = %s OR i.source_category_id = %s)
AND i.status NOT IN ('finished', 'canceled')
AND (
(t.last_phytosanitary_date IS NOT NULL AND (
(i.expected_begin_time IS NOT NULL AND i.expected_begin_time::date < t.last_phytosanitary_date)
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < t.last_phytosanitary_date)
))
OR (i.expected_end_time IS NOT NULL AND i.expected_end_time::date < %s)
);
""", [content_type_tree.id, content_type_tree.id, source_fulcrum_id, cutoff_date])
updated_count = cur.rowcount
# Traitement des interventions orphelines sans lien ia (ex: source Fulcrum sans arbre matché)
cur.execute("""
UPDATE interventions_intervention i
SET status = 'canceled',
status_order = 200,
cancellation_type = 'non_relevant'
WHERE i.source_category_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND i.expected_end_time IS NOT NULL
AND i.expected_end_time::date < %s;
""", [source_fulcrum_id, cutoff_date])
updated_count += cur.rowcount
self.stdout.write(self.style.SUCCESS(
f" {updated_count} interventions passées au statut 'canceled'."
))
self.stdout.write(f" {tl_diag_count + tl_expired_count} événements d'historique créés dans la timeline.")
# 3. Recalcul des prochaines dates d'inspection sur les arbres
if recalc_trees:
self.stdout.write("Recalcul des prochaines inspections sur les arbres...")
# 3a. Arbres avec intervention active résiduelle
cur.execute("""
UPDATE assets_naturetree t
SET next_inspection_date = sub.min_date
FROM (
SELECT ia.object_id AS tree_id, MIN(i.expected_end_time)::date AS min_date
FROM interventions_interventionasset ia
JOIN interventions_intervention i ON i.id = ia.intervention_id
WHERE ia.content_type_id = %s
AND i.status NOT IN ('finished', 'canceled')
AND i.expected_end_time IS NOT NULL
GROUP BY ia.object_id
) sub
WHERE t.id = sub.tree_id;
""", [content_type_tree.id])
trees_active = cur.rowcount
# 3b. Arbres sans intervention active : formule théorique = dernier diagnostic + périodicité
cur.execute("""
UPDATE assets_naturetree t
SET next_inspection_date = (t.last_phytosanitary_date + (COALESCE(t.pruning_frequency_years, 3) || ' years')::interval)::date
WHERE t.last_phytosanitary_date IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM interventions_interventionasset ia
JOIN interventions_intervention i ON i.id = ia.intervention_id
WHERE ia.content_type_id = %s
AND ia.object_id = t.id
AND i.status NOT IN ('finished', 'canceled')
AND i.expected_end_time IS NOT NULL
);
""", [content_type_tree.id])
trees_theo = cur.rowcount
self.stdout.write(f" {trees_active} arbres mis à jour avec prochaine intervention active.")
self.stdout.write(f" {trees_theo} arbres recalculés selon cycle théorique (diag + périodicité).")
self.stdout.write(self.style.SUCCESS("\nOpération terminée avec succès !"))

View file

@ -1,210 +0,0 @@
"""
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}"
))

View file

@ -1,373 +0,0 @@
import os
import shutil
import subprocess
import tempfile
import zipfile
from django.core.management.base import BaseCommand, CommandError
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth import get_user_model
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils import timezone
from django.utils.text import slugify
from assets.models.structures import Structure
from documents.models import DocumentFolder, DocumentFolderAttachment, ManagedDocument, DocumentVersion, DocumentAttachment, DocumentTag
from documents.services import DocumentManagementService
User = get_user_model()
class Command(BaseCommand):
help = (
"Imports As-Built documents from a local directory, a ZIP archive, or via rclone "
"into the 'Electromechanics / As-Built' subfolder (preserving subfolder structure) of specified structures."
)
def add_arguments(self, parser):
parser.add_argument(
"--source-dir",
default="/tmp/as_built_sync",
help="Path to local directory containing files/folders to import (default: /tmp/as_built_sync).",
)
parser.add_argument(
"--source-zip",
default=None,
help="Path to a ZIP file downloaded from OneDrive to extract and import.",
)
parser.add_argument(
"--rclone-remote",
default=None,
help="Optional rclone remote path (e.g. 'onedrive:DPO_TUNNEL_EM_AS-BUILT') to sync before import.",
)
parser.add_argument(
"--structures",
nargs="+",
help="One or more structure codes (e.g. OA006 OA007) to target specifically.",
)
parser.add_argument(
"--clean-existing",
action="store_true",
default=False,
help="Deletes existing documents in the As-Built subfolder hierarchy before importing.",
)
parser.add_argument(
"--dry-run",
action="store_true",
default=False,
help="Simulates document import without saving to database.",
)
parser.add_argument(
"--user",
default=None,
help="Username of creator/uploader.",
)
parser.add_argument(
"--tag",
default=None,
help="Optional tag name or slug to attach to imported documents.",
)
def handle(self, *args, **options):
source_dir = options["source_dir"]
source_zip = options["source_zip"]
rclone_remote = options["rclone_remote"]
target_codes = options["structures"]
clean_existing = options["clean_existing"]
dry_run = options["dry_run"]
username = options["user"]
tag_name = options["tag"]
if dry_run:
self.stdout.write(self.style.WARNING("DRY RUN — no changes will be committed."))
# Traitement d'une archive ZIP si spécifiée
if source_zip:
if not os.path.isfile(source_zip):
raise CommandError(f"ZIP file '{source_zip}' does not exist.")
self.stdout.write(self.style.NOTICE(f"Extracting ZIP archive '{source_zip}' into '{source_dir}'..."))
if os.path.exists(source_dir):
shutil.rmtree(source_dir, ignore_errors=True)
os.makedirs(source_dir, exist_ok=True)
with zipfile.ZipFile(source_zip, 'r') as zip_ref:
zip_ref.extractall(source_dir)
self.stdout.write(self.style.SUCCESS("ZIP extraction completed."))
# Synchronisation via rclone si un remote est spécifié
elif rclone_remote:
self.stdout.write(self.style.NOTICE(f"Syncing from rclone remote '{rclone_remote}' to '{source_dir}'..."))
os.makedirs(source_dir, exist_ok=True)
rclone_cmd = ["rclone", "copy", rclone_remote, source_dir]
rclone_conf = os.path.join(os.getcwd(), "rclone.conf")
if os.path.exists(rclone_conf):
rclone_cmd.extend(["--config", rclone_conf])
res = subprocess.run(rclone_cmd, capture_output=True, text=True)
if res.returncode != 0:
raise CommandError(f"rclone sync failed:\n{res.stderr}")
self.stdout.write(self.style.SUCCESS("rclone sync completed successfully."))
if not os.path.isdir(source_dir):
raise CommandError(f"Source directory '{source_dir}' does not exist or is not a directory.")
# Creator user lookup
creator_user = None
if username:
try:
creator_user = User.objects.get(username=username)
except User.DoesNotExist:
raise CommandError(f"User '{username}' not found.")
else:
creator_user = User.objects.filter(username="kdeterme").first() or User.objects.filter(is_superuser=True).first()
structure_ct = ContentType.objects.get_for_model(Structure)
# Optional tag lookup
doc_tag = None
if tag_name:
doc_tag = DocumentTag.objects.filter(slug=slugify(tag_name)).first() or DocumentTag.objects.filter(name_fr__iexact=tag_name).first()
def get_or_create_as_built_folder(structure):
root_att = DocumentFolderAttachment.objects.filter(
content_type=structure_ct,
object_id=structure.pk,
is_default=True
).select_related("folder").first()
if not root_att:
return None
root_folder = root_att.folder
# Electromechanics subfolder
em_folder = root_folder.child_folders.filter(name="Electromechanics").first()
if not em_folder:
em_slug = f"{root_folder.slug}-electromechanics"[:150]
if dry_run:
em_folder = DocumentFolder(name="Electromechanics", slug=em_slug)
else:
em_folder, _ = DocumentFolder.objects.get_or_create(
slug=em_slug,
defaults={
"name": "Electromechanics",
"application_label": root_folder.application_label,
"created_by": creator_user
}
)
em_folder.parent_folders.add(root_folder)
# As-Built subfolder
ab_folder = em_folder.child_folders.filter(name="As-Built").first() if em_folder.pk else None
if not ab_folder:
ab_slug = f"{em_folder.slug}-as-built"[:150]
if dry_run:
ab_folder = DocumentFolder(name="As-Built", slug=ab_slug)
else:
ab_folder, _ = DocumentFolder.objects.get_or_create(
slug=ab_slug,
defaults={
"name": "As-Built",
"application_label": em_folder.application_label,
"created_by": creator_user
}
)
ab_folder.parent_folders.add(em_folder)
return ab_folder
def get_or_create_nested_folder(parent, rel_dir_path):
if not rel_dir_path or rel_dir_path == ".":
return parent
parts = [p.strip() for p in rel_dir_path.split("/") if p.strip()]
current = parent
for part in parts:
base_slug = f"{current.slug}-{slugify(part)}"
slug = base_slug[:150]
folder = current.child_folders.filter(name=part).first() if current.pk else None
if not folder:
folder = DocumentFolder.objects.filter(slug=slug).first()
if not folder:
counter = 1
while DocumentFolder.objects.filter(slug=slug).exists():
slug = f"{base_slug[:140]}-{counter}"
counter += 1
if dry_run:
folder = DocumentFolder(name=part, slug=slug)
else:
folder, _ = DocumentFolder.objects.get_or_create(
slug=slug,
defaults={
"name": part,
"application_label": current.application_label,
"created_by": creator_user
}
)
folder.thematics.set(current.thematics.all())
folder.parent_folders.add(current)
current = folder
return current
# Determine structures to process
if target_codes:
structures = Structure.objects.filter(code__in=target_codes)
if not structures.exists():
raise CommandError(f"No structures found matching codes: {target_codes}")
else:
structures = Structure.objects.all()
total_imported = 0
total_skipped = 0
for structure in structures:
# Check if there is a dedicated subfolder for this structure code, location codes or name in source_dir
struct_dir = None
possible_dirs = [
os.path.join(source_dir, structure.code),
os.path.join(source_dir, f"{structure.code}_{structure.name_fr}"),
os.path.join(source_dir, structure.name_fr or ""),
]
# Also check all associated location codes (e.g. WTB03)
loc_codes = list(structure.all_locations.values_list("code", flat=True))
for loc_code in loc_codes:
possible_dirs.append(os.path.join(source_dir, loc_code))
if os.path.isdir(source_dir):
for folder_item in os.listdir(source_dir):
if folder_item.upper().startswith(loc_code.upper()):
possible_dirs.append(os.path.join(source_dir, folder_item))
for p in possible_dirs:
if os.path.isdir(p):
struct_dir = p
break
if not struct_dir and os.path.isdir(source_dir):
# Check if any subfolder name contains structure code or main keywords from name_fr
name_words = [w.lower() for w in (structure.name_fr or "").replace("-", " ").replace("_", " ").replace("(", "").replace(")", "").split() if len(w) > 3 and w.lower() not in ["tunnel", "vers", "pont", "viaduc"]]
for folder_item in os.listdir(source_dir):
full_p = os.path.join(source_dir, folder_item)
if os.path.isdir(full_p) and not folder_item.startswith("."):
folder_lower = folder_item.lower()
if structure.code.lower() in folder_lower or any(w in folder_lower for w in name_words):
struct_dir = full_p
break
# If still not found and single structure requested with single directory extracted
if not struct_dir and target_codes and len(target_codes) == 1:
subdirs = [os.path.join(source_dir, d) for d in os.listdir(source_dir) if os.path.isdir(os.path.join(source_dir, d)) and not d.startswith(".")]
if len(subdirs) == 1:
struct_dir = subdirs[0]
# Collect files with their relative subfolder paths
file_entries = [] # tuples of (full_file_path, relative_dir_path)
if struct_dir:
for root, _, files in os.walk(struct_dir):
rel_dir = os.path.relpath(root, struct_dir)
for f in files:
if not f.startswith("."):
file_entries.append((os.path.join(root, f), rel_dir))
elif target_codes:
# If specific structures requested but no subfolder, import all files in source_dir for these structures
for root, _, files in os.walk(source_dir):
rel_dir = os.path.relpath(root, source_dir)
for f in files:
if not f.startswith("."):
file_entries.append((os.path.join(root, f), rel_dir))
if not file_entries:
continue
self.stdout.write(f"\nProcessing Structure [{structure.code}] {structure.get_name()}")
as_built_folder = get_or_create_as_built_folder(structure)
if not as_built_folder:
self.stdout.write(self.style.ERROR(f" No default folder/As-Built folder for Structure {structure.code}. Skipped."))
continue
if clean_existing and as_built_folder.pk:
all_ab_folders = DocumentManagementService.get_folder_and_descendants(as_built_folder)
existing_docs = ManagedDocument.objects.filter(
folders__pk__in=all_ab_folders,
is_archived=False
).distinct()
deleted_count = existing_docs.count()
if deleted_count > 0:
self.stdout.write(self.style.WARNING(f" Cleaning {deleted_count} existing document(s) in As-Built folder..."))
if not dry_run:
existing_docs.delete()
descendant_folder_ids = [f_id for f_id in all_ab_folders if f_id != as_built_folder.pk]
if descendant_folder_ids:
descendant_folders = DocumentFolder.objects.filter(pk__in=descendant_folder_ids)
f_count = descendant_folders.count()
if f_count > 0:
self.stdout.write(self.style.WARNING(f" Cleaning {f_count} existing subfolder(s) in As-Built folder..."))
if not dry_run:
descendant_folders.delete()
self.stdout.write(f" Target root subfolder: Electromechanics / As-Built (ID: {as_built_folder.pk or 'dry-run'})")
self.stdout.write(f" Found {len(file_entries)} file(s) to process.")
for file_path, rel_dir_path in file_entries:
rel_name = os.path.basename(file_path)
doc_title = os.path.splitext(rel_name)[0]
target_folder = get_or_create_nested_folder(as_built_folder, rel_dir_path)
# Deduplication check
if target_folder.pk and not clean_existing:
existing_doc = ManagedDocument.objects.filter(
folders=target_folder,
title=doc_title,
is_archived=False
).first()
if existing_doc:
self.stdout.write(f" Document '{rel_name}' already exists in folder '{target_folder.name}' (Doc ID: {existing_doc.pk}). Skipped.")
total_skipped += 1
continue
folder_display = rel_dir_path if rel_dir_path != "." else "As-Built"
if dry_run:
self.stdout.write(f" [Dry-run] Would import '{rel_name}' into folder 'As-Built / {folder_display}'")
total_imported += 1
else:
with open(file_path, "rb") as f:
file_data = f.read()
with transaction.atomic():
doc = ManagedDocument.objects.create(
title=doc_title,
description=f"As-Built document imported for structure {structure.code}",
created_by=creator_user,
)
version = DocumentVersion(
document=doc,
uploaded_by=creator_user,
comment="Imported As-Built document",
status=DocumentVersion.STATUS_APPROVED,
validated_at=timezone.now(),
)
version.file.save(rel_name, ContentFile(file_data), save=True)
doc.folders.add(target_folder)
if doc_tag:
doc.tags.add(doc_tag)
DocumentAttachment.objects.create(
document=doc,
content_type=structure_ct,
object_id=structure.pk,
attached_by=creator_user
)
self.stdout.write(self.style.SUCCESS(f" Imported '{rel_name}' into 'As-Built / {folder_display}' (Doc ID: {doc.pk})"))
total_imported += 1
self.stdout.write(self.style.SUCCESS(f"\nDone! Imported: {total_imported}, Skipped (already imported): {total_skipped}"))

View file

@ -1,278 +0,0 @@
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()})."))

View file

@ -1,293 +0,0 @@
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()}"))

View file

@ -1,256 +0,0 @@
import os
import csv
import re
from decimal import Decimal
from django.core.management.base import BaseCommand
from django.db import transaction
from assets.models import Structure, StructureExceptionalTransport, StructureExceptionalTransportNotice
def parse_float_safe(val):
if val is None or val == "":
return None
try:
f = float(str(val).replace(",", ".").strip())
return round(f, 2)
except (ValueError, TypeError):
return None
def parse_int_safe(val):
if val is None or val == "":
return None
try:
return int(float(str(val).replace(",", ".").strip()))
except (ValueError, TypeError):
return None
def parse_height(val):
if val is None or val == "" or val == 0:
return None
try:
f = float(str(val).replace(",", ".").strip())
if f <= 0 or f >= 999:
return None
if f > 100: # e.g. 443 cm -> 4.43 m
return round(f / 100.0, 2)
if f > 15: # e.g. 45 dm -> 4.50 m
return round(f / 10.0, 2)
return round(f, 2)
except (ValueError, TypeError):
return None
class Command(BaseCommand):
help = "Importe les données de transport exceptionnel (FOD/SPF) sur les ouvrages d'art correspondants."
def add_arguments(self, parser):
parser.add_argument(
"--file",
type=str,
default="/home/kdt/Downloads/matching_structures_transports_exceptionnels.csv",
help="Chemin vers le fichier CSV de matching."
)
parser.add_argument(
"--confidence",
type=str,
default="HAUTE",
choices=["HAUTE", "MOYENNE", "TOUS"],
help="Niveau de confiance à importer (défaut: HAUTE)."
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Exécute sans enregistrer les modifications en base de données."
)
def handle(self, *args, **options):
csv_file = options["file"]
confidence_filter = options["confidence"]
dry_run = options["dry_run"]
if not os.path.exists(csv_file):
self.stderr.write(self.style.ERROR(f"Fichier introuvable : {csv_file}"))
return
self.stdout.write(self.style.NOTICE(f"=== Import Transport Exceptionnel FOD ==="))
self.stdout.write(f"Source : {csv_file}")
self.stdout.write(f"Filtre de confiance : {confidence_filter}")
self.stdout.write(f"Mode simulation (dry-run) : {dry_run}\n")
with open(csv_file, mode="r", encoding="utf-8-sig") as f:
reader = csv.DictReader(f, delimiter=";")
rows = list(reader)
# Filtrer par niveau de confiance
if confidence_filter == "HAUTE":
target_rows = [r for r in rows if r.get("confidence_level") == "HAUTE"]
elif confidence_filter == "MOYENNE":
target_rows = [r for r in rows if r.get("confidence_level") in ("HAUTE", "MOYENNE")]
else:
target_rows = [r for r in rows if r.get("matched_structure_code")]
self.stdout.write(f"Total de lignes éligibles : {len(target_rows)}")
seen_structures = set()
to_process = []
duplicates_skipped = 0
for r in target_rows:
code = (r.get("user_validation_choice") or r.get("matched_structure_code") or "").strip()
if not code:
continue
if code in seen_structures:
duplicates_skipped += 1
continue
seen_structures.add(code)
to_process.append(r)
self.stdout.write(f"Ouvrages cibles uniques à mettre à jour : {len(to_process)}")
self.stdout.write(f"Lignes doublons ignorées (première conservée) : {duplicates_skipped}\n")
created_count = 0
updated_count = 0
skipped_count = 0
notices_created_count = 0
with transaction.atomic():
for r in to_process:
code = (r.get("user_validation_choice") or r.get("matched_structure_code") or "").strip()
structure = Structure.objects.filter(code__iexact=code).first()
if not structure:
self.stdout.write(self.style.WARNING(f" [IGNORE] Structure avec code '{code}' introuvable en DB."))
skipped_count += 1
continue
fod_vnr = parse_int_safe(r.get("fod_vnr"))
fod_idn = (r.get("fod_idn") or "").strip() or None
bridge_class = (r.get("fod_bridge_class") or "").strip() or None
maxwa = parse_float_safe(r.get("fod_maxwa_tonnes"))
minwr = parse_float_safe(r.get("fod_minwr_tonnes"))
mx = parse_float_safe(r.get("fod_mx_tonnes"))
usable_width = parse_float_safe(r.get("fod_usable_width_m"))
# Spoorlijn
spoorlijn_raw = str(r.get("fod_spoorlijn") or "").strip().lower()
is_railway_metro = spoorlijn_raw in ("true", "1", "t", "oui", "yes")
# Hauteurs
h_str = r.get("fod_clearance_heights") or ""
h1_m, h2_m, h3_m = None, None, None
if h_str and "/" in h_str:
parts = h_str.split("/")
if len(parts) >= 3:
h1_m = parse_height(parts[0])
h2_m = parse_height(parts[1])
h3_m = parse_height(parts[2])
# Prescriptions textuelles
p1 = (r.get("fod_prescriptions_1") or "").strip()
p2 = (r.get("fod_prescriptions_2") or "").strip()
p3 = (r.get("fod_prescriptions_3") or "").strip()
prescriptions_fr_list = []
prescriptions_nl_list = []
for p in (p1, p2, p3):
if not p or len(p) < 3:
continue
p_lower = p.lower()
if any(w in p_lower for w in ("passage", "autorisé", "condition", "vitesse", "limitée", "freinage", "chaussée", "ouvrage", "surplombe")):
if p not in prescriptions_fr_list:
prescriptions_fr_list.append(p)
if any(w in p_lower for w in ("toegestaan", "voorwaarde", "snelheid", "beperkt", "metrotunnel", "overbelastingen", "structuur", "rijbaan")):
if p not in prescriptions_nl_list:
prescriptions_nl_list.append(p)
# Si W14 était en FR et non classé, fallback
if p1 and not prescriptions_fr_list and not prescriptions_nl_list:
prescriptions_fr_list.append(p1)
presc_fr = "\n\n".join(prescriptions_fr_list) if prescriptions_fr_list else None
presc_nl = "\n\n".join(prescriptions_nl_list) if prescriptions_nl_list else None
# Détection des règles d'exploitation
all_text = f"{p1} {p2} {p3}".lower()
is_braking_forbidden = any(w in all_text for w in ("frein", "remmen", "sans freinage", "niet remmen"))
is_isolated_crossing = any(w in all_text for w in ("convoi seul", "seul", "alleen", "geen andere gelijktijdige", "pas simultanément d'autres surcharges", "pas d'autre surcharge"))
has_speed_5 = any(w in all_text for w in ("5km/h", "5 km/h", "5km/u", "5 km/u"))
max_speed = 5 if has_speed_5 else None
# Statut d'admissibilité
if presc_fr or presc_nl or is_braking_forbidden or is_isolated_crossing or max_speed:
passage_status = "conditional"
elif maxwa or mx or minwr:
passage_status = "authorized"
else:
passage_status = "not_evaluated"
# Détection référence d'étude
last_study_ref = None
study_match = re.search(r"(avis\s*DITP[_\s\w\d]*|Mission\s*\d+|note\s*de\s*calcul[_\s\w\d]*)", all_text, re.IGNORECASE)
if study_match:
last_study_ref = study_match.group(0).strip()
te_obj, created = StructureExceptionalTransport.objects.get_or_create(structure=structure)
te_obj.fod_vnr = fod_vnr
te_obj.national_id = fod_idn
if bridge_class:
te_obj.bridge_class = bridge_class
if maxwa is not None:
te_obj.max_authorized_weight_tonnes = maxwa
if minwr is not None:
te_obj.min_guaranteed_capacity_tonnes = minwr
if mx is not None:
te_obj.max_calculated_weight_tonnes = mx
if usable_width is not None:
te_obj.usable_width_m = usable_width
if h1_m is not None:
te_obj.clearance_height_left_m = h1_m
if h2_m is not None:
te_obj.clearance_height_center_m = h2_m
if h3_m is not None:
te_obj.clearance_height_right_m = h3_m
if is_railway_metro:
te_obj.is_railway_metro_crossing = True
if is_braking_forbidden:
te_obj.is_braking_forbidden = True
if is_isolated_crossing:
te_obj.is_isolated_crossing_only = True
if max_speed:
te_obj.max_speed_kmh = max_speed
if passage_status:
te_obj.passage_status = passage_status
if presc_fr:
te_obj.prescriptions_fr = presc_fr
if presc_nl:
te_obj.prescriptions_nl = presc_nl
if last_study_ref:
te_obj.last_study_ref = last_study_ref
if not dry_run:
te_obj.save()
if created:
created_count += 1
else:
updated_count += 1
self.stdout.write(
f" [OK] {structure.code} ({structure.get_name()}) : VNR={fod_vnr}, BKL={bridge_class or '-'}, MAXWA={maxwa or '-'}t, Statut={passage_status}"
)
if dry_run:
transaction.set_rollback(True)
self.stdout.write("\n" + self.style.SUCCESS("=== Résultat de l'import ==="))
self.stdout.write(f"Ouvrages créés / initialisés : {created_count}")
self.stdout.write(f"Ouvrages mis à jour : {updated_count}")
self.stdout.write(f"Ouvrages ignorés : {skipped_count}")
if dry_run:
self.stdout.write(self.style.WARNING("Mode simulation : aucune modification n'a été enregistrée en base."))
else:
self.stdout.write(self.style.SUCCESS("Toutes les données ont été enregistrées avec succès en base de données."))

View file

@ -1,5 +0,0 @@
from assets.management.commands.import_fulcrum_trees import Command as BaseFulcrumCommand
class Command(BaseFulcrumCommand):
help = "Importe l'inventaire Fulcrum des arbres régionaux (arbres, commandes/gestion, interventions)."

File diff suppressed because it is too large Load diff

View file

@ -1,125 +0,0 @@
"""
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}")

View file

@ -1,85 +0,0 @@
from django.core.management.base import BaseCommand
from django.apps import apps
from django.db import connection, transaction
class Command(BaseCommand):
help = "Synchronizes the geojson field (and lon/lat if missing) from geom for all assets/models using PostGIS SQL."
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Perform a dry run without modifying the database.',
)
parser.add_argument(
'--only-missing',
action='store_true',
help='Only update records where geojson is null or empty.',
)
def handle(self, *args, **options):
dry_run = options['dry_run']
only_missing = options['only_missing']
total_scanned = 0
total_updated = 0
with connection.cursor() as cursor:
for model in apps.get_models():
fields = [f.name for f in model._meta.get_fields()]
if 'geom' not in fields or 'geojson' not in fields:
continue
table = model._meta.db_table
is_json_field = model._meta.get_field('geojson').get_internal_type() == 'JSONField'
has_lon = 'lon' in fields
has_lat = 'lat' in fields
# Count total candidates with non-null geom
cursor.execute(f'SELECT COUNT(*) FROM "{table}" WHERE geom IS NOT NULL;')
count = cursor.fetchone()[0]
if count == 0:
continue
self.stdout.write(f"Processing {model._meta.app_label}.{model.__name__} ({table}, {count} candidate records)...")
if only_missing:
where_clause = "geom IS NOT NULL AND (geojson IS NULL OR geojson::text = '' OR geojson::text = 'null')"
else:
where_clause = "geom IS NOT NULL"
cursor.execute(f'SELECT COUNT(*) FROM "{table}" WHERE {where_clause};')
records_to_update = cursor.fetchone()[0]
if records_to_update == 0:
self.stdout.write(self.style.SUCCESS(f" {model.__name__}: 0 records need update."))
continue
if dry_run:
self.stdout.write(self.style.SUCCESS(f" [DRY-RUN] {model.__name__}: Would update {records_to_update}/{count} records."))
total_scanned += count
total_updated += records_to_update
continue
cast_geojson = "ST_AsGeoJSON(ST_Transform(geom, 4326))::jsonb" if is_json_field else "ST_AsGeoJSON(ST_Transform(geom, 4326))"
set_clauses = [f"geojson = {cast_geojson}"]
if has_lon:
set_clauses.append("lon = COALESCE(lon, ST_X(ST_PointOnSurface(ST_Transform(geom, 4326))))")
if has_lat:
set_clauses.append("lat = COALESCE(lat, ST_Y(ST_PointOnSurface(ST_Transform(geom, 4326))))")
set_str = ", ".join(set_clauses)
update_sql = f'UPDATE "{table}" SET {set_str} WHERE {where_clause};'
with transaction.atomic():
cursor.execute(update_sql)
updated_count = cursor.rowcount
self.stdout.write(self.style.SUCCESS(f" {model.__name__}: {updated_count}/{count} records updated in DB."))
total_scanned += count
total_updated += updated_count
prefix = "[DRY-RUN] Would update" if dry_run else "Successfully updated"
self.stdout.write(self.style.SUCCESS(f"\n{prefix} {total_updated} / {total_scanned} records across all models."))

View file

@ -1,379 +0,0 @@
import logging
from django.core.management.base import BaseCommand
from django.db import transaction
from assets.models.trafficlights import (
TrafficLightLantern,
TrafficLightAccessory,
TrafficLightDetector,
TrafficLightControllerHardware,
TrafficLightNetworkHardware,
TrafficLightVoltageHardware,
TrafficLightElectricalCabinetContent,
)
from assets.models.sign import SignPanel
from assets.models.publiclighting import PublicLightingLightFixture
from assets.models.its import ITSGeoAsset
from assets.models.structures import StructureGeoAsset
from assets.models.buildings import BuildingAsset
from assets.models.outdoor import OutdoorAsset
logger = logging.getLogger(__name__)
class Command(BaseCommand):
help = "Synchronizes coordinates (geom, lon, lat, geojson) and locations from parent supports to child assets."
def add_arguments(self, parser):
parser.add_argument(
'--batch-size',
type=int,
default=1000,
help="Batch size for processing records."
)
def handle(self, *args, **options):
batch_size = options['batch_size']
self.stdout.write(self.style.NOTICE("Starting parent-child asset data synchronization..."))
# 1. Traffic Light Lanterns
self.stdout.write("1. Syncing Traffic Light Lanterns...")
lanterns = TrafficLightLantern.objects.select_related('pole').filter(pole__geom__isnull=False)
count = 0
to_update = []
for lantern in lanterns.iterator(chunk_size=batch_size):
if lantern.pole and lantern.pole.geom:
if lantern.geom != lantern.pole.geom or lantern.lon != lantern.pole.lon or lantern.lat != lantern.pole.lat:
lantern.geom = lantern.pole.geom
lantern.lon = lantern.pole.lon
lantern.lat = lantern.pole.lat
lantern.geojson = lantern.pole.geojson
to_update.append(lantern)
count += 1
if len(to_update) >= batch_size:
TrafficLightLantern.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson'])
to_update = []
if to_update:
TrafficLightLantern.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Traffic Light Lanterns"))
# 2. Traffic Light Accessories
self.stdout.write("2. Syncing Traffic Light Accessories...")
accessories = TrafficLightAccessory.objects.select_related('pole', 'intersection').filter(pole__geom__isnull=False)
count = 0
to_update = []
for acc in accessories.iterator(chunk_size=batch_size):
changed = False
if acc.pole and acc.pole.geom:
if acc.geom != acc.pole.geom or acc.lon != acc.pole.lon or acc.lat != acc.pole.lat:
acc.geom = acc.pole.geom
acc.lon = acc.pole.lon
acc.lat = acc.pole.lat
acc.geojson = acc.pole.geojson
changed = True
if acc.pole and acc.pole.intersection and not acc.intersection_id:
acc.intersection = acc.pole.intersection
changed = True
if changed:
to_update.append(acc)
count += 1
if len(to_update) >= batch_size:
TrafficLightAccessory.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'intersection'])
to_update = []
if to_update:
TrafficLightAccessory.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'intersection'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Traffic Light Accessories"))
# 3. Traffic Light Detectors
self.stdout.write("3. Syncing Traffic Light Detectors...")
detectors = TrafficLightDetector.objects.select_related('pole', 'controller')
count = 0
to_update = []
for det in detectors.iterator(chunk_size=batch_size):
changed = False
support = det.pole or det.controller
if support and getattr(support, 'geom', None):
if det.geom != support.geom or det.lon != support.lon or det.lat != support.lat:
det.geom = support.geom
det.lon = support.lon
det.lat = support.lat
det.geojson = support.geojson
changed = True
if det.pole and det.pole.intersection and not det.intersection_id:
det.intersection = det.pole.intersection
changed = True
elif det.controller and det.controller.intersection and not det.intersection_id:
det.intersection = det.controller.intersection
changed = True
if changed:
to_update.append(det)
count += 1
if len(to_update) >= batch_size:
TrafficLightDetector.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'intersection'])
to_update = []
if to_update:
TrafficLightDetector.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'intersection'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Traffic Light Detectors"))
# 4. Traffic Light Controller Hardware
self.stdout.write("4. Syncing Traffic Light Controller Hardware...")
hws = TrafficLightControllerHardware.objects.select_related('controller').filter(controller__geom__isnull=False)
count = 0
to_update = []
for hw in hws.iterator(chunk_size=batch_size):
if hw.controller and hw.controller.geom:
if hw.geom != hw.controller.geom or hw.lon != hw.controller.lon or hw.lat != hw.controller.lat:
hw.geom = hw.controller.geom
hw.lon = hw.controller.lon
hw.lat = hw.controller.lat
hw.geojson = hw.controller.geojson
to_update.append(hw)
count += 1
if len(to_update) >= batch_size:
TrafficLightControllerHardware.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson'])
to_update = []
if to_update:
TrafficLightControllerHardware.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Traffic Light Controller Hardwares"))
# 5. Traffic Light Network Hardware
self.stdout.write("5. Syncing Traffic Light Network Hardware...")
nets = TrafficLightNetworkHardware.objects.select_related('controller').filter(controller__geom__isnull=False)
count = 0
to_update = []
for net in nets.iterator(chunk_size=batch_size):
changed = False
if net.controller and net.controller.geom:
if net.geom != net.controller.geom or net.lon != net.controller.lon or net.lat != net.controller.lat:
net.geom = net.controller.geom
net.lon = net.controller.lon
net.lat = net.controller.lat
net.geojson = net.controller.geojson
changed = True
if net.controller and net.controller.intersection and not net.intersection_id:
net.intersection = net.controller.intersection
changed = True
if changed:
to_update.append(net)
count += 1
if len(to_update) >= batch_size:
TrafficLightNetworkHardware.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'intersection'])
to_update = []
if to_update:
TrafficLightNetworkHardware.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'intersection'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Traffic Light Network Hardwares"))
# 6. Traffic Light Voltage Hardware
self.stdout.write("6. Syncing Traffic Light Voltage Hardware...")
volts = TrafficLightVoltageHardware.objects.select_related('controller').filter(controller__geom__isnull=False)
count = 0
to_update = []
for volt in volts.iterator(chunk_size=batch_size):
changed = False
if volt.controller and volt.controller.geom:
if volt.geom != volt.controller.geom or volt.lon != volt.controller.lon or volt.lat != volt.controller.lat:
volt.geom = volt.controller.geom
volt.lon = volt.controller.lon
volt.lat = volt.controller.lat
volt.geojson = volt.controller.geojson
changed = True
if volt.controller and volt.controller.intersection and not volt.intersection_id:
volt.intersection = volt.controller.intersection
changed = True
if changed:
to_update.append(volt)
count += 1
if len(to_update) >= batch_size:
TrafficLightVoltageHardware.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'intersection'])
to_update = []
if to_update:
TrafficLightVoltageHardware.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'intersection'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Traffic Light Voltage Hardwares"))
# 7. Traffic Light Electrical Cabinet Contents
self.stdout.write("7. Syncing Traffic Light Electrical Cabinet Contents...")
contents = TrafficLightElectricalCabinetContent.objects.select_related('cabinet').filter(cabinet__geom__isnull=False)
count = 0
to_update = []
for content in contents.iterator(chunk_size=batch_size):
if content.cabinet and content.cabinet.geom:
if content.geom != content.cabinet.geom or content.lon != content.cabinet.lon or content.lat != content.cabinet.lat:
content.geom = content.cabinet.geom
content.lon = content.cabinet.lon
content.lat = content.cabinet.lat
content.geojson = content.cabinet.geojson
to_update.append(content)
count += 1
if len(to_update) >= batch_size:
TrafficLightElectricalCabinetContent.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson'])
to_update = []
if to_update:
TrafficLightElectricalCabinetContent.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Electrical Cabinet Contents"))
# 8. Road Sign Panels
self.stdout.write("8. Syncing Road Sign Panels...")
panels = SignPanel.objects.select_related('pole', 'trafficlight_pole', 'publiclighting_pole')
count = 0
to_update = []
for panel in panels.iterator(chunk_size=batch_size):
changed = False
support = panel.pole or panel.trafficlight_pole or panel.publiclighting_pole
if support and getattr(support, 'geom', None):
if panel.geom != support.geom or panel.lon != support.lon or panel.lat != support.lat:
panel.geom = support.geom
panel.lon = support.lon
panel.lat = support.lat
panel.geojson = support.geojson
changed = True
if panel.pole and panel.pole.street and not panel.street_id:
panel.street = panel.pole.street
changed = True
if changed:
to_update.append(panel)
count += 1
if len(to_update) >= batch_size:
SignPanel.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'street'])
to_update = []
if to_update:
SignPanel.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'street'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Road Sign Panels"))
# 9. Public Lighting Light Fixtures (Luminaires)
self.stdout.write("9. Syncing Public Lighting Light Fixtures...")
fixtures = PublicLightingLightFixture.objects.select_related('pole', 'traffic_light_pole')
count = 0
to_update = []
for fix in fixtures.iterator(chunk_size=batch_size):
changed = False
support = fix.pole or fix.traffic_light_pole
if support and getattr(support, 'geom', None):
if fix.geom != support.geom or fix.lon != support.lon or fix.lat != support.lat:
fix.geom = support.geom
fix.lon = support.lon
fix.lat = support.lat
fix.geojson = support.geojson
changed = True
if fix.pole and fix.pole.street and not fix.street_id:
fix.street = fix.pole.street
changed = True
if changed:
to_update.append(fix)
count += 1
if len(to_update) >= batch_size:
PublicLightingLightFixture.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'street'])
to_update = []
if to_update:
PublicLightingLightFixture.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'street'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Public Lighting Light Fixtures"))
# 10. ITS Assets
self.stdout.write("10. Syncing ITS Assets...")
its_assets = ITSGeoAsset.objects.select_related('trafficlights_pole', 'lighting_pole')
count = 0
to_update = []
for its in its_assets.iterator(chunk_size=batch_size):
support = its.trafficlights_pole or its.lighting_pole
if support and getattr(support, 'geom', None):
if its.geom != support.geom or its.lon != support.lon or its.lat != support.lat:
its.geom = support.geom
its.lon = support.lon
its.lat = support.lat
its.geojson = support.geojson
to_update.append(its)
count += 1
if len(to_update) >= batch_size:
ITSGeoAsset.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson'])
to_update = []
if to_update:
ITSGeoAsset.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} ITS Assets"))
# 11. Structure Assets
self.stdout.write("11. Syncing Structure Assets...")
struct_assets = StructureGeoAsset.objects.select_related('parent_asset')
count = 0
to_update = []
for sa in struct_assets.iterator(chunk_size=batch_size):
changed = False
if sa.parent_asset and getattr(sa.parent_asset, 'geom', None) and not sa.geom:
sa.geom = sa.parent_asset.geom
sa.lon = sa.parent_asset.lon
sa.lat = sa.parent_asset.lat
sa.geojson = sa.parent_asset.geojson
changed = True
if sa.parent_asset and sa.parent_asset.location and not sa.location_id:
sa.location = sa.parent_asset.location
changed = True
if sa.parent_asset and sa.parent_asset.structure and not sa.structure_id:
sa.structure = sa.parent_asset.structure
changed = True
if changed:
to_update.append(sa)
count += 1
if len(to_update) >= batch_size:
StructureGeoAsset.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'location', 'structure'])
to_update = []
if to_update:
StructureGeoAsset.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'location', 'structure'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Structure Assets"))
# 12. Building Assets
self.stdout.write("12. Syncing Building Assets...")
bld_assets = BuildingAsset.objects.select_related('parent_asset')
count = 0
to_update = []
for ba in bld_assets.iterator(chunk_size=batch_size):
changed = False
if ba.parent_asset and getattr(ba.parent_asset, 'geom', None) and not ba.geom:
ba.geom = ba.parent_asset.geom
ba.lon = ba.parent_asset.lon
ba.lat = ba.parent_asset.lat
ba.geojson = ba.parent_asset.geojson
changed = True
if ba.parent_asset and ba.parent_asset.location and not ba.location_id:
ba.location = ba.parent_asset.location
changed = True
if ba.parent_asset and ba.parent_asset.building and not ba.building_id:
ba.building = ba.parent_asset.building
changed = True
if changed:
to_update.append(ba)
count += 1
if len(to_update) >= batch_size:
BuildingAsset.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'location', 'building'])
to_update = []
if to_update:
BuildingAsset.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'location', 'building'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Building Assets"))
# 13. Outdoor Assets
self.stdout.write("13. Syncing Outdoor Assets...")
out_assets = OutdoorAsset.objects.select_related('parent_asset')
count = 0
to_update = []
for oa in out_assets.iterator(chunk_size=batch_size):
changed = False
if oa.parent_asset and getattr(oa.parent_asset, 'geom', None) and not oa.geom:
oa.geom = oa.parent_asset.geom
oa.lon = oa.parent_asset.lon
oa.lat = oa.parent_asset.lat
oa.geojson = oa.parent_asset.geojson
changed = True
if oa.parent_asset and oa.parent_asset.location and not oa.location_id:
oa.location = oa.parent_asset.location
changed = True
if oa.parent_asset and oa.parent_asset.site and not oa.site_id:
oa.site = oa.parent_asset.site
changed = True
if changed:
to_update.append(oa)
count += 1
if len(to_update) >= batch_size:
OutdoorAsset.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'location', 'site'])
to_update = []
if to_update:
OutdoorAsset.objects.bulk_update(to_update, ['geom', 'lon', 'lat', 'geojson', 'location', 'site'])
self.stdout.write(self.style.SUCCESS(f" ✓ Updated {count} Outdoor Assets"))
self.stdout.write(self.style.SUCCESS("All asset parent data synchronized successfully!"))

View file

@ -1,171 +0,0 @@
# Generated by Django 6.0.7 on 2026-07-26 13:20
import assets.models.buildings
import common.storages
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0004_initial'),
('documents', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BuildingLevel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50, verbose_name='Code du niveau')),
('name', models.CharField(max_length=200, verbose_name='Nom du niveau')),
('level_number', models.IntegerField(default=0, verbose_name="Numéro d'étage (0=RDC, 1=1er, -1=Sous-sol)")),
('floor_plan', models.FileField(blank=True, null=True, storage=common.storages.PrivateMediaStorage(), upload_to='buildings/plans/%Y/%m/', verbose_name="Plan d'étage")),
],
options={
'verbose_name': 'Niveau / Étage',
'verbose_name_plural': 'Niveaux / Étages',
'ordering': ['building', 'level_number'],
},
),
migrations.CreateModel(
name='PublicBuilding',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Emprise cartographique')),
('address', models.CharField(blank=True, max_length=500, null=True, verbose_name='Adresse complète')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('owner', models.CharField(blank=True, max_length=255, null=True, verbose_name='Propriétaire')),
('occupant_entity', models.CharField(blank=True, max_length=255, null=True, verbose_name='Direction / Service occupant')),
('usage_type', models.CharField(choices=[('administrative', 'Administratif / Mairie'), ('education', 'Enseignement / École / Crèche'), ('sports', 'Sport & Équipement sportif'), ('culture', 'Culture / Médiathèque / Musée'), ('social', 'Social / Centre communautaire'), ('technical', 'Technique / Ateliers / Stock'), ('housing', 'Logement communal'), ('other', 'Autre bâtiment public')], default='administrative', max_length=50, verbose_name="Type d'usage principal")),
('building_status', models.CharField(choices=[('in_service', 'En service'), ('under_renovation', 'En travaux / Rénovation'), ('decommissioned', 'Désaffecté'), ('ceded', 'Cédé / Vendu')], default='in_service', max_length=50, verbose_name="Statut d'occupation")),
('construction_year', models.IntegerField(blank=True, null=True, verbose_name='Année de construction')),
('floors_count', models.IntegerField(default=1, verbose_name="Nombre d'étages hors-sol")),
('basement_count', models.IntegerField(default=0, verbose_name='Nombre de niveaux en sous-sol')),
('total_surface_m2', models.FloatField(blank=True, null=True, verbose_name='Surface brute totale (m²)')),
('usable_surface_m2', models.FloatField(blank=True, null=True, verbose_name='Surface utile (m²)')),
('is_erp', models.BooleanField(default=True, verbose_name='Établissement Recevant du Public (ERP)')),
('erp_category', models.CharField(blank=True, choices=[('1', '1ère catégorie (> 1500 pers.)'), ('2', '2ème catégorie (301 - 1500 pers.)'), ('3', '3ème catégorie (101 - 300 pers.)'), ('4', "4ème catégorie (jusqu'à 100 pers.)"), ('5', '5ème catégorie (seuils spécifiques)')], max_length=10, null=True, verbose_name='Catégorie ERP')),
('erp_type', models.CharField(blank=True, choices=[('J', "J - Structures d'accueil pour personnes âgées ou handicapées"), ('L', 'L - Salles de spectacles, de réunions, polyvalentes'), ('M', 'M - Magasins, centres commerciaux'), ('N', 'N - Restaurants et débits de boissons'), ('O', 'O - Hôtels et pensions de famille'), ('P', 'P - Salles de danse et salles de jeux'), ('R', "R - Établissements d'éveil, d'enseignement, colonies de vacances"), ('S', 'S - Bibliothèques, centres de documentation'), ('T', "T - Salles d'expositions"), ('U', 'U - Établissements de soins'), ('V', 'V - Établissements de culte'), ('W', 'W - Administrations, banques, bureaux'), ('X', 'X - Établissements sportifs couverts'), ('Y', 'Y - Musées')], max_length=10, null=True, verbose_name='Type ERP')),
('max_capacity', models.IntegerField(blank=True, null=True, verbose_name="Jauge maximale d'accueil du public")),
('dpe_rating', models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D'), ('E', 'E'), ('F', 'F'), ('G', 'G')], max_length=2, null=True, verbose_name='Diagnostic de Performance Énergétique (DPE)')),
('ges_rating', models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D'), ('E', 'E'), ('F', 'F'), ('G', 'G')], max_length=2, null=True, verbose_name='Émission de Gaz à Effet de Serre (GES)')),
('has_asbestos_diag', models.BooleanField(default=False, verbose_name='Diagnostic Amiante réalisé')),
('has_lead_diag', models.BooleanField(default=False, verbose_name='Diagnostic Plomb réalisé')),
('pmr_accessible', models.BooleanField(default=False, verbose_name='Accessible PMR')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_buildings', to=settings.AUTH_USER_MODEL, verbose_name='Gestionnaire référent')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.publicbuilding', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
],
options={
'verbose_name': 'Bâtiment public',
'verbose_name_plural': 'Bâtiments publics',
'ordering': ['code', 'name_fr'],
},
),
migrations.CreateModel(
name='BuildingRoom',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50, verbose_name='Code du local')),
('name', models.CharField(max_length=200, verbose_name='Nom de la pièce')),
('room_type', models.CharField(blank=True, max_length=100, null=True, verbose_name='Type de local (ex: Bureau, Classe, Sanitaires)')),
('surface_m2', models.FloatField(blank=True, null=True, verbose_name='Surface (m²)')),
('level', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rooms', to='assets.buildinglevel', verbose_name='Niveau / Étage')),
('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rooms', to='assets.publicbuilding', verbose_name='Bâtiment')),
],
options={
'verbose_name': 'Pièce / Local',
'verbose_name_plural': 'Pièces / Locaux',
'ordering': ['building', 'code'],
},
),
migrations.AddField(
model_name='buildinglevel',
name='building',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='levels', to='assets.publicbuilding', verbose_name='Bâtiment'),
),
migrations.CreateModel(
name='BuildingEquipment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('updated_at', models.DateTimeField(auto_now=True)),
('equipment_type', models.CharField(choices=[('hvac', 'Chauffage / CVC / Chaudière'), ('elevator', 'Ascenseur / Monte-charge'), ('fire_safety', 'Sécurité incendie / Extincteur / RIA'), ('electricity', 'Tableau électrique / TGBT'), ('alarm', "Alarme / Contrôle d'accès"), ('meter', 'Compteur (Eau / Gaz / Électricité)'), ('sanitary', 'Plomberie / Sanitaires'), ('other', 'Autre équipement technique')], default='hvac', max_length=50, verbose_name="Type d'équipement")),
('last_control_date', models.DateField(blank=True, null=True, verbose_name='Dernier contrôle réglementaire')),
('next_control_date', models.DateField(blank=True, null=True, verbose_name='Prochain contrôle réglementaire')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.buildingequipment', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
('room', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='equipments', to='assets.buildingroom', verbose_name='Local')),
('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='equipments', to='assets.publicbuilding', verbose_name='Bâtiment')),
],
options={
'verbose_name': 'Équipement technique de bâtiment',
'verbose_name_plural': 'Équipements techniques de bâtiment',
'ordering': ['building', 'code'],
},
),
migrations.CreateModel(
name='PublicBuildingDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.TextField(blank=True, null=True, verbose_name='Description du document')),
('upload_date', models.DateTimeField(auto_now=True, verbose_name='Date de téléversement')),
('status', models.CharField(choices=[('active', 'Actif'), ('archived', 'Archivé')], default='active')),
('file', models.FileField(max_length=255, storage=common.storages.PrivateMediaStorage(), upload_to=assets.models.buildings.upload_to_building, verbose_name='Fichier')),
('document_type', models.CharField(default='other', max_length=50, verbose_name='Type de document')),
('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.publicbuilding', verbose_name='Bâtiment')),
('managed_document', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_sources', to='documents.manageddocument')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par')),
],
options={
'verbose_name': 'Document de bâtiment',
'verbose_name_plural': 'Documents de bâtiment',
},
),
]

View file

@ -1,219 +0,0 @@
# Generated by Django 6.0.7 on 2026-07-27 12:00
import common.storages
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0005_buildinglevel_publicbuilding_buildingroom_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='buildinglevel',
name='building',
),
migrations.RemoveField(
model_name='buildingroom',
name='level',
),
migrations.RemoveField(
model_name='buildingroom',
name='building',
),
migrations.AlterModelOptions(
name='publicbuilding',
options={'ordering': ['code', 'name_fr'], 'verbose_name': 'Localisation Bâtiment public', 'verbose_name_plural': 'Localisations Bâtiments publics'},
),
migrations.RemoveField(
model_name='publicbuilding',
name='brand',
),
migrations.RemoveField(
model_name='publicbuilding',
name='category',
),
migrations.RemoveField(
model_name='publicbuilding',
name='installation_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='last_corrective_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='last_inspection_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='last_preventive_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='last_survey_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='marked_as_removed_by',
),
migrations.RemoveField(
model_name='publicbuilding',
name='marked_as_removed_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='note',
),
migrations.RemoveField(
model_name='publicbuilding',
name='obsolescence',
),
migrations.RemoveField(
model_name='publicbuilding',
name='replaced_by',
),
migrations.RemoveField(
model_name='publicbuilding',
name='serial_number',
),
migrations.RemoveField(
model_name='publicbuilding',
name='status',
),
migrations.RemoveField(
model_name='publicbuilding',
name='uninstallation_date',
),
migrations.RemoveField(
model_name='publicbuilding',
name='validation_status',
),
migrations.RemoveField(
model_name='publicbuilding',
name='warranty_duration',
),
migrations.AddField(
model_name='publicbuilding',
name='floor_plan',
field=models.FileField(blank=True, null=True, storage=common.storages.PrivateMediaStorage(), upload_to='buildings/plans/%Y/%m/', verbose_name="Plan d'étage"),
),
migrations.AddField(
model_name='publicbuilding',
name='is_main_location',
field=models.BooleanField(default=False, verbose_name='Localisation principale'),
),
migrations.AddField(
model_name='publicbuilding',
name='level_number',
field=models.IntegerField(default=0, verbose_name="Numéro d'étage (ex: 0=RDC, 1=1er, -1=Sous-sol)"),
),
migrations.AddField(
model_name='publicbuilding',
name='location_type',
field=models.CharField(choices=[('site', 'Site / Complexe'), ('building', 'Bâtiment / Bloc'), ('level', 'Niveau / Étage'), ('room', 'Pièce / Local'), ('other', 'Autre localisation')], default='building', max_length=50, verbose_name='Type de localisation'),
),
migrations.AddField(
model_name='publicbuilding',
name='parent_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_locations', to='assets.publicbuilding', verbose_name='Localisation parente'),
),
migrations.AddField(
model_name='publicbuilding',
name='surface_m2',
field=models.FloatField(blank=True, null=True, verbose_name='Surface spécifique (m²)'),
),
migrations.AlterField(
model_name='publicbuilding',
name='dpe_rating',
field=models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D'), ('E', 'E'), ('F', 'F'), ('G', 'G')], max_length=2, null=True, verbose_name='Performance Énergétique (PEB / DPE / EPC)'),
),
migrations.AlterField(
model_name='publicbuilding',
name='erp_category',
field=models.CharField(blank=True, choices=[('1', '1ère catégorie (> 1500 pers.)'), ('2', '2ème catégorie (301 - 1500 pers.)'), ('3', '3ème catégorie (101 - 300 pers.)'), ('4', "4ème catégorie (jusqu'à 100 pers.)"), ('5', '5ème catégorie (seuils spécifiques)')], max_length=10, null=True, verbose_name="Catégorie d'accueil / ERP"),
),
migrations.AlterField(
model_name='publicbuilding',
name='erp_type',
field=models.CharField(blank=True, choices=[('J', "J - Structures d'accueil pour personnes âgées ou handicapées"), ('L', 'L - Salles de spectacles, de réunions, polyvalentes'), ('M', 'M - Magasins, centres commerciaux'), ('N', 'N - Restaurants et débits de boissons'), ('O', 'O - Hôtels et pensions de famille'), ('P', 'P - Salles de danse et salles de jeux'), ('R', "R - Établissements d'éveil, d'enseignement, colonies de vacances"), ('S', 'S - Bibliothèques, centres de documentation'), ('T', "T - Salles d'expositions"), ('U', 'U - Établissements de soins'), ('V', 'V - Établissements de culte'), ('W', 'W - Administrations, banques, bureaux'), ('X', 'X - Établissements sportifs couverts'), ('Y', 'Y - Musées')], max_length=10, null=True, verbose_name="Type d'établissement / ERP"),
),
migrations.AlterField(
model_name='publicbuilding',
name='ges_rating',
field=models.CharField(blank=True, choices=[('A', 'A'), ('B', 'B'), ('C', 'C'), ('D', 'D'), ('E', 'E'), ('F', 'F'), ('G', 'G')], max_length=2, null=True, verbose_name='Émissions de Carbone & GES'),
),
migrations.AlterField(
model_name='publicbuilding',
name='is_erp',
field=models.BooleanField(default=True, verbose_name='Accueil & Sécurité du Public (ERP / PMR)'),
),
migrations.AlterField(
model_name='publicbuilding',
name='updated_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_location_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AlterField(
model_name='publicbuilding',
name='usage_type',
field=models.CharField(choices=[('administrative', 'Administrative (Town Hall / Municipal Building)'), ('education', 'Enseignement / École / Crèche'), ('sports', 'Sport & Équipement sportif'), ('culture', 'Culture / Médiathèque / Musée'), ('social', 'Social / Centre communautaire'), ('technical', 'Technique / Ateliers / Stock'), ('housing', 'Logement communal'), ('other', 'Autre bâtiment public')], default='administrative', max_length=50, verbose_name="Type d'usage principal"),
),
migrations.CreateModel(
name='BuildingAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=3812)),
('geojson', models.TextField(blank=True, null=True)),
('asset_type', models.CharField(choices=[('civil_engineering', 'Génie civil / Bâti'), ('facade', 'Façade / Mur extérieur'), ('wall', 'Mur intérieur / Cloison'), ('roof', 'Toiture / Couverture'), ('hvac', 'Chauffage / CVC / Chaudière'), ('elevator', 'Ascenseur / Monte-charge'), ('electricity', 'Électricité / TGBT'), ('fire_safety', 'Sécurité incendie / Extincteur / RIA'), ('alarm', "Alarme / Contrôle d'accès"), ('meter', 'Compteur (Eau / Gaz / Électricité)'), ('sanitary', 'Plomberie / Sanitaires'), ('other', 'Autre équipement / asset')], default='civil_engineering', max_length=50, verbose_name="Type d'asset / Équipement")),
('last_control_date', models.DateField(blank=True, null=True, verbose_name='Dernier contrôle réglementaire')),
('next_control_date', models.DateField(blank=True, null=True, verbose_name='Prochain contrôle réglementaire')),
('building', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='all_building_assets', to='assets.publicbuilding', verbose_name='Bâtiment / Site principal')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='location_assets', to='assets.publicbuilding', verbose_name='Localisation spécifique')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('parent_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_assets', to='assets.buildingasset', verbose_name='Asset parent')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.buildingasset', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
],
options={
'verbose_name': 'Asset / Équipement de bâtiment',
'verbose_name_plural': 'Assets / Équipements de bâtiment',
'ordering': ['building', 'code'],
},
),
migrations.DeleteModel(
name='BuildingEquipment',
),
migrations.DeleteModel(
name='BuildingLevel',
),
migrations.DeleteModel(
name='BuildingRoom',
),
]

View file

@ -1,121 +0,0 @@
# Generated by Django 6.0.7 on 2026-07-27 12:54
import assets.models.outdoor
import common.storages
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0006_remove_buildinglevel_building_and_more'),
('documents', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='OutdoorLocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Emprise cartographique')),
('site_type', models.CharField(choices=[('playground', 'Aire / Plaine de jeux'), ('sports_ground', 'Terrain de sport extérieur'), ('multisports_agora', 'Terrain multisports / City-stadium'), ('skatepark_pumptrack', 'Skatepark / Pumptrack / Bowl'), ('outdoor_fitness', 'Fitness plein air / Street Workout'), ('open_air_theater', 'Théâtre de verdure / Scène extérieure'), ('park_recreation', 'Parc & Espace récréatif'), ('dog_park', 'Espace canin / Canisite'), ('cemetery', 'Cimetière communal'), ('event_plaza', 'Place publique / Zone événementielle'), ('other', 'Autre espace plein air')], default='playground', max_length=50, verbose_name='Type de site plein air')),
('is_main_location', models.BooleanField(default=False, verbose_name='Localisation principale')),
('address', models.CharField(blank=True, max_length=500, null=True, verbose_name='Adresse complète')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('owner', models.CharField(blank=True, max_length=255, null=True, verbose_name='Propriétaire')),
('occupant_entity', models.CharField(blank=True, max_length=255, null=True, verbose_name='Direction / Service gestionnaire')),
('surface_m2', models.FloatField(blank=True, null=True, verbose_name='Surface totale (m²)')),
('max_capacity', models.IntegerField(blank=True, null=True, verbose_name="Jauge / Capacité d'accueil")),
('pmr_accessible', models.BooleanField(default=False, verbose_name='Accessible PMR')),
('has_water_point', models.BooleanField(default=False, verbose_name="Point d'eau potable")),
('has_power_supply', models.BooleanField(default=False, verbose_name='Alimentation électrique disponible')),
('is_fenced', models.BooleanField(default=False, verbose_name='Site clôturé / Sécurisé')),
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_outdoor_locations', to=settings.AUTH_USER_MODEL, verbose_name='Gestionnaire référent')),
('parent_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_locations', to='assets.outdoorlocation', verbose_name='Localisation parente')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_location_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
],
options={
'verbose_name': "Espace public d'extérieur",
'verbose_name_plural': "Espaces publics d'extérieur",
'ordering': ['code', 'name_fr'],
},
),
migrations.CreateModel(
name='OutdoorAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=3812)),
('geojson', models.TextField(blank=True, null=True)),
('asset_type', models.CharField(choices=[('play_structure', 'Structure combinée / Tour de jeu'), ('swing', 'Balançoire / Portique'), ('spring_rider', 'Jeu à ressort / Bascule'), ('slide', 'Toboggan'), ('zip_line', 'Tyrolienne'), ('amortizing_surface', 'Sol amortissant / Réception'), ('sports_goal', 'But / Cage / Panneau de basket'), ('fitness_station', 'Agrès de fitness outdoor / Workout'), ('skate_module', 'Module de skate / Rampe / Rail'), ('stage_platform', 'Scène extérieure / Podium'), ('audience_seating', 'Gradins / Bancs de spectateurs'), ('event_power_box', 'Borne foraine / Armoire électrique événementielle'), ('outdoor_lighting', 'Éclairage plein air / Projecteur scénique'), ('bench_table', 'Banc / Table de pique-nique'), ('shade_shelter', "Kiosque / Voile d'ombrage / Abri"), ('drinking_fountain', "Fontaine d'eau potable"), ('fence_gate', 'Clôture / Portillon de sécurité'), ('info_panel', "Panneau d'information / Consignes"), ('other', 'Autre équipement plein air')], default='play_structure', max_length=50, verbose_name="Type d'asset / Équipement")),
('norm', models.CharField(blank=True, choices=[('EN_1176', "EN 1176 - Équipements & sols d'aires de jeux"), ('EN_16630', "EN 16630 - Modules de fitness d'extérieur"), ('EN_14974', 'EN 14974 - Équipements de skatepark'), ('EN_15312', 'EN 15312 - Équipements multisports en accès libre'), ('other', 'Autre norme / Non spécifié')], max_length=50, null=True, verbose_name='Norme de sécurité appliquée')),
('last_control_date', models.DateField(blank=True, null=True, verbose_name='Dernier contrôle visuel/opérationnel')),
('next_control_date', models.DateField(blank=True, null=True, verbose_name='Prochain contrôle requis')),
('last_annual_inspection', models.DateField(blank=True, null=True, verbose_name='Dernier contrôle annuel certifié')),
('next_annual_inspection', models.DateField(blank=True, null=True, verbose_name='Prochain contrôle annuel certifié')),
('certifying_body', models.CharField(blank=True, max_length=255, null=True, verbose_name='Organisme de contrôle certifié')),
('impact_surface_type', models.CharField(blank=True, max_length=150, null=True, verbose_name="Type de sol d'amortissement")),
('critical_fall_height_m', models.FloatField(blank=True, null=True, verbose_name='Hauteur critique de chute (m)')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('parent_asset', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_assets', to='assets.outdoorasset', verbose_name='Asset parent')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.outdoorasset', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='location_assets', to='assets.outdoorlocation', verbose_name='Localisation spécifique')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='all_outdoor_assets', to='assets.outdoorlocation', verbose_name='Site / Espace principal')),
],
options={
'verbose_name': 'Asset / Équipement plein air',
'verbose_name_plural': 'Assets / Équipements plein air',
'ordering': ['site', 'code'],
},
),
migrations.CreateModel(
name='OutdoorLocationDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.TextField(blank=True, null=True, verbose_name='Description du document')),
('upload_date', models.DateTimeField(auto_now=True, verbose_name='Date de téléversement')),
('status', models.CharField(choices=[('active', 'Actif'), ('archived', 'Archivé')], default='active')),
('file', models.FileField(max_length=255, storage=common.storages.PrivateMediaStorage(), upload_to=assets.models.outdoor.upload_to_outdoor, verbose_name='Fichier')),
('document_type', models.CharField(default='other', max_length=50, verbose_name='Type de document')),
('managed_document', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_sources', to='documents.manageddocument')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.outdoorlocation', verbose_name='Espace extérieur')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par')),
],
options={
'verbose_name': "Document d'espace extérieur",
'verbose_name_plural': "Documents d'espace extérieur",
},
),
]

View file

@ -1,125 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-07 07:54
import assets.models.core
import django.contrib.gis.db.models.fields
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0007_outdoorlocation_outdoorasset_outdoorlocationdocument'),
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='AssetInspection',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('asset_object_id', models.PositiveIntegerField(verbose_name="ID de l'asset")),
('inspection_date', models.DateTimeField(default=django.utils.timezone.now, verbose_name="Date d'inspection")),
('result_status', models.CharField(choices=[('compliant', 'Conforme'), ('non_compliant', 'Non conforme / Anomalie'), ('updated', 'Données corrigées'), ('damaged', 'Endommagé'), ('missing', 'Absent / Retiré')], default='compliant', max_length=50, verbose_name='Résultat du contrôle')),
('verified_standard_stand_count', models.IntegerField(blank=True, null=True, verbose_name="Nombre d'arceaux classiques constaté")),
('verified_cargo_stand_count', models.IntegerField(blank=True, null=True, verbose_name="Nombre d'arceaux cargo constaté")),
('verified_stand_type', models.CharField(blank=True, max_length=50, null=True, verbose_name="Type d'arceau constaté")),
('verified_has_scooter_drop_zone', models.BooleanField(blank=True, null=True, verbose_name='Drop zone trottinette constatée')),
('verified_marking_quality', models.CharField(blank=True, max_length=50, null=True, verbose_name='Qualité du marquage constatée')),
('verified_obsolescence', models.CharField(blank=True, max_length=50, null=True, verbose_name="État d'obsolescence constaté")),
('notes', models.TextField(blank=True, null=True, verbose_name='Remarques / Constats')),
('apply_changes_to_asset', models.BooleanField(default=True, verbose_name="Appliquer directement les modifications à l'asset")),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('asset_content_type', models.ForeignKey(limit_choices_to={'app_label': 'assets'}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name="Type d'asset")),
('inspector', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='asset_inspections', to=settings.AUTH_USER_MODEL, verbose_name='Inspecteur / Vérificateur')),
],
options={
'verbose_name': "Inspection / Vérification d'asset",
'verbose_name_plural': "Inspections / Vérifications d'assets",
'ordering': ['-inspection_date'],
},
),
migrations.CreateModel(
name='ParkingLocation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Emprise cartographique')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('address', models.CharField(blank=True, max_length=500, null=True, verbose_name='Adresse')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_location_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
],
options={
'verbose_name': 'Zone de stationnement',
'verbose_name_plural': 'Zones de stationnement',
'ordering': ['code', 'name_fr'],
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
migrations.CreateModel(
name='ParkingSpot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.PolygonField(blank=True, null=True, srid=3812, verbose_name='Emprise polygone')),
('spot_type', models.CharField(choices=[('bicycle', 'Stationnement vélo'), ('scooter_drop_zone', 'Drop zone trottinettes / EDPM'), ('cargo_bike', 'Stationnement vélo cargo'), ('delivery', 'Zone de livraison / Chargement-déchargement'), ('pmr', 'Emplacement PMR'), ('kiss_and_ride', 'Arrêt minute / Court-durée (CD)'), ('car_parking', 'Stationnement automobile'), ('carsharing', 'Autopartage'), ('ev_charging', 'Recharge véhicule électrique'), ('other', 'Autre')], default='bicycle', max_length=50, verbose_name="Type d'emplacement")),
('standard_stand_count', models.IntegerField(default=0, verbose_name="Nombre d'arceaux vélos classiques")),
('cargo_stand_count', models.IntegerField(default=0, verbose_name="Nombre d'arceaux vélos cargo")),
('other_stand_count', models.IntegerField(default=0, verbose_name='Autres arceaux / agencements')),
('stand_type', models.CharField(choices=[('normal', 'Arceau standard (U inversé)'), ('cargo', 'Arceau vélo cargo'), ('mixed', 'Mixte (normal et cargo)'), ('rack', 'Râteau / Pince-roue'), ('box', 'Box / Abri sécurisé'), ('other', 'Autre')], default='normal', max_length=50, verbose_name="Type d'arceau principal")),
('has_scooter_drop_zone', models.BooleanField(default=False, verbose_name='Drop zone trottinettes / EDPM')),
('scooter_capacity', models.IntegerField(blank=True, null=True, verbose_name='Capacité estimée trottinettes')),
('capacity', models.IntegerField(blank=True, null=True, verbose_name='Capacité générale (nbr véhicules)')),
('is_paid', models.BooleanField(default=False, verbose_name='Payant')),
('time_limit_minutes', models.IntegerField(blank=True, null=True, verbose_name='Durée maximale (minutes)')),
('marking_quality', models.CharField(choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('damaged', 'Dégradé'), ('missing', 'Absent / Effacé')], default='good', max_length=50, verbose_name='Qualité du marquage au sol')),
('is_temporary', models.BooleanField(default=False, verbose_name='Temporaire (chantier/événement)')),
('is_compliant', models.BooleanField(default=True, verbose_name='Conforme aux spécifications')),
('non_compliance_reason', models.TextField(blank=True, null=True, verbose_name='Motif de non-conformité')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parking_spots', to='assets.parkinglocation', verbose_name='Zone / Localisation parente')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('panels', models.ManyToManyField(blank=True, related_name='parking_spots_attached_new', to='assets.signpanel', verbose_name='Panneaux de signalisation associés')),
('poles', models.ManyToManyField(blank=True, related_name='parking_spots_attached_new', to='assets.signpole', verbose_name='Poteaux de signalisation associés')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.parkingspot', verbose_name='Remplacé par')),
('street', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='parking_spots_new', to='assets.signstreet', verbose_name='Rue')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
],
options={
'verbose_name': 'Emplacement de stationnement',
'verbose_name_plural': 'Emplacements de stationnement',
'ordering': ['code', 'name_fr'],
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
]

View file

@ -1,254 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-07 08:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0008_assetinspection_parkinglocation_parkingspot'),
]
operations = [
migrations.RemoveField(
model_name='assetinspection',
name='verified_has_scooter_drop_zone',
),
migrations.RemoveField(
model_name='assetinspection',
name='verified_stand_type',
),
migrations.RemoveField(
model_name='assetinspection',
name='verified_marking_quality',
),
migrations.RemoveField(
model_name='assetinspection',
name='verified_cargo_stand_count',
),
migrations.RemoveField(
model_name='assetinspection',
name='verified_standard_stand_count',
),
migrations.AlterModelOptions(
name='assetinspection',
options={'ordering': ['-inspection_date'], 'verbose_name': "Inspection d'asset (Générique)", 'verbose_name_plural': "Inspections d'assets (Génériques)"},
),
migrations.AddField(
model_name='artworkfountain',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='artworkmonument',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='buildingasset',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='cleanglasscontainer',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='cleanlitterbin',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='controlcentergeoasset',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='itsgeoasset',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='naturebench',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='naturegreensurface',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='naturerwiasset',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='natureshrub',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='naturetree',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='outdoorasset',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='parkingspot',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='publiclightingelectricalcabinet',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='publiclightinglightfixture',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='publiclightingpole',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='roadconcreteblockset',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='roaddrain',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='roadfurniture',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='roadsidewalk',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='roadway',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='signpanel',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='signparkingspot',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='signpole',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='structuregeoasset',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightaccessory',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightcable',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightcontroller',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightcontrollerhardware',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightdetector',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightelectricalcabinet',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightelectricalcabinetcontent',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightlantern',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightnetworkhardware',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightpole',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightprogramming',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightradar',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightradarpole',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.AddField(
model_name='trafficlightvoltagehardware',
name='inspection_status',
field=models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection"),
),
migrations.CreateModel(
name='ParkingSpotInspection',
fields=[
('assetinspection_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='assets.assetinspection')),
('verified_standard_stand_count', models.IntegerField(blank=True, null=True, verbose_name="Nombre d'arceaux classiques constaté")),
('verified_cargo_stand_count', models.IntegerField(blank=True, null=True, verbose_name="Nombre d'arceaux cargo constaté")),
('verified_stand_type', models.CharField(blank=True, max_length=50, null=True, verbose_name="Type d'arceau constaté")),
('verified_has_scooter_drop_zone', models.BooleanField(blank=True, null=True, verbose_name='Drop zone trottinette constatée')),
('verified_marking_quality', models.CharField(blank=True, max_length=50, null=True, verbose_name='Qualité du marquage constatée')),
],
options={
'verbose_name': "Inspection d'emplacement de stationnement",
'verbose_name_plural': "Inspections d'emplacements de stationnement",
},
bases=('assets.assetinspection',),
),
]

View file

@ -1,74 +0,0 @@
from django.db import migrations
def create_parking_thematic(apps, schema_editor):
Thematic = apps.get_model('common', 'Thematic')
AssetCategory = apps.get_model('assets', 'AssetCategory')
ContentType = apps.get_model('contenttypes', 'ContentType')
thematic, _created = Thematic.objects.get_or_create(
code='parking',
defaults={
'name_fr': 'Stationnement',
'name_nl': 'Parkeren & Mobiliteit',
'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',
}
)
category, _cat_created = AssetCategory.objects.get_or_create(
code='parking_spot',
defaults={
'thematic': thematic,
'name_fr': 'Emplacements de stationnement',
'name_nl': 'Parkeerplaatsen',
}
)
if category.thematic_id != thematic.id:
category.thematic = thematic
category.save()
# Link ContentTypes
try:
spot_ct = ContentType.objects.get(app_label='assets', model='parkingspot')
category.allowed_models.add(spot_ct)
except Exception:
pass
try:
loc_ct = ContentType.objects.get(app_label='assets', model='parkinglocation')
category.allowed_models.add(loc_ct)
except Exception:
pass
# Assign category to existing ParkingSpot records
ParkingSpot = apps.get_model('assets', 'ParkingSpot')
ParkingSpot.objects.filter(category__isnull=True).update(category=category)
# Grant thematic permissions only to admin user configurations
UserConfig = apps.get_model('common', 'UserConfig')
UserThematics = apps.get_model('common', 'UserThematics')
for uc in UserConfig.objects.filter(roles__name='admin').distinct():
UserThematics.objects.get_or_create(
user_config=uc,
thematic=thematic,
defaults={'can_view_assets': True, 'can_edit_assets': True}
)
def remove_parking_thematic(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('assets', '0009_remove_assetinspection_verified_has_scooter_drop_zone_and_more'),
('common', '0001_initial'),
]
operations = [
migrations.RunPython(create_parking_thematic, remove_parking_thematic),
]

View file

@ -1,213 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-07 09:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0010_add_parking_thematic'),
]
operations = [
migrations.AddField(
model_name='artworkfountain',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='artworkmonument',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='buildingasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='cleanglasscontainer',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='cleanlitterbin',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='controlcentergeoasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='itsgeoasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='naturebench',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='naturegreensurface',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='naturerwiasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='natureshrub',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='naturetree',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='outdoorasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='parkingspot',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='publiclightingelectricalcabinet',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='publiclightinglightfixture',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='publiclightingpole',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='roadconcreteblockset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='roaddrain',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='roadfurniture',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='roadsidewalk',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='roadway',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='signpanel',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='signparkingspot',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='signpole',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='structuregeoasset',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightaccessory',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightcable',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightcontroller',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightcontrollerhardware',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightdetector',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightelectricalcabinet',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightelectricalcabinetcontent',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightlantern',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightnetworkhardware',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightpole',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightprogramming',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightradar',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightradarpole',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
migrations.AddField(
model_name='trafficlightvoltagehardware',
name='funding_program',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet'),
),
]

View file

@ -1,35 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-07 10:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0011_artworkfountain_funding_program_and_more'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.AddField(
model_name='assetinspection',
name='inspector_role',
field=models.CharField(choices=[('observer', 'Observer / Agent de terrain'), ('provider', 'Prestataire technique externe'), ('manager', 'Gestionnaire / Contrôleur interne')], default='observer', max_length=50, verbose_name="Rôle de l'inspecteur"),
),
migrations.AddField(
model_name='assetinspection',
name='nature',
field=models.CharField(choices=[('visual', 'Inspection visuelle'), ('technical', 'Inspection technique'), ('post_intervention', 'Suivi / Qualité post-intervention')], default='visual', max_length=50, verbose_name="Nature de l'inspection"),
),
migrations.AlterField(
model_name='assetinspection',
name='asset_content_type',
field=models.ForeignKey(limit_choices_to={'app_label__in': ['assets', 'interventions']}, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name="Type d'asset / Cible"),
),
migrations.AlterField(
model_name='assetinspection',
name='asset_object_id',
field=models.PositiveIntegerField(verbose_name='ID de la cible'),
),
]

View file

@ -1,33 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-07 15:40
import inspections.models
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0012_assetinspection_inspector_role_and_more'),
]
operations = [
migrations.AlterField(
model_name='parkingspotinspection',
name='verified_marking_quality',
field=models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('damaged', 'Dégradé'), ('missing', 'Absent / Effacé')], max_length=50, null=True, verbose_name='Qualité du marquage constatée'),
),
migrations.CreateModel(
name='InspectionDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to=inspections.models.inspection_document_upload_path, verbose_name='Fichier / Photo / Document')),
('created_at', models.DateTimeField(auto_now_add=True)),
('inspection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.assetinspection', verbose_name='Inspection')),
],
options={
'verbose_name': "Document / Photo d'inspection",
'verbose_name_plural': "Documents / Photos d'inspection",
},
),
]

View file

@ -1,20 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-07 15:46
import inspections.models
import common.storages
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0013_alter_parkingspotinspection_verified_marking_quality_and_more'),
]
operations = [
migrations.AlterField(
model_name='inspectiondocument',
name='file',
field=models.FileField(storage=common.storages.PrivateMediaStorage(), upload_to=inspections.models.inspection_document_upload_path, verbose_name='Fichier / Photo / Document'),
),
]

View file

@ -1,18 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-08 07:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0014_alter_inspectiondocument_file'),
]
operations = [
migrations.AddField(
model_name='parkingspotinspection',
name='apply_physical_counts_to_asset',
field=models.BooleanField(default=False, verbose_name="Mettre à jour la capacité / comptages de l'asset"),
),
]

View file

@ -1,39 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-10 08:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0015_parkingspotinspection_apply_physical_counts_to_asset'),
]
operations = [
migrations.RemoveField(
model_name='assetinspection',
name='asset_content_type',
),
migrations.RemoveField(
model_name='assetinspection',
name='inspector',
),
migrations.RemoveField(
model_name='inspectiondocument',
name='inspection',
),
migrations.AddField(
model_name='parkingspot',
name='drop_off_stand_count',
field=models.IntegerField(default=0, verbose_name="Nombre d'arceaux drop-off"),
),
migrations.DeleteModel(
name='ParkingSpotInspection',
),
migrations.DeleteModel(
name='AssetInspection',
),
migrations.DeleteModel(
name='InspectionDocument',
),
]

View file

@ -1,59 +0,0 @@
from django.db import migrations
def create_bike_parking_category(apps, schema_editor):
Thematic = apps.get_model('common', 'Thematic')
AssetCategory = apps.get_model('assets', 'AssetCategory')
ContentType = apps.get_model('contenttypes', 'ContentType')
try:
thematic = Thematic.objects.get(code='parking')
except Thematic.DoesNotExist:
# Fallback if thematic doesn't exist yet
thematic, _ = Thematic.objects.get_or_create(
code='parking',
defaults={
'name_fr': 'Stationnement',
'name_nl': 'Parkeren & Mobiliteit',
'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',
}
)
category, _cat_created = AssetCategory.objects.get_or_create(
code='bike_parking_spot',
defaults={
'thematic': thematic,
'name_fr': 'Emplacement vélos',
'name_nl': 'Fietsenstalling',
}
)
# Link ContentTypes
try:
spot_ct = ContentType.objects.get(app_label='assets', model='parkingspot')
category.allowed_models.add(spot_ct)
except Exception:
pass
try:
loc_ct = ContentType.objects.get(app_label='assets', model='parkinglocation')
category.allowed_models.add(loc_ct)
except Exception:
pass
def remove_bike_parking_category(apps, schema_editor):
AssetCategory = apps.get_model('assets', 'AssetCategory')
AssetCategory.objects.filter(code='bike_parking_spot').delete()
class Migration(migrations.Migration):
dependencies = [
('assets', '0016_remove_assetinspection_asset_content_type_and_more'),
]
operations = [
migrations.RunPython(create_bike_parking_category, remove_bike_parking_category),
]

View file

@ -1,17 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-10 09:37
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('assets', '0017_add_bike_parking_category'),
]
operations = [
migrations.RemoveField(
model_name='parkingspot',
name='scooter_capacity',
),
]

View file

@ -1,29 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-11 12:13
import django.db.models.deletion
from django.db import migrations, models
def populate_locations(apps, schema_editor):
NatureRWIZ = apps.get_model('assets', 'NatureRWIZ')
NatureRWIAsset = apps.get_model('assets', 'NatureRWIAsset')
zones = NatureRWIZ.objects.filter(geom__isnull=False)
for zone in zones:
NatureRWIAsset.objects.filter(geom__intersects=zone.geom, location__isnull=True).update(location=zone)
class Migration(migrations.Migration):
dependencies = [
('assets', '0018_remove_parkingspot_scooter_capacity'),
]
operations = [
migrations.AddField(
model_name='naturerwiasset',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assets', to='assets.naturerwiz', verbose_name='Zone GIEP / ZIEP'),
),
migrations.RunPython(populate_locations, reverse_code=migrations.RunPython.noop),
]

View file

@ -1,34 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-17 07:23
import django.contrib.gis.db.models.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0019_naturerwiasset_location'),
]
operations = [
migrations.AddField(
model_name='trafficlightlantern',
name='geojson',
field=models.TextField(blank=True, null=True),
),
migrations.AddField(
model_name='trafficlightlantern',
name='geom',
field=django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=3812),
),
migrations.AddField(
model_name='trafficlightlantern',
name='lat',
field=models.FloatField(blank=True, null=True),
),
migrations.AddField(
model_name='trafficlightlantern',
name='lon',
field=models.FloatField(blank=True, null=True),
),
]

View file

@ -1,21 +0,0 @@
from django.db import migrations
from django.core.management import call_command
def sync_parent_data(apps, schema_editor):
try:
call_command('sync_asset_parent_data')
except Exception as e:
print(f"Warning during sync_asset_parent_data migration: {e}")
class Migration(migrations.Migration):
atomic = False
dependencies = [
('assets', '0020_trafficlightlantern_geojson_trafficlightlantern_geom_and_more'),
]
operations = [
migrations.RunPython(sync_parent_data, migrations.RunPython.noop),
]

View file

@ -1,22 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-19 09:20
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0021_sync_asset_parent_data'),
]
operations = [
migrations.AlterModelOptions(
name='trafficlightprogramming',
options={'ordering': ['-commissioning_date'], 'verbose_name': 'Programmation', 'verbose_name_plural': 'Programmations'},
),
migrations.AlterField(
model_name='trafficlightprogramming',
name='index',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Index'),
),
]

View file

@ -1,52 +0,0 @@
from django.db import migrations
def create_programming_category(apps, schema_editor):
Thematic = apps.get_model('common', 'Thematic')
AssetCategory = apps.get_model('assets', 'AssetCategory')
ContentType = apps.get_model('contenttypes', 'ContentType')
try:
thematic = Thematic.objects.get(code='trafficlights')
except Thematic.DoesNotExist:
thematic, _ = Thematic.objects.get_or_create(
code='trafficlights',
defaults={
'name_fr': 'Feux de signalisation',
'name_nl': 'Verkeerslichten',
'description': 'Gestion des carrefours à feux et équipements associés',
'icon': 'bi-stoplights',
'color': '#ffc107',
}
)
category, _cat_created = AssetCategory.objects.get_or_create(
code='TL_PROGRAMMING',
defaults={
'thematic': thematic,
'name_fr': 'Programmation',
'name_nl': 'Programmatie',
}
)
# Link ContentType for TrafficLightProgramming
try:
prog_ct = ContentType.objects.get(app_label='assets', model='trafficlightprogramming')
category.allowed_models.add(prog_ct)
except Exception:
pass
def remove_programming_category(apps, schema_editor):
AssetCategory = apps.get_model('assets', 'AssetCategory')
AssetCategory.objects.filter(code='TL_PROGRAMMING').delete()
class Migration(migrations.Migration):
dependencies = [
('assets', '0022_alter_trafficlightprogramming_options_and_more'),
]
operations = [
migrations.RunPython(create_programming_category, remove_programming_category),
]

View file

@ -1,75 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-21 08:20
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0023_add_trafficlight_programming_category'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='StructureExceptionalTransport',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('fod_vnr', models.IntegerField(blank=True, help_text='Volgnummer pont dans la base fédérale', null=True, verbose_name="Numéro d'ordre FOD/SPF (VNR)")),
('national_id', models.CharField(blank=True, max_length=50, null=True, verbose_name='Identifiant national (IDN)')),
('bridge_class', models.CharField(blank=True, help_text='Ex: T180152402024030, D18015..., T09015', max_length=100, null=True, verbose_name='Classe de charge du pont (BKL)')),
('is_railway_metro_crossing', models.BooleanField(default=False, help_text="Indique si l'ouvrage surplombe ou supporte des voies ferrées/métro/tram", verbose_name='Ouvrage ferroviaire / métro / tram (Spoorlijn)')),
('number_of_spans', models.PositiveSmallIntegerField(blank=True, null=True, verbose_name='Nombre de travées (OVS)')),
('max_authorized_weight_tonnes', models.DecimalField(blank=True, decimal_places=2, help_text='Poids maximal standard sans étude particulière (ex: 44 t)', max_digits=6, null=True, verbose_name='Poids max. autorisé standard (MAXWA) [t]')),
('min_guaranteed_capacity_tonnes', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='Capacité minimale garantie (MINWR) [t]')),
('max_calculated_weight_tonnes', models.DecimalField(blank=True, decimal_places=2, help_text="Charge maximale absolue évaluée pour l'ouvrage (ex: 140t, 265t, 450t)", max_digits=6, null=True, verbose_name='Tonnage maximal admissible calculé (MX) [t]')),
('max_axle_load_tonnes', models.DecimalField(blank=True, decimal_places=2, help_text='Ex: 12.0, 12.8, 13.0 t/essieu', max_digits=5, null=True, verbose_name='Charge max. par essieu [t/essieu]')),
('min_axle_spacing_m', models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True, verbose_name='Entraxe minimal entre essieux [m]')),
('clearance_height_left_m', models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True, verbose_name='Hauteur libre gauche / entrée (H1) [m]')),
('clearance_height_center_m', models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True, verbose_name="Hauteur libre dans l'axe (H2) [m]")),
('clearance_height_right_m', models.DecimalField(blank=True, decimal_places=2, max_digits=4, null=True, verbose_name='Hauteur libre droite / sortie (H3) [m]')),
('usable_width_m', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='Largeur utile roulable (BRE) [m]')),
('passage_status', models.CharField(choices=[('authorized', 'Autorisé sans condition particulière'), ('conditional', 'Autorisé sous conditions / prescriptions'), ('prior_study_required', 'Étude / calcul préalable obligatoire'), ('forbidden', 'Strictement interdit'), ('unknown', 'Non évalué')], default='unknown', max_length=30, verbose_name="Statut d'admissibilité TE")),
('max_speed_kmh', models.PositiveIntegerField(blank=True, default=5, help_text='Généralement 5 km/h pour supprimer les effets dynamiques', null=True, verbose_name='Vitesse maximale de franchissement (km/h)')),
('is_braking_forbidden', models.BooleanField(default=False, help_text='Notamment sur les dalles de toit de métro', verbose_name="Interdiction formelle de freiner sur l'ouvrage")),
('is_isolated_crossing_only', models.BooleanField(default=True, help_text="Aucune autre charge mobile simultanée autorisée sur l'ouvrage", verbose_name="Convoi seul sur l'ouvrage")),
('safety_distance_m', models.PositiveIntegerField(blank=True, default=5, help_text='Distance minimale avec tout autre véhicule (souvent 5m)', null=True, verbose_name='Distance de sécurité / zone tampon (m)')),
('traffic_interruption_required', models.BooleanField(default=False, verbose_name='Interruption de circulation requise pendant le passage')),
('lane_positioning', models.CharField(choices=[('standard', 'Voie de circulation normale'), ('center_axis', "Dans l'axe de l'ouvrage / ligne médiane"), ('first_lane', 'Première bande de circulation'), ('tram_tracks', 'Sur les voies de tram'), ('specific_path', 'Trajectoire spécifique (voir prescriptions)')], default='standard', max_length=30, verbose_name='Positionnement sur la chaussée')),
('is_escort_required', models.BooleanField(default=False, verbose_name='Escorte policière ou accompagnement requis')),
('prescriptions_fr', models.TextField(blank=True, null=True, verbose_name='Prescriptions techniques (FR)')),
('prescriptions_nl', models.TextField(blank=True, null=True, verbose_name='Prescriptions techniques (NL)')),
('last_study_date', models.DateField(blank=True, null=True, verbose_name='Date de la dernière étude / note de calcul')),
('last_study_ref', models.CharField(blank=True, max_length=150, null=True, verbose_name='Référence de la dernière note de calcul / Mission')),
('updated_at', models.DateTimeField(auto_now=True)),
('structure', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='exceptional_transport_info', to='assets.structure', verbose_name='Ouvrage associé')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_structure_te_infos', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'Transport exceptionnel (Ouvrage)',
'verbose_name_plural': 'Transports exceptionnels (Ouvrages)',
},
),
migrations.CreateModel(
name='StructureExceptionalTransportNotice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('mission_reference', models.CharField(help_text='Ex: Mission 64, Avis DITP 468830', max_length=100, verbose_name='Référence mission / Avis DITP')),
('notice_date', models.DateField(blank=True, null=True, verbose_name="Date de l'avis")),
('result', models.CharField(choices=[('positive', 'Favorable / Positif'), ('conditional', 'Favorable sous conditions'), ('negative', 'Défavorable / Négatif')], default='positive', max_length=20, verbose_name='Résultat')),
('evaluated_total_weight_tonnes', models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, verbose_name='Tonnage évalué [t]')),
('evaluated_axle_load_tonnes', models.DecimalField(blank=True, decimal_places=2, max_digits=5, null=True, verbose_name='Charge par essieu évaluée [t/essieu]')),
('comments', models.TextField(blank=True, verbose_name="Conditions / Remarques de l'avis")),
('created_at', models.DateTimeField(auto_now_add=True)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
('structure', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exceptional_transport_notices', to='assets.structure', verbose_name='Ouvrage')),
],
options={
'verbose_name': "Avis / Mission d'étude TE",
'verbose_name_plural': "Avis / Missions d'études TE",
'ordering': ['-notice_date', '-created_at'],
},
),
]

View file

@ -1,213 +0,0 @@
# Generated by Django 6.0.7 on 2026-08-26 13:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0024_structureexceptionaltransport_and_more'),
]
operations = [
migrations.AddField(
model_name='artworkfountain',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='artworkmonument',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='buildingasset',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='cleanglasscontainer',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='cleanlitterbin',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='controlcentergeoasset',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='itsgeoasset',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='naturebench',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='naturegreensurface',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='naturerwiasset',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='natureshrub',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='naturetree',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='outdoorasset',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='parkingspot',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='publiclightingelectricalcabinet',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='publiclightinglightfixture',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='publiclightingpole',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='roadconcreteblockset',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='roaddrain',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='roadfurniture',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='roadsidewalk',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='roadway',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='signpanel',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='signparkingspot',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='signpole',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='structuregeoasset',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightaccessory',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightcable',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightcontroller',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightcontrollerhardware',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightdetector',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightelectricalcabinet',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightelectricalcabinetcontent',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightlantern',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightnetworkhardware',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightpole',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightprogramming',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightradar',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightradarpole',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
migrations.AddField(
model_name='trafficlightvoltagehardware',
name='external_reference',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe'),
),
]

View file

@ -1,137 +0,0 @@
# Generated by Django 6.0.7 on 2026-09-12 12:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0025_artworkfountain_external_reference_and_more'),
]
operations = [
migrations.AlterModelOptions(
name='naturetree',
options={'verbose_name': 'Arbre', 'verbose_name_plural': 'Arbres'},
),
migrations.AddField(
model_name='natureassetmodel',
name='foliage_type',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Type de feuillage'),
),
migrations.AddField(
model_name='natureassetmodel',
name='scientific_name',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Nom scientifique'),
),
migrations.AddField(
model_name='naturetree',
name='circumference',
field=models.FloatField(blank=True, null=True, verbose_name='Circonférence à 1m30 (cm)'),
),
migrations.AddField(
model_name='naturetree',
name='crown_clearance',
field=models.FloatField(blank=True, null=True, verbose_name='Hauteur sous couronne (m)'),
),
migrations.AddField(
model_name='naturetree',
name='crown_diameter',
field=models.FloatField(blank=True, null=True, verbose_name='Diamètre du houppier (m)'),
),
migrations.AddField(
model_name='naturetree',
name='development_stage',
field=models.CharField(blank=True, choices=[('young', 'Jeune plant'), ('semi_mature', 'Semi-mature / En croissance'), ('mature', 'Adulte / Mature'), ('senescent', 'Sénescent / Dépérissant'), ('remarkable', 'Vénérable / Remarquable')], default='mature', max_length=50, null=True, verbose_name='Stade de développement'),
),
migrations.AddField(
model_name='naturetree',
name='health_defects',
field=models.TextField(blank=True, null=True, verbose_name='Défauts constatés & pathologies'),
),
migrations.AddField(
model_name='naturetree',
name='implantation_context',
field=models.CharField(blank=True, choices=[('street', 'Alignement de voirie'), ('park', 'Parc / Espace vert'), ('square', 'Square / Place publique'), ('roundabout', 'Rond-point / Îlot central'), ('cemetery', 'Cimetière'), ('isolated', 'Isolé / Autre')], default='street', max_length=50, null=True, verbose_name="Contexte d'implantation"),
),
migrations.AddField(
model_name='naturetree',
name='is_remarkable',
field=models.BooleanField(default=False, verbose_name='Arbre remarquable / classé'),
),
migrations.AddField(
model_name='naturetree',
name='last_phytosanitary_date',
field=models.DateField(blank=True, null=True, verbose_name='Dernier diagnostic phytosanitaire'),
),
migrations.AddField(
model_name='naturetree',
name='last_pruning_date',
field=models.DateField(blank=True, null=True, verbose_name='Dernière taille / élagage'),
),
migrations.AddField(
model_name='naturetree',
name='mechanical_state',
field=models.CharField(blank=True, choices=[('good', 'Bon (aucun défaut majeur)'), ('fair', 'Moyen (défauts mineurs)'), ('cavity', 'Présence de cavité(s)'), ('crack', 'Fissure(s) / Écorce incluse'), ('dead_branches', 'Branches mortes importantes'), ('critical', 'Critique (risque de rupture)')], default='good', max_length=50, null=True, verbose_name='État mécanique'),
),
migrations.AddField(
model_name='naturetree',
name='next_inspection_date',
field=models.DateField(blank=True, null=True, verbose_name='Prochaine inspection préconisée'),
),
migrations.AddField(
model_name='naturetree',
name='pit_type',
field=models.CharField(blank=True, choices=[('open_ground', 'Pleine terre / Engazonné'), ('vegetated', 'Pied végétalisé / Fleuri'), ('metal_grate', "Grille d'arbre métallique"), ('permeable_paving', 'Revêtement drainant / Pavés poreux'), ('compacted_soil', 'Sol compacté / Bitume')], default='open_ground', max_length=50, null=True, verbose_name='Type de pied / Fosse'),
),
migrations.AddField(
model_name='naturetree',
name='protection_type',
field=models.CharField(blank=True, choices=[('none', 'Aucun (affranchi)'), ('staking', 'Tuteur (simple / bipode / tripode)'), ('corset', 'Corset métallique de protection'), ('guying', 'Haubanage'), ('trunk_protector', 'Gaine de protection du tronc')], default='none', max_length=50, null=True, verbose_name='Protection / Tuteurage'),
),
migrations.AddField(
model_name='naturetree',
name='pruning_frequency_years',
field=models.IntegerField(blank=True, null=True, verbose_name='Périodicité de taille (années)'),
),
migrations.AddField(
model_name='naturetree',
name='pruning_type',
field=models.CharField(blank=True, choices=[('free_crown', 'Port libre'), ('semi_free', 'Port semi-libre'), ('architectured_head', 'Taille architecturée (tête de chat / marotte)'), ('architectured_curtain', 'Taille en rideau / tonnelle'), ('formation', 'Taille de formation'), ('none', 'Non taillé / Entretien minimal')], default='free_crown', max_length=50, null=True, verbose_name='Type de conduite / Taille'),
),
migrations.AddField(
model_name='naturetree',
name='risk_level',
field=models.CharField(blank=True, choices=[('low', 'Faible'), ('moderate', 'Modéré'), ('high', 'Élevé'), ('imminent_danger', 'Danger immédiat')], default='low', max_length=50, null=True, verbose_name='Niveau de risque'),
),
migrations.AddField(
model_name='naturetree',
name='tag_number',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name="N° de plaquette / bague d'inventaire"),
),
migrations.AddField(
model_name='naturetree',
name='urban_constraints',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Contraintes urbaines'),
),
migrations.AddField(
model_name='naturetree',
name='vitality',
field=models.CharField(blank=True, choices=[('strong', 'Forte (très vigoureux)'), ('normal', 'Normale'), ('weak', 'Faible (stress / manque de vigueur)'), ('declining', 'Dépérissant'), ('dead', 'Mort')], default='normal', max_length=50, null=True, verbose_name='Vigueur / Vitalité'),
),
migrations.AlterField(
model_name='naturetree',
name='depth',
field=models.FloatField(blank=True, null=True, verbose_name='Profondeur de fosse (m)'),
),
migrations.AlterField(
model_name='naturetree',
name='height',
field=models.FloatField(blank=True, null=True, verbose_name='Hauteur totale (m)'),
),
migrations.AlterField(
model_name='naturetree',
name='protection_buffer',
field=models.FloatField(blank=True, null=True, verbose_name='Rayon de protection racinaire (m)'),
),
]

View file

@ -1,18 +0,0 @@
# 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),
),
]

View file

@ -1,118 +0,0 @@
# Generated by Django 6.0.7 on 2026-10-05 07:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0027_alter_structure_structure_type'),
]
operations = [
migrations.AddField(
model_name='buildingasset',
name='contractor_name',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Prestataire / Mainteneur'),
),
migrations.AddField(
model_name='buildingasset',
name='dimensions',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Dimensions / Format'),
),
migrations.AddField(
model_name='buildingasset',
name='energy_source',
field=models.CharField(blank=True, choices=[('natural_gas', 'Gaz naturel'), ('electricity', 'Électricité'), ('fuel_oil', 'Fioul domestique'), ('biomass_wood', 'Biomasse / Bois / Pellets'), ('heat_network', 'Réseau de chaleur urbain'), ('heat_pump_air', 'Aérothermie (PAC air-eau / air-air)'), ('heat_pump_geo', 'Géothermie (PAC eau-eau / sol-eau)'), ('solar_thermal', 'Solaire thermique'), ('other', 'Autre source')], max_length=50, null=True, verbose_name="Source d'énergie"),
),
migrations.AddField(
model_name='buildingasset',
name='fire_rating',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Classement au feu / Coupe-feu'),
),
migrations.AddField(
model_name='buildingasset',
name='glazing_type',
field=models.CharField(blank=True, choices=[('single', 'Simple vitrage'), ('double_standard', 'Double vitrage standard'), ('double_low_e', 'Double vitrage ITR / Haute isolation'), ('triple', 'Triple vitrage'), ('laminated_safety', 'Vitrage feuilleté de sécurité'), ('other', 'Autre vitrage')], max_length=50, null=True, verbose_name='Type de vitrage'),
),
migrations.AddField(
model_name='buildingasset',
name='maintenance_contract_ref',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Réf. contrat maintenance'),
),
migrations.AddField(
model_name='buildingasset',
name='material',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Matériau principal'),
),
migrations.AddField(
model_name='buildingasset',
name='opening_type',
field=models.CharField(blank=True, choices=[('hinged', 'Battant / À la française'), ('sliding', 'Coulissant'), ('tilt_and_turn', 'Oscillo-battant'), ('double_action', 'Va-et-vient'), ('sectional_overhead', 'Sectionnel / Basculant'), ('motorized_automatic', 'Automatique / Motorisé'), ('fixed', 'Châssis fixe / Non ouvrant'), ('other', 'Autre type')], max_length=50, null=True, verbose_name="Type d'ouverture"),
),
migrations.AddField(
model_name='buildingasset',
name='power_kw',
field=models.FloatField(blank=True, null=True, verbose_name='Puissance nominale (kW)'),
),
migrations.AddField(
model_name='buildingasset',
name='refrigerant_charge_kg',
field=models.FloatField(blank=True, null=True, verbose_name='Charge fluide frigorigène (kg)'),
),
migrations.AddField(
model_name='buildingasset',
name='refrigerant_type',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Fluide frigorigène'),
),
migrations.AddField(
model_name='buildingasset',
name='regulatory_periodicity_months',
field=models.IntegerField(blank=True, null=True, verbose_name='Périodicité contrôle réglementaire (mois)'),
),
migrations.AddField(
model_name='buildingasset',
name='replacement_cost_estimate',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=12, null=True, verbose_name='Coût estimé de renouvellement (€)'),
),
migrations.AddField(
model_name='buildingasset',
name='surface_m2',
field=models.FloatField(blank=True, null=True, verbose_name='Surface unitaire (m²)'),
),
migrations.AddField(
model_name='publicbuilding',
name='ceiling_height_m',
field=models.FloatField(blank=True, null=True, verbose_name='Hauteur sous plafond (m)'),
),
migrations.AddField(
model_name='publicbuilding',
name='fire_compartment',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Secteur / Compartiment coupe-feu'),
),
migrations.AddField(
model_name='publicbuilding',
name='floor_type',
field=models.CharField(blank=True, choices=[('tiles', 'Carrelage / Faïence'), ('parquet', 'Parquet / Bois'), ('linoleum_pvc', 'Linoléum / Vinyle / PVC'), ('concrete', 'Béton lissé / Chape / Résine'), ('carpet', 'Moquette / Textile'), ('other', 'Autre revêtement')], max_length=50, null=True, verbose_name='Revêtement de sol dominant'),
),
migrations.AddField(
model_name='publicbuilding',
name='is_heated',
field=models.BooleanField(default=True, verbose_name='Local chauffé'),
),
migrations.AddField(
model_name='publicbuilding',
name='room_number',
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='Numéro de local / porte'),
),
migrations.AlterField(
model_name='buildingasset',
name='asset_type',
field=models.CharField(choices=[('roof', 'Toiture / Couverture / Étanchéité'), ('roof_drainage', 'Zinguerie & Évacuation EP (Gouttière, chéneau)'), ('facade', 'Façade / Bardage / Mur extérieur'), ('window', 'Fenêtre / Baie vitrée / Châssis'), ('skylight', 'Lanterneau / Verrière / Puits de lumière'), ('external_door', "Porte extérieure / Porte d'entrée / Portail"), ('shutter_blind', 'Volet / Store / Brise-soleil (BSO)'), ('insulation', 'Isolation thermique (ITE / ITI)'), ('wall', 'Mur intérieur / Cloison'), ('interior_door', 'Porte intérieure / Bloc-porte'), ('fire_door', 'Porte coupe-feu / Bloc pare-flamme'), ('ceiling', 'Faux-plafond / Plafond suspendu'), ('flooring', 'Revêtement de sol'), ('stairs', 'Escalier / Garde-corps / Main courante'), ('boiler', 'Chaudière (Gaz / Fioul / Biomasse / Pellets)'), ('heat_pump', 'Pompe à chaleur (PAC) / Climatisation réversible'), ('heat_substation', "Sous-station d'échangeur thermique / Réseau urbain"), ('radiator_emitter', 'Émetteur de chaleur (Radiateur, convecteur, plancher)'), ('air_handling_unit', "Centrale de traitement d'air (CTA) / VMC"), ('air_conditioning', 'Climatisation / Groupe froid / Chiller / VRV'), ('water_heater', 'Production Eau Chaude Sanitaire (Ballon ECS)'), ('hvac', 'Chauffage / CVC / Régulation générale'), ('electricity', 'Électricité / TGBT / Tableau divisionnaire'), ('inverter_generator', 'Onduleur / Groupe électrogène de secours'), ('lighting', 'Éclairage intérieur / Luminaires'), ('emergency_lighting', 'Éclairage de sécurité / BAES / BAEH'), ('meter', 'Compteur (Eau / Gaz / Électricité / Calories)'), ('solar_panel', 'Panneaux solaires (Photovoltaïque / Thermique)'), ('ev_charging', 'Borne de recharge véhicule électrique'), ('sanitary', 'Plomberie / Sanitaires (WC, lavabo, douche)'), ('plumbing_piping', "Réseau d'eau / Vanne d'arrêt / Disconnecteur"), ('sump_pump', 'Pompe de relevage (Eaux usées / Eaux pluviales)'), ('rainwater_harvesting', "Récupération d'eau de pluie / Cuve de stockage"), ('fire_safety', 'Sécurité incendie / Centrale SSI / CMSI'), ('smoke_detector', 'Détecteur de fumée / Déclencheur manuel (DM)'), ('fire_extinguisher', "Extincteur / Robinet d'Incendie Armé (RIA)"), ('smoke_exhaust', 'Désenfumage / Trappe / Skydome'), ('access_control', "Contrôle d'accès (Badge, interphone, gâche)"), ('alarm', 'Alarme anti-intrusion / Détecteur de présence'), ('cctv_surveillance', 'Vidéosurveillance / Caméra CCTV'), ('elevator', 'Ascenseur / Monte-charge'), ('disabled_lift', 'Élévateur PMR / Plateforme élévatrice'), ('network_rack', 'Baie de brassage / Rack informatique / RJ45'), ('bms_system', 'Gestion Technique du Bâtiment (GTB / GTC)'), ('civil_engineering', 'Génie civil / Bâti / Gros œuvre'), ('other', 'Autre équipement / asset')], default='civil_engineering', max_length=50, verbose_name="Type d'asset / Équipement"),
),
migrations.AlterField(
model_name='publicbuilding',
name='location_type',
field=models.CharField(choices=[('site', 'Site / Complexe'), ('building', 'Bâtiment / Bloc'), ('wing', 'Aile / Corps de bâtiment'), ('level', 'Niveau / Étage'), ('basement', 'Sous-sol / Cave'), ('attic', 'Combles / Grenier / Toiture-terrasse'), ('mezzanine', 'Mezzanine'), ('stairwell', "Cage d'escalier"), ('corridor', 'Couloir / Coursive / Dégagement'), ('lobby', "Hall d'accueil / Entrée / Sas"), ('emergency_exit', "Issue de secours / Sas d'évacuation"), ('elevator_shaft', "Gaine / Palier d'ascenseur"), ('technical_room', 'Local technique général'), ('boiler_room', 'Chaufferie / Sous-station thermique'), ('electrical_room', 'Local TGBT / Électrique'), ('ventilation_room', 'Local ventilation / CTA'), ('elevator_machine_room', 'Local machinerie ascenseur'), ('server_room', 'Salle serveurs / Baie informatique'), ('room', 'Pièce / Local polyvalent'), ('office', 'Bureau / Espace de travail'), ('meeting_room', 'Salle de réunion / Conférence'), ('classroom', 'Salle de classe / Formation'), ('workshop', 'Atelier / Laboratoire'), ('sanitary_room', 'Sanitaires / Vestiaires / Douches'), ('storage_room', 'Réserve / Stockage / Archives'), ('canteen_kitchen', 'Cuisine / Réfectoire / Restauration'), ('cleaning_room', 'Local ménage / Entretien'), ('waste_room', 'Local poubelles / Déchets'), ('indoor_parking', 'Parking couvert / Garage'), ('outdoor_area', 'Abords / Cour / Parvis / Terrasse'), ('other', 'Autre localisation')], default='building', max_length=50, verbose_name='Type de localisation'),
),
]

View file

@ -1,24 +0,0 @@
# Generated by Django 6.0.7 on 2026-10-05 08:25
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0028_buildingasset_contractor_name_and_more'),
]
operations = [
migrations.AddField(
model_name='publicbuildingdocument',
name='floor_location',
field=models.ForeignKey(blank=True, help_text='Niveau ou local auquel ce document/plan se rapporte.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='floor_documents', to='assets.publicbuilding', verbose_name='Niveau / Étage concerné'),
),
migrations.AlterField(
model_name='publicbuildingdocument',
name='document_type',
field=models.CharField(choices=[('floor_plan', "Plan d'étage / Plan architectural"), ('as_built', 'Plan de récolement (As-Built)'), ('erp_notice', 'Notice de sécurité / Dossier ERP'), ('dpe', 'Diagnostic énergétique (DPE / PEB)'), ('technical_notice', "Notice technique / Manuel d'exploitation"), ('other', 'Autre document')], default='other', max_length=50, verbose_name='Type de document'),
),
]

View file

@ -1,102 +0,0 @@
# Generated by Django 6.0.7 on 2026-10-05 13:06
import common.storages
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0029_publicbuildingdocument_floor_location_and_more'),
('common', '0017_territory'),
('contenttypes', '0002_remove_content_type_name'),
('documents', '0004_alter_documentfolder_visibility_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ManagementZone',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=100, unique=True, verbose_name='Code')),
('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')),
('name_nl', models.CharField(blank=True, max_length=255, null=True, verbose_name='Nom (NL)')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('instructions', models.TextField(blank=True, help_text="Consignes d'exploitation, points de vigilance, règles de sécurité...", null=True, verbose_name="Consignes et points d'attention")),
('geom', django.contrib.gis.db.models.fields.MultiPolygonField(blank=True, null=True, srid=3812, verbose_name='Périmètre géographique')),
('color', models.CharField(blank=True, help_text='Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone.', max_length=20, null=True, verbose_name="Couleur d'affichage")),
('is_active', models.BooleanField(default=True, verbose_name='Active')),
('target_frequency_days', models.IntegerField(blank=True, help_text='Intervalle indicatif entre interventions en jours.', null=True, verbose_name='Fréquence cible (jours)')),
('seasonality_notes', models.CharField(blank=True, help_text="Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps.", max_length=255, null=True, verbose_name='Saisonnalité / Calendrier')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Date de création')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Date de mise à jour')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_management_zones', to=settings.AUTH_USER_MODEL, verbose_name='Créé par')),
('thematic', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='management_zones', to='common.thematic', verbose_name='Thématique')),
],
options={
'verbose_name': 'Zone de gestion',
'verbose_name_plural': 'Zones de gestion',
'ordering': ['thematic', 'code'],
},
),
migrations.CreateModel(
name='ManagementZoneDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('description', models.TextField(blank=True, null=True, verbose_name='Description du document')),
('upload_date', models.DateTimeField(auto_now=True, verbose_name='Date de téléversement')),
('status', models.CharField(choices=[('active', 'Actif'), ('archived', 'Archivé')], default='active')),
('file', models.FileField(max_length=255, storage=common.storages.PrivateMediaStorage(), upload_to='assets_documents/management_zones/', verbose_name='Fichier')),
('thumbnail', models.ImageField(blank=True, max_length=255, null=True, storage=common.storages.PrivateMediaStorage(), upload_to='assets_documents/management_zones/')),
('managed_document', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_sources', to='documents.manageddocument')),
('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par')),
('zone', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.managementzone', verbose_name='Zone de gestion')),
],
options={
'verbose_name': 'Document de zone de gestion',
'verbose_name_plural': 'Documents de zones de gestion',
},
),
migrations.CreateModel(
name='ManagementZoneType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=100, unique=True, verbose_name='Code')),
('name_fr', models.CharField(max_length=255, verbose_name='Nom (FR)')),
('name_nl', models.CharField(blank=True, max_length=255, null=True, verbose_name='Nom (NL)')),
('description', models.TextField(blank=True, null=True, verbose_name='Description')),
('default_color', models.CharField(default='#198754', max_length=20, verbose_name='Couleur par défaut')),
('thematic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='management_zone_types', to='common.thematic', verbose_name='Thématique')),
],
options={
'verbose_name': 'Type de zone de gestion',
'verbose_name_plural': 'Types de zones de gestion',
'ordering': ['thematic', 'name_fr'],
},
),
migrations.AddField(
model_name='managementzone',
name='zone_type',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='management_zones', to='assets.managementzonetype', verbose_name='Type de zone de gestion'),
),
migrations.CreateModel(
name='ManagementZoneAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('object_id', models.PositiveIntegerField(verbose_name="ID de l'asset")),
('is_excluded', models.BooleanField(default=False, help_text='Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre.', verbose_name='Exclu')),
('notes', models.CharField(blank=True, max_length=255, null=True, verbose_name='Notes')),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name="Type d'asset")),
('management_zone', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='explicit_assets', to='assets.managementzone', verbose_name='Zone de gestion')),
],
options={
'verbose_name': 'Asset de zone de gestion',
'verbose_name_plural': 'Assets de zones de gestion',
'unique_together': {('management_zone', 'content_type', 'object_id')},
},
),
]

View file

@ -1,160 +0,0 @@
# Generated by Django 6.0.7 on 2026-10-08 10:19
import assets.models.core
import django.contrib.gis.db.models.fields
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0030_managementzone_managementzonedocument_and_more'),
('contracts', '0014_contract_color_contract_geom'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='itsgeoasset',
name='extra_data',
field=models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques'),
),
migrations.CreateModel(
name='ITSBollardSystem',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('external_reference', models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe')),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('inspection_status', models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection")),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('funding_program', models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.LineStringField(blank=True, null=True, srid=3812, verbose_name='Géométrie (Ligne)')),
('width', models.FloatField(blank=True, null=True, verbose_name='Largeur de passage (m)')),
('direction', models.CharField(blank=True, choices=[('unidirectional', 'Sens unique'), ('bidirectional', 'Double sens')], default='bidirectional', max_length=50, null=True, verbose_name='Sens de circulation')),
('extra_data', models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bollard_systems', to='assets.itslocation', verbose_name='Localisation ITS')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='assets.itsassetmodel', verbose_name='Modèle de système')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.itsbollardsystem', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
],
options={
'verbose_name': 'Système de bornes',
'verbose_name_plural': 'Systèmes de bornes',
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
migrations.CreateModel(
name='ITSBollardSystemAsset',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(choices=[('camera_entry', 'Caméra entrée (LAPI)'), ('camera_exit', 'Caméra sortie (LAPI)'), ('camera_cctv', 'Caméra de contexte / surveillance'), ('cabinet', 'Armoire électrique / automate'), ('totem', 'Totem de commande'), ('traffic_light', 'Feu de franchissement'), ('loop', 'Boucle de détection'), ('other', 'Autre')], default='other', max_length=50, verbose_name="Rôle de l'équipement")),
('comment', models.CharField(blank=True, max_length=255, null=True, verbose_name='Commentaire')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name="Date d'association")),
('asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_system_links', to='assets.itsgeoasset', verbose_name='Équipement ITS')),
('bollard_system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='system_assets', to='assets.itsbollardsystem', verbose_name='Système de bornes')),
],
options={
'verbose_name': 'Équipement associé au système de bornes',
'verbose_name_plural': 'Équipements associés au système de bornes',
'unique_together': {('bollard_system', 'asset')},
},
),
migrations.AddField(
model_name='itsbollardsystem',
name='associated_assets',
field=models.ManyToManyField(blank=True, related_name='bollard_systems', through='assets.ITSBollardSystemAsset', to='assets.itsgeoasset', verbose_name='Équipements associés'),
),
migrations.CreateModel(
name='ITSBollardSystemContract',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('status', models.CharField(choices=[('active', 'Actif'), ('inactive', 'Inactif'), ('archived', 'Archivé')], default='active', max_length=50)),
('maintenance_type', models.CharField(choices=[('equipments', 'Equipements'), ('office', 'Bureautique'), ('system', 'Système'), ('other', 'Autre')], default='other', max_length=50)),
('contract', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_systems_contracts', to='contracts.contract')),
('system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='bollard_systems_contracts', to='assets.itsbollardsystem')),
],
options={
'verbose_name': 'Contrat de système de bornes',
'verbose_name_plural': 'Contrats de systèmes de bornes',
},
),
migrations.CreateModel(
name='ITSBollardPlot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(max_length=50)),
('name_fr', models.CharField(blank=True, max_length=200, null=True)),
('name_nl', models.CharField(blank=True, max_length=200, null=True)),
('external_reference', models.CharField(blank=True, max_length=100, null=True, verbose_name='Référence externe')),
('status', models.CharField(choices=[('to_be_approved', 'À approuver'), ('planned', 'Planifié'), ('active', 'Actif'), ('damaged', 'Endommagé'), ('to_replace', 'A remplacer'), ('voluntarily_out_of_service', 'Mis hors service volontairement'), ('removed', 'Enlevé'), ('archived', 'Archivé')], default='active', max_length=50)),
('obsolescence', models.CharField(blank=True, choices=[('good', 'Bon état'), ('medium', 'Moyen'), ('bad', 'Dégradé'), ('critical', 'Critique'), ('unknown', 'Inconnu')], default='unknown', null=True)),
('installation_date', models.DateTimeField(blank=True, null=True)),
('uninstallation_date', models.DateTimeField(blank=True, null=True)),
('serial_number', models.CharField(blank=True, max_length=200, null=True)),
('warranty_duration', models.IntegerField(blank=True, null=True, verbose_name='Duration of the warranty (months)')),
('last_inspection_date', models.DateTimeField(blank=True, null=True)),
('inspection_status', models.CharField(blank=True, choices=[('to_inspect', 'À inspecter'), ('compliant', 'Conforme'), ('non_compliant', 'Non conforme'), ('updated', 'Mis à jour'), ('unknown', 'Non inspecté')], default='unknown', max_length=50, null=True, verbose_name="Statut d'inspection")),
('last_preventive_date', models.DateTimeField(blank=True, null=True)),
('last_corrective_date', models.DateTimeField(blank=True, null=True)),
('last_survey_date', models.DateTimeField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('marked_as_removed_date', models.DateTimeField(blank=True, null=True, verbose_name='Date de marquage comme retiré')),
('note', models.TextField(blank=True, null=True, verbose_name='Note')),
('validation_status', models.CharField(blank=True, choices=[('to_be_validated', 'À valider'), ('validated', 'Validé'), ('to_be_completed', 'À compléter'), ('rejected', 'Rejeté')], max_length=50, null=True, verbose_name='Statut de validation')),
('funding_program', models.CharField(blank=True, max_length=200, null=True, verbose_name='Programme de financement / Projet')),
('updated_at', models.DateTimeField(auto_now=True)),
('lon', models.FloatField(blank=True, null=True)),
('lat', models.FloatField(blank=True, null=True)),
('geojson', models.TextField(blank=True, null=True)),
('geom', django.contrib.gis.db.models.fields.PointField(blank=True, null=True, srid=3812, verbose_name='Géométrie (Point)')),
('position', models.PositiveSmallIntegerField(default=1, help_text='Ordre de gauche à droite (1, 2, 3...)', verbose_name='Position sur la traverse')),
('bollard_type', models.CharField(choices=[('automatic', 'Automatique / Escamotable'), ('semi_automatic', 'Semi-automatique'), ('fixed', 'Fixe'), ('removable', 'Amovible')], default='automatic', max_length=50, verbose_name='Type de plot')),
('extra_data', models.JSONField(blank=True, default=dict, verbose_name='Métadonnées techniques')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='bollard_plots', to='assets.itslocation', verbose_name='Localisation ITS')),
('marked_as_removed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_marked_as_removed', to=settings.AUTH_USER_MODEL, verbose_name='Marqué comme retiré par')),
('model', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='assets.itsassetmodel', verbose_name='Modèle de plot')),
('replaced_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.itsbollardplot', verbose_name='Remplacé par')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(app_label)s_%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par')),
('system', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='plots', to='assets.itsbollardsystem', verbose_name='Système de bornes')),
],
options={
'verbose_name': 'Plot de borne',
'verbose_name_plural': 'Plots de bornes',
'ordering': ['system', 'position'],
'indexes': [models.Index(fields=['code'], name='assets_itsb_code_de6000_idx')],
},
bases=(assets.models.core.GeoSyncMixin, models.Model),
),
migrations.AddIndex(
model_name='itsbollardsystem',
index=models.Index(fields=['code'], name='assets_itsb_code_a4c148_idx'),
),
migrations.AlterUniqueTogether(
name='itsbollardsystemcontract',
unique_together={('system', 'contract')},
),
]

View file

@ -1,108 +0,0 @@
# Generated by Django 6.0.7 on 2026-10-07 15:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0030_managementzone_managementzonedocument_and_more'),
]
operations = [
migrations.AddField(
model_name='naturetree',
name='amenity_value',
field=models.FloatField(blank=True, null=True, verbose_name="Valeur d'aménité (€)"),
),
migrations.AddField(
model_name='naturetree',
name='biodiversity_index',
field=models.FloatField(blank=True, null=True, verbose_name='Indice de biodiversité'),
),
migrations.AddField(
model_name='naturetree',
name='carbon_stock',
field=models.FloatField(blank=True, null=True, verbose_name='Stock de carbone (t CO2)'),
),
migrations.AddField(
model_name='naturetree',
name='cooling_energy_indicator',
field=models.FloatField(blank=True, null=True, verbose_name='Énergie de refroidissement (kWh)'),
),
migrations.AddField(
model_name='naturetree',
name='cooling_indicator',
field=models.FloatField(blank=True, null=True, verbose_name='Indice de rafraîchissement'),
),
migrations.AddField(
model_name='naturetree',
name='cultivar',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Cultivar / Variété'),
),
migrations.AddField(
model_name='naturetree',
name='death_date',
field=models.DateField(blank=True, null=True, verbose_name='Date de dépérissement / mort'),
),
migrations.AddField(
model_name='naturetree',
name='genus',
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='Genre botanique'),
),
migrations.AddField(
model_name='naturetree',
name='is_chronoxyle',
field=models.BooleanField(default=False, verbose_name='Arbre sénescent / Chronoxyle (bois mort sur pied)'),
),
migrations.AddField(
model_name='naturetree',
name='is_dead',
field=models.BooleanField(default=False, verbose_name='Arbre mort'),
),
migrations.AddField(
model_name='naturetree',
name='is_stump',
field=models.BooleanField(default=False, verbose_name='Souche'),
),
migrations.AddField(
model_name='naturetree',
name='management_type',
field=models.CharField(blank=True, choices=[('municipal', 'Communal (Régie communale)'), ('regional', 'Régional (Bruxelles Mobilité / Région)'), ('community', 'Intercommunal / Autre pouvoir public'), ('private', 'Privé / Tiers')], default='municipal', max_length=50, null=True, verbose_name='Type de gestionnaire'),
),
migrations.AddField(
model_name='naturetree',
name='managing_authority',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Autorité gestionnaire'),
),
migrations.AddField(
model_name='naturetree',
name='number_of_trunks',
field=models.IntegerField(blank=True, default=1, null=True, verbose_name='Nombre de troncs / Cépée'),
),
migrations.AddField(
model_name='naturetree',
name='plantation_coefficient',
field=models.FloatField(blank=True, null=True, verbose_name='Coefficient de plantation'),
),
migrations.AddField(
model_name='naturetree',
name='planting_date',
field=models.DateField(blank=True, null=True, verbose_name='Date de plantation réelle'),
),
migrations.AddField(
model_name='naturetree',
name='scientific_name',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Nom scientifique / latin'),
),
migrations.AddField(
model_name='naturetree',
name='situation_coefficient',
field=models.FloatField(blank=True, null=True, verbose_name='Coefficient de situation'),
),
migrations.AddField(
model_name='naturetree',
name='vernacular_name',
field=models.CharField(blank=True, max_length=200, null=True, verbose_name='Nom vernaculaire / Essence'),
),
]

View file

@ -1,14 +0,0 @@
# Generated by Django 6.0.7 on 2026-10-09 11:48
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('assets', '0031_itsgeoasset_extra_data_itsbollardsystem_and_more'),
('assets', '0031_naturetree_amenity_value_and_more'),
]
operations = [
]

View file

@ -1,741 +0,0 @@
# -----------------------------
# Public Buildings Models
# -----------------------------
from django.contrib.gis.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
from assets.models import AbstractLocation, AbstractAsset, AbstractGeoAsset, AbstractAssetModel, ASSET_STATUS_CHOICES
from common.models import Document
from common.storages import PrivateMediaStorage
private_storage = PrivateMediaStorage()
def get_usage_type_choices(standard='BE'):
"""
Renvoie la liste des choix de type d'usage adaptés à la norme régionale active (BE, FR, INT).
"""
from common.utils import get_regional_terms
terms = get_regional_terms(standard)
admin_label = _("Administratif") + f" ({terms['admin_building']})"
return (
('administrative', admin_label),
('education', _('Enseignement / École / Crèche')),
('sports', _('Sport & Équipement sportif')),
('culture', _('Culture / Médiathèque / Musée')),
('social', _('Social / Centre communautaire')),
('technical', _('Technique / Ateliers / Stock')),
('housing', _('Logement communal')),
('other', _('Autre bâtiment public')),
)
USAGE_TYPE_CHOICES = get_usage_type_choices('BE')
BUILDING_STATUS_CHOICES = (
('in_service', _('En service')),
('under_renovation', _('En travaux / Rénovation')),
('decommissioned', _('Désaffecté')),
('ceded', _('Cédé / Vendu')),
)
DPE_RATING_CHOICES = (
('A', 'A'),
('B', 'B'),
('C', 'C'),
('D', 'D'),
('E', 'E'),
('F', 'F'),
('G', 'G'),
)
ERP_CATEGORY_CHOICES = (
('1', _('1ère catégorie (> 1500 pers.)')),
('2', _('2ème catégorie (301 - 1500 pers.)')),
('3', _('3ème catégorie (101 - 300 pers.)')),
('4', _('4ème catégorie (jusqu\'à 100 pers.)')),
('5', _('5ème catégorie (seuils spécifiques)')),
)
ERP_TYPE_CHOICES = (
('J', _('J - Structures d\'accueil pour personnes âgées ou handicapées')),
('L', _('L - Salles de spectacles, de réunions, polyvalentes')),
('M', _('M - Magasins, centres commerciaux')),
('N', _('N - Restaurants et débits de boissons')),
('O', _('O - Hôtels et pensions de famille')),
('P', _('P - Salles de danse et salles de jeux')),
('R', _('R - Établissements d\'éveil, d\'enseignement, colonies de vacances')),
('S', _('S - Bibliothèques, centres de documentation')),
('T', _('T - Salles d\'expositions')),
('U', _('U - Établissements de soins')),
('V', _('V - Établissements de culte')),
('W', _('W - Administrations, banques, bureaux')),
('X', _('X - Établissements sportifs couverts')),
('Y', _('Y - Musées')),
)
LOCATION_TYPE_CHOICES = (
# Ensembles & Bâtiments
('site', _('Site / Complexe')),
('building', _('Bâtiment / Bloc')),
('wing', _('Aile / Corps de bâtiment')),
# Niveaux & Hauteurs
('level', _('Niveau / Étage')),
('basement', _('Sous-sol / Cave')),
('attic', _('Combles / Grenier / Toiture-terrasse')),
('mezzanine', _('Mezzanine')),
# Circulations & Dégagements
('stairwell', _('Cage d\'escalier')),
('corridor', _('Couloir / Coursive / Dégagement')),
('lobby', _('Hall d\'accueil / Entrée / Sas')),
('emergency_exit', _('Issue de secours / Sas d\'évacuation')),
('elevator_shaft', _('Gaine / Palier d\'ascenseur')),
# Locaux techniques
('technical_room', _('Local technique général')),
('boiler_room', _('Chaufferie / Sous-station thermique')),
('electrical_room', _('Local TGBT / Électrique')),
('ventilation_room', _('Local ventilation / CTA')),
('elevator_machine_room', _('Local machinerie ascenseur')),
('server_room', _('Salle serveurs / Baie informatique')),
# Locaux de travail, d'accueil & services
('room', _('Pièce / Local polyvalent')),
('office', _('Bureau / Espace de travail')),
('meeting_room', _('Salle de réunion / Conférence')),
('classroom', _('Salle de classe / Formation')),
('workshop', _('Atelier / Laboratoire')),
('sanitary_room', _('Sanitaires / Vestiaires / Douches')),
('storage_room', _('Réserve / Stockage / Archives')),
('canteen_kitchen', _('Cuisine / Réfectoire / Restauration')),
('cleaning_room', _('Local ménage / Entretien')),
('waste_room', _('Local poubelles / Déchets')),
('indoor_parking', _('Parking couvert / Garage')),
# Espaces extérieurs immédiats
('outdoor_area', _('Abords / Cour / Parvis / Terrasse')),
# Autre
('other', _('Autre localisation')),
)
FLOOR_TYPE_CHOICES = (
('tiles', _('Carrelage / Faïence')),
('parquet', _('Parquet / Bois')),
('linoleum_pvc', _('Linoléum / Vinyle / PVC')),
('concrete', _('Béton lissé / Chape / Résine')),
('carpet', _('Moquette / Textile')),
('other', _('Autre revêtement')),
)
BUILDING_ASSET_TYPE_CHOICES = (
# Clos, couvert & enveloppe
('roof', _('Toiture / Couverture / Étanchéité')),
('roof_drainage', _('Zinguerie & Évacuation EP (Gouttière, chéneau)')),
('facade', _('Façade / Bardage / Mur extérieur')),
('window', _('Fenêtre / Baie vitrée / Châssis')),
('skylight', _('Lanterneau / Verrière / Puits de lumière')),
('external_door', _('Porte extérieure / Porte d\'entrée / Portail')),
('shutter_blind', _('Volet / Store / Brise-soleil (BSO)')),
('insulation', _('Isolation thermique (ITE / ITI)')),
# Second œuvre & aménagements intérieurs
('wall', _('Mur intérieur / Cloison')),
('interior_door', _('Porte intérieure / Bloc-porte')),
('fire_door', _('Porte coupe-feu / Bloc pare-flamme')),
('ceiling', _('Faux-plafond / Plafond suspendu')),
('flooring', _('Revêtement de sol')),
('stairs', _('Escalier / Garde-corps / Main courante')),
# Génie climatique & thermique (CVC)
('boiler', _('Chaudière (Gaz / Fioul / Biomasse / Pellets)')),
('heat_pump', _('Pompe à chaleur (PAC) / Climatisation réversible')),
('heat_substation', _('Sous-station d\'échangeur thermique / Réseau urbain')),
('radiator_emitter', _('Émetteur de chaleur (Radiateur, convecteur, plancher)')),
('air_handling_unit', _('Centrale de traitement d\'air (CTA) / VMC')),
('air_conditioning', _('Climatisation / Groupe froid / Chiller / VRV')),
('water_heater', _('Production Eau Chaude Sanitaire (Ballon ECS)')),
('hvac', _('Chauffage / CVC / Régulation générale')),
# Électricité, Énergie & Réseaux
('electricity', _('Électricité / TGBT / Tableau divisionnaire')),
('inverter_generator', _('Onduleur / Groupe électrogène de secours')),
('lighting', _('Éclairage intérieur / Luminaires')),
('emergency_lighting', _('Éclairage de sécurité / BAES / BAEH')),
('meter', _('Compteur (Eau / Gaz / Électricité / Calories)')),
('solar_panel', _('Panneaux solaires (Photovoltaïque / Thermique)')),
('ev_charging', _('Borne de recharge véhicule électrique')),
# Plomberie & Sanitaires
('sanitary', _('Plomberie / Sanitaires (WC, lavabo, douche)')),
('plumbing_piping', _('Réseau d\'eau / Vanne d\'arrêt / Disconnecteur')),
('sump_pump', _('Pompe de relevage (Eaux usées / Eaux pluviales)')),
('rainwater_harvesting', _('Récupération d\'eau de pluie / Cuve de stockage')),
# Sécurité incendie, sûreté & contrôle d'accès
('fire_safety', _('Sécurité incendie / Centrale SSI / CMSI')),
('smoke_detector', _('Détecteur de fumée / Déclencheur manuel (DM)')),
('fire_extinguisher', _('Extincteur / Robinet d\'Incendie Armé (RIA)')),
('smoke_exhaust', _('Désenfumage / Trappe / Skydome')),
('access_control', _('Contrôle d\'accès (Badge, interphone, gâche)')),
('alarm', _('Alarme anti-intrusion / Détecteur de présence')),
('cctv_surveillance', _('Vidéosurveillance / Caméra CCTV')),
# Mobilité verticale
('elevator', _('Ascenseur / Monte-charge')),
('disabled_lift', _('Élévateur PMR / Plateforme élévatrice')),
# Réseaux, GTB & Automatismes
('network_rack', _('Baie de brassage / Rack informatique / RJ45')),
('bms_system', _('Gestion Technique du Bâtiment (GTB / GTC)')),
# Gros œuvre & Divers
('civil_engineering', _('Génie civil / Bâti / Gros œuvre')),
('other', _('Autre équipement / asset')),
)
# For backward compatibility
EQUIPMENT_TYPE_CHOICES = BUILDING_ASSET_TYPE_CHOICES
ENERGY_SOURCE_CHOICES = (
('natural_gas', _('Gaz naturel')),
('electricity', _('Électricité')),
('fuel_oil', _('Fioul domestique')),
('biomass_wood', _('Biomasse / Bois / Pellets')),
('heat_network', _('Réseau de chaleur urbain')),
('heat_pump_air', _('Aérothermie (PAC air-eau / air-air)')),
('heat_pump_geo', _('Géothermie (PAC eau-eau / sol-eau)')),
('solar_thermal', _('Solaire thermique')),
('other', _('Autre source')),
)
GLAZING_TYPE_CHOICES = (
('single', _('Simple vitrage')),
('double_standard', _('Double vitrage standard')),
('double_low_e', _('Double vitrage ITR / Haute isolation')),
('triple', _('Triple vitrage')),
('laminated_safety', _('Vitrage feuilleté de sécurité')),
('other', _('Autre vitrage')),
)
OPENING_TYPE_CHOICES = (
('hinged', _('Battant / À la française')),
('sliding', _('Coulissant')),
('tilt_and_turn', _('Oscillo-battant')),
('double_action', _('Va-et-vient')),
('sectional_overhead', _('Sectionnel / Basculant')),
('motorized_automatic', _('Automatique / Motorisé')),
('fixed', _('Châssis fixe / Non ouvrant')),
('other', _('Autre type')),
)
class PublicBuilding(AbstractLocation):
"""
Modèle représentant une Localisation du Patrimoine Bâti (Complexe, Bâtiment, Bloc, Étage, Local).
Les localisations peuvent former une arborescence parent-enfant.
"""
geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise cartographique"))
parent_location = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_locations", verbose_name=_("Localisation parente"))
location_type = models.CharField(max_length=50, choices=LOCATION_TYPE_CHOICES, default='building', verbose_name=_("Type de localisation"))
is_main_location = models.BooleanField(default=False, verbose_name=_("Localisation principale"))
address = models.CharField(max_length=500, null=True, blank=True, verbose_name=_("Adresse complète"))
description = models.TextField(null=True, blank=True, verbose_name=_("Description"))
# Propriété & Gestion
owner = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Propriétaire"))
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="managed_buildings", verbose_name=_("Gestionnaire référent"))
occupant_entity = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Direction / Service occupant"))
# Usage & Statut
usage_type = models.CharField(max_length=50, choices=USAGE_TYPE_CHOICES, default='administrative', verbose_name=_("Type d'usage principal"))
building_status = models.CharField(max_length=50, choices=BUILDING_STATUS_CHOICES, default='in_service', verbose_name=_("Statut d'occupation"))
# Caractéristiques physiques
construction_year = models.IntegerField(null=True, blank=True, verbose_name=_("Année de construction"))
floors_count = models.IntegerField(default=1, verbose_name=_("Nombre d'étages hors-sol"))
basement_count = models.IntegerField(default=0, verbose_name=_("Nombre de niveaux en sous-sol"))
total_surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface brute totale (m²)"))
usable_surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface utile (m²)"))
surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface spécifique (m²)"))
level_number = models.IntegerField(default=0, verbose_name=_("Numéro d'étage (ex: 0=RDC, 1=1er, -1=Sous-sol)"))
room_number = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Numéro de local / porte"))
ceiling_height_m = models.FloatField(null=True, blank=True, verbose_name=_("Hauteur sous plafond (m)"))
is_heated = models.BooleanField(default=True, verbose_name=_("Local chauffé"))
fire_compartment = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Secteur / Compartiment coupe-feu"))
floor_type = models.CharField(max_length=50, choices=FLOOR_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Revêtement de sol dominant"))
floor_plan = models.FileField(upload_to='buildings/plans/%Y/%m/', storage=private_storage, null=True, blank=True, verbose_name=_("Plan d'étage"))
# Réglementation & Accueil du Public
is_erp = models.BooleanField(default=True, verbose_name=_("Accueil & Sécurité du Public (ERP / PMR)"))
erp_category = models.CharField(max_length=10, choices=ERP_CATEGORY_CHOICES, null=True, blank=True, verbose_name=_("Catégorie d'accueil / ERP"))
erp_type = models.CharField(max_length=10, choices=ERP_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Type d'établissement / ERP"))
max_capacity = models.IntegerField(null=True, blank=True, verbose_name=_("Jauge maximale d'accueil du public"))
# Diagnostics & Énergie
dpe_rating = models.CharField(max_length=2, choices=DPE_RATING_CHOICES, null=True, blank=True, verbose_name=_("Performance Énergétique (PEB / DPE / EPC)"))
ges_rating = models.CharField(max_length=2, choices=DPE_RATING_CHOICES, null=True, blank=True, verbose_name=_("Émissions de Carbone & GES"))
has_asbestos_diag = models.BooleanField(default=False, verbose_name=_("Diagnostic Amiante réalisé"))
has_lead_diag = models.BooleanField(default=False, verbose_name=_("Diagnostic Plomb réalisé"))
pmr_accessible = models.BooleanField(default=False, verbose_name=_("Accessible PMR"))
class Meta:
verbose_name = _("Localisation Bâtiment public")
verbose_name_plural = _("Localisations Bâtiments publics")
ordering = ['code', 'name_fr']
@property
def name(self):
return self.name_fr or self.name_nl or self.code
def get_absolute_url(self):
return reverse('assets:publicbuilding_detail', kwargs={'pk': self.pk})
def get_floor_plan_url(self):
"""
Renvoie l'URL du plan d'étage s'il existe (via floor_plan ou document associé de type floor_plan).
"""
if self.floor_plan and self.floor_plan.name:
return reverse('assets:publicbuilding_plan_file', kwargs={'pk': self.pk})
docs = getattr(self, 'floor_documents', None)
if docs:
first_doc = docs.filter(document_type='floor_plan').first()
if first_doc and first_doc.file and first_doc.file.name:
return reverse('assets:publicbuilding_plan_file', kwargs={'pk': self.pk})
return None
def get_usage_type_display(self, standard=None):
"""
Renvoie le libellé du type d'usage adapté dynamiquement à la norme régionale (BE, FR, INT).
"""
from common.utils import get_regional_terms
if self.usage_type == 'administrative':
terms = get_regional_terms(standard or getattr(self, '_regional_standard', 'BE'))
return f"Administratif ({terms['admin_building']})"
return dict(USAGE_TYPE_CHOICES).get(self.usage_type, self.usage_type)
def get_icon_class(self):
"""
Retourne la classe d'icône Bootstrap adaptée au type de localisation.
"""
mapping = {
'site': 'bi-diagram-3',
'building': 'bi-building',
'wing': 'bi-building-add',
'level': 'bi-layers',
'basement': 'bi-layer-backward',
'attic': 'bi-triangle',
'mezzanine': 'bi-border-inner',
'stairwell': 'bi-stairs',
'corridor': 'bi-arrow-left-right',
'lobby': 'bi-door-open-fill',
'emergency_exit': 'bi-box-arrow-right',
'elevator_shaft': 'bi-arrow-down-up',
'technical_room': 'bi-tools',
'boiler_room': 'bi-fire',
'electrical_room': 'bi-lightning-charge',
'ventilation_room': 'bi-fan',
'elevator_machine_room': 'bi-gear-wide-connected',
'server_room': 'bi-hdd-network',
'room': 'bi-door-open',
'office': 'bi-briefcase',
'meeting_room': 'bi-people',
'classroom': 'bi-mortarboard',
'workshop': 'bi-hammer',
'sanitary_room': 'bi-badge-wc',
'storage_room': 'bi-archive',
'canteen_kitchen': 'bi-cup-hot',
'cleaning_room': 'bi-bucket',
'waste_room': 'bi-trash',
'indoor_parking': 'bi-car-front',
'outdoor_area': 'bi-tree',
}
return mapping.get(self.location_type, 'bi-geo-alt')
def get_child_locations(self):
return self.child_locations.all()
def get_active_assets(self):
def collect_assets(loc):
assets = list(loc.location_assets.filter(status='active'))
for child in loc.child_locations.all():
assets += collect_assets(child)
return assets
return collect_assets(self)
def get_all_assets(self):
def collect_assets(loc):
if loc.parent_location is None:
assets = list(loc.location_assets.select_related('location', 'parent_asset', 'building').all() | loc.all_building_assets.select_related('location', 'parent_asset', 'building').filter(location__isnull=True))
else:
assets = list(loc.location_assets.select_related('location', 'parent_asset', 'building').all())
for child in loc.child_locations.all():
assets += collect_assets(child)
return assets
seen = set()
unique_assets = []
for a in collect_assets(self):
if a.id not in seen:
seen.add(a.id)
unique_assets.append(a)
return unique_assets
def get_direct_child_assets(self):
return self.location_assets.all()
class BuildingAsset(AbstractGeoAsset):
"""
Modèle représentant un asset ou équipement physique du bâtiment (Génie civil / Bâti, Façades, Toitures, CVC, Ascenseurs...).
Les assets peuvent former une arborescence parent-enfant (ex: Bâti -> Façade -> Châssis, ou Chaudière -> Brûleur).
"""
building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="all_building_assets", verbose_name=_("Bâtiment / Site principal"))
location = models.ForeignKey(PublicBuilding, on_delete=models.SET_NULL, null=True, blank=True, related_name="location_assets", verbose_name=_("Localisation spécifique"))
parent_asset = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_assets", verbose_name=_("Asset parent"))
asset_type = models.CharField(max_length=50, choices=BUILDING_ASSET_TYPE_CHOICES, default='civil_engineering', verbose_name=_("Type d'asset / Équipement"))
# Caractéristiques physiques & dimensionnelles
dimensions = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Dimensions / Format"))
surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface unitaire (m²)"))
material = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Matériau principal"))
# Caractéristiques thermiques, énergétiques & techniques
power_kw = models.FloatField(null=True, blank=True, verbose_name=_("Puissance nominale (kW)"))
energy_source = models.CharField(max_length=50, choices=ENERGY_SOURCE_CHOICES, null=True, blank=True, verbose_name=_("Source d'énergie"))
refrigerant_type = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Fluide frigorigène"))
refrigerant_charge_kg = models.FloatField(null=True, blank=True, verbose_name=_("Charge fluide frigorigène (kg)"))
# Menuiseries, ouvrants & protection incendie
glazing_type = models.CharField(max_length=50, choices=GLAZING_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Type de vitrage"))
fire_rating = models.CharField(max_length=50, null=True, blank=True, verbose_name=_("Classement au feu / Coupe-feu"))
opening_type = models.CharField(max_length=50, choices=OPENING_TYPE_CHOICES, null=True, blank=True, verbose_name=_("Type d'ouverture"))
# Maintenance, exploitation & suivi réglementaire
maintenance_contract_ref = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Réf. contrat maintenance"))
contractor_name = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Prestataire / Mainteneur"))
last_control_date = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle réglementaire"))
next_control_date = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle réglementaire"))
regulatory_periodicity_months = models.IntegerField(null=True, blank=True, verbose_name=_("Périodicité contrôle réglementaire (mois)"))
replacement_cost_estimate = models.DecimalField(max_digits=12, decimal_places=2, null=True, blank=True, verbose_name=_("Coût estimé de renouvellement (€)"))
def clean(self):
super().clean()
if self.parent_asset and getattr(self.parent_asset, 'geom', None) and not self.geom:
self.geom = self.parent_asset.geom
self.lon = self.parent_asset.lon
self.lat = self.parent_asset.lat
self.geojson = self.parent_asset.geojson
if self.parent_asset and self.parent_asset.location and not self.location_id:
self.location = self.parent_asset.location
if self.parent_asset and self.parent_asset.building and not self.building_id:
self.building = self.parent_asset.building
def save(self, *args, **kwargs):
if self.parent_asset and getattr(self.parent_asset, 'geom', None) and not self.geom:
self.geom = self.parent_asset.geom
self.lon = self.parent_asset.lon
self.lat = self.parent_asset.lat
self.geojson = self.parent_asset.geojson
if self.parent_asset and self.parent_asset.location and not self.location_id:
self.location = self.parent_asset.location
if self.parent_asset and self.parent_asset.building and not self.building_id:
self.building = self.parent_asset.building
super().save(*args, **kwargs)
class Meta:
verbose_name = _("Asset / Équipement de bâtiment")
verbose_name_plural = _("Assets / Équipements de bâtiment")
ordering = ['building', 'code']
@property
def name(self):
return self.name_fr or self.name_nl or self.code
def get_absolute_url(self):
return reverse('assets:publicbuildings_assets_detail', kwargs={'asset_model': 'buildingasset', 'asset_id': self.pk})
def get_child_assets(self):
return self.child_assets.all()
def get_macro_lot(self):
"""
Retourne le macro-lot / domaine technique de l'équipement.
"""
lots = {
# Clos & Couvert
'roof': _('Clos & Couvert'),
'roof_drainage': _('Clos & Couvert'),
'facade': _('Clos & Couvert'),
'window': _('Clos & Couvert'),
'skylight': _('Clos & Couvert'),
'external_door': _('Clos & Couvert'),
'shutter_blind': _('Clos & Couvert'),
'insulation': _('Clos & Couvert'),
# Aménagements & Second œuvre
'wall': _('Aménagements & Second œuvre'),
'interior_door': _('Aménagements & Second œuvre'),
'fire_door': _('Aménagements & Second œuvre'),
'ceiling': _('Aménagements & Second œuvre'),
'flooring': _('Aménagements & Second œuvre'),
'stairs': _('Aménagements & Second œuvre'),
# Génie climatique & CVC
'boiler': _('Génie climatique & CVC'),
'heat_pump': _('Génie climatique & CVC'),
'heat_substation': _('Génie climatique & CVC'),
'radiator_emitter': _('Génie climatique & CVC'),
'air_handling_unit': _('Génie climatique & CVC'),
'air_conditioning': _('Génie climatique & CVC'),
'water_heater': _('Génie climatique & CVC'),
'hvac': _('Génie climatique & CVC'),
# Électricité & Énergie
'electricity': _('Électricité & Énergie'),
'inverter_generator': _('Électricité & Énergie'),
'lighting': _('Électricité & Éclairage'),
'emergency_lighting': _('Électricité & Sécurité'),
'meter': _('Fluides & Comptage'),
'solar_panel': _('Énergies renouvelables'),
'ev_charging': _('Mobilité électrique'),
# Plomberie & Sanitaires
'sanitary': _('Plomberie & Sanitaires'),
'plumbing_piping': _('Plomberie & Réseaux'),
'sump_pump': _('Assainissement & Relevage'),
'rainwater_harvesting': _('Gestion des eaux pluviales'),
# Sécurité incendie & Sûreté
'fire_safety': _('Sécurité incendie'),
'smoke_detector': _('Sécurité incendie'),
'fire_extinguisher': _('Sécurité incendie'),
'smoke_exhaust': _('Sécurité incendie'),
'access_control': _('Sûreté & Contrôle d\'accès'),
'alarm': _('Sûreté & Contrôle d\'accès'),
'cctv_surveillance': _('Vidéosurveillance'),
# Mobilité verticale
'elevator': _('Ascenseurs & Élévateurs'),
'disabled_lift': _('Ascenseurs & Élévateurs'),
# Réseaux & Numérique
'network_rack': _('Réseaux & Télécom'),
'bms_system': _('Gestion Technique du Bâtiment (GTB)'),
# Gros œuvre & Divers
'civil_engineering': _('Génie civil & Structure'),
'other': _('Autre équipement'),
}
return lots.get(self.asset_type, _('Autre'))
def get_icon_class(self):
"""
Retourne l'icône Bootstrap adaptée au type d'asset.
"""
icons = {
'roof': 'bi-house-gear',
'roof_drainage': 'bi-water',
'facade': 'bi-border-style',
'window': 'bi-window',
'skylight': 'bi-sun',
'external_door': 'bi-door-closed',
'shutter_blind': 'bi-blinds',
'insulation': 'bi-shield',
'wall': 'bi-bricks',
'interior_door': 'bi-door-open',
'fire_door': 'bi-shield-shaded',
'ceiling': 'bi-grid-3x3',
'flooring': 'bi-square',
'stairs': 'bi-stairs',
'boiler': 'bi-fire',
'heat_pump': 'bi-snow',
'heat_substation': 'bi-shuffle',
'radiator_emitter': 'bi-thermometer-high',
'air_handling_unit': 'bi-fan',
'air_conditioning': 'bi-wind',
'water_heater': 'bi-droplet-half',
'hvac': 'bi-thermometer-half',
'electricity': 'bi-lightning-charge',
'inverter_generator': 'bi-battery-charging',
'lighting': 'bi-lightbulb',
'emergency_lighting': 'bi-exclamation-triangle',
'meter': 'bi-speedometer2',
'solar_panel': 'bi-sun-fill',
'ev_charging': 'bi-ev-station',
'sanitary': 'bi-badge-wc',
'plumbing_piping': 'bi-pipette',
'sump_pump': 'bi-arrow-up-circle',
'rainwater_harvesting': 'bi-cloud-rain',
'fire_safety': 'bi-shield-shaded',
'smoke_detector': 'bi-bell',
'fire_extinguisher': 'bi-cone-striped',
'smoke_exhaust': 'bi-cloud-arrow-up',
'access_control': 'bi-key',
'alarm': 'bi-shield-exclamation',
'cctv_surveillance': 'bi-camera-video',
'elevator': 'bi-arrow-down-up',
'disabled_lift': 'bi-person-wheelchair',
'network_rack': 'bi-hdd-rack',
'bms_system': 'bi-cpu',
'civil_engineering': 'bi-building-gear',
'other': 'bi-gear',
}
return icons.get(self.asset_type, 'bi-gear')
def get_asset_theme(self):
"""
Retourne la configuration graphique (couleurs d'icône et de badge)
selon la typologie métier de l'asset.
"""
# 1. Sécurité incendie (Rouge alerte)
if self.asset_type in ('fire_safety', 'smoke_detector', 'fire_extinguisher',
'smoke_exhaust', 'fire_door'):
return {
'color': '#b91c1c', # red-700
'bg_color': '#fef2f2', # red-50
'border_color': '#fecaca', # red-200
'icon_color': '#dc2626', # red-600
'badge_class': 'bg-danger-subtle text-danger border',
}
# 2. Génie climatique & CVC (Orange thermique / Chaleur & Froid)
if self.asset_type in ('boiler', 'heat_pump', 'heat_substation', 'radiator_emitter',
'air_handling_unit', 'air_conditioning', 'water_heater', 'hvac'):
return {
'color': '#c2410c', # orange-700
'bg_color': '#fff7ed', # orange-50
'border_color': '#fed7aa', # orange-200
'icon_color': '#ea580c', # orange-600
'badge_class': 'bg-warning-subtle text-danger-emphasis border',
}
# 3. Électricité, Énergie & Éclairage (Ambre / Jaune or)
if self.asset_type in ('electricity', 'inverter_generator', 'lighting', 'emergency_lighting',
'meter', 'solar_panel', 'ev_charging'):
return {
'color': '#a16207', # yellow-700
'bg_color': '#fefce8', # yellow-50
'border_color': '#fef08a', # yellow-200
'icon_color': '#ca8a04', # yellow-600
'badge_class': 'bg-warning-subtle text-dark border',
}
# 4. Sûreté, Contrôle d'accès & Vidéosurveillance (Pourpre / Violet)
if self.asset_type in ('access_control', 'alarm', 'cctv_surveillance'):
return {
'color': '#7e22ce', # purple-700
'bg_color': '#faf5ff', # purple-50
'border_color': '#e9d5ff', # purple-200
'icon_color': '#9333ea', # purple-600
'badge_class': 'bg-primary-subtle text-primary border',
}
# 5. Clos & Couvert / Enveloppe (Teal / Vert canard)
if self.asset_type in ('roof', 'roof_drainage', 'facade', 'window', 'skylight',
'external_door', 'shutter_blind', 'insulation'):
return {
'color': '#0f766e', # teal-700
'bg_color': '#f0fdfa', # teal-50
'border_color': '#99f6e4', # teal-200
'icon_color': '#0d9488', # teal-600
'badge_class': 'bg-success-subtle text-success border',
}
# 6. Plomberie, Sanitaires & Gestion de l'eau (Bleu azur / Sky)
if self.asset_type in ('sanitary', 'plumbing_piping', 'sump_pump', 'rainwater_harvesting'):
return {
'color': '#0369a1', # sky-700
'bg_color': '#f0f9ff', # sky-50
'border_color': '#bae6fd', # sky-200
'icon_color': '#0284c7', # sky-600
'badge_class': 'bg-info-subtle text-info border',
}
# 7. Mobilité verticale / Ascenseurs (Bleu cobalt)
if self.asset_type in ('elevator', 'disabled_lift'):
return {
'color': '#1d4ed8', # blue-700
'bg_color': '#eff6ff', # blue-50
'border_color': '#bfdbfe', # blue-200
'icon_color': '#2563eb', # blue-600
'badge_class': 'bg-primary-subtle text-primary border',
}
# 8. Réseaux & Numérique / GTB (Indigo informatique)
if self.asset_type in ('network_rack', 'bms_system'):
return {
'color': '#4338ca', # indigo-700
'bg_color': '#eef2ff', # indigo-50
'border_color': '#c7d2fe', # indigo-200
'icon_color': '#4f46e5', # indigo-600
'badge_class': 'bg-dark-subtle text-dark border',
}
# 9. Second œuvre & Aménagements intérieurs (Ardoise neutre)
if self.asset_type in ('wall', 'interior_door', 'ceiling', 'flooring', 'stairs'):
return {
'color': '#334155', # slate-700
'bg_color': '#f8fafc', # slate-50
'border_color': '#cbd5e1', # slate-300
'icon_color': '#64748b', # slate-500
'badge_class': 'bg-secondary-subtle text-secondary border',
}
# Par défaut (Gros œuvre & Autre)
return {
'color': '#475569',
'bg_color': '#f1f5f9',
'border_color': '#cbd5e1',
'icon_color': '#64748b',
'badge_class': 'bg-light text-muted border',
}
@property
def theme_color(self):
return self.get_asset_theme()['color']
@property
def theme_icon_color(self):
return self.get_asset_theme()['icon_color']
@property
def theme_bg_color(self):
return self.get_asset_theme()['bg_color']
@property
def theme_border_color(self):
return self.get_asset_theme()['border_color']
@property
def theme_badge_style(self):
t = self.get_asset_theme()
return f"background-color: {t['bg_color']} !important; color: {t['color']} !important; border: 1px solid {t['border_color']} !important;"
@property
def theme_badge_class(self):
return self.get_asset_theme()['badge_class']
# Backward compatibility alias
BuildingEquipment = BuildingAsset
def upload_to_building(instance, filename):
return f"buildings/{instance.building.code}/{filename}"
BUILDING_DOCUMENT_TYPE_CHOICES = (
('floor_plan', _("Plan d'étage / Plan architectural")),
('as_built', _("Plan de récolement (As-Built)")),
('erp_notice', _("Notice de sécurité / Dossier ERP")),
('dpe', _("Diagnostic énergétique (DPE / PEB)")),
('technical_notice', _("Notice technique / Manuel d'exploitation")),
('other', _("Autre document")),
)
class PublicBuildingDocument(Document):
"""
Documents associés à un bâtiment public (plans, notices ERP, DPE).
"""
building = models.ForeignKey(PublicBuilding, on_delete=models.CASCADE, related_name="documents", verbose_name=_("Bâtiment"))
floor_location = models.ForeignKey(
PublicBuilding,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="floor_documents",
verbose_name=_("Niveau / Étage concerné"),
help_text=_("Niveau ou local auquel ce document/plan se rapporte.")
)
file = models.FileField(upload_to=upload_to_building, storage=private_storage, verbose_name=_("Fichier"), max_length=255)
document_type = models.CharField(max_length=50, choices=BUILDING_DOCUMENT_TYPE_CHOICES, default='other', verbose_name=_("Type de document"))
class Meta:
verbose_name = _("Document de bâtiment")
verbose_name_plural = _("Documents de bâtiment")

View file

@ -1,414 +0,0 @@
from django.contrib.gis.db import models
from django.contrib.gis.geos import Point
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericRelation
from django.utils.translation import get_language, gettext_lazy as _
from common.models import Thematic
class GeoSyncMixin:
"""
Mixin providing spatial field synchronization (geom, lon, lat, geojson).
Automatically updates geojson and lon/lat when geom or lon/lat change.
"""
def sync_geom_and_geojson(self):
modified_fields = set()
old_lon = None
old_lat = None
old_geom = None
if self.pk:
try:
old_instance = type(self).objects.get(pk=self.pk)
old_lon = getattr(old_instance, 'lon', None)
old_lat = getattr(old_instance, 'lat', None)
old_geom = getattr(old_instance, 'geom', None)
except Exception:
pass
cur_geom = getattr(self, 'geom', None)
cur_lon = getattr(self, 'lon', None)
cur_lat = getattr(self, 'lat', None)
# Check if lon/lat were modified while geom remained unchanged
lon_lat_modified = (
cur_lon is not None and cur_lat is not None and
(cur_lon != old_lon or cur_lat != old_lat) and
(cur_geom == old_geom)
)
if lon_lat_modified:
# lon/lat were explicitly updated: rebuild geom from lon/lat
try:
pt_4326 = Point(cur_lon, cur_lat, srid=4326)
new_geojson = pt_4326.geojson
if getattr(self, 'geojson', None) != new_geojson:
self.geojson = new_geojson
modified_fields.add('geojson')
if hasattr(self, 'geom'):
field = type(self)._meta.get_field('geom')
geom_type = getattr(field, 'geom_type', '').upper()
if geom_type in ('POINT', 'GEOMETRY'):
srid = getattr(field, 'srid', 3812)
pt_target = pt_4326 if srid == 4326 else pt_4326.transform(srid, clone=True)
if getattr(self, 'geom', None) != pt_target:
self.geom = pt_target
modified_fields.add('geom')
except Exception:
pass
elif cur_geom is not None:
# geom is present and is the source of truth
try:
geom_4326 = cur_geom.transform(4326, clone=True)
new_geojson = geom_4326.geojson
if getattr(self, 'geojson', None) != new_geojson:
self.geojson = new_geojson
modified_fields.add('geojson')
if geom_4326.geom_type == 'Point':
target_point = geom_4326
else:
centroid = geom_4326.centroid
if geom_4326.intersects(centroid) or geom_4326.contains(centroid):
target_point = centroid
else:
target_point = geom_4326.point_on_surface
new_lon = target_point.x
new_lat = target_point.y
if getattr(self, 'lon', None) != new_lon:
self.lon = new_lon
modified_fields.add('lon')
if getattr(self, 'lat', None) != new_lat:
self.lat = new_lat
modified_fields.add('lat')
except Exception:
pass
elif cur_lon is not None and cur_lat is not None:
# geom is None, but lon and lat are provided
try:
pt_4326 = Point(cur_lon, cur_lat, srid=4326)
new_geojson = pt_4326.geojson
if getattr(self, 'geojson', None) != new_geojson:
self.geojson = new_geojson
modified_fields.add('geojson')
if hasattr(self, 'geom'):
field = type(self)._meta.get_field('geom')
geom_type = getattr(field, 'geom_type', '').upper()
if geom_type in ('POINT', 'GEOMETRY'):
srid = getattr(field, 'srid', 3812)
pt_target = pt_4326 if srid == 4326 else pt_4326.transform(srid, clone=True)
if getattr(self, 'geom', None) != pt_target:
self.geom = pt_target
modified_fields.add('geom')
except Exception:
pass
elif cur_geom is None and cur_lon is None and cur_lat is None:
if getattr(self, 'geojson', None) is not None:
self.geojson = None
modified_fields.add('geojson')
return modified_fields
def save(self, *args, **kwargs):
modified = self.sync_geom_and_geojson()
if 'update_fields' in kwargs and kwargs['update_fields'] is not None:
update_fields = set(kwargs['update_fields'])
update_fields.update(modified)
kwargs['update_fields'] = list(update_fields)
super().save(*args, **kwargs)
# Abstract base model for locations
class AbstractLocation(GeoSyncMixin, models.Model):
code = models.CharField(max_length=50)
name_fr = models.CharField(max_length=200,null=True,blank=True)
name_nl = models.CharField(max_length=200,null=True,blank=True)
lon = models.FloatField(null=True,blank=True)
lat = models.FloatField(null=True,blank=True)
geom = models.PolygonField(srid=3812, null=True,blank=True)
geojson = models.TextField(null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='%(app_label)s_%(class)s_location_updated_by', verbose_name="Mis à jour par")
def get_name(self):
lang = get_language()
name = getattr(self, f"name_{lang}", self.name_fr) or "" # Fallback to name_fr
return name
def __str__(self):
return self.code + " - " + self.get_name()
class Meta:
abstract = True
ordering = ["code"]
# Category of assets
class AssetCategory(models.Model):
thematic = models.ForeignKey(Thematic, on_delete=models.SET_NULL, null=True, blank=True, related_name="asset_categories")
parent = models.ForeignKey(
'self',
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='children',
verbose_name=_("Catégorie parente"),
help_text=_("Laisser vide pour une catégorie de premier niveau. Une sous-catégorie ne peut pas avoir d'enfants."),
)
code = models.CharField(max_length=200)
name_fr = models.CharField(max_length=255)
name_nl = models.CharField(max_length=255)
allowed_models = models.ManyToManyField(
ContentType,
blank=True,
related_name="asset_categories",
verbose_name=_("Modèles Django autorisés"),
help_text=_("Les types de modèles d'assets (Django) autorisés pour cette catégorie.")
)
def get_name(self):
lang = get_language()
return getattr(self, f"name_{lang}", self.name_fr) # Fallback to name_fr
@property
def name(self):
return self.get_name()
def __str__(self):
return f"{self.get_name()}"
@property
def is_leaf(self):
"""Retourne True si cette catégorie n'a pas d'enfants (feuille de l'arbre)."""
return not self.children.exists()
def get_children(self):
"""Retourne le queryset des sous-catégories directes."""
return self.children.all()
def get_category_ids_with_children(self):
"""Retourne la liste des ids : cette catégorie + toutes ses sous-catégories."""
ids = [self.pk]
ids.extend(self.children.values_list('pk', flat=True))
return ids
class Meta:
ordering = ["thematic", "name_fr"]
# Abstract base model for "models of assets"
class AbstractAssetModel(models.Model):
code = models.CharField(max_length=50)
name_fr = models.CharField(max_length=200,null=True,blank=True)
name_nl = models.CharField(max_length=200,null=True,blank=True)
description = models.TextField(null=True,blank=True)
brand = models.CharField(max_length=500, null=True,blank=True)
category = models.ForeignKey(AssetCategory, on_delete=models.SET_NULL, null=True, blank=True)
def get_name(self):
lang = get_language()
name = getattr(self, f"name_{lang}", self.name_fr) or "" # Fallback to name_fr
return name
def __str__(self):
return self.code + " - " + self.get_name()
class Meta:
abstract = True
ASSET_STATUS_CHOICES = {
'to_be_approved': _('À approuver'),
'planned': _('Planifié'),
'active': _('Actif'),
'damaged': _('Endommagé'),
'to_replace': _('A remplacer'),
'voluntarily_out_of_service': _('Mis hors service volontairement'),
'removed': _('Enlevé'),
'archived': _('Archivé'),
}
ASSET_OBSOLESCENCE_CHOICES = {
'good': _('Bon état'),
'medium': _('Moyen'),
'bad': _('Dégradé'),
'critical': _('Critique'),
'unknown': _('Inconnu'),
}
ASSET_VALIDATION_STATUS_CHOICES = {
'to_be_validated': _('À valider'),
'validated': _('Validé'),
'to_be_completed': _('À compléter'),
'rejected': _('Rejeté'),
}
ASSET_INSPECTION_STATUS_CHOICES = {
'to_inspect': _('À inspecter'),
'compliant': _('Conforme'),
'non_compliant': _('Non conforme'),
'updated': _('Mis à jour'),
'unknown': _('Non inspecté'),
}
# Abstract base model for assets
class AbstractAsset(models.Model):
code = models.CharField(max_length=50)
name_fr = models.CharField(max_length=200,null=True,blank=True)
name_nl = models.CharField(max_length=200,null=True,blank=True)
external_reference = models.CharField(max_length=100, null=True, blank=True, verbose_name=_("Référence externe"))
status = models.CharField(choices=ASSET_STATUS_CHOICES, default='active', max_length=50)
obsolescence = models.CharField(choices=ASSET_OBSOLESCENCE_CHOICES, default='unknown', null=True, blank=True)
installation_date = models.DateTimeField(null=True,blank=True)
uninstallation_date = models.DateTimeField(null=True,blank=True)
serial_number = models.CharField(max_length=200,null=True,blank=True)
warranty_duration = models.IntegerField(null=True,blank=True, verbose_name="Duration of the warranty (months)")
last_inspection_date = models.DateTimeField(null=True,blank=True)
inspection_status = models.CharField(max_length=50, choices=ASSET_INSPECTION_STATUS_CHOICES, default='unknown', null=True, blank=True, verbose_name="Statut d'inspection")
last_preventive_date = models.DateTimeField(null=True,blank=True)
last_corrective_date = models.DateTimeField(null=True,blank=True)
last_survey_date = models.DateTimeField(null=True,blank=True)
category = models.ForeignKey(AssetCategory, on_delete=models.SET_NULL, null=True, blank=True)
brand = models.CharField(max_length=500, null=True,blank=True)
replaced_by = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='replaces', verbose_name="Remplacé par")
marked_as_removed_date = models.DateTimeField(null=True, blank=True, verbose_name="Date de marquage comme retiré")
marked_as_removed_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='%(app_label)s_%(class)s_marked_as_removed', verbose_name="Marqué comme retiré par")
note = models.TextField(null=True, blank=True, verbose_name="Note")
validation_status = models.CharField(max_length=50, choices=ASSET_VALIDATION_STATUS_CHOICES, null=True, blank=True, verbose_name="Statut de validation")
funding_program = models.CharField(max_length=200, null=True, blank=True, verbose_name=_("Programme de financement / Projet"))
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True, related_name='%(app_label)s_%(class)s_updated_by', verbose_name="Mis à jour par")
asset_inspections = GenericRelation(
'inspections.Inspection',
content_type_field='asset_content_type',
object_id_field='asset_object_id',
related_query_name='%(app_label)s_%(class)s'
)
@property
def is_compliant(self):
return self.inspection_status == 'compliant'
@is_compliant.setter
def is_compliant(self, value):
if value is True:
self.inspection_status = 'compliant'
elif value is False:
self.inspection_status = 'non_compliant'
def get_replacement_chain(self):
"""Get the complete replacement chain starting from this asset."""
chain = [self]
current = self
seen_ids = {self.id}
while current.replaced_by:
if current.replaced_by.id in seen_ids: # Avoid infinite loops
break
chain.append(current.replaced_by)
seen_ids.add(current.replaced_by.id)
current = current.replaced_by
return chain
def get_original_asset(self):
"""Get the first asset in the replacement chain."""
original = self
seen_ids = {self.id}
# Try to find an asset that replaces this one
replacer = self.replaces.first()
while replacer:
if replacer.id in seen_ids: # Avoid infinite loops
break
original = replacer
seen_ids.add(replacer.id)
replacer = replacer.replaces.first()
return original
def get_parent_support(self):
"""
Returns the direct parent support object if this asset is mounted or attached to another asset.
e.g., pole, controller, cabinet, parent_asset, etc.
"""
for attr in (
'pole', 'trafficlight_pole', 'trafficlights_pole',
'publiclighting_pole', 'lighting_pole', 'controller',
'cabinet', 'parent_asset'
):
support = getattr(self, attr, None)
if support is not None:
return support
return None
def get_location(self):
"""
Resolves the associated location object (Street, Intersection, StructureLocation, PublicBuilding, etc.)
either directly from the asset or indirectly via its parent support.
"""
# 1. Direct location fields
for attr in ('location', 'street', 'intersection', 'building', 'site', 'structure'):
loc = getattr(self, attr, None)
if loc is not None:
if attr == 'structure' and hasattr(loc, 'main_location') and loc.main_location:
return loc.main_location
return loc
# 2. Indirectly via parent support
parent_support = self.get_parent_support()
if parent_support is not None:
if hasattr(parent_support, 'get_location'):
loc = parent_support.get_location()
if loc is not None:
return loc
for attr in ('location', 'street', 'intersection', 'building', 'site', 'structure'):
loc = getattr(parent_support, attr, None)
if loc is not None:
if attr == 'structure' and hasattr(loc, 'main_location') and loc.main_location:
return loc.main_location
return loc
return None
def get_name(self):
lang = get_language()
name = getattr(self, f"name_{lang}", self.name_fr) or "" # Fallback to name_fr
return name
def __str__(self):
return self.code + " - " + self.get_name()
class Meta:
abstract = True
# Abstract base model for geo assets
class AbstractGeoAsset(GeoSyncMixin, AbstractAsset):
lon = models.FloatField(null=True,blank=True)
lat = models.FloatField(null=True,blank=True)
geom = models.PointField(srid=3812, null=True,blank=True)
geojson = models.TextField(null=True, blank=True)
class Meta:
abstract = True

View file

@ -1,300 +0,0 @@
# ----------------------
# ITS Models
# ----------------------
from django.contrib.gis.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import get_language, gettext_lazy as _
import os
from assets.models import AbstractLocation, AbstractAsset, AbstractGeoAsset, AbstractAssetModel
from common.models import Municipality, Document
from common.storages import PrivateMediaStorage
private_storage = PrivateMediaStorage()
# ITS locations
class ITSLocation(AbstractLocation):
geom = models.MultiPolygonField(srid=3812, null=True,blank=True)
parent_location = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_locations")
is_main_location = models.BooleanField(default=False, verbose_name="Main Location")
def get_active_assets(self):
def collect_assets(location):
assets = list(location.assets.filter(status='active'))
for child in location.child_locations.all():
assets += collect_assets(child)
return assets
return collect_assets(self)
def get_all_assets(self):
def collect_assets(location):
assets = list(location.assets.all())
for child in location.child_locations.all():
assets += collect_assets(child)
return assets
return collect_assets(self)
def get_direct_child_assets(self):
return self.assets.all()
def get_absolute_url(self):
return reverse(
'assets:its_locations_detail',
kwargs={
'location_id': self.id
}
)
class Meta:
verbose_name = "ITS Location"
verbose_name_plural = "ITS Locations"
ordering = ["code"]
def upload_to_its_location(instance, filename):
location = instance.location
code = slugify(location.code or "unknown") # Fallback si code est None
return os.path.join("assets_documents", "its_locations", code, filename)
ITS_LOC_DOCUMENT_TYPE_CHOICES = (
('as_built', 'As Built'),
('schema', "Schéma"),
('other', 'Autre'),
)
# ITS Location Document
class ITSLocationDocument(Document):
location = models.ForeignKey(ITSLocation, on_delete=models.CASCADE, related_name="documents", verbose_name="Associated ITS Location")
file = models.FileField(upload_to=upload_to_its_location, storage=private_storage, verbose_name="Fichier", max_length=255)
thumbnail = models.ImageField(upload_to=upload_to_its_location, storage=private_storage, null=True, blank=True, max_length=255)
document_type = models.CharField(max_length=50, choices=ITS_LOC_DOCUMENT_TYPE_CHOICES, default='other', verbose_name="Type de document")
class ITSAssetModel(AbstractAssetModel):
class Meta:
verbose_name = "ITS Asset Model"
verbose_name_plural = "ITS Asset Models"
class ITSGeoAsset(AbstractGeoAsset):
model = models.ForeignKey(ITSAssetModel, on_delete=models.PROTECT)
location = models.ForeignKey(ITSLocation, related_name="assets", on_delete=models.SET_NULL, null=True, blank=True)
trafficlights_pole = models.ForeignKey("assets.TrafficLightPole", on_delete=models.SET_NULL, null=True, blank=True, related_name="its_assets", verbose_name="Traffic Light Pole")
lighting_pole = models.ForeignKey("assets.PublicLightingPole", on_delete=models.SET_NULL, null=True, blank=True, related_name="its_assets", verbose_name="Lighting Pole")
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
def clean(self):
super().clean()
support = self.trafficlights_pole or self.lighting_pole
if support and getattr(support, 'geom', None):
self.geom = support.geom
self.lon = support.lon
self.lat = support.lat
self.geojson = support.geojson
def save(self, *args, **kwargs):
support = self.trafficlights_pole or self.lighting_pole
if support and getattr(support, 'geom', None):
self.geom = support.geom
self.lon = support.lon
self.lat = support.lat
self.geojson = support.geojson
super().save(*args, **kwargs)
class Meta:
indexes = [
models.Index(fields=["code"]),
]
def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsgeoasset', 'asset_id':self.id})
BOLLARD_DIRECTION_CHOICES = (
('unidirectional', _('Sens unique')),
('bidirectional', _('Double sens')),
)
class ITSBollardSystem(AbstractGeoAsset):
"""
Système / Traverse de bornes escamotables (représentation linéaire).
Contrôle l'accès à une voie ou zone piétonne.
"""
geom = models.LineStringField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie (Ligne)"))
model = models.ForeignKey(ITSAssetModel, on_delete=models.PROTECT, null=True, blank=True, verbose_name=_("Modèle de système"))
location = models.ForeignKey(ITSLocation, related_name="bollard_systems", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Localisation ITS"))
width = models.FloatField(null=True, blank=True, verbose_name=_("Largeur de passage (m)"))
direction = models.CharField(max_length=50, choices=BOLLARD_DIRECTION_CHOICES, default='bidirectional', null=True, blank=True, verbose_name=_("Sens de circulation"))
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
associated_assets = models.ManyToManyField(
'assets.ITSGeoAsset',
through='assets.ITSBollardSystemAsset',
related_name="bollard_systems",
blank=True,
verbose_name=_("Équipements associés")
)
class Meta:
verbose_name = _("Système de bornes")
verbose_name_plural = _("Systèmes de bornes")
indexes = [
models.Index(fields=["code"]),
]
def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsbollardsystem', 'asset_id': self.id})
@property
def plots_count(self):
return self.plots.count()
@property
def name(self):
return self.get_name()
BOLLARD_TYPE_CHOICES = (
('automatic', _('Automatique / Escamotable')),
('semi_automatic', _('Semi-automatique')),
('fixed', _('Fixe')),
('removable', _('Amovible')),
)
class ITSBollardPlot(AbstractGeoAsset):
"""
Plot / borne physique individuel sur un système de bornes.
"""
geom = models.PointField(srid=3812, null=True, blank=True, verbose_name=_("Géométrie (Point)"))
system = models.ForeignKey(ITSBollardSystem, on_delete=models.CASCADE, related_name="plots", verbose_name=_("Système de bornes"))
model = models.ForeignKey(ITSAssetModel, on_delete=models.PROTECT, null=True, blank=True, verbose_name=_("Modèle de plot"))
position = models.PositiveSmallIntegerField(default=1, verbose_name=_("Position sur la traverse"), help_text=_("Ordre de gauche à droite (1, 2, 3...)"))
bollard_type = models.CharField(max_length=50, choices=BOLLARD_TYPE_CHOICES, default='automatic', verbose_name=_("Type de plot"))
location = models.ForeignKey(ITSLocation, related_name="bollard_plots", on_delete=models.SET_NULL, null=True, blank=True, verbose_name=_("Localisation ITS"))
extra_data = models.JSONField(default=dict, blank=True, verbose_name=_("Métadonnées techniques"))
@property
def name(self):
return self.get_name()
def clean(self):
super().clean()
if not self.location_id and self.system and self.system.location_id:
self.location = self.system.location
def save(self, *args, **kwargs):
if not self.location_id and self.system and self.system.location_id:
self.location = self.system.location
super().save(*args, **kwargs)
class Meta:
verbose_name = _("Plot de borne")
verbose_name_plural = _("Plots de bornes")
ordering = ["system", "position"]
indexes = [
models.Index(fields=["code"]),
]
def get_absolute_url(self):
return reverse('assets:its_assets_detail', kwargs={'asset_model': 'itsbollardplot', 'asset_id': self.id})
class ITSBollardSystemAsset(models.Model):
"""
Table de liaison ManyToMany entre un ITSBollardSystem et des ITSGeoAsset
(caméras LAPI, totems, armoires électriques, feux bicolores, boucles...).
"""
class AssetRole(models.TextChoices):
CAMERA_ENTRY = "camera_entry", _("Caméra entrée (LAPI)")
CAMERA_EXIT = "camera_exit", _("Caméra sortie (LAPI)")
CAMERA_CCTV = "camera_cctv", _("Caméra de contexte / surveillance")
CABINET = "cabinet", _("Armoire électrique / automate")
TOTEM = "totem", _("Totem de commande")
TRAFFIC_LIGHT = "traffic_light", _("Feu de franchissement")
LOOP = "loop", _("Boucle de détection")
OTHER = "other", _("Autre")
bollard_system = models.ForeignKey(
ITSBollardSystem,
on_delete=models.CASCADE,
related_name="system_assets",
verbose_name=_("Système de bornes")
)
asset = models.ForeignKey(
ITSGeoAsset,
on_delete=models.CASCADE,
related_name="bollard_system_links",
verbose_name=_("Équipement ITS")
)
role = models.CharField(
max_length=50,
choices=AssetRole.choices,
default=AssetRole.OTHER,
verbose_name=_("Rôle de l'équipement")
)
comment = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Commentaire"))
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Date d'association"))
class Meta:
verbose_name = _("Équipement associé au système de bornes")
verbose_name_plural = _("Équipements associés au système de bornes")
unique_together = ('bollard_system', 'asset')
def __str__(self):
return f"{self.bollard_system.code} - {self.asset.code} ({self.get_role_display()})"
class ITSBollardSystemContract(models.Model):
system = models.ForeignKey(ITSBollardSystem, on_delete=models.CASCADE, related_name="bollard_systems_contracts")
contract = models.ForeignKey("contracts.Contract", on_delete=models.CASCADE, related_name="bollard_systems_contracts")
status = models.CharField(max_length=50, choices=[('active', 'Actif'), ('inactive', 'Inactif'), ('archived', 'Archivé')], default='active')
maintenance_type = models.CharField(max_length=50, choices=[('equipments', 'Equipements'), ('office', 'Bureautique'), ('system', 'Système'), ('other', 'Autre')], default='other')
def __str__(self):
return f"{self.system.code} - {self.contract.contract_number}"
class Meta:
verbose_name = _("Contrat de système de bornes")
verbose_name_plural = _("Contrats de systèmes de bornes")
unique_together = ('system', 'contract')
class ITSLocationContract(models.Model):
location = models.ForeignKey(ITSLocation, on_delete=models.CASCADE, related_name="its_locations_contracts")
contract = models.ForeignKey("contracts.Contract", on_delete=models.CASCADE, related_name="its_locations_contracts")
status = models.CharField(max_length=50, choices=[('active', 'Actif'), ('inactive', 'Inactif'), ('archived', 'Archivé')], default='active')
maintenance_type = models.CharField(max_length=50, choices=[('equipments', 'Equipements'), ('office', 'Bureautique'), ('system', 'Système'), ('other', 'Autre')], default='other')
def __str__(self):
return f"{self.location.code} - {self.contract.contract_number}"
class Meta:
verbose_name = "ITS Location Contract"
verbose_name_plural = "ITS Location Contracts"
unique_together = ('location', 'contract')
class ITSGeoAssetContract(models.Model):
asset = models.ForeignKey(ITSGeoAsset, on_delete=models.CASCADE, related_name="its_assets_contracts")
contract = models.ForeignKey("contracts.Contract", on_delete=models.CASCADE, related_name="its_assets_contracts")
status = models.CharField(max_length=50, choices=[('active', 'Actif'), ('inactive', 'Inactif'), ('archived', 'Archivé')], default='active')
maintenance_type = models.CharField(max_length=50, choices=[('equipments', 'Equipements'), ('office', 'Bureautique'), ('system', 'Système'), ('other', 'Autre')], default='other')
def __str__(self):
return f"{self.asset.code} - {self.contract.contract_number}"
class Meta:
verbose_name = "ITS Asset Contract"
verbose_name_plural = "ITS Asset Contracts"
unique_together = ('asset', 'contract')

View file

@ -1,214 +0,0 @@
from django.contrib.gis.db import models
from django.contrib.contenttypes.models import ContentType
from django.contrib.contenttypes.fields import GenericForeignKey
from django.conf import settings
from django.utils.translation import gettext_lazy as _, get_language
import json
from common.models import Thematic, Document
from common.storages import PrivateMediaStorage
private_storage = PrivateMediaStorage()
class ManagementZoneType(models.Model):
"""
Typologie de zone de gestion spécifique à une thématique
(ex: Fauche tardive / refuge biodiversité, Tonte d'agrément, Onde verte coordonnée, etc.).
"""
code = models.CharField(max_length=100, unique=True, verbose_name=_("Code"))
name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)"))
name_nl = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Nom (NL)"))
thematic = models.ForeignKey(
Thematic,
on_delete=models.CASCADE,
related_name="management_zone_types",
verbose_name=_("Thématique")
)
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
default_color = models.CharField(max_length=20, default="#198754", verbose_name=_("Couleur par défaut"))
def get_name(self):
lang = get_language()
if lang and lang.startswith("nl") and self.name_nl:
return self.name_nl
return self.name_fr
@property
def name(self):
return self.get_name()
def __str__(self):
return f"{self.get_name()} ({self.thematic.name})"
class Meta:
ordering = ["thematic", "name_fr"]
verbose_name = _("Type de zone de gestion")
verbose_name_plural = _("Types de zones de gestion")
class ManagementZone(models.Model):
"""
Zone de gestion générique avec géométrie polygonale.
Regroupe des équipements/assets selon une logique métier, d'exploitation ou de gestion écologique.
"""
code = models.CharField(max_length=100, unique=True, verbose_name=_("Code"))
name_fr = models.CharField(max_length=255, verbose_name=_("Nom (FR)"))
name_nl = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Nom (NL)"))
thematic = models.ForeignKey(
Thematic,
on_delete=models.PROTECT,
related_name="management_zones",
verbose_name=_("Thématique")
)
zone_type = models.ForeignKey(
ManagementZoneType,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="management_zones",
verbose_name=_("Type de zone de gestion")
)
description = models.TextField(blank=True, null=True, verbose_name=_("Description"))
instructions = models.TextField(
blank=True,
null=True,
verbose_name=_("Consignes et points d'attention"),
help_text=_("Consignes d'exploitation, points de vigilance, règles de sécurité...")
)
geom = models.MultiPolygonField(
srid=3812,
null=True,
blank=True,
verbose_name=_("Périmètre géographique")
)
color = models.CharField(
max_length=20,
blank=True,
null=True,
verbose_name=_("Couleur d'affichage"),
help_text=_("Code hexadécimal (ex: #198754). Si vide, utilise la couleur du type de zone.")
)
is_active = models.BooleanField(default=True, verbose_name=_("Active"))
target_frequency_days = models.IntegerField(
null=True,
blank=True,
verbose_name=_("Fréquence cible (jours)"),
help_text=_("Intervalle indicatif entre interventions en jours.")
)
seasonality_notes = models.CharField(
max_length=255,
blank=True,
null=True,
verbose_name=_("Saisonnalité / Calendrier"),
help_text=_("Ex: Tonte d'avril à octobre; fauche tardive en septembre; interdiction de taille au printemps.")
)
created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Date de création"))
updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Date de mise à jour"))
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="created_management_zones",
verbose_name=_("Créé par")
)
def get_color(self):
if self.color:
return self.color
if self.zone_type and self.zone_type.default_color:
return self.zone_type.default_color
return "#198754"
@property
def geojson_geometry(self):
"""
Retourne la géométrie GeoJSON transformée en WGS84 (EPSG:4326)
pour utilisation directe dans MapLibre / Leaflet.
"""
if not self.geom:
return None
try:
geom_4326 = self.geom.transform(4326, clone=True)
return json.loads(geom_4326.geojson)
except Exception:
return None
def get_name(self):
lang = get_language()
if lang and lang.startswith("nl") and self.name_nl:
return self.name_nl
return self.name_fr
@property
def name(self):
return self.get_name()
def __str__(self):
return f"{self.code} - {self.get_name()}"
class Meta:
ordering = ["thematic", "code"]
verbose_name = _("Zone de gestion")
verbose_name_plural = _("Zones de gestion")
class ManagementZoneAsset(models.Model):
"""
Liaison explicite (inclusion ou exclusion) d'un asset à une zone de gestion.
Permet d'affiner le rattachement au-delà de la stricte inclusion spatiale.
"""
management_zone = models.ForeignKey(
ManagementZone,
on_delete=models.CASCADE,
related_name="explicit_assets",
verbose_name=_("Zone de gestion")
)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE, verbose_name=_("Type d'asset"))
object_id = models.PositiveIntegerField(verbose_name=_("ID de l'asset"))
asset = GenericForeignKey('content_type', 'object_id')
is_excluded = models.BooleanField(
default=False,
verbose_name=_("Exclu"),
help_text=_("Si coché, cet équipement est exclu de la zone même si sa géométrie intersecte le périmètre.")
)
notes = models.CharField(max_length=255, blank=True, null=True, verbose_name=_("Notes"))
def __str__(self):
status = "Exclu" if self.is_excluded else "Inclus"
return f"{self.management_zone.code} -> {self.content_type.model} #{self.object_id} ({status})"
class Meta:
unique_together = ('management_zone', 'content_type', 'object_id')
verbose_name = _("Asset de zone de gestion")
verbose_name_plural = _("Assets de zones de gestion")
class ManagementZoneDocument(Document):
"""
Documents, fiches techniques et arrêtés associés à une zone de gestion.
"""
zone = models.ForeignKey(
ManagementZone,
on_delete=models.CASCADE,
related_name="documents",
verbose_name=_("Zone de gestion")
)
file = models.FileField(
upload_to="assets_documents/management_zones/",
storage=private_storage,
verbose_name=_("Fichier"),
max_length=255
)
thumbnail = models.ImageField(
upload_to="assets_documents/management_zones/",
storage=private_storage,
null=True,
blank=True,
max_length=255
)
class Meta:
verbose_name = _("Document de zone de gestion")
verbose_name_plural = _("Documents de zones de gestion")

View file

@ -1,201 +0,0 @@
# -----------------------------
# Outdoor Spaces & Assets Models
# -----------------------------
from django.contrib.gis.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.contrib.auth.models import User
from assets.models import AbstractLocation, AbstractGeoAsset, ASSET_STATUS_CHOICES
from common.models import Document
from common.storages import PrivateMediaStorage
private_storage = PrivateMediaStorage()
SITE_TYPE_CHOICES = (
('playground', _('Aire / Plaine de jeux')),
('sports_ground', _('Terrain de sport extérieur')),
('multisports_agora', _('Terrain multisports / City-stadium')),
('skatepark_pumptrack', _('Skatepark / Pumptrack / Bowl')),
('outdoor_fitness', _('Fitness plein air / Street Workout')),
('open_air_theater', _('Théâtre de verdure / Scène extérieure')),
('park_recreation', _('Parc & Espace récréatif')),
('dog_park', _('Espace canin / Canisite')),
('cemetery', _('Cimetière communal')),
('event_plaza', _('Place publique / Zone événementielle')),
('other', _('Autre espace plein air')),
)
OUTDOOR_ASSET_TYPE_CHOICES = (
('play_structure', _('Structure combinée / Tour de jeu')),
('swing', _('Balançoire / Portique')),
('spring_rider', _('Jeu à ressort / Bascule')),
('slide', _('Toboggan')),
('zip_line', _('Tyrolienne')),
('amortizing_surface', _('Sol amortissant / Réception')),
('sports_goal', _('But / Cage / Panneau de basket')),
('fitness_station', _('Agrès de fitness outdoor / Workout')),
('skate_module', _('Module de skate / Rampe / Rail')),
('stage_platform', _('Scène extérieure / Podium')),
('audience_seating', _('Gradins / Bancs de spectateurs')),
('event_power_box', _('Borne foraine / Armoire électrique événementielle')),
('outdoor_lighting', _('Éclairage plein air / Projecteur scénique')),
('bench_table', _('Banc / Table de pique-nique')),
('shade_shelter', _('Kiosque / Voile d\'ombrage / Abri')),
('drinking_fountain', _('Fontaine d\'eau potable')),
('fence_gate', _('Clôture / Portillon de sécurité')),
('info_panel', _('Panneau d\'information / Consignes')),
('other', _('Autre équipement plein air')),
)
NORM_CHOICES = (
('EN_1176', _('EN 1176 - Équipements & sols d\'aires de jeux')),
('EN_16630', _('EN 16630 - Modules de fitness d\'extérieur')),
('EN_14974', _('EN 14974 - Équipements de skatepark')),
('EN_15312', _('EN 15312 - Équipements multisports en accès libre')),
('other', _('Autre norme / Non spécifié')),
)
class OutdoorLocation(AbstractLocation):
"""
Modèle représentant un Espace Public Extérieur (Plaine de jeux, Terrain de sport, Théâtre plein air, Parc, etc.).
Peut comporter une arborescence (ex: Parc municipal > Aire de jeux > Zone toboggan).
"""
geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise cartographique"))
parent_location = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_locations", verbose_name=_("Localisation parente"))
site_type = models.CharField(max_length=50, choices=SITE_TYPE_CHOICES, default='playground', verbose_name=_("Type de site plein air"))
is_main_location = models.BooleanField(default=False, verbose_name=_("Localisation principale"))
address = models.CharField(max_length=500, null=True, blank=True, verbose_name=_("Adresse complète"))
description = models.TextField(null=True, blank=True, verbose_name=_("Description"))
# Propriété & Gestion
owner = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Propriétaire"))
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name="managed_outdoor_locations", verbose_name=_("Gestionnaire référent"))
occupant_entity = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Direction / Service gestionnaire"))
# Caractéristiques physiques & aménagements
surface_m2 = models.FloatField(null=True, blank=True, verbose_name=_("Surface totale (m²)"))
max_capacity = models.IntegerField(null=True, blank=True, verbose_name=_("Jauge / Capacité d'accueil"))
pmr_accessible = models.BooleanField(default=False, verbose_name=_("Accessible PMR"))
has_water_point = models.BooleanField(default=False, verbose_name=_("Point d'eau potable"))
has_power_supply = models.BooleanField(default=False, verbose_name=_("Alimentation électrique disponible"))
is_fenced = models.BooleanField(default=False, verbose_name=_("Site clôturé / Sécurisé"))
class Meta:
verbose_name = _("Espace public d'extérieur")
verbose_name_plural = _("Espaces publics d'extérieur")
ordering = ['code', 'name_fr']
def get_child_locations(self):
return self.child_locations.all()
def get_active_assets(self):
def collect_assets(loc):
assets = list(loc.location_assets.filter(status='active'))
for child in loc.child_locations.all():
assets += collect_assets(child)
return assets
return collect_assets(self)
def get_all_assets(self):
def collect_assets(loc):
if loc.parent_location is None:
assets = list(loc.location_assets.select_related('location', 'parent_asset', 'site').all() | loc.all_outdoor_assets.select_related('location', 'parent_asset', 'site').filter(location__isnull=True))
else:
assets = list(loc.location_assets.select_related('location', 'parent_asset', 'site').all())
for child in loc.child_locations.all():
assets += collect_assets(child)
return assets
seen = set()
unique_assets = []
for a in collect_assets(self):
if a.id not in seen:
seen.add(a.id)
unique_assets.append(a)
return unique_assets
def get_direct_child_assets(self):
return self.location_assets.all()
def get_absolute_url(self):
return reverse('assets:outdoor_location_detail', kwargs={'pk': self.pk})
class OutdoorAsset(AbstractGeoAsset):
"""
Modèle représentant un asset ou équipement physique d'extérieur (Structure de jeu, Sol amortissant, Agrès, Cage, Scène...).
Les assets peuvent former une arborescence parent-enfant.
"""
site = models.ForeignKey(OutdoorLocation, on_delete=models.CASCADE, related_name="all_outdoor_assets", verbose_name=_("Site / Espace principal"))
location = models.ForeignKey(OutdoorLocation, on_delete=models.SET_NULL, null=True, blank=True, related_name="location_assets", verbose_name=_("Localisation spécifique"))
parent_asset = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_assets", verbose_name=_("Asset parent"))
asset_type = models.CharField(max_length=50, choices=OUTDOOR_ASSET_TYPE_CHOICES, default='play_structure', verbose_name=_("Type d'asset / Équipement"))
# Norme & Sécurité réglementaire
norm = models.CharField(max_length=50, choices=NORM_CHOICES, blank=True, null=True, verbose_name=_("Norme de sécurité appliquée"))
last_control_date = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle visuel/opérationnel"))
next_control_date = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle requis"))
last_annual_inspection = models.DateField(null=True, blank=True, verbose_name=_("Dernier contrôle annuel certifié"))
next_annual_inspection = models.DateField(null=True, blank=True, verbose_name=_("Prochain contrôle annuel certifié"))
certifying_body = models.CharField(max_length=255, null=True, blank=True, verbose_name=_("Organisme de contrôle certifié"))
# Sols amortissants & hauteurs d'impact
impact_surface_type = models.CharField(max_length=150, blank=True, null=True, verbose_name=_("Type de sol d'amortissement"))
critical_fall_height_m = models.FloatField(null=True, blank=True, verbose_name=_("Hauteur critique de chute (m)"))
def clean(self):
super().clean()
if self.parent_asset and getattr(self.parent_asset, 'geom', None) and not self.geom:
self.geom = self.parent_asset.geom
self.lon = self.parent_asset.lon
self.lat = self.parent_asset.lat
self.geojson = self.parent_asset.geojson
if self.parent_asset and self.parent_asset.location and not self.location_id:
self.location = self.parent_asset.location
if self.parent_asset and self.parent_asset.site and not self.site_id:
self.site = self.parent_asset.site
def save(self, *args, **kwargs):
if self.parent_asset and getattr(self.parent_asset, 'geom', None) and not self.geom:
self.geom = self.parent_asset.geom
self.lon = self.parent_asset.lon
self.lat = self.parent_asset.lat
self.geojson = self.parent_asset.geojson
if self.parent_asset and self.parent_asset.location and not self.location_id:
self.location = self.parent_asset.location
if self.parent_asset and self.parent_asset.site and not self.site_id:
self.site = self.parent_asset.site
super().save(*args, **kwargs)
class Meta:
verbose_name = _("Asset / Équipement plein air")
verbose_name_plural = _("Assets / Équipements plein air")
ordering = ['site', 'code']
def get_absolute_url(self):
return reverse('assets:outdoor_assets_detail', kwargs={'asset_model': 'outdoorasset', 'asset_id': self.pk})
def get_child_assets(self):
return self.child_assets.all()
def upload_to_outdoor(instance, filename):
return f"outdoor/{instance.site.code}/{filename}"
class OutdoorLocationDocument(Document):
"""
Documents associés à un espace extérieur (certificats de conformité, registres de sécurité, plans).
"""
site = models.ForeignKey(OutdoorLocation, on_delete=models.CASCADE, related_name="documents", verbose_name=_("Espace extérieur"))
file = models.FileField(upload_to=upload_to_outdoor, storage=private_storage, verbose_name=_("Fichier"), max_length=255)
document_type = models.CharField(max_length=50, default='other', verbose_name=_("Type de document"))
class Meta:
verbose_name = _("Document d'espace extérieur")
verbose_name_plural = _("Documents d'espace extérieur")

View file

@ -1,143 +0,0 @@
from django.contrib.gis.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from assets.models import AbstractLocation, AbstractGeoAsset
from assets.models.sign import SignStreet, SignPole, SignPanel
SPOT_TYPE_CHOICES = (
('bicycle', _('Stationnement vélo')),
('scooter_drop_zone', _('Drop zone trottinettes / EDPM')),
('cargo_bike', _('Stationnement vélo cargo')),
('delivery', _('Zone de livraison / Chargement-déchargement')),
('pmr', _('Emplacement PMR')),
('kiss_and_ride', _('Arrêt minute / Court-durée (CD)')),
('car_parking', _('Stationnement automobile')),
('carsharing', _('Autopartage')),
('ev_charging', _('Recharge véhicule électrique')),
('other', _('Autre')),
)
STAND_TYPE_CHOICES = (
('normal', _('Arceau standard (U inversé)')),
('cargo', _('Arceau vélo cargo')),
('mixed', _('Mixte (normal et cargo)')),
('rack', _('Râteau / Pince-roue')),
('box', _('Box / Abri sécurisé')),
('other', _('Autre')),
)
MARKING_QUALITY_CHOICES = (
('good', _('Bon état')),
('medium', _('Moyen')),
('damaged', _('Dégradé')),
('missing', _('Absent / Effacé')),
)
class ParkingLocation(AbstractLocation):
"""
Modèle représentant un secteur ou zone d'ensemble de stationnement.
"""
geom = models.MultiPolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise cartographique"))
description = models.TextField(null=True, blank=True, verbose_name=_("Description"))
address = models.CharField(max_length=500, null=True, blank=True, verbose_name=_("Adresse"))
class Meta:
verbose_name = _("Zone de stationnement")
verbose_name_plural = _("Zones de stationnement")
ordering = ['code', 'name_fr']
class ParkingSpot(AbstractGeoAsset):
"""
Modèle représentant un emplacement ou zone de stationnement vélo/trottinette/PMR/livraison.
Utilise une géométrie en Polygone.
"""
geom = models.PolygonField(srid=3812, null=True, blank=True, verbose_name=_("Emprise polygone"))
location = models.ForeignKey(
ParkingLocation,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='parking_spots',
verbose_name=_("Zone / Localisation parente")
)
street = models.ForeignKey(
SignStreet,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name='parking_spots_new',
verbose_name=_("Rue")
)
spot_type = models.CharField(
max_length=50,
choices=SPOT_TYPE_CHOICES,
default='bicycle',
verbose_name=_("Type d'emplacement")
)
# Métriques et types d'arceaux vélos
standard_stand_count = models.IntegerField(default=0, verbose_name=_("Nombre d'arceaux vélos classiques"))
cargo_stand_count = models.IntegerField(default=0, verbose_name=_("Nombre d'arceaux vélos cargo"))
drop_off_stand_count = models.IntegerField(default=0, verbose_name=_("Nombre d'arceaux drop-off"))
other_stand_count = models.IntegerField(default=0, verbose_name=_("Autres arceaux / agencements"))
stand_type = models.CharField(
max_length=50,
choices=STAND_TYPE_CHOICES,
default='normal',
verbose_name=_("Type d'arceau principal")
)
has_scooter_drop_zone = models.BooleanField(default=False, verbose_name=_("Drop zone trottinettes / EDPM"))
# Métriques générales & stationnement auto/livraison/PMR
capacity = models.IntegerField(null=True, blank=True, verbose_name=_("Capacité générale (nbr véhicules)"))
is_paid = models.BooleanField(default=False, verbose_name=_("Payant"))
time_limit_minutes = models.IntegerField(null=True, blank=True, verbose_name=_("Durée maximale (minutes)"))
marking_quality = models.CharField(
max_length=50,
choices=MARKING_QUALITY_CHOICES,
default='good',
verbose_name=_("Qualité du marquage au sol")
)
is_temporary = models.BooleanField(default=False, verbose_name=_("Temporaire (chantier/événement)"))
# Conformité & résultat d'inspection
is_compliant = models.BooleanField(default=True, verbose_name=_("Conforme aux spécifications"))
non_compliance_reason = models.TextField(null=True, blank=True, verbose_name=_("Motif de non-conformité"))
# Signalisation associée
poles = models.ManyToManyField(
SignPole,
related_name='parking_spots_attached_new',
blank=True,
verbose_name=_("Poteaux de signalisation associés")
)
panels = models.ManyToManyField(
SignPanel,
related_name='parking_spots_attached_new',
blank=True,
verbose_name=_("Panneaux de signalisation associés")
)
@property
def total_stand_count(self):
"""Retourne le nombre total d'arceaux (classiques + cargo + drop-off + autres)."""
return (self.standard_stand_count or 0) + (self.cargo_stand_count or 0) + (self.drop_off_stand_count or 0) + (self.other_stand_count or 0)
class Meta:
verbose_name = _("Emplacement de stationnement")
verbose_name_plural = _("Emplacements de stationnement")
ordering = ['code', 'name_fr']
def get_absolute_url(self):
return reverse('assets:parking_assets_detail', kwargs={'asset_model': 'parking_spot', 'asset_id': self.id})
def __str__(self):
type_label = self.get_spot_type_display()
name = self.get_name()
if name:
return f"{self.code} - {type_label} ({name})"
return f"{self.code} - {type_label}"

View file

@ -1,418 +0,0 @@
from django.conf import settings
from django.contrib.gis.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import get_language, gettext_lazy as _
from django.core.exceptions import ValidationError
import os
from assets.models import AbstractLocation, AbstractAsset, AbstractGeoAsset, AbstractAssetModel
from common.models import Municipality, Document
from common.storages import PrivateMediaStorage
private_storage = PrivateMediaStorage()
STRUCTURE_TYPE_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')),
)
# Structure locations
class StructureLocation(AbstractLocation):
geom = models.MultiPolygonField(srid=3812, null=True,blank=True)
parent_location = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_locations")
is_main_location = models.BooleanField(default=False, verbose_name="Main Location")
structure = models.ForeignKey('Structure', on_delete=models.SET_NULL, null=True, blank=True, related_name="all_locations")
def get_active_assets(self):
def collect_assets(location):
assets = list(location.assets.filter(status='active'))
for child in location.child_locations.all():
assets += collect_assets(child)
return assets
return collect_assets(self)
def get_all_assets(self):
def collect_assets(location):
assets = list(location.assets.all())
for child in location.child_locations.all():
assets += collect_assets(child)
return assets
return collect_assets(self)
def get_direct_child_assets(self):
return self.assets.all()
def clean(self):
if self.is_main_location and StructureLocation.objects.filter(structure=self.structure, is_main_location=True).exclude(pk=self.pk).exists():
raise ValidationError("Cette structure a déjà une localisation principale.")
def get_absolute_url(self):
return reverse(
'assets:structures_locations_detail',
kwargs={
# 'asset_model': content_type.model,
'location_id': self.id
}
)
class Meta:
verbose_name = "Structure Location"
verbose_name_plural = "Structure Locations"
ordering = ["code"]
class Structure(models.Model):
code = models.CharField(max_length=50, unique=True)
name_fr = models.CharField(max_length=200,null=True,blank=True)
name_nl = models.CharField(max_length=200,null=True,blank=True)
structure_type = models.CharField(max_length=200, choices=STRUCTURE_TYPE_CHOICES, null=True, blank=True)
main_location = models.OneToOneField(StructureLocation, on_delete=models.SET_NULL, null=True, blank=True, related_name="main_structure")
description = models.TextField(null=True, blank=True)
linked_folder = models.CharField(max_length=1024, null=True, blank=True)
structure_number = models.IntegerField(null=True, blank=True, verbose_name="Numéro de l'ouvrage")
def get_name(self):
lang = get_language()
name = getattr(self, f"name_{lang}", self.name_fr) or "" # Fallback to name_fr
return name
def get_active_assets(self):
return self.assets.filter(status='active')
def get_all_assets(self):
return self.assets.all()
def get_all_locations(self):
return self.all_locations.all()
def get_main_location(self):
return self.main_location
def __str__(self):
return self.code + " - " + self.get_name()
class Meta:
ordering = ["code"]
def upload_to_structure(instance, filename):
location = instance.structure
code = slugify(location.code or "unknown") # Fallback si code est None
return os.path.join("assets_documents", "structures", code, filename)
TU_DOCUMENT_TYPE_CHOICES = (
('as_built', 'As Built'),
('cme', "CME"),
('inspection_ce', 'Inspection G.C.'),
('inspection_em', 'Inspection E.M.'),
('pis', 'PIS'),
('other', 'Autre'),
)
# Structure Location Document
class StructureDocument(Document):
structure = models.ForeignKey(Structure, on_delete=models.CASCADE, related_name="documents", verbose_name="Associated Structure")
file = models.FileField(upload_to=upload_to_structure, storage=private_storage, verbose_name="Fichier", max_length=255)
thumbnail = models.ImageField(upload_to=upload_to_structure, storage=private_storage, null=True, blank=True, max_length=255)
document_type = models.CharField(max_length=50, choices=TU_DOCUMENT_TYPE_CHOICES, default='other', verbose_name="Type de document")
SC_MAINTENANCE_TYPE_CHOICES = (
('electromeca', 'Eléctromécanique'),
('civil_engineering', 'Génie civil'),
('urgent_cleaning', 'Nettoyage urgent'),
('other', 'Autre'),
)
class StructureContract(models.Model):
structure = models.ForeignKey(Structure, on_delete=models.CASCADE, related_name="contracts")
contract = models.ForeignKey("contracts.Contract", on_delete=models.CASCADE, related_name="structure_contracts")
status = models.CharField(max_length=50, choices=[('active', 'Actif'), ('inactive', 'Inactif'), ('archived', 'Archivé')], default='active')
maintenance_type = models.CharField(max_length=50, choices=SC_MAINTENANCE_TYPE_CHOICES, default='other')
def __str__(self):
return f"{self.structure.code} - {self.contract.contract_number}"
class Meta:
verbose_name = "Structure Contract"
verbose_name_plural = "Structure Contracts"
unique_together = ('structure', 'contract')
class StructureAssetModel(AbstractAssetModel):
class Meta:
verbose_name = "Structure Asset Model"
verbose_name_plural = "Structure Asset Models"
class StructureGeoAsset(AbstractGeoAsset):
model = models.ForeignKey(StructureAssetModel, on_delete=models.PROTECT)
location = models.ForeignKey(StructureLocation, related_name="assets", on_delete=models.SET_NULL, null=True, blank=True)
structure = models.ForeignKey(Structure, on_delete=models.SET_NULL, related_name="assets", null=True, blank=True)
parent_asset = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name="child_assets")
def clean(self):
super().clean()
if self.parent_asset and getattr(self.parent_asset, 'geom', None) and not self.geom:
self.geom = self.parent_asset.geom
self.lon = self.parent_asset.lon
self.lat = self.parent_asset.lat
self.geojson = self.parent_asset.geojson
if self.parent_asset and self.parent_asset.location and not self.location_id:
self.location = self.parent_asset.location
if self.parent_asset and self.parent_asset.structure and not self.structure_id:
self.structure = self.parent_asset.structure
def save(self, *args, **kwargs):
if self.parent_asset and getattr(self.parent_asset, 'geom', None) and not self.geom:
self.geom = self.parent_asset.geom
self.lon = self.parent_asset.lon
self.lat = self.parent_asset.lat
self.geojson = self.parent_asset.geojson
if self.parent_asset and self.parent_asset.location and not self.location_id:
self.location = self.parent_asset.location
if self.parent_asset and self.parent_asset.structure and not self.structure_id:
self.structure = self.parent_asset.structure
super().save(*args, **kwargs)
class Meta:
indexes = [
models.Index(fields=["code"]),
]
def get_absolute_url(self):
return reverse('assets:structures_assets_detail', kwargs={'asset_model': 'structuregeoasset', 'asset_id':self.id})
TE_PASSAGE_STATUS_CHOICES = (
('authorized', _('Autorisé sans condition particulière')),
('conditional', _('Autorisé sous conditions / prescriptions')),
('prior_study_required', _('Étude / calcul préalable obligatoire')),
('forbidden', _('Strictement interdit')),
('unknown', _('Non évalué')),
)
TE_LANE_POSITIONING_CHOICES = (
('standard', _('Voie de circulation normale')),
('center_axis', _('Dans l\'axe de l\'ouvrage / ligne médiane')),
('first_lane', _('Première bande de circulation')),
('tram_tracks', _('Sur les voies de tram')),
('specific_path', _('Trajectoire spécifique (voir prescriptions)')),
)
class StructureExceptionalTransport(models.Model):
structure = models.OneToOneField(
Structure,
on_delete=models.CASCADE,
related_name='exceptional_transport_info',
verbose_name=_("Ouvrage associé")
)
# --- Références administratives & Classification SPF / Région ---
fod_vnr = models.IntegerField(
null=True, blank=True,
verbose_name=_("Numéro d'ordre FOD/SPF (VNR)"),
help_text=_("Volgnummer pont dans la base fédérale")
)
national_id = models.CharField(
max_length=50, null=True, blank=True,
verbose_name=_("Identifiant national (IDN)")
)
bridge_class = models.CharField(
max_length=100, null=True, blank=True,
verbose_name=_("Classe de charge du pont (BKL)"),
help_text=_("Ex: T180152402024030, D18015..., T09015")
)
is_railway_metro_crossing = models.BooleanField(
default=False,
verbose_name=_("Ouvrage ferroviaire / métro / tram (Spoorlijn)"),
help_text=_("Indique si l'ouvrage surplombe ou supporte des voies ferrées/métro/tram")
)
number_of_spans = models.PositiveSmallIntegerField(
null=True, blank=True,
verbose_name=_("Nombre de travées (OVS)")
)
# --- Capacités portantes & Tonnages ---
max_authorized_weight_tonnes = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
verbose_name=_("Poids max. autorisé standard (MAXWA) [t]"),
help_text=_("Poids maximal standard sans étude particulière (ex: 44 t)")
)
min_guaranteed_capacity_tonnes = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
verbose_name=_("Capacité minimale garantie (MINWR) [t]")
)
max_calculated_weight_tonnes = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
verbose_name=_("Tonnage maximal admissible calculé (MX) [t]"),
help_text=_("Charge maximale absolue évaluée pour l'ouvrage (ex: 140t, 265t, 450t)")
)
max_axle_load_tonnes = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
verbose_name=_("Charge max. par essieu [t/essieu]"),
help_text=_("Ex: 12.0, 12.8, 13.0 t/essieu")
)
min_axle_spacing_m = models.DecimalField(
max_digits=4, decimal_places=2, null=True, blank=True,
verbose_name=_("Entraxe minimal entre essieux [m]")
)
# --- Gabarits géométriques & Hauteurs libres ---
clearance_height_left_m = models.DecimalField(
max_digits=4, decimal_places=2, null=True, blank=True,
verbose_name=_("Hauteur libre gauche / entrée (H1) [m]")
)
clearance_height_center_m = models.DecimalField(
max_digits=4, decimal_places=2, null=True, blank=True,
verbose_name=_("Hauteur libre dans l'axe (H2) [m]")
)
clearance_height_right_m = models.DecimalField(
max_digits=4, decimal_places=2, null=True, blank=True,
verbose_name=_("Hauteur libre droite / sortie (H3) [m]")
)
usable_width_m = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
verbose_name=_("Largeur utile roulable (BRE) [m]")
)
# --- Prescriptions de franchissement & Exploitation ---
passage_status = models.CharField(
max_length=30,
choices=TE_PASSAGE_STATUS_CHOICES,
default='unknown',
verbose_name=_("Statut d'admissibilité TE")
)
max_speed_kmh = models.PositiveIntegerField(
default=5, null=True, blank=True,
verbose_name=_("Vitesse maximale de franchissement (km/h)"),
help_text=_("Généralement 5 km/h pour supprimer les effets dynamiques")
)
is_braking_forbidden = models.BooleanField(
default=False,
verbose_name=_("Interdiction formelle de freiner sur l'ouvrage"),
help_text=_("Notamment sur les dalles de toit de métro")
)
is_isolated_crossing_only = models.BooleanField(
default=True,
verbose_name=_("Convoi seul sur l'ouvrage"),
help_text=_("Aucune autre charge mobile simultanée autorisée sur l'ouvrage")
)
safety_distance_m = models.PositiveIntegerField(
default=5, null=True, blank=True,
verbose_name=_("Distance de sécurité / zone tampon (m)"),
help_text=_("Distance minimale avec tout autre véhicule (souvent 5m)")
)
traffic_interruption_required = models.BooleanField(
default=False,
verbose_name=_("Interruption de circulation requise pendant le passage")
)
lane_positioning = models.CharField(
max_length=30,
choices=TE_LANE_POSITIONING_CHOICES,
default='standard',
verbose_name=_("Positionnement sur la chaussée")
)
is_escort_required = models.BooleanField(
default=False,
verbose_name=_("Escorte policière ou accompagnement requis")
)
# --- Prescriptions textuelles bilingues ---
prescriptions_fr = models.TextField(
null=True, blank=True,
verbose_name=_("Prescriptions techniques (FR)")
)
prescriptions_nl = models.TextField(
null=True, blank=True,
verbose_name=_("Prescriptions techniques (NL)")
)
# --- Suivi & Métadonnées ---
last_study_date = models.DateField(
null=True, blank=True,
verbose_name=_("Date de la dernière étude / note de calcul")
)
last_study_ref = models.CharField(
max_length=150, null=True, blank=True,
verbose_name=_("Référence de la dernière note de calcul / Mission")
)
updated_at = models.DateTimeField(auto_now=True)
updated_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True, blank=True,
related_name='updated_structure_te_infos'
)
class Meta:
verbose_name = _("Transport exceptionnel (Ouvrage)")
verbose_name_plural = _("Transports exceptionnels (Ouvrages)")
def __str__(self):
return f"TE - {self.structure.code} ({self.get_passage_status_display()})"
class StructureExceptionalTransportNotice(models.Model):
RESULT_CHOICES = (
('positive', _('Favorable / Positif')),
('conditional', _('Favorable sous conditions')),
('negative', _('Défavorable / Négatif')),
)
structure = models.ForeignKey(
Structure,
on_delete=models.CASCADE,
related_name='exceptional_transport_notices',
verbose_name=_("Ouvrage")
)
mission_reference = models.CharField(
max_length=100,
verbose_name=_("Référence mission / Avis DITP"),
help_text=_("Ex: Mission 64, Avis DITP 468830")
)
notice_date = models.DateField(null=True, blank=True, verbose_name=_("Date de l'avis"))
result = models.CharField(max_length=20, choices=RESULT_CHOICES, default='positive', verbose_name=_("Résultat"))
evaluated_total_weight_tonnes = models.DecimalField(
max_digits=6, decimal_places=2, null=True, blank=True,
verbose_name=_("Tonnage évalué [t]")
)
evaluated_axle_load_tonnes = models.DecimalField(
max_digits=5, decimal_places=2, null=True, blank=True,
verbose_name=_("Charge par essieu évaluée [t/essieu]")
)
comments = models.TextField(blank=True, verbose_name=_("Conditions / Remarques de l'avis"))
created_at = models.DateTimeField(auto_now_add=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True)
class Meta:
verbose_name = _("Avis / Mission d'étude TE")
verbose_name_plural = _("Avis / Missions d'études TE")
ordering = ['-notice_date', '-created_at']
def __str__(self):
return f"{self.structure.code} - {self.mission_reference} ({self.get_result_display()})"

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,48 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.3 KiB

File diff suppressed because it is too large Load diff

View file

@ -1,36 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<ellipse cx="32" cy="54" rx="22" ry="5" fill="#1e293b" opacity="0.3"/>
<ellipse cx="32" cy="52" rx="18" ry="4" fill="#334155"/>
<ellipse cx="32" cy="51" rx="16" ry="3.5" fill="#64748b"/>
<ellipse cx="32" cy="50.5" rx="13" ry="2.8" fill="#1e293b"/>
<defs>
<linearGradient id="bollardGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#0284c7"/>
<stop offset="25%" stop-color="#38bdf8"/>
<stop offset="60%" stop-color="#0284c7"/>
<stop offset="100%" stop-color="#0369a1"/>
</linearGradient>
<linearGradient id="capGrad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#7dd3fc"/>
<stop offset="100%" stop-color="#0284c7"/>
</linearGradient>
<linearGradient id="bandGrad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#f59e0b"/>
<stop offset="35%" stop-color="#fef08a"/>
<stop offset="70%" stop-color="#f59e0b"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
</defs>
<path d="M 21,24 L 21,50 C 21,53 43,53 43,50 L 43,24 Z" fill="url(#bollardGrad)"/>
<path d="M 21,21 L 21,27 C 21,29.5 43,29.5 43,27 L 43,21 C 43,23.5 21,23.5 21,21 Z" fill="url(#bandGrad)"/>
<path d="M 21,15 L 21,21 C 21,23.5 43,23.5 43,21 L 43,15 Z" fill="url(#bollardGrad)"/>
<ellipse cx="32" cy="15" rx="11" ry="3.5" fill="url(#capGrad)" stroke="#bae6fd" stroke-width="0.8"/>
<ellipse cx="32" cy="14.2" rx="7" ry="2" fill="#ffffff" opacity="0.6"/>
<circle cx="26" cy="24.5" r="1" fill="#ffffff"/>
<circle cx="32" cy="25.2" r="1.1" fill="#ffffff"/>
<circle cx="38" cy="24.5" r="1" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -1,405 +0,0 @@
/**
* Management Zones Map Overlay & Layer Switcher Integration
*
* Permet d'afficher en surimpression les zones de gestion (gestion écologique,
* ondes vertes, secteurs prioritaires, etc.) sur la carte MapLibre
* et ajoute une section dédiée "Zones de gestion" dans le LayerSwitcherControl.
*/
(function (window) {
'use strict';
/**
* Calcule la bounding box [[minX, minY], [maxX, maxY]] d'une géométrie GeoJSON.
*/
function getGeometryBBox(geometry) {
if (!geometry || !geometry.coordinates) return null;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
function traverse(coords) {
if (typeof coords[0] === 'number') {
const [x, y] = coords;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
} else if (Array.isArray(coords)) {
for (let i = 0; i < coords.length; i++) {
traverse(coords[i]);
}
}
}
traverse(geometry.coordinates);
if (minX === Infinity || minY === Infinity) return null;
return [[minX, minY], [maxX, maxY]];
}
/**
* Initialise l'overlay des zones de gestion sur la carte MapLibre.
*/
async function initManagementZonesMapOverlay(options = {}) {
const map = options.map;
if (!map) {
console.warn('[ManagementZonesMapOverlay] Map instance missing.');
return;
}
if (!map.loaded()) {
map.once('load', () => initManagementZonesMapOverlay(options));
return;
}
const thematic = options.thematic || 'nature';
const geojsonUrl = options.geojsonUrl || '/assets/api/management-zones/geojson/';
const layerSwitcher = options.layerSwitcher || window.layerSwitcher;
const beforeLayerId = options.beforeLayerId || null;
const _t = (key, fallback) => (typeof gettext === 'function' ? gettext(key) : (fallback || key));
try {
const url = new URL(geojsonUrl, window.location.origin);
url.searchParams.set('thematic', thematic);
const response = await fetch(url.toString(), {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!response.ok) {
console.warn('[ManagementZonesMapOverlay] Failed to fetch geojson:', response.status);
return;
}
const geojson = await response.json();
if (!geojson || geojson.type !== 'FeatureCollection') {
console.warn('[ManagementZonesMapOverlay] Invalid GeoJSON received.');
return;
}
const features = geojson.features || [];
// 1. Ajouter la source et les couches à MapLibre
const sourceId = 'management-zones-source';
const fillLayerId = 'management-zones-fill';
const lineLayerId = 'management-zones-line';
if (map.getSource(sourceId)) {
map.getSource(sourceId).setData(geojson);
} else {
map.addSource(sourceId, {
type: 'geojson',
data: geojson
});
}
// Couche de remplissage translucide
if (!map.getLayer(fillLayerId)) {
const fillLayerDef = {
id: fillLayerId,
type: 'fill',
source: sourceId,
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#198754'],
'fill-opacity': 0.20
}
};
if (beforeLayerId && map.getLayer(beforeLayerId)) {
map.addLayer(fillLayerDef, beforeLayerId);
} else {
map.addLayer(fillLayerDef);
}
}
// Couche de contour avec tirets pour marquer une zone de gestion
if (!map.getLayer(lineLayerId)) {
const lineLayerDef = {
id: lineLayerId,
type: 'line',
source: sourceId,
paint: {
'line-color': ['coalesce', ['get', 'color'], '#198754'],
'line-width': 2.5,
'line-opacity': 0.85,
'line-dasharray': [3, 1.5]
}
};
if (beforeLayerId && map.getLayer(beforeLayerId)) {
map.addLayer(lineLayerDef, beforeLayerId);
} else {
map.addLayer(lineLayerDef);
}
}
// 2. Gestion des popups et du curseur sur clic
const zonePopup = new maplibregl.Popup({
closeButton: true,
closeOnClick: true,
maxWidth: '340px'
});
map.on('click', fillLayerId, (e) => {
const assetLayerIds = (window.assets_layers_defs || []).map(l => l.id).filter(id => map.getLayer(id));
if (assetLayerIds.length > 0) {
const rendered = map.queryRenderedFeatures(e.point, { layers: assetLayerIds });
if (rendered && rendered.length > 0) {
return;
}
}
if (!e.features || !e.features[0]) return;
const props = e.features[0].properties || {};
let typeHtml = '';
if (props.zone_type_name) {
typeHtml = `<span class="badge me-1" style="background-color: ${props.color || '#198754'}; color: #fff; font-size: 10px;">
${props.zone_type_name}
</span>`;
}
let freqHtml = '';
if (props.target_frequency_days) {
freqHtml = `<div style="color: #64748b; font-size: 11px; margin-top: 3px;">
<i class="bi bi-arrow-repeat me-1"></i>${_t('Fréquence cible :', 'Fréquence cible :')} <strong>${props.target_frequency_days} ${_t('jours', 'jours')}</strong>
</div>`;
}
let seasonHtml = '';
if (props.seasonality_notes) {
seasonHtml = `<div style="color: #64748b; font-size: 11px; margin-top: 3px;">
<i class="bi bi-calendar3 me-1"></i>${props.seasonality_notes}
</div>`;
}
let instructionsHtml = '';
if (props.instructions) {
instructionsHtml = `<div style="background-color: #f8fafc; border-left: 3px solid ${props.color || '#198754'}; padding: 6px; margin-top: 6px; font-size: 11px; color: #334155; border-radius: 2px; max-height: 100px; overflow-y: auto;">
<div style="font-weight: 600; margin-bottom: 2px;"><i class="bi bi-exclamation-triangle me-1"></i>${_t('Consignes & points d\'attention', 'Consignes & points d\'attention')}</div>
${props.instructions}
</div>`;
}
let descHtml = '';
if (props.description) {
descHtml = `<div style="color: #475569; font-size: 11px; margin-top: 4px;">
${props.description}
</div>`;
}
const html = `
<div style="font-family: inherit; font-size: 12px; padding: 2px;">
<div style="display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 4px;">
<div style="display: flex; align-items: center; gap: 6px;">
<span style="display: inline-block; width: 12px; height: 12px; border-radius: 3px; background-color: ${props.color || '#198754'}; border: 1px solid rgba(0,0,0,0.2); flex-shrink: 0;"></span>
<strong style="font-size: 13px; color: #1e293b;">${props.code}</strong>
</div>
${typeHtml}
</div>
<div style="font-weight: 600; color: #1e293b; margin-bottom: 4px;">${props.name}</div>
${descHtml}
${freqHtml}
${seasonHtml}
${instructionsHtml}
<div style="margin-top: 8px; text-align: right;">
<a href="/assets/management-zones/${props.id}/" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 11px;">
<i class="bi bi-eye me-1"></i>${_t('Détails & gestion', 'Détails & gestion')} &rarr;
</a>
</div>
</div>
`;
zonePopup.setLngLat(e.lngLat).setHTML(html).addTo(map);
});
map.on('mousemove', fillLayerId, (e) => {
const assetLayerIds = (window.assets_layers_defs || []).map(l => l.id).filter(id => map.getLayer(id));
if (assetLayerIds.length > 0) {
const rendered = map.queryRenderedFeatures(e.point, { layers: assetLayerIds });
if (rendered && rendered.length > 0) {
return;
}
}
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', fillLayerId, () => {
map.getCanvas().style.cursor = '';
});
// 3. Intégration dans le LayerSwitcherControl
if (layerSwitcher && typeof layerSwitcher.addCustomSection === 'function') {
layerSwitcher.addCustomSection({
id: 'management-zones',
title: `<span class="d-flex align-items-center gap-1"><i class="bi bi-bounding-box-circles me-1"></i>${_t('Zones de gestion', 'Zones de gestion')}</span>`,
render: (container, switcher) => {
renderManagementZonesSection(container, switcher, features, map, fillLayerId, lineLayerId, zonePopup, _t, thematic);
}
});
if (features.length > 0) {
layerSwitcher.setOverlayActive(true);
}
}
} catch (err) {
console.error('[ManagementZonesMapOverlay] Error initializing overlay:', err);
}
}
/**
* Rendu HTML et interactions de la section "Zones de gestion" dans le LayerSwitcherControl.
*/
function renderManagementZonesSection(container, switcher, features, map, fillLayerId, lineLayerId, zonePopup, _t, thematic) {
if (!features || features.length === 0) {
const manageUrl = `/assets/management-zones/${thematic ? '?thematic=' + thematic : ''}`;
container.innerHTML = `
<div class="text-muted small px-2 py-1 fst-italic d-flex justify-content-between align-items-center">
<span>${_t('Aucune zone de gestion définie pour cette thématique.', 'Aucune zone de gestion définie pour cette thématique.')}</span>
<a href="${manageUrl}" class="btn btn-link btn-sm p-0 text-muted ms-1" title="${_t('Gérer les zones de gestion', 'Gérer les zones de gestion')}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
`;
return;
}
const countText = `${features.length} ${_t('zones', 'zones')}`;
const manageUrl = `/assets/management-zones/${thematic ? '?thematic=' + thematic : ''}`;
// En-tête avec toggle général
const headerEl = document.createElement('div');
headerEl.className = 'd-flex justify-content-between align-items-center px-1 mb-2';
headerEl.innerHTML = `
<div class="d-flex align-items-center gap-1">
<span class="text-muted small fw-semibold">${countText}</span>
<a href="${manageUrl}" class="btn btn-link btn-sm p-0 text-muted" style="font-size: 11px;" title="${_t('Gérer les zones de gestion', 'Gérer les zones de gestion')}">
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input management-zones-master-toggle" type="checkbox" role="switch" checked title="${_t('Afficher / masquer toutes les zones', 'Afficher / masquer toutes les zones')}">
</div>
`;
container.appendChild(headerEl);
const masterToggle = headerEl.querySelector('.management-zones-master-toggle');
// Curseur d'opacité
const opacityRow = document.createElement('div');
opacityRow.className = 'px-1 mb-2';
opacityRow.innerHTML = `
<div class="d-flex justify-content-between align-items-center small text-muted mb-1">
<span>${_t('Opacité', 'Opacité')}</span>
<span class="management-zones-opacity-val">20%</span>
</div>
<input type="range" class="form-range management-zones-opacity-slider" min="5" max="80" value="20" style="height: 4px;">
`;
container.appendChild(opacityRow);
const opacitySlider = opacityRow.querySelector('.management-zones-opacity-slider');
const opacityValLabel = opacityRow.querySelector('.management-zones-opacity-val');
opacitySlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10);
opacityValLabel.textContent = `${val}%`;
if (map.getLayer(fillLayerId)) {
map.setPaintProperty(fillLayerId, 'fill-opacity', val / 100);
}
});
// Liste des zones
const listEl = document.createElement('div');
listEl.className = 'management-zones-list d-flex flex-column gap-1';
listEl.style.maxHeight = '240px';
listEl.style.overflowY = 'auto';
const activeZoneIds = new Set(features.map(f => f.id));
function updateLayersVisibility() {
if (!masterToggle.checked || activeZoneIds.size === 0) {
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'none');
if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'none');
} else {
if (map.getLayer(fillLayerId)) map.setLayoutProperty(fillLayerId, 'visibility', 'visible');
if (map.getLayer(lineLayerId)) map.setLayoutProperty(lineLayerId, 'visibility', 'visible');
const filter = ['in', ['id'], ['literal', Array.from(activeZoneIds)]];
if (map.getLayer(fillLayerId)) map.setFilter(fillLayerId, filter);
if (map.getLayer(lineLayerId)) map.setFilter(lineLayerId, filter);
}
}
masterToggle.addEventListener('change', () => {
const checked = masterToggle.checked;
listEl.querySelectorAll('.zone-item-toggle').forEach(chk => {
chk.checked = checked;
});
if (checked) {
features.forEach(f => activeZoneIds.add(f.id));
} else {
activeZoneIds.clear();
}
updateLayersVisibility();
});
features.forEach(feat => {
const props = feat.properties || {};
const color = props.color || '#198754';
const itemEl = document.createElement('div');
itemEl.className = 'd-flex align-items-center justify-content-between p-1 rounded hover-bg small';
itemEl.style.cursor = 'pointer';
itemEl.innerHTML = `
<div class="d-flex align-items-center gap-2 text-truncate me-2 zone-click-target" style="flex: 1;">
<span style="display:inline-block; width: 12px; height: 12px; border-radius: 50%; background-color: ${color}; border: 1px solid rgba(0,0,0,0.25); flex-shrink: 0;"></span>
<div class="text-truncate">
<div class="fw-semibold text-truncate text-dark" style="font-size: 11px;">${props.code}</div>
<div class="text-muted text-truncate" style="font-size: 10px;">${props.name} ${props.zone_type_name ? `(${props.zone_type_name})` : ''}</div>
</div>
</div>
<div class="d-flex align-items-center gap-1 flex-shrink-0">
<button type="button" class="btn btn-link btn-sm p-0 text-muted zoom-btn" title="${_t('Centrer sur la zone', 'Centrer sur la zone')}">
<i class="bi bi-geo-alt" style="font-size: 12px;"></i>
</button>
<div class="form-check form-switch mb-0 ms-1">
<input class="form-check-input zone-item-toggle" type="checkbox" role="switch" checked data-zone-id="${feat.id}">
</div>
</div>
`;
const itemToggle = itemEl.querySelector('.zone-item-toggle');
itemToggle.addEventListener('change', (e) => {
e.stopPropagation();
if (itemToggle.checked) {
activeZoneIds.add(feat.id);
} else {
activeZoneIds.delete(feat.id);
}
masterToggle.checked = (activeZoneIds.size > 0);
updateLayersVisibility();
});
function zoomToZone() {
const bbox = getGeometryBBox(feat.geometry);
if (bbox) {
map.fitBounds(bbox, { padding: 40, maxZoom: 16 });
}
}
itemEl.querySelector('.zoom-btn').addEventListener('click', (e) => {
e.stopPropagation();
zoomToZone();
});
itemEl.querySelector('.zone-click-target').addEventListener('click', () => {
zoomToZone();
});
listEl.appendChild(itemEl);
});
container.appendChild(listEl);
}
window.initManagementZonesMapOverlay = initManagementZonesMapOverlay;
})(window);

View file

@ -1,69 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<!-- Background grass blades gradient (deep rich green) -->
<linearGradient id="grass-dark" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2e7d32"/>
<stop offset="100%" stop-color="#1b5e20"/>
</linearGradient>
<!-- Mid grass blades gradient (fresh vibrant green) -->
<linearGradient id="grass-mid" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#66bb6a"/>
<stop offset="100%" stop-color="#2e7d32"/>
</linearGradient>
<!-- Side arching grass blades gradient -->
<linearGradient id="grass-light" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#81c784"/>
<stop offset="100%" stop-color="#388e3c"/>
</linearGradient>
<!-- Ground turf base gradient -->
<linearGradient id="turf-base" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#43a047"/>
<stop offset="100%" stop-color="#1b5e20"/>
</linearGradient>
<!-- Front turf lip gradient -->
<linearGradient id="turf-front" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#4caf50"/>
<stop offset="100%" stop-color="#2e7d32"/>
</linearGradient>
<!-- Soil ground base gradient -->
<linearGradient id="soil-base" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#6d4c41"/>
<stop offset="100%" stop-color="#4e342e"/>
</linearGradient>
</defs>
<!-- Ground / Soil base slab -->
<rect x="9" y="52" width="46" height="6" rx="3" fill="url(#soil-base)"/>
<!-- Back turf mound -->
<ellipse cx="32" cy="49" rx="25" ry="7" fill="url(#turf-base)"/>
<!-- Background grass blades -->
<!-- Mid-left tall blade -->
<path d="M 21 48 C 17 32, 16 19, 20 11 C 24 15, 27 29, 28 48 Z" fill="url(#grass-dark)"/>
<!-- Mid-right tall blade -->
<path d="M 36 48 C 37 29, 40 15, 44 11 C 48 19, 47 32, 43 48 Z" fill="url(#grass-dark)"/>
<!-- Main foreground grass blades -->
<!-- Left arching blade -->
<path d="M 14 49 C 10 38, 7 28, 8 19 C 14 21, 21 34, 25 49 Z" fill="url(#grass-light)"/>
<!-- Right arching blade -->
<path d="M 39 49 C 43 34, 50 21, 56 19 C 57 28, 54 38, 50 49 Z" fill="url(#grass-light)"/>
<!-- Center tall proud blade -->
<path d="M 26 49 C 27 30, 28 16, 32 6 C 36 16, 37 30, 38 49 Z" fill="url(#grass-mid)"/>
<!-- Center blade subtle highlight -->
<path d="M 31 48 C 31.5 32, 31.8 18, 32 9 C 32.2 18, 32.8 32, 32.5 48 Z" fill="#a5d6a7" opacity="0.6"/>
<!-- Foreground turf rim -->
<ellipse cx="32" cy="51" rx="23" ry="5" fill="url(#turf-front)"/>
<!-- Small front sprouting blades -->
<path d="M 21 51 C 20 44, 18 36, 17 30 C 20 34, 23 42, 24 51 Z" fill="url(#grass-light)"/>
<path d="M 40 51 C 41 42, 44 34, 47 30 C 46 36, 44 44, 43 51 Z" fill="url(#grass-light)"/>
</svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -1,393 +0,0 @@
/**
* Visualisation cartographique détaillée d'une localisation Nature (StreetUp / Loko)
*/
document.addEventListener('DOMContentLoaded', function () {
const mapContainer = document.getElementById('map');
if (!mapContainer) return;
const lon = typeof feature_lon !== 'undefined' && feature_lon !== null ? feature_lon : 4.3517;
const lat = typeof feature_lat !== 'undefined' && feature_lat !== null ? feature_lat : 50.8503;
const map = new maplibregl.Map({
container: 'map',
style: window.MAP_STYLE_LIGHT || 'https://api.maptiler.com/maps/streets-v2-light/style.json',
center: [lon, lat],
zoom: 16,
pitch: 35,
bearing: -10,
antialias: true
});
// ── Helper to place raster basemaps below nature layers ──
function getBasemapBeforeLayer(m) {
if (!m || !m.getStyle) return undefined;
if (m.getLayer('nature-location-fill')) return 'nature-location-fill';
if (m.getLayer('nature-location-outline')) return 'nature-location-outline';
if (m.getLayer('layer-surfaces-fill')) return 'layer-surfaces-fill';
if (m.getLayer('layer-surfaces-outline')) return 'layer-surfaces-outline';
if (m.getLayer('layer-shrubs')) return 'layer-shrubs';
if (m.getLayer('layer-benches')) return 'layer-benches';
if (m.getLayer('layer-trees')) return 'layer-trees';
return undefined;
}
// ── Ensure strict layer stacking order on top of basemaps ──
function reorderNatureLayers() {
const layerOrder = [
'nature-location-fill',
'nature-location-outline',
'layer-surfaces-fill',
'layer-surfaces-outline',
'layer-shrubs',
'layer-benches',
'layer-trees',
];
layerOrder.forEach(function (layerId) {
if (map.getLayer(layerId)) {
try {
map.moveLayer(layerId);
} catch (e) {
// ignore
}
}
});
}
// Add layer switcher control for basemaps / orthophotos
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'basemap',
name: 'Fonds de plan & Orthophotos',
icon: 'satellite',
group: 'basemap',
beforeLayer: getBasemapBeforeLayer,
onLayerAdded: () => reorderNatureLayers(),
onVisibilityChange: () => reorderNatureLayers(),
layer: {
type: 'raster',
paint: { 'raster-opacity': 1 }
},
basemaps: (window.MAP_BACKGROUNDS && window.MAP_BACKGROUNDS.length > 0) ? window.MAP_BACKGROUNDS : undefined,
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 || '© 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: '© Digitaal Vlaanderen - Orthofoto 2023'
}
}
]
}]
});
map.addControl(layerSwitcher, 'top-right');
}
// Add navigation and fullscreen controls
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right');
map.addControl(new maplibregl.FullscreenControl(), 'top-right');
map.on('load', function () {
let combinedBbox = null;
// 1. Location geometry (polygon / boundary)
if (typeof section_geojson !== 'undefined' && section_geojson) {
const locGeom = typeof section_geojson === 'string' ? JSON.parse(section_geojson) : section_geojson;
const locationFeature = {
type: 'Feature',
geometry: locGeom,
properties: {
name: 'Location'
}
};
map.addSource('nature-location', {
type: 'geojson',
data: locationFeature
});
// Fill layer for the section
map.addLayer({
id: 'nature-location-fill',
type: 'fill',
source: 'nature-location',
paint: {
'fill-color': '#16a34a',
'fill-opacity': 0.15
}
});
// Outline layer for the section
map.addLayer({
id: 'nature-location-outline',
type: 'line',
source: 'nature-location',
paint: {
'line-color': '#15803d',
'line-width': 2.5
}
});
if (typeof turf !== 'undefined') {
try {
combinedBbox = turf.bbox(locationFeature);
} catch (e) {
console.warn('Could not compute location bbox:', e);
}
}
}
// 2. Assets (green surfaces, trees, shrubs, benches)
if (typeof assets_geojson !== 'undefined' && assets_geojson) {
const assetsData = typeof assets_geojson === 'string' ? JSON.parse(assets_geojson) : assets_geojson;
map.addSource('nature-assets', {
type: 'geojson',
data: assetsData
});
// Green surfaces fill
map.addLayer({
id: 'layer-surfaces-fill',
type: 'fill',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturegreensurface'],
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#22c55e'],
'fill-opacity': 0.65
}
});
// Green surfaces outline
map.addLayer({
id: 'layer-surfaces-outline',
type: 'line',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturegreensurface'],
paint: {
'line-color': '#166534',
'line-width': 1.5
}
});
// Trees (circles)
map.addLayer({
id: 'layer-trees',
type: 'circle',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturetree'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
13, 4,
16, 7,
19, 12
],
'circle-color': '#15803d',
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff'
}
});
// Shrubs (circles)
map.addLayer({
id: 'layer-shrubs',
type: 'circle',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'natureshrub'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
13, 3,
16, 5,
19, 9
],
'circle-color': '#84cc16',
'circle-stroke-width': 1.5,
'circle-stroke-color': '#ffffff'
}
});
// Benches (circles)
map.addLayer({
id: 'layer-benches',
type: 'circle',
source: 'nature-assets',
filter: ['==', ['get', 'asset_type'], 'naturebench'],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
13, 3,
16, 5,
19, 9
],
'circle-color': '#b45309',
'circle-stroke-width': 1.5,
'circle-stroke-color': '#ffffff'
}
});
// Fit bounds with Turf if features exist
if (typeof turf !== 'undefined' && assetsData.features && assetsData.features.length > 0) {
try {
const assetsBbox = turf.bbox(assetsData);
if (!combinedBbox) {
combinedBbox = assetsBbox;
} else {
combinedBbox = [
Math.min(combinedBbox[0], assetsBbox[0]),
Math.min(combinedBbox[1], assetsBbox[1]),
Math.max(combinedBbox[2], assetsBbox[2]),
Math.max(combinedBbox[3], assetsBbox[3])
];
}
} catch (e) {
console.warn('Could not compute assets bbox:', e);
}
}
}
// Apply fit bounds
if (combinedBbox && combinedBbox[0] !== Infinity) {
map.fitBounds(combinedBbox, {
padding: { top: 40, bottom: 40, left: 40, right: 40 },
maxZoom: 18,
duration: 800
});
}
reorderNatureLayers();
// 3. Interactive Popups
const interactiveLayers = [
'layer-surfaces-fill',
'layer-trees',
'layer-shrubs',
'layer-benches'
];
interactiveLayers.forEach(function (layerId) {
map.on('mouseenter', layerId, function () {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', layerId, function () {
map.getCanvas().style.cursor = '';
});
map.on('click', layerId, function (e) {
if (!e.features || !e.features.length) return;
const f = e.features[0];
const props = f.properties;
let iconClass = 'bi-tree-fill text-success';
let typeBadge = '<span class="badge bg-success">Surface</span>';
if (props.asset_type === 'naturetree') {
iconClass = 'bi-tree-fill text-success';
typeBadge = '<span class="badge bg-success">Arbre</span>';
} else if (props.asset_type === 'natureshrub') {
iconClass = 'bi-flower3 text-success';
typeBadge = '<span class="badge bg-info">Arbuste</span>';
} else if (props.asset_type === 'naturebench') {
iconClass = 'bi-inbox text-warning';
typeBadge = '<span class="badge bg-warning text-dark">Banc / Mobilier</span>';
} else if (props.asset_type === 'naturegreensurface') {
iconClass = 'bi-grid-fill text-success';
typeBadge = '<span class="badge bg-primary">Surface verte</span>';
}
let detailsHtml = '';
if (props.area) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-rulers me-1"></i>Superficie : <strong>${props.area}</strong></div>`;
}
if (props.vitality) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-heart-pulse me-1"></i>Vitalité : <strong>${props.vitality}</strong></div>`;
}
if (props.stage) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-clock-history me-1"></i>Stade : <strong>${props.stage}</strong></div>`;
}
if (props.height) {
detailsHtml += `<div class="small text-muted"><i class="bi bi-arrows-vertical me-1"></i>Hauteur : <strong>${props.height}</strong></div>`;
}
if (props.is_remarkable === 'true' || props.is_remarkable === true) {
detailsHtml += `<div class="badge bg-warning text-dark my-1"><i class="bi bi-star-fill me-1"></i>Arbre remarquable</div>`;
}
const popupHtml = `
<div class="nature-map-popup" style="min-width: 200px; padding: 4px;">
<div class="d-flex justify-content-between align-items-center mb-1 pb-1 border-bottom">
<span class="fw-bold"><i class="bi ${iconClass} me-1"></i>${props.code || ''}</span>
${typeBadge}
</div>
<div class="mb-2">
${props.model ? `<div class="fw-semibold text-dark">${props.model}</div>` : ''}
${props.scientific_name ? `<div class="fst-italic text-muted small">${props.scientific_name}</div>` : ''}
${props.status ? `<div class="small"><span class="badge bg-light text-secondary border mt-1">${props.status}</span></div>` : ''}
${detailsHtml}
</div>
${props.detail_url ? `
<a href="${props.detail_url}" class="btn btn-xs btn-primary w-100 text-center py-1" style="font-size: 0.8rem;">
<i class="bi bi-arrow-right-circle me-1"></i>Consulter la fiche
</a>
` : ''}
</div>
`;
new maplibregl.Popup({ closeButton: true, closeOnClick: true, maxWidth: '280px' })
.setLngLat(e.lngLat)
.setHTML(popupHtml)
.addTo(map);
});
});
// 4. Layer Toggle Handlers
window.toggleNatureLayer = function (layerGroup, isChecked) {
const layerMapping = {
'surfaces': ['layer-surfaces-fill', 'layer-surfaces-outline'],
'trees': ['layer-trees'],
'shrubs': ['layer-shrubs'],
'benches': ['layer-benches']
};
const layers = layerMapping[layerGroup];
if (layers) {
const visibility = isChecked ? 'visible' : 'none';
layers.forEach(function (id) {
if (map.getLayer(id)) {
map.setLayoutProperty(id, 'visibility', visibility);
}
});
}
};
// 5. Global Zoom to Asset helper
window.zoomToAsset = function (assetLon, assetLat, assetCode) {
if (assetLon && assetLat) {
map.flyTo({
center: [assetLon, assetLat],
zoom: 18.5,
pitch: 45,
duration: 1000
});
}
};
});
});

View file

@ -1,60 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-dead" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#9ca3af"/>
<stop offset="100%" stop-color="#4b5563"/>
</linearGradient>
<linearGradient id="trunk-dead" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#78716c"/>
<stop offset="100%" stop-color="#57534e"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-dead)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-dead)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-dead)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-dead)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk-dead)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#44403c"/>
</svg>

Before

Width:  |  Height:  |  Size: 928 B

View file

@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-reg" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#60a5fa"/>
<stop offset="100%" stop-color="#1d4ed8"/>
</linearGradient>
<linearGradient id="trunk" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8d6e63"/>
<stop offset="100%" stop-color="#5d4037"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-reg)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-reg)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-reg)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-reg)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#4e342e"/>
</svg>

Before

Width:  |  Height:  |  Size: 913 B

View file

@ -1,20 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64" aria-hidden="true">
<defs>
<linearGradient id="foliage-rem" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#fbbf24"/>
<stop offset="100%" stop-color="#d97706"/>
</linearGradient>
<linearGradient id="trunk" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8d6e63"/>
<stop offset="100%" stop-color="#5d4037"/>
</linearGradient>
</defs>
<ellipse cx="24" cy="24" rx="12" ry="10" fill="url(#foliage-rem)"/>
<ellipse cx="40" cy="24" rx="12" ry="10" fill="url(#foliage-rem)"/>
<ellipse cx="32" cy="18" rx="13" ry="11" fill="url(#foliage-rem)"/>
<ellipse cx="32" cy="29" rx="15" ry="10" fill="url(#foliage-rem)"/>
<rect x="28" y="30" width="8" height="24" rx="2" fill="url(#trunk)"/>
<rect x="25" y="54" width="14" height="5" rx="2" fill="#4e342e"/>
</svg>

Before

Width:  |  Height:  |  Size: 913 B

View file

@ -1,38 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because it is too large Load diff

View file

@ -1,51 +0,0 @@
{% load i18n %}
<ul class="list-group list-group-flush bg-transparent border-0">
{% for asset_node in asset_nodes %}
<li class="list-group-item bg-transparent border-0 py-1 px-0">
<!-- Row Header -->
<div class="d-flex align-items-center justify-content-between py-1 px-3">
<!-- Contenu gauche (indenté selon la profondeur) -->
<div class="d-flex align-items-center gap-2 flex-wrap" style="padding-left: calc(({{ depth|default:1 }} - 1) * 1.25rem);">
{% if asset_node.children %}
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-asset-{{ asset_node.asset.id }}"
aria-expanded="true">
<i class="bi bi-chevron-down small"></i>
</button>
{% else %}
<span class="d-inline-block text-center text-muted" style="width: 1.35rem;"><i class="bi bi-dash small"></i></span>
{% endif %}
<!-- Icône colorée selon la famille d'asset -->
<i class="bi {{ asset_node.asset.get_icon_class|default:'bi-tools' }}" style="font-size: 1rem; color: {{ asset_node.asset.theme_icon_color }};"></i>
<!-- Code cliquable avec couleur thématique -->
<a href="{{ asset_node.asset.get_absolute_url }}" class="fw-bold text-decoration-underline small" style="color: {{ asset_node.asset.theme_color }};">
{{ asset_node.asset.code }}
</a>
<span class="small text-dark">{{ asset_node.asset.get_name }}</span>
<!-- Badge Macro-lot sobre -->
<span class="badge bg-light text-muted border small">{{ asset_node.asset.get_macro_lot }}</span>
<!-- Badge Typologie avec couleur thématique différenciée -->
<span class="badge {{ asset_node.asset.theme_badge_class }} small" style="{{ asset_node.asset.theme_badge_style }}">
{{ asset_node.asset.get_asset_type_display }}
</span>
{% if asset_node.asset.status == 'inactive' %}
<span class="badge bg-danger-subtle text-danger border small">{% translate "Inactif" %}</span>
{% endif %}
</div>
</div>
{% if asset_node.children %}
<div id="tree-asset-{{ asset_node.asset.id }}" class="collapse show tree-collapse-node">
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=asset_node.children depth=depth|default:1|add:1 only %}
</div>
{% endif %}
</li>
{% endfor %}
</ul>

View file

@ -1,991 +0,0 @@
{% extends "base.html" %}
{% load static i18n documents_tags %}
{% block title %}{{ building.code }} - {{ building.get_name }} | {{ product_name }}{% endblock %}
{% block extra_head %}
<!-- MapLibre GL CSS -->
<link href="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/maplibre-gl@3.6.2/dist/maplibre-gl.js"></script>
{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Actions -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{% url 'assets:buildings_index' %}">{% translate "Bâtiments publics" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ building.code }}</li>
</ol>
</nav>
<div class="d-flex gap-2">
{% if can_edit %}
<a href="{% url 'assets:publicbuilding_edit' building.pk %}" class="btn btn-primary btn-sm">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier" %}
</a>
{% endif %}
<a href="{% url 'assets:buildings_index' %}" class="btn btn-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la liste" %}
</a>
</div>
</div>
<!-- Header Card -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<h2 class="h4 mb-1">
<i class="bi bi-building text-primary me-2"></i>{{ building.code }} - {{ building.get_name }}
</h2>
<p class="text-muted mb-2"><i class="bi bi-geo-alt me-1"></i>{{ building.address|default:_("Adresse non renseignée") }}</p>
<div class="d-flex gap-2 flex-wrap">
<span class="badge bg-primary-subtle text-primary border">{{ building.get_usage_type_display }}</span>
<span class="badge bg-success-subtle text-success border">{{ building.get_building_status_display }}</span>
{% if building.is_erp %}
<span class="badge bg-info-subtle text-info border">{{ regional_terms.public_access_short }} (Cat. {{ building.erp_category|default:"-" }})</span>
{% endif %}
</div>
</div>
<div class="col-md-4 text-md-end mt-3 mt-md-0">
{% if building.dpe_rating %}
<div class="d-inline-block text-center me-3">
<span class="d-block text-muted small">{{ regional_terms.energy_diag_short }}</span>
<span class="badge bg-success fs-5 px-3 py-2">Classe {{ building.dpe_rating }}</span>
</div>
{% endif %}
{% if building.ges_rating %}
<div class="d-inline-block text-center">
<span class="d-block text-muted small">{{ regional_terms.carbon_diag_short }}</span>
<span class="badge bg-primary fs-5 px-3 py-2">Classe {{ building.ges_rating }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- 3 Core Tabs Navigation -->
<ul class="nav nav-tabs mb-4" id="buildingTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab">
<i class="bi bi-info-circle me-1"></i>{% translate "Vue d'ensemble" %}
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="equipments-tab" data-bs-toggle="tab" data-bs-target="#equipments" type="button" role="tab">
<i class="bi bi-gear me-1"></i>{% translate "Assets & Équipements" %} ({{ equipments|length }})
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="interventions-tab" data-bs-toggle="tab" data-bs-target="#interventions" type="button" role="tab">
<i class="bi bi-tools me-1"></i>{% translate "Interventions" %} ({{ interventions.count }})
</button>
</li>
</ul>
<!-- Tabs Content -->
<div class="tab-content" id="buildingTabsContent">
<!-- 1. Vue d'ensemble (Dashboard avec Cards directes) -->
<div class="tab-pane fade show active" id="overview" role="tabpanel">
<div class="row g-4">
<!-- Left Column: Propriété, Caractéristiques & Arborescence Niveaux/Locaux/Équipements -->
<div class="col-md-7">
<!-- Card 1: Propriété & Responsables -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold"><i class="bi bi-person-badge me-2"></i>{% translate "Propriété & Responsables" %}</div>
<div class="card-body">
<table class="table table-borderless mb-0">
<tr>
<th class="w-50 text-muted">{% translate "Propriétaire" %} :</th>
<td>{{ building.owner|default:"-" }}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Gestionnaire référent" %} :</th>
<td>{% if building.manager %}{{ building.manager.get_full_name|default:building.manager.username }}{% else %}-{% endif %}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Service occupant" %} :</th>
<td>{{ building.occupant_entity|default:"-" }}</td>
</tr>
</table>
</div>
</div>
<!-- Card 2: Caractéristiques physiques & Normes -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold"><i class="bi bi-rulers me-2"></i>{% translate "Caractéristiques physiques &" %} {{ regional_terms.public_access_short }}</div>
<div class="card-body">
<table class="table table-borderless mb-0">
<tr>
<th class="w-50 text-muted">{% translate "Année de construction" %} :</th>
<td>{{ building.construction_year|default:"-" }}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Étages / Sous-sols" %} :</th>
<td>{{ building.floors_count }} {% translate "étage(s)" %} / {{ building.basement_count }} {% translate "sous-sol(s)" %}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Surface Utile / Brut" %} :</th>
<td>{{ building.usable_surface_m2|default:"-" }} m² / {{ building.total_surface_m2|default:"-" }} m²</td>
</tr>
{% if building.room_number %}
<tr>
<th class="text-muted">{% translate "Numéro de local / porte" %} :</th>
<td><span class="badge bg-secondary-subtle text-secondary border font-monospace"><i class="bi bi-tag me-1"></i>{{ building.room_number }}</span></td>
</tr>
{% endif %}
{% if building.surface_m2 %}
<tr>
<th class="text-muted">{% translate "Surface spécifique" %} :</th>
<td>{{ building.surface_m2 }} m²</td>
</tr>
{% endif %}
{% if building.ceiling_height_m %}
<tr>
<th class="text-muted">{% translate "Hauteur sous plafond" %} :</th>
<td>{{ building.ceiling_height_m }} m</td>
</tr>
{% endif %}
<tr>
<th class="text-muted">{% translate "Chauffage du local" %} :</th>
<td>{% if building.is_heated %}<span class="text-success"><i class="bi bi-fire me-1"></i>{% translate "Chauffé" %}</span>{% else %}<span class="text-muted"><i class="bi bi-slash-circle me-1"></i>{% translate "Non chauffé" %}</span>{% endif %}</td>
</tr>
{% if building.floor_type %}
<tr>
<th class="text-muted">{% translate "Revêtement de sol" %} :</th>
<td>{{ building.get_floor_type_display }}</td>
</tr>
{% endif %}
{% if building.fire_compartment %}
<tr>
<th class="text-muted">{% translate "Compartiment coupe-feu" %} :</th>
<td>{{ building.fire_compartment }}</td>
</tr>
{% endif %}
<tr>
<th class="text-muted">{{ regional_terms.public_access_category }} :</th>
<td>{{ building.erp_category|default:"-" }}</td>
</tr>
<tr>
<th class="text-muted">{% translate "Accessibilité PMR" %} :</th>
<td>{% if building.pmr_accessible %}<span class="text-success"><i class="bi bi-check-circle-fill me-1"></i>{% translate "Oui" %}</span>{% else %}<span class="text-muted">{% translate "Non" %}</span>{% endif %}</td>
</tr>
</table>
</div>
</div>
<!-- Card 3: Arborescence Unifiée des Localisations & Assets -->
<div class="card shadow-sm mb-4" id="card-hierarchy-tree">
<div class="card-header bg-light fw-bold d-flex justify-content-between align-items-center flex-wrap gap-2">
<span><i class="bi bi-diagram-3 me-2"></i>{% translate "Arborescence Hiérarchique" %}</span>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group" id="tree-expand-collapse-group">
<button type="button" class="btn btn-outline-secondary py-0 px-2 small" id="btn-tree-collapse-all" title="{% translate 'Tout replier' %}">
<i class="bi bi-arrows-collapse me-1"></i>{% translate "Tout replier" %}
</button>
<button type="button" class="btn btn-outline-secondary py-0 px-2 small" id="btn-tree-expand-all" title="{% translate 'Tout déplier' %}">
<i class="bi bi-arrows-expand me-1"></i>{% translate "Tout déplier" %}
</button>
</div>
<span class="badge bg-primary-subtle text-primary border">{{ location_tree.children|length }} {% translate "sous-localisation(s)" %}</span>
</div>
</div>
<div class="card-body p-3">
<!-- Localisation Parente (si existante) -->
{% if parent_location %}
<div class="card bg-light border-secondary-subtle p-2 mb-3">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-geo-alt-fill text-danger fs-5"></i>
<div>
<div class="small text-muted fw-semibold">{% translate "Localisation parente" %} :</div>
<a href="{{ parent_location.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline">
{{ parent_location.code }} - {{ parent_location.get_name }}
</a>
<span class="badge bg-secondary-subtle text-dark border small ms-1">{{ parent_location.get_location_type_display }}</span>
</div>
</div>
<a href="{{ parent_location.get_absolute_url }}" class="btn btn-sm btn-outline-secondary py-0 px-2 small">
<i class="bi bi-arrow-up me-1"></i>{% translate "Voir la parente" %}
</a>
</div>
</div>
{% endif %}
<!-- Localisation Courante (Racine de l'affichage) -->
<div class="p-2 border rounded bg-white mb-2">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center gap-2 flex-wrap">
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-root-content"
aria-expanded="true">
<i class="bi bi-chevron-down small"></i>
</button>
<i class="bi {{ building.get_icon_class }} text-primary fs-5"></i>
<span class="fw-bold fs-6 text-primary">{{ building.code }}</span>
{% if building.room_number %}
<span class="badge bg-secondary-subtle text-secondary border ms-1 font-monospace"><i class="bi bi-tag me-1"></i>{{ building.room_number }}</span>
{% endif %}
<span class="fw-bold fs-6">- {{ building.get_name }}</span>
<span class="badge bg-primary text-white ms-1">{{ building.get_location_type_display }}</span>
{% if location_tree.assets %}
<span class="badge bg-info-subtle text-info border small ms-1" title="{% translate 'Assets rattachés directement au bâtiment' %}">
<i class="bi bi-tools me-1"></i>{{ location_tree.assets|length }} {% translate "asset(s)" %}
</span>
{% endif %}
</div>
</div>
<div id="tree-root-content" class="collapse show mt-2">
<!-- Assets rattachés directement au bâtiment racine -->
{% if location_tree.assets %}
<div class="tree-assets-group">
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=location_tree.assets depth=2 only %}
</div>
{% endif %}
<!-- Sous-localisations & leurs assets -->
<div class="tree-locations-group">
{% include "assets/buildings/publicbuilding_location_tree.html" with node=location_tree depth=1 only %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Localisation & Plans d'étage / Carte interactive -->
<div class="col-md-5">
<!-- Card Localisation, Carte interactive & Plans d'étage -->
<div class="card shadow-sm mb-4" id="card-location-plans">
<div class="card-header bg-light d-flex justify-content-between align-items-center flex-wrap gap-2 py-2">
<div class="d-flex align-items-center">
{% if level_specific_mode %}
<span class="fw-bold text-dark"><i class="bi bi-layers text-primary me-2"></i>{% translate "Plan du niveau" %} : <span class="text-primary">{{ active_floor.name|default:building.name }}</span></span>
{% else %}
<span class="fw-bold text-dark"><i class="bi bi-geo-alt text-primary me-2"></i>{% translate "Localisation & Plans" %}</span>
{% endif %}
</div>
<div class="d-flex align-items-center gap-2">
<div class="btn-group btn-group-sm" role="group" id="view-mode-toggle">
<button type="button" class="btn btn-outline-primary active" id="btn-toggle-plans" title="{% translate "Afficher les plans d'étage" %}">
<i class="bi bi-layers me-1"></i>{% translate "Plans" %}
</button>
<button type="button" class="btn btn-outline-primary" id="btn-toggle-map" title="{% translate "Afficher la carte du site" %}">
<i class="bi bi-map me-1"></i>{% translate "Carte" %}
</button>
</div>
{% if center_lat and center_lon %}
<a href="https://www.google.com/maps?cbll={{ center_lat_json }},{{ center_lon_json }}&cbp=12,0,0,0,5&layer=c&panoid="
target="_blank" class="btn btn-sm btn-outline-secondary py-0 px-2" title="Street View">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="Street View" height="14" class="me-1">
Street View
</a>
{% endif %}
</div>
</div>
<!-- 1. VUE PLANS D'ÉTAGE -->
<div id="section-plans" class="card-body p-0">
{% if not level_specific_mode and floors_data %}
<!-- Sélecteur d'étage horizontal -->
<div class="bg-light border-bottom px-3 py-2 d-flex align-items-center gap-1 overflow-auto" id="floors-nav-bar" style="white-space: nowrap;">
<span class="text-muted small fw-semibold me-2"><i class="bi bi-filter me-1"></i>{% translate "Niveaux" %} :</span>
{% for f in floors_data %}
<button type="button"
class="btn btn-sm floor-nav-btn {% if f.id == active_floor.id %}btn-primary text-white shadow-sm{% else %}btn-outline-secondary{% endif %}"
data-floor-id="{{ f.id }}"
data-plan-url="{{ f.plan_url }}"
data-has-plan="{{ f.has_plan|yesno:'true,false' }}"
data-floor-name="{{ f.name }}"
data-surface="{{ f.surface_m2|default:'' }}"
data-rooms-count="{{ f.rooms_count }}"
data-assets-count="{{ f.assets_count }}"
data-floor-url="{{ f.url }}">
{{ f.name }}
{% if f.has_plan %}
<span class="badge bg-white text-primary ms-1" title="{% translate "Plan architectural disponible" %}">📐</span>
{% endif %}
</button>
{% endfor %}
</div>
{% endif %}
<!-- Conteneur du viewer interactif -->
<div id="plan-viewer-wrapper" style="position: relative; height: 380px; width: 100%; background: #0F172A05; overflow: hidden; display: flex; align-items: center; justify-content: center;">
<!-- Floating Controls (Zoom / Pan / Fullscreen / Download) -->
<div class="btn-group-vertical btn-group-sm shadow-sm" style="position: absolute; top: 12px; right: 12px; z-index: 10; background: rgba(255, 255, 255, 0.92); backdrop-filter: blur(4px); border-radius: 6px; border: 1px solid #CBD5E1;">
<button type="button" class="btn btn-light py-1 px-2" id="plan-zoom-in" title="{% translate "Zoom avant (+)" %}">
<i class="bi bi-zoom-in"></i>
</button>
<button type="button" class="btn btn-light py-1 px-2" id="plan-zoom-out" title="{% translate "Zoom arrière (-)" %}">
<i class="bi bi-zoom-out"></i>
</button>
<button type="button" class="btn btn-light py-1 px-2" id="plan-reset" title="{% translate "Recentrer / Ajuster (100%)" %}">
<i class="bi bi-arrows-angle-contract"></i>
</button>
<button type="button" class="btn btn-light py-1 px-2" id="plan-fullscreen" title="{% translate "Plein écran" %}">
<i class="bi bi-arrows-fullscreen"></i>
</button>
<a href="{{ active_floor.plan_url|default:'#' }}" target="_blank" class="btn btn-light py-1 px-2" id="plan-download-link" title="{% translate "Ouvrir / Télécharger le plan" %}" {% if not active_floor.has_plan %}style="display:none;"{% endif %}>
<i class="bi bi-box-arrow-up-right"></i>
</a>
</div>
<!-- Indicateur du niveau affiché -->
<div class="badge bg-dark bg-opacity-75 text-white px-2 py-1" style="position: absolute; bottom: 12px; left: 12px; z-index: 10; font-size: 0.8rem; pointer-events: none;" id="plan-level-indicator">
<i class="bi bi-layers me-1"></i><span id="plan-active-name">{{ active_floor.name|default:building.name }}</span>
</div>
<!-- Pan/Zoom Canvas -->
<div id="plan-viewport" style="width: 100%; height: 100%; cursor: grab; display: flex; align-items: center; justify-content: center; transform-origin: center center; transition: transform 0.08s ease-out;">
<img id="plan-image"
src="{{ active_floor.plan_url|default:'' }}"
alt="{% translate "Plan d'étage" %}"
style="max-width: 95%; max-height: 95%; object-fit: contain; user-select: none; pointer-events: none; {% if not active_floor.has_plan %}display: none;{% endif %}">
</div>
<!-- Empty State when floor has no plan -->
<div id="plan-empty-state" class="text-center p-4" style="{% if active_floor.has_plan %}display: none;{% else %}display: block;{% endif %}">
<i class="bi bi-file-earmark-image fs-1 text-muted d-block mb-2"></i>
<h6 class="text-muted mb-1">{% translate "Aucun plan associé à ce niveau" %}</h6>
<p class="text-muted small mb-3">{% translate "Vous pouvez téléverser un plan d'étage (SVG, image ou PDF) pour ce niveau." %}</p>
{% if can_edit %}
<a href="{% url 'assets:publicbuilding_edit' building.pk %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-upload me-1"></i>{% translate "Associer un plan" %}
</a>
{% endif %}
</div>
</div>
<!-- Footer info du niveau actif (locaux & caractéristiques) -->
<div class="card-footer bg-white p-3 border-top" id="plan-floor-info">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-2">
<div>
<strong class="text-dark" id="info-floor-title">{{ active_floor.name|default:building.name }}</strong>
<span class="text-muted small ms-2" id="info-floor-code">({{ active_floor.code|default:building.code }})</span>
</div>
<div class="d-flex gap-2">
<span class="badge bg-light text-dark border" id="info-floor-surface">
{% if active_floor.surface_m2 %}
<i class="bi bi-bounding-box me-1 text-primary"></i>{{ active_floor.surface_m2|floatformat:1 }} m²
{% endif %}
</span>
<span class="badge bg-light text-dark border" id="info-floor-rooms">
<i class="bi bi-door-closed me-1 text-primary"></i><span id="info-rooms-count">{{ active_floor.rooms_count|default:0 }}</span> {% translate "local/locaux" %}
</span>
<span class="badge bg-light text-dark border" id="info-floor-assets">
<i class="bi bi-tools me-1 text-primary"></i><span id="info-assets-count">{{ active_floor.assets_count|default:0 }}</span> {% translate "équipement(s)" %}
</span>
</div>
</div>
<!-- Chips des locaux / pièces situés sur cet étage -->
<div id="info-floor-rooms-chips" class="d-flex flex-wrap gap-1 mt-2">
{% for r in active_floor.rooms %}
<a href="{{ r.url }}" class="badge bg-light text-dark border text-decoration-none py-1 px-2" title="{{ r.location_type_display }}">
<i class="bi bi-door-open me-1 text-primary"></i><strong>{{ r.room_number|default:r.code }}</strong> - {{ r.name }}
</a>
{% empty %}
<span class="text-muted small fst-italic">{% translate "Aucun sous-local direct répertorié sur ce niveau." %}</span>
{% endfor %}
</div>
</div>
</div>
<!-- 2. VUE CARTE DU SITE / EMPRISE (MapLibre) -->
<div id="section-map" style="display: none;">
<div class="card-body p-0">
<div id="building-map" style="height: 380px; width: 100%;"></div>
</div>
<div class="card-footer bg-white p-3 border-top">
<p class="mb-2 small">
<strong>{% translate "Adresse" %} :</strong> {{ building.address|default:_("Non renseignée") }}
</p>
{% if center_lat and center_lon %}
<div class="small text-muted font-monospace">
WGS84: {{ center_lat|floatformat:6 }}, {{ center_lon|floatformat:6 }}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Card Documents associés (placée sous les plans et la carte) -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold"><i class="bi bi-file-earmark-text me-2"></i>{% translate "Documents associés" %} ({{ documents.count }})</span>
<a href="{% url 'admin:assets_publicbuildingdocument_add' %}?building={{ building.pk }}" class="btn btn-sm btn-outline-primary py-0 px-2">
<i class="bi bi-plus-lg me-1"></i>{% translate "Ajouter" %}
</a>
</div>
<div class="card-body">
<div class="row row-cols-1 row-cols-sm-2 g-3">
{% for doc in documents %}
<div class="col">
<div class="card h-100 border p-2 text-center position-relative">
{% if doc.document_type == 'floor_plan' %}
<span class="badge bg-primary text-white position-absolute top-0 end-0 m-1" style="font-size: 0.65rem;">{% translate "Plan" %}</span>
<i class="bi bi-layers text-primary fs-2 mb-1"></i>
{% else %}
<span class="badge bg-secondary text-white position-absolute top-0 end-0 m-1" style="font-size: 0.65rem;">{{ doc.get_document_type_display }}</span>
<i class="bi bi-file-earmark-pdf text-danger fs-2 mb-1"></i>
{% endif %}
<div class="small text-truncate w-100 fw-bold" title="{{ doc.description|default:doc.file.name }}">
{{ doc.description|default:doc.file.name }}
</div>
{% if doc.floor_location %}
<div class="small text-muted text-truncate mb-1" style="font-size: 0.75rem;">
<i class="bi bi-layers-half me-1"></i>{{ doc.floor_location.name }}
</div>
{% endif %}
{% if doc.file %}
<a href="{% url 'private_file_download' %}?app=assets&model=PublicBuildingDocument&pk={{ doc.pk }}&field=file" target="_blank" class="btn btn-sm btn-outline-secondary mt-auto py-0 px-2" style="font-size: 0.8rem;">
<i class="bi bi-download me-1"></i>{% translate "Télécharger" %}
</a>
{% else %}
<span class="text-muted small">{% translate "Fichier indisponible" %}</span>
{% endif %}
</div>
</div>
{% empty %}
<div class="col-12 text-center text-muted py-3">
<i class="bi bi-folder-x fs-4 d-block mb-1"></i>
{% translate "Aucun document associé." %}
</div>
{% endfor %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 2. Assets & Équipements Tab -->
<div class="tab-pane fade" id="equipments" role="tabpanel">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title h6 mb-0"><i class="bi bi-tools me-2"></i>{% translate "Assets Physiques & Équipements de la Localisation et de ses sous-localisations" %}</h5>
<span class="badge bg-info-subtle text-info border">{{ equipments|length }} {% translate "asset(s)" %}</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "Code / Nom" %}</th>
<th>{% translate "Lot / Type d'asset" %}</th>
<th>{% translate "Caractéristiques & Spécifications" %}</th>
<th>{% translate "Localisation spécifique" %}</th>
<th>{% translate "Asset Parent" %}</th>
<th>{% translate "Statut" %}</th>
<th>{% translate "Prochain contrôle" %}</th>
<th>{% translate "Maintenance & Prestataire" %}</th>
<th class="text-end pe-3">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for eq in equipments %}
<tr>
<td>
<a href="{{ eq.get_absolute_url }}" class="fw-bold text-decoration-none" style="color: {{ eq.theme_color }};">
<i class="bi {{ eq.get_icon_class }} me-1" style="color: {{ eq.theme_icon_color }};"></i>{{ eq.code }}
</a>
<div class="small text-muted">{{ eq.get_name }}</div>
</td>
<td>
<span class="badge bg-light text-muted border me-1">{{ eq.get_macro_lot }}</span>
<span class="badge {{ eq.theme_badge_class }} d-block mt-1" style="{{ eq.theme_badge_style }}">
{{ eq.get_asset_type_display }}
</span>
</td>
<td>
{% if eq.dimensions or eq.surface_m2 %}
<div class="small fw-semibold">{% if eq.dimensions %}{{ eq.dimensions }}{% endif %}{% if eq.surface_m2 %} ({{ eq.surface_m2 }} m²){% endif %}</div>
{% endif %}
{% if eq.power_kw or eq.energy_source %}
<div class="small text-muted">
{% if eq.power_kw %}<i class="bi bi-lightning-charge me-1"></i>{{ eq.power_kw }} kW{% endif %}
{% if eq.energy_source %}<span class="badge bg-warning-subtle text-dark border ms-1">{{ eq.get_energy_source_display }}</span>{% endif %}
</div>
{% endif %}
{% if eq.material or eq.fire_rating or eq.glazing_type %}
<div class="small text-muted">
{% if eq.material %}{{ eq.material }}{% endif %}
{% if eq.glazing_type %} - {{ eq.get_glazing_type_display }}{% endif %}
{% if eq.fire_rating %}<span class="badge bg-danger-subtle text-danger border ms-1">{{ eq.fire_rating }}</span>{% endif %}
</div>
{% endif %}
</td>
<td>
{% if eq.location %}
<a href="{{ eq.location.get_absolute_url }}" class="small text-dark text-decoration-none">
<i class="bi {{ eq.location.get_icon_class }} me-1"></i>{{ eq.location.code }} - {{ eq.location.get_name }}
</a>
{% else %}
<span class="small text-muted">{{ eq.building.code }}</span>
{% endif %}
</td>
<td>
{% if eq.parent_asset %}
<a href="{{ eq.parent_asset.get_absolute_url }}" class="small text-decoration-none">
<i class="bi {{ eq.parent_asset.get_icon_class }} me-1"></i>{{ eq.parent_asset.code }}
</a>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
<span class="badge {% if eq.status == 'active' %}bg-success-subtle text-success border{% else %}bg-secondary-subtle text-secondary border{% endif %}">
{{ eq.get_status_display }}
</span>
</td>
<td>{{ eq.next_control_date|date:"d/m/Y"|default:"-" }}</td>
<td>
{% if eq.contractor_name %}
<div class="small fw-semibold"><i class="bi bi-building me-1"></i>{{ eq.contractor_name }}</div>
{% endif %}
{% if eq.maintenance_contract_ref %}
<div class="small text-muted font-monospace">Réf: {{ eq.maintenance_contract_ref }}</div>
{% elif not eq.contractor_name %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td class="text-end pe-3">
<a href="{{ eq.get_absolute_url }}" class="btn btn-sm btn-outline-info py-0 px-2 small text-nowrap" title="{% translate 'Détail de l’asset' %}">
<i class="bi bi-eye me-1"></i>{% translate "Détail" %}
</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="9" class="text-center text-muted py-4">{% translate "Aucun asset ou équipement technique enregistré." %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- 3. Interventions Tab -->
<div class="tab-pane fade" id="interventions" role="tabpanel">
<div class="card shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title h6 mb-0"><i class="bi bi-tools me-2"></i>{% translate "Interventions & Maintenance" %}</h5>
<a href="{% url 'interventions:interventions_index' %}?asset={{ building.pk }}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-plus-lg me-1"></i>{% translate "Nouvelle intervention" %}
</a>
</div>
<div class="card-body p-0">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 50px;"></th>
<th>{% translate "Code" %}</th>
<th>{% translate "Titre / Objet" %}</th>
<th>{% translate "Date de début" %}</th>
<th>{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for interv in interventions %}
<tr>
<td>
<a href="{% url 'interventions:interventions_index' %}{{ interv.id }}" class="btn btn-sm btn-light border" title="{% translate 'Détail' %}">
<i class="bi bi-info-circle"></i>
</a>
</td>
<td><strong>{{ interv.code }}</strong></td>
<td>{{ interv.title }}</td>
<td>{{ interv.begin_time|date:"d/m/Y H:i"|default:"-" }}</td>
<td><span class="badge bg-secondary">{{ interv.get_status_display }}</span></td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="bi bi-tools fs-2 d-block mb-2 text-muted"></i>
{% translate "Aucune intervention enregistrée pour ce bâtiment." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
document.addEventListener("DOMContentLoaded", function() {
const buildingGeom = {{ building_geojson_json|safe }};
const centerLon = {{ center_lon_json|safe }} || 4.3572;
const centerLat = {{ center_lat_json|safe }} || 50.8477;
const floorsData = {{ floors_data_json|safe }} || [];
let currentFloor = {{ active_floor_json|safe }};
const hasAnyPlan = {{ has_any_plan|yesno:"true,false" }};
const levelSpecificMode = {{ level_specific_mode|yesno:"true,false" }};
let map = null;
// 1. Initialisation de la carte MapLibre
function initMap() {
const container = document.getElementById("building-map");
if (!container || map) return;
map = new maplibregl.Map({
container: "building-map",
style: "{{ MAP_STYLE_LIGHT|default:'https://api.maptiler.com/maps/streets-v2-light/style.json' }}",
center: [centerLon, centerLat],
zoom: 16
});
map.addControl(new maplibregl.NavigationControl());
map.on("load", function() {
if (buildingGeom) {
map.addSource("building-footprint", {
"type": "geojson",
"data": buildingGeom
});
map.addLayer({
"id": "building-fill",
"type": "fill",
"source": "building-footprint",
"paint": {
"fill-color": "#2B579A",
"fill-opacity": 0.4
}
});
map.addLayer({
"id": "building-outline",
"type": "line",
"source": "building-footprint",
"paint": {
"line-color": "#2B579A",
"line-width": 3
}
});
}
});
}
// 2. Bascule entre Mode Plans d'étage et Mode Carte
const btnTogglePlans = document.getElementById("btn-toggle-plans");
const btnToggleMap = document.getElementById("btn-toggle-map");
const sectionPlans = document.getElementById("section-plans");
const sectionMap = document.getElementById("section-map");
function setViewMode(mode) {
if (mode === "map") {
btnToggleMap?.classList.add("active");
btnTogglePlans?.classList.remove("active");
if (sectionPlans) sectionPlans.style.display = "none";
if (sectionMap) sectionMap.style.display = "block";
if (!map) {
initMap();
} else {
map.resize();
}
} else {
btnTogglePlans?.classList.add("active");
btnToggleMap?.classList.remove("active");
if (sectionPlans) sectionPlans.style.display = "block";
if (sectionMap) sectionMap.style.display = "none";
}
}
btnTogglePlans?.addEventListener("click", function() { setViewMode("plans"); });
btnToggleMap?.addEventListener("click", function() { setViewMode("map"); });
// Par défaut, si aucun plan n'existe et qu'on n'est pas en mode spécifique à un niveau, afficher la carte
if (!hasAnyPlan && !levelSpecificMode && (buildingGeom || (centerLon && centerLat))) {
setViewMode("map");
} else {
setViewMode("plans");
}
// 3. Pan & Zoom interactif sur le viewer de plan
const planViewport = document.getElementById("plan-viewport");
const planImage = document.getElementById("plan-image");
const planWrapper = document.getElementById("plan-viewer-wrapper");
const planEmptyState = document.getElementById("plan-empty-state");
const planDownloadLink = document.getElementById("plan-download-link");
const planActiveName = document.getElementById("plan-active-name");
let scale = 1;
let translateX = 0;
let translateY = 0;
let isDragging = false;
let startX = 0;
let startY = 0;
function updateTransform() {
if (planViewport) {
planViewport.style.transform = `translate(${translateX}px, ${translateY}px) scale(${scale})`;
}
}
function resetPlanView() {
scale = 1;
translateX = 0;
translateY = 0;
updateTransform();
}
document.getElementById("plan-zoom-in")?.addEventListener("click", function() {
scale = Math.min(scale * 1.3, 6);
updateTransform();
});
document.getElementById("plan-zoom-out")?.addEventListener("click", function() {
scale = Math.max(scale / 1.3, 0.35);
updateTransform();
});
document.getElementById("plan-reset")?.addEventListener("click", resetPlanView);
document.getElementById("plan-fullscreen")?.addEventListener("click", function() {
if (!document.fullscreenElement) {
planWrapper?.requestFullscreen?.();
} else {
document.exitFullscreen?.();
}
});
// Zoom molette
planWrapper?.addEventListener("wheel", function(e) {
e.preventDefault();
const delta = e.deltaY > 0 ? 0.88 : 1.14;
scale = Math.min(Math.max(scale * delta, 0.35), 6);
updateTransform();
}, { passive: false });
// Déplacement souris (pan)
planWrapper?.addEventListener("mousedown", function(e) {
if (e.button !== 0) return;
isDragging = true;
startX = e.clientX - translateX;
startY = e.clientY - translateY;
if (planViewport) planViewport.style.cursor = "grabbing";
});
window.addEventListener("mousemove", function(e) {
if (!isDragging) return;
translateX = e.clientX - startX;
translateY = e.clientY - startY;
updateTransform();
});
window.addEventListener("mouseup", function() {
if (isDragging) {
isDragging = false;
if (planViewport) planViewport.style.cursor = "grab";
}
});
// 4. Navigation entre niveaux / étages
function displayFloor(floor) {
currentFloor = floor;
resetPlanView();
if (planActiveName) planActiveName.textContent = floor.name;
// Mise à jour de l'image du plan
if (floor.has_plan && floor.plan_url) {
if (planImage) {
planImage.src = floor.plan_url;
planImage.style.display = "block";
}
if (planEmptyState) planEmptyState.style.display = "none";
if (planDownloadLink) {
planDownloadLink.href = floor.plan_url;
planDownloadLink.style.display = "inline-block";
}
} else {
if (planImage) planImage.style.display = "none";
if (planEmptyState) planEmptyState.style.display = "block";
if (planDownloadLink) planDownloadLink.style.display = "none";
}
// Mise à jour du bandeau d'information sous le plan
const infoTitle = document.getElementById("info-floor-title");
const infoCode = document.getElementById("info-floor-code");
const infoSurface = document.getElementById("info-floor-surface");
const infoRoomsCount = document.getElementById("info-rooms-count");
const infoAssetsCount = document.getElementById("info-assets-count");
const chipsContainer = document.getElementById("info-floor-rooms-chips");
if (infoTitle) infoTitle.textContent = floor.name;
if (infoCode) infoCode.textContent = `(${floor.code})`;
if (infoSurface) {
if (floor.surface_m2) {
infoSurface.innerHTML = `<i class="bi bi-bounding-box me-1 text-primary"></i>${parseFloat(floor.surface_m2).toFixed(1)} m²`;
infoSurface.style.display = "inline-block";
} else {
infoSurface.style.display = "none";
}
}
if (infoRoomsCount) infoRoomsCount.textContent = floor.rooms_count || 0;
if (infoAssetsCount) infoAssetsCount.textContent = floor.assets_count || 0;
// Locaux de cet étage
if (chipsContainer) {
chipsContainer.innerHTML = "";
if (floor.rooms && floor.rooms.length > 0) {
floor.rooms.forEach(function(room) {
const chip = document.createElement("a");
chip.href = room.url;
chip.className = "badge bg-light text-dark border text-decoration-none py-1 px-2";
chip.title = room.location_type_display || "";
chip.innerHTML = `<i class="bi bi-door-open me-1 text-primary"></i><strong>${room.room_number || room.code}</strong> - ${room.name}`;
chipsContainer.appendChild(chip);
});
} else {
chipsContainer.innerHTML = '<span class="text-muted small fst-italic">{% translate "Aucun sous-local direct répertorié sur ce niveau." %}</span>';
}
}
}
// Écouteurs sur les boutons d'étages
document.querySelectorAll(".floor-nav-btn").forEach(function(btn) {
btn.addEventListener("click", function() {
const floorId = parseInt(btn.getAttribute("data-floor-id"));
const targetFloor = floorsData.find(f => f.id === floorId);
if (!targetFloor) return;
document.querySelectorAll(".floor-nav-btn").forEach(b => {
b.classList.remove("btn-primary", "text-white", "shadow-sm");
b.classList.add("btn-outline-secondary");
});
btn.classList.remove("btn-outline-secondary");
btn.classList.add("btn-primary", "text-white", "shadow-sm");
displayFloor(targetFloor);
});
});
const overviewTab = document.getElementById("overview-tab");
if (overviewTab) {
overviewTab.addEventListener("shown.bs.tab", function () {
if (map) map.resize();
});
}
// Gestion du chevron plié / déplié dans l'arborescence
document.querySelectorAll(".tree-toggle-btn").forEach(function(btn) {
const targetId = btn.getAttribute("data-bs-target");
const target = document.querySelector(targetId);
if (target) {
target.addEventListener("show.bs.collapse", function (e) {
if (e.target !== target) return;
const icon = btn.querySelector("i");
if (icon) {
icon.classList.remove("bi-chevron-right");
icon.classList.add("bi-chevron-down");
}
});
target.addEventListener("hide.bs.collapse", function (e) {
if (e.target !== target) return;
const icon = btn.querySelector("i");
if (icon) {
icon.classList.remove("bi-chevron-down");
icon.classList.add("bi-chevron-right");
}
});
}
});
// Gestion du repliage / dépliage global de l'arborescence
const treeCard = document.getElementById("card-hierarchy-tree");
if (treeCard) {
const collapseAllBtn = document.getElementById("btn-tree-collapse-all");
const expandAllBtn = document.getElementById("btn-tree-expand-all");
if (collapseAllBtn) {
collapseAllBtn.addEventListener("click", function() {
// Replier tous les nœuds enfants de l'arborescence (localisations et assets)
treeCard.querySelectorAll(".tree-collapse-node").forEach(function(el) {
if (window.bootstrap && bootstrap.Collapse) {
const bsCollapse = bootstrap.Collapse.getOrCreateInstance(el, { toggle: false });
bsCollapse.hide();
} else {
el.classList.remove("show");
}
});
// Mettre à jour l'état visuel de tous les boutons chevrons
treeCard.querySelectorAll(".tree-toggle-btn").forEach(function(btn) {
if (btn.getAttribute("data-bs-target") !== "#tree-root-content") {
btn.classList.add("collapsed");
btn.setAttribute("aria-expanded", "false");
const icon = btn.querySelector("i");
if (icon) {
icon.classList.remove("bi-chevron-down");
icon.classList.add("bi-chevron-right");
}
}
});
});
}
if (expandAllBtn) {
expandAllBtn.addEventListener("click", function() {
// S'assurer que le conteneur racine est bien déplié
const rootContent = document.getElementById("tree-root-content");
if (rootContent) {
if (window.bootstrap && bootstrap.Collapse) {
bootstrap.Collapse.getOrCreateInstance(rootContent, { toggle: false }).show();
} else {
rootContent.classList.add("show");
}
}
// Déplier tous les nœuds enfants (localisations et assets)
treeCard.querySelectorAll(".tree-collapse-node").forEach(function(el) {
if (window.bootstrap && bootstrap.Collapse) {
const bsCollapse = bootstrap.Collapse.getOrCreateInstance(el, { toggle: false });
bsCollapse.show();
} else {
el.classList.add("show");
}
});
// Mettre à jour l'état visuel de tous les boutons chevrons
treeCard.querySelectorAll(".tree-toggle-btn").forEach(function(btn) {
btn.classList.remove("collapsed");
btn.setAttribute("aria-expanded", "true");
const icon = btn.querySelector("i");
if (icon) {
icon.classList.remove("bi-chevron-right");
icon.classList.add("bi-chevron-down");
}
});
});
}
}
});
</script>
{% endblock script %}

View file

@ -1,456 +0,0 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% translate "Modifier le bâtiment" %} {{ building.code }} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container py-4">
<!-- Breadcrumb & Title -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="{% url 'assets:buildings_index' %}">{% translate "Bâtiments publics" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:publicbuilding_detail' building.pk %}">{{ building.code }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% translate "Modifier" %}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="bi bi-pencil-square text-primary me-2"></i>{% translate "Modifier le bâtiment" %} {{ building.code }}
</h1>
</div>
<div>
<a href="{% url 'assets:publicbuilding_detail' building.pk %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% translate "Annuler" %}
</a>
</div>
</div>
<form method="post" action="" class="needs-validation" novalidate>
{% csrf_token %}
{% if form.errors %}
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>{% translate "Certains champs contiennent des erreurs." %}</strong> {% translate "Veuillez vérifier les informations ci-dessous." %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<!-- Card 1: Identités & Localisation -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-info-circle me-2"></i>{% translate "Informations générales" %}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label for="{{ form.code.id_for_label }}" class="form-label fw-semibold">{% translate "Code du bâtiment" %} <span class="text-danger">*</span></label>
{{ form.code }}
{% if form.code.errors %}<div class="text-danger small mt-1">{{ form.code.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.name_fr.id_for_label }}" class="form-label fw-semibold">{% translate "Nom (FR)" %}</label>
{{ form.name_fr }}
{% if form.name_fr.errors %}<div class="text-danger small mt-1">{{ form.name_fr.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.name_nl.id_for_label }}" class="form-label fw-semibold">{% translate "Nom (NL)" %}</label>
{{ form.name_nl }}
{% if form.name_nl.errors %}<div class="text-danger small mt-1">{{ form.name_nl.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.location_type.id_for_label }}" class="form-label fw-semibold">{% translate "Type de localisation" %}</label>
{{ form.location_type }}
{% if form.location_type.errors %}<div class="text-danger small mt-1">{{ form.location_type.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.parent_location.id_for_label }}" class="form-label fw-semibold">{% translate "Localisation parente" %}</label>
{{ form.parent_location }}
{% if form.parent_location.errors %}<div class="text-danger small mt-1">{{ form.parent_location.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 d-flex align-items-end mb-2">
<div class="form-check">
{{ form.is_main_location }}
<label for="{{ form.is_main_location.id_for_label }}" class="form-check-label fw-semibold">{% translate "Localisation principale" %}</label>
</div>
</div>
<div class="col-md-12">
<label for="{{ form.address.id_for_label }}" class="form-label fw-semibold">{% translate "Adresse complète" %}</label>
{{ form.address }}
{% if form.address.errors %}<div class="text-danger small mt-1">{{ form.address.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-12">
<label for="{{ form.description.id_for_label }}" class="form-label fw-semibold">{% translate "Description" %}</label>
{{ form.description }}
{% if form.description.errors %}<div class="text-danger small mt-1">{{ form.description.errors.0 }}</div>{% endif %}
</div>
</div>
</div>
</div>
<!-- Card 2: Propriété & Gestion -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-person-badge me-2"></i>{% translate "Propriété & Gestion" %}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-4">
<label for="{{ form.owner.id_for_label }}" class="form-label fw-semibold">{% translate "Propriétaire" %}</label>
{{ form.owner }}
{% if form.owner.errors %}<div class="text-danger small mt-1">{{ form.owner.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.manager.id_for_label }}" class="form-label fw-semibold">{% translate "Gestionnaire référent" %}</label>
{{ form.manager }}
{% if form.manager.errors %}<div class="text-danger small mt-1">{{ form.manager.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.occupant_entity.id_for_label }}" class="form-label fw-semibold">{% translate "Direction / Service occupant" %}</label>
{{ form.occupant_entity }}
{% if form.occupant_entity.errors %}<div class="text-danger small mt-1">{{ form.occupant_entity.errors.0 }}</div>{% endif %}
</div>
</div>
</div>
</div>
<!-- Card 3: Usage & Caractéristiques physiques -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-building-gear me-2"></i>{% translate "Usage & Caractéristiques physiques" %}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label for="{{ form.usage_type.id_for_label }}" class="form-label fw-semibold">{% translate "Type d'usage principal" %}</label>
{{ form.usage_type }}
{% if form.usage_type.errors %}<div class="text-danger small mt-1">{{ form.usage_type.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.building_status.id_for_label }}" class="form-label fw-semibold">{% translate "Statut d'occupation" %}</label>
{{ form.building_status }}
{% if form.building_status.errors %}<div class="text-danger small mt-1">{{ form.building_status.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.construction_year.id_for_label }}" class="form-label fw-semibold">{% translate "Année de construction" %}</label>
{{ form.construction_year }}
{% if form.construction_year.errors %}<div class="text-danger small mt-1">{{ form.construction_year.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.floors_count.id_for_label }}" class="form-label fw-semibold">{% translate "Nombre d'étages hors-sol" %}</label>
{{ form.floors_count }}
{% if form.floors_count.errors %}<div class="text-danger small mt-1">{{ form.floors_count.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.basement_count.id_for_label }}" class="form-label fw-semibold">{% translate "Nombre de niveaux en sous-sol" %}</label>
{{ form.basement_count }}
{% if form.basement_count.errors %}<div class="text-danger small mt-1">{{ form.basement_count.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.total_surface_m2.id_for_label }}" class="form-label fw-semibold">{% translate "Surface brute totale (m²)" %}</label>
{{ form.total_surface_m2 }}
{% if form.total_surface_m2.errors %}<div class="text-danger small mt-1">{{ form.total_surface_m2.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.usable_surface_m2.id_for_label }}" class="form-label fw-semibold">{% translate "Surface utile (m²)" %}</label>
{{ form.usable_surface_m2 }}
{% if form.usable_surface_m2.errors %}<div class="text-danger small mt-1">{{ form.usable_surface_m2.errors.0 }}</div>{% endif %}
</div>
<div class="col-12 mt-3">
<h6 class="border-bottom pb-2 text-primary fw-semibold"><i class="bi bi-door-open me-2"></i>{% translate "Spécificités du local ou du niveau (si applicable)" %}</h6>
</div>
<div class="col-md-3">
<label for="{{ form.room_number.id_for_label }}" class="form-label fw-semibold">{% translate "Numéro de local / porte" %}</label>
{{ form.room_number }}
{% if form.room_number.errors %}<div class="text-danger small mt-1">{{ form.room_number.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-3">
<label for="{{ form.level_number.id_for_label }}" class="form-label fw-semibold">{% translate "Numéro d'étage" %}</label>
{{ form.level_number }}
{% if form.level_number.errors %}<div class="text-danger small mt-1">{{ form.level_number.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-3">
<label for="{{ form.surface_m2.id_for_label }}" class="form-label fw-semibold">{% translate "Surface spécifique (m²)" %}</label>
{{ form.surface_m2 }}
{% if form.surface_m2.errors %}<div class="text-danger small mt-1">{{ form.surface_m2.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-3">
<label for="{{ form.ceiling_height_m.id_for_label }}" class="form-label fw-semibold">{% translate "Hauteur sous plafond (m)" %}</label>
{{ form.ceiling_height_m }}
{% if form.ceiling_height_m.errors %}<div class="text-danger small mt-1">{{ form.ceiling_height_m.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.floor_type.id_for_label }}" class="form-label fw-semibold">{% translate "Revêtement de sol" %}</label>
{{ form.floor_type }}
{% if form.floor_type.errors %}<div class="text-danger small mt-1">{{ form.floor_type.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.fire_compartment.id_for_label }}" class="form-label fw-semibold">{% translate "Compartiment coupe-feu" %}</label>
{{ form.fire_compartment }}
{% if form.fire_compartment.errors %}<div class="text-danger small mt-1">{{ form.fire_compartment.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4 d-flex align-items-center mt-4">
<div class="form-check">
{{ form.is_heated }}
<label for="{{ form.is_heated.id_for_label }}" class="form-check-label fw-semibold">
{% translate "Local chauffé" %}
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Card 4: Réglementation & Accueil du Public (ERP / PMR) -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-shield-check me-2"></i>{% translate "Accueil & Sécurité du Public (ERP / PMR)" %}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6 d-flex align-items-center">
<div class="form-check mt-3">
{{ form.is_erp }}
<label for="{{ form.is_erp.id_for_label }}" class="form-check-label fw-semibold">
{% translate "Bâtiment recevant du public (ERP / PMR)" %}
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check mt-3">
{{ form.pmr_accessible }}
<label for="{{ form.pmr_accessible.id_for_label }}" class="form-check-label fw-semibold">
{% translate "Accessible PMR" %}
</label>
</div>
</div>
<div class="col-md-4">
<label for="{{ form.erp_category.id_for_label }}" class="form-label fw-semibold">{% translate "Catégorie d'accueil / ERP" %}</label>
{{ form.erp_category }}
{% if form.erp_category.errors %}<div class="text-danger small mt-1">{{ form.erp_category.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.erp_type.id_for_label }}" class="form-label fw-semibold">{% translate "Type d'établissement / ERP" %}</label>
{{ form.erp_type }}
{% if form.erp_type.errors %}<div class="text-danger small mt-1">{{ form.erp_type.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.max_capacity.id_for_label }}" class="form-label fw-semibold">{% translate "Jauge maximale d'accueil du public" %}</label>
{{ form.max_capacity }}
{% if form.max_capacity.errors %}<div class="text-danger small mt-1">{{ form.max_capacity.errors.0 }}</div>{% endif %}
</div>
</div>
</div>
</div>
<!-- Card 5: Diagnostics & Énergie -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-lightning-charge me-2"></i>{% translate "Diagnostics & Énergie" %}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-6">
<label for="{{ form.dpe_rating.id_for_label }}" class="form-label fw-semibold">{% translate "Performance Énergétique (PEB / DPE / EPC)" %}</label>
{{ form.dpe_rating }}
{% if form.dpe_rating.errors %}<div class="text-danger small mt-1">{{ form.dpe_rating.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.ges_rating.id_for_label }}" class="form-label fw-semibold">{% translate "Émissions de Carbone & GES" %}</label>
{{ form.ges_rating }}
{% if form.ges_rating.errors %}<div class="text-danger small mt-1">{{ form.ges_rating.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check mt-2">
{{ form.has_asbestos_diag }}
<label for="{{ form.has_asbestos_diag.id_for_label }}" class="form-check-label fw-semibold">
{% translate "Diagnostic Amiante réalisé" %}
</label>
</div>
</div>
<div class="col-md-6 d-flex align-items-center">
<div class="form-check mt-2">
{{ form.has_lead_diag }}
<label for="{{ form.has_lead_diag.id_for_label }}" class="form-check-label fw-semibold">
{% translate "Diagnostic Plomb réalisé" %}
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Card 6: Géolocalisation (Carte & Coordonnées) -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-geo-alt me-2"></i>{% translate "Coordonnées géographiques (GPS WGS84)" %}
</div>
<div class="card-body">
<p class="text-muted small mb-3">
<i class="bi bi-info-circle me-1"></i>{% translate "Cliquez sur la carte ou déplacez le marqueur pour ajuster la position du bâtiment. Vous pouvez également rechercher une adresse ou basculer en vue orthophoto." %}
</p>
<div id="edit-building-map" style="height: 400px; width: 100%; border-radius: 8px; border: 1px solid #dee2e6;" class="mb-3"></div>
<div class="row g-3">
<div class="col-md-6">
<label for="{{ form.lat.id_for_label }}" class="form-label fw-semibold">{% translate "Latitude" %}</label>
{{ form.lat }}
{% if form.lat.errors %}<div class="text-danger small mt-1">{{ form.lat.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.lon.id_for_label }}" class="form-label fw-semibold">{% translate "Longitude" %}</label>
{{ form.lon }}
{% if form.lon.errors %}<div class="text-danger small mt-1">{{ form.lon.errors.0 }}</div>{% endif %}
</div>
</div>
</div>
</div>
<!-- Form Submit & Cancel -->
<div class="d-flex justify-content-end gap-2 mb-5">
<a href="{% url 'assets:publicbuilding_detail' building.pk %}" class="btn btn-secondary px-4">
<i class="bi bi-x-lg me-1"></i>{% translate "Annuler" %}
</a>
<button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>{% translate "Enregistrer les modifications" %}
</button>
</div>
</form>
</div>
{% endblock content %}
{% block script %}
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
const latInput = document.getElementById("{{ form.lat.id_for_label }}");
const lonInput = document.getElementById("{{ form.lon.id_for_label }}");
let initLat = parseFloat("{{ center_lat|default_if_none:'' }}") || parseFloat(latInput.value) || 50.8477;
let initLon = parseFloat("{{ center_lon|default_if_none:'' }}") || parseFloat(lonInput.value) || 4.3572;
let hasCoords = Boolean("{{ center_lat|default_if_none:'' }}" || (latInput && latInput.value));
const map = new maplibregl.Map({
container: 'edit-building-map',
style: window.MAP_STYLE_LIGHT || 'https://tiles.openfreemap.org/styles/positron',
center: [initLon, initLat],
zoom: hasCoords ? 17 : 12,
});
// 1. Navigation & Geolocate Controls
map.addControl(new maplibregl.NavigationControl(), 'bottom-right');
map.addControl(new maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true }), 'bottom-right');
// 2. Custom Address Search Control
if (typeof AddressSearchControl !== 'undefined') {
const searchCtrl = new AddressSearchControl({
autocompleteUrl: "{% url 'common:autocomplete_street' %}"
});
map.addControl(searchCtrl, 'top-left');
searchCtrl.addEventListener('address-selected', function(e) {
if (e.detail && e.detail.lon && e.detail.lat) {
const lon = parseFloat(e.detail.lon);
const lat = parseFloat(e.detail.lat);
map.flyTo({ center: [lon, lat], zoom: 18 });
setMarkerPosition(lat, lon);
}
});
}
// 3. Orthophotos / Satellite LayerSwitcherControl
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [
{
id: 'ortho',
name: 'Orthophotos',
icon: 'satellite',
group: 'ortho',
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 || '© CIRB / 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: '© Digitaal Vlaanderen - Orthofoto 2023'
}
}
]
}
]
});
map.addControl(layerSwitcher, 'top-right');
}
// 4. Marker logic & Form synchronization
let marker = null;
if (hasCoords) {
marker = new maplibregl.Marker({ draggable: true, color: '#2B579A' })
.setLngLat([initLon, initLat])
.addTo(map);
marker.on('dragend', function() {
const lngLat = marker.getLngLat();
updateInputValues(lngLat.lat, lngLat.lng);
});
}
map.on('click', function(e) {
setMarkerPosition(e.lngLat.lat, e.lngLat.lng);
});
function setMarkerPosition(lat, lon) {
if (!marker) {
marker = new maplibregl.Marker({ draggable: true, color: '#2B579A' })
.setLngLat([lon, lat])
.addTo(map);
marker.on('dragend', function() {
const lngLat = marker.getLngLat();
updateInputValues(lngLat.lat, lngLat.lng);
});
} else {
marker.setLngLat([lon, lat]);
}
updateInputValues(lat, lon);
}
function updateInputValues(lat, lon) {
if (latInput) latInput.value = parseFloat(lat).toFixed(7);
if (lonInput) lonInput.value = parseFloat(lon).toFixed(7);
}
function syncMarkerFromInputs() {
const lat = parseFloat(latInput.value);
const lon = parseFloat(lonInput.value);
if (!isNaN(lat) && !isNaN(lon)) {
setMarkerPosition(lat, lon);
map.panTo([lon, lat]);
}
}
if (latInput) latInput.addEventListener('change', syncMarkerFromInputs);
if (lonInput) lonInput.addEventListener('change', syncMarkerFromInputs);
});
</script>
{% endblock script %}

View file

@ -1,173 +0,0 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% translate "Bâtiments publics" %} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1"><i class="bi bi-building me-2 text-primary"></i>{% translate "Gestion des Bâtiments Publics" %}</h1>
<p class="text-muted mb-0">{% translate "Patrimoine bâti, pièces, équipements techniques et diagnostics ERP" %}</p>
</div>
<div>
<a href="{% url 'admin:assets_publicbuilding_add' %}" class="btn btn-primary">
<i class="bi bi-plus-lg me-1"></i>{% translate "Nouveau Bâtiment" %}
</a>
</div>
</div>
<!-- Filters Card -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<form method="get" class="row g-3">
<div class="col-md-3">
<label class="form-label small text-muted">{% translate "Recherche" %}</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="q" class="form-control" placeholder="{% translate 'Code, nom, adresse, propriétaire...' %}" value="{{ search_query }}">
</div>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Usage" %}</label>
<select name="usage_type" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les usages" %} --</option>
{% for key, label in usage_type_choices %}
<option value="{{ key }}" {% if selected_usage == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Type de localisation" %}</label>
<select name="location_type" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les types" %} --</option>
{% for key, label in location_type_choices %}
<option value="{{ key }}" {% if selected_location_type == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Statut" %}</label>
<select name="building_status" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les statuts" %} --</option>
{% for key, label in building_status_choices %}
<option value="{{ key }}" {% if selected_status == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-1">
<label class="form-label small text-muted">{% translate "DPE" %}</label>
<select name="dpe_rating" class="form-select form-select-sm">
<option value="">-- {% translate "Tous" %} --</option>
{% for key, label in dpe_rating_choices %}
<option value="{{ key }}" {% if selected_dpe == key %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-2 d-flex align-items-end gap-2">
<button type="submit" class="btn btn-sm btn-primary w-100"><i class="bi bi-filter me-1"></i>{% translate "Filtrer" %}</button>
<a href="{% url 'assets:buildings_index' %}" class="btn btn-sm btn-outline-secondary w-100">{% translate "Réinitialiser" %}</a>
</div>
</form>
</div>
</div>
<!-- Table of Public Buildings -->
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>{% translate "Code / Nom" %}</th>
<th>{% translate "Type & Parent" %}</th>
<th>{% translate "Usage principal" %}</th>
<th>{% translate "Propriétaire & Gestionnaire" %}</th>
<th>{% translate "ERP" %}</th>
<th>{% translate "DPE / GES" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for b in buildings %}
<tr>
<td>
<a href="{% url 'assets:publicbuilding_detail' b.pk %}" class="fw-bold text-decoration-none">
<i class="bi {{ b.get_icon_class }} me-1 text-primary"></i>{{ b.code }}
</a>
{% if b.room_number %}
<span class="badge bg-secondary-subtle text-secondary border small font-monospace"><i class="bi bi-tag me-1"></i>{{ b.room_number }}</span>
{% endif %}
<div class="small text-muted">{{ b.get_name }}</div>
</td>
<td>
<span class="badge bg-primary-subtle text-primary border me-1">
{{ b.get_location_type_display }}
</span>
{% if b.parent_location %}
<div class="small text-muted mt-1"><i class="bi bi-diagram-2 me-1"></i>{{ b.parent_location.code }}</div>
{% endif %}
</td>
<td>
<span class="badge bg-secondary-subtle text-dark border">
{{ b.get_usage_type_display }}
</span>
</td>
<td>
{% if b.owner %}
<div class="small"><i class="bi bi-shield-check text-muted me-1"></i><strong>{% translate "Propriétaire" %}:</strong> {{ b.owner }}</div>
{% endif %}
{% if b.manager %}
<div class="small text-muted"><i class="bi bi-person me-1"></i><strong>{% translate "Gestionnaire" %}:</strong> {{ b.manager.get_full_name|default:b.manager.username }}</div>
{% endif %}
</td>
<td>
{% if b.is_erp %}
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-people me-1"></i>ERP Cat {{ b.erp_category|default:"-" }} ({{ b.erp_type|default:"-" }})
</span>
{% else %}
<span class="badge bg-light text-muted border">{% translate "Non ERP" %}</span>
{% endif %}
</td>
<td>
{% if b.dpe_rating %}
<span class="badge bg-success border me-1">DPE {{ b.dpe_rating }}</span>
{% endif %}
{% if b.ges_rating %}
<span class="badge bg-primary border">GES {{ b.ges_rating }}</span>
{% endif %}
</td>
<td>
<span class="badge {% if b.building_status == 'in_service' %}bg-success-subtle text-success{% else %}bg-warning-subtle text-warning{% endif %} border">
{{ b.get_building_status_display }}
</span>
</td>
<td class="text-end">
<a href="{% url 'assets:publicbuilding_detail' b.pk %}" class="btn btn-sm btn-outline-primary" title="{% translate 'Voir détails' %}">
<i class="bi bi-eye"></i>
</a>
{% if b.can_edit %}
<a href="{% url 'assets:publicbuilding_edit' b.pk %}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Éditer' %}">
<i class="bi bi-pencil"></i>
</a>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center py-5 text-muted">
<i class="bi bi-building-exclamation fs-1 d-block mb-2"></i>
{% translate "Aucun bâtiment public ne correspond à vos critères." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -1,63 +0,0 @@
{% load i18n %}
<ul class="list-group list-group-flush border-0">
{% for child_node in node.children %}
<li class="list-group-item border-0 py-1 px-0">
<!-- Row Header -->
<div class="d-flex align-items-center justify-content-between py-1 px-3">
<!-- Contenu gauche (indenté selon la profondeur) -->
<div class="d-flex align-items-center gap-2 flex-wrap" style="padding-left: calc(({{ depth|default:1 }} - 1) * 1.25rem);">
{% if child_node.children or child_node.assets %}
<button class="btn btn-sm btn-light border p-0 px-1 tree-toggle-btn"
type="button"
data-bs-toggle="collapse"
data-bs-target="#tree-loc-{{ child_node.loc.id }}"
aria-expanded="true">
<i class="bi bi-chevron-down small"></i>
</button>
{% else %}
<span class="d-inline-block" style="width: 1.35rem;"></span>
{% endif %}
<i class="bi {{ child_node.loc.get_icon_class }} text-primary"></i>
<!-- Code en bleu pour indiquer clairement un lien cliquable -->
<a href="{{ child_node.loc.get_absolute_url }}" class="fw-bold text-primary text-decoration-underline small">
{{ child_node.loc.code }}
</a>
{% if child_node.loc.room_number %}
<span class="badge bg-secondary-subtle text-secondary border small font-monospace"><i class="bi bi-tag me-1"></i>{{ child_node.loc.room_number }}</span>
{% endif %}
<span class="small text-muted">- {{ child_node.loc.get_name }}</span>
<span class="badge bg-primary-subtle text-primary border small">{{ child_node.loc.get_location_type_display }}</span>
{% if child_node.assets %}
<span class="badge bg-info-subtle text-info border small" title="{% translate 'Assets physiques rattachés' %}">
<i class="bi bi-tools me-1"></i>{{ child_node.assets|length }}
</span>
{% endif %}
</div>
</div>
<!-- Collapsible Content (Direct Assets & Child Locations) -->
{% if child_node.children or child_node.assets %}
<div id="tree-loc-{{ child_node.loc.id }}" class="collapse show tree-collapse-node">
<!-- 1. Assets directement rattachés à cette localisation -->
{% if child_node.assets %}
{% include "assets/buildings/publicbuilding_asset_tree.html" with asset_nodes=child_node.assets depth=depth|default:1|add:1 only %}
{% endif %}
<!-- 2. Sous-localisations -->
{% if child_node.children %}
{% include "assets/buildings/publicbuilding_location_tree.html" with node=child_node depth=depth|default:1|add:1 only %}
{% endif %}
</div>
{% endif %}
</li>
{% empty %}
{% if not node.assets %}
<li class="list-group-item border-0 text-muted small fst-italic py-2 ps-3">
{% translate "Aucune sous-localisation." %}
</li>
{% endif %}
{% endfor %}
</ul>

View file

@ -1,373 +0,0 @@
{% load i18n static inspections_tags short_name %}
{# Inspections et contrôles liés à l'asset #}
<div class="card mb-3" id="asset-inspections-card">
<div class="card-header bg-light d-flex align-items-center justify-content-between">
<h5 class="mb-0 fs-6 fw-bold">
<i class="bi bi-patch-check me-2 text-primary"></i>{% translate "Inspections et contrôles terrain" %}
{% if inspections %}
<span class="badge bg-secondary rounded-pill ms-1">{{ total_count }}</span>
{% endif %}
</h5>
{% if can_inspect %}
<button type="button"
class="btn btn-outline-primary btn-sm btn-trigger-asset-inspection"
data-bs-toggle="modal"
data-bs-target="#quickInspectionModal"
data-content-type-id="{{ asset_ct_id }}"
data-model-name="{{ asset_ct_model }}"
data-object-id="{{ asset_pk }}"
data-target-code="{{ asset.code|default:asset.pk }}"
data-target-name="{{ asset.name_fr|default:asset.name_nl|default:asset }}"
data-target-type="{% if asset.category %}{{ asset.category.name_fr }}{% else %}{% translate 'Zone / Équipement' %}{% endif %}"
data-target-status="{% if asset.get_status_display %}{{ asset.get_status_display }}{% else %}-{% endif %}">
<i class="bi bi-plus-lg me-1"></i>{% translate "Nouvelle inspection" %}
</button>
{% endif %}
</div>
<div class="card-body p-0">
{% if inspections %}
<div class="table-responsive">
<table class="table table-hover table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 15%;" class="ps-3">{% translate "Date & Heure" %}</th>
<th style="width: 18%;">{% translate "Statut du contrôle" %}</th>
<th style="width: 15%;">{% translate "Nature" %}</th>
<th style="width: 20%;">{% translate "Inspecteur" %}</th>
<th style="width: 22%;">{% translate "Remarques" %}</th>
<th style="width: 10%;" class="pe-3 text-end">{% translate "Action" %}</th>
</tr>
</thead>
<tbody>
{% for insp in inspections %}
<tr class="{% if insp.is_cancelled %}table-light text-muted opacity-75 fst-italic{% endif %}">
<td class="ps-3 fw-semibold">
{{ insp.inspection_date|date:"d/m/Y H:i" }}
</td>
<td>
<span class="badge {{ insp.status_badge_class }}">
<i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}
</span>
</td>
<td>
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
</td>
<td>
<i class="bi bi-person me-1 text-muted"></i>{{ insp.inspector|short_name|default:"-" }}
</td>
<td>
<span class="small text-muted text-truncate d-inline-block" style="max-width: 220px;" title="{{ insp.details_summary }}">
{{ insp.details_summary }}
</span>
</td>
<td class="pe-3 text-end">
<button type="button" class="btn btn-xs btn-outline-primary px-2 py-1 rounded" data-bs-toggle="modal" data-bs-target="#assetInspectionDetailModal{{ insp.id }}">
<i class="bi bi-eye me-1"></i>{% translate "Détail" %}
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-center text-muted small">
<i class="bi bi-info-circle fs-5 d-block mb-1 text-secondary"></i>
{% translate "Aucune inspection ni vérification enregistrée pour cet équipement." %}
</div>
{% endif %}
</div>
</div>
{% for insp in inspections %}
<!-- Modale de détail de l'inspection pour l'asset -->
<div class="modal fade" id="assetInspectionDetailModal{{ insp.id }}" tabindex="-1" aria-labelledby="assetInspectionDetailModalLabel{{ insp.id }}" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content border-0 shadow">
<div class="modal-header bg-light py-2.5">
<h5 class="modal-title fs-6 fw-bold" id="assetInspectionDetailModalLabel{{ insp.id }}">
<i class="bi bi-patch-check me-2 text-primary"></i>{% translate "Détail de l'inspection" %} - {{ insp.inspection_date|date:"d/m/Y H:i" }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-3">
{% if insp.is_cancelled %}
<div class="alert alert-secondary py-2 px-3 small d-flex align-items-center mb-3">
<i class="bi bi-slash-circle me-2 fs-6"></i>
<div>
<strong>{% translate "Inspection annulée" %}</strong> — {% translate "Cette inspection a été annulée et n'est plus prise en compte dans la conformité active de la cible." %}
</div>
</div>
{% 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">
<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="fw-bold text-end ms-2">{{ insp.target_display }}</span>
</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">
<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>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-shield-check me-1"></i>{% translate "Résultat du contrôle" %}</span>
<span class="badge {{ insp.status_badge_class }}"><i class="bi {{ insp.status_icon }} me-1"></i>{{ insp.get_result_status_display }}</span>
</li>
<li class="list-group-item d-flex justify-content-between align-items-center px-0">
<span class="text-muted"><i class="bi bi-eye me-1"></i>{% translate "Nature" %}</span>
<span class="badge bg-light text-dark border">{{ insp.get_nature_display }}</span>
</li>
<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="fw-semibold">{{ insp.inspector|short_name|default:"-" }}</span>
</li>
<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="fw-semibold">{{ insp.updated_at|date:"d/m/Y H:i" }}</span>
</li>
{% get_inspection_detail_template insp as detail_template %}
{% if detail_template %}
{% include detail_template with insp=insp %}
{% endif %}
</ul>
{% if insp.notes and not insp.is_giep %}
<div class="mb-3">
<label class="form-label text-muted small fw-semibold"><i class="bi bi-journal-text me-1"></i>{% translate "Remarques / Observations" %}:</label>
<div class="p-2 bg-light rounded border text-secondary small">
{{ insp.notes|linebreaksbr }}
</div>
</div>
{% endif %}
{% if insp.documents.all and not insp.is_giep %}
<div>
<label class="form-label text-muted small fw-semibold mb-2"><i class="bi bi-camera me-1"></i>{% translate "Photos & Documents joints" %} ({{ insp.documents.count }}):</label>
<div class="d-flex flex-wrap gap-2 align-items-center">
{% for doc in insp.documents.all %}
{% if doc.is_image %}
<a href="{{ doc.file_url }}" target="_blank" class="d-inline-block border rounded p-1 bg-light" title="{{ doc.filename }}">
<img data-src="{{ doc.file_url }}" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1 1'%3E%3C/svg%3E" loading="lazy" alt="{{ doc.filename }}" class="rounded" style="width: 75px; height: 75px; object-fit: cover;">
</a>
{% else %}
<a href="{{ doc.file_url }}" target="_blank" class="btn btn-sm btn-outline-secondary d-inline-flex align-items-center gap-1" title="{{ doc.filename }}">
<i class="bi bi-file-earmark-arrow-down text-primary"></i>
<span class="text-truncate" style="max-width: 160px;">{{ doc.filename }}</span>
</a>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
</div>
<div class="modal-footer py-2 bg-light">
{% can_edit_inspection insp request.user as user_can_edit %}
{% if user_can_edit %}
{% if insp.is_cancelled %}
<button type="button" class="btn btn-outline-success btn-sm fw-semibold" onclick="handleToggleCancelAssetInspection({{ insp.id }}, 'restore')">
<i class="bi bi-arrow-counterclockwise me-1"></i>{% translate "Rétablir l'inspection" %}
</button>
{% else %}
<button type="button" class="btn btn-outline-danger btn-sm fw-semibold" onclick="handleToggleCancelAssetInspection({{ insp.id }}, 'cancel')">
<i class="bi bi-x-circle me-1"></i>{% translate "Annuler l'inspection" %}
</button>
{% endif %}
<button type="button" class="btn btn-outline-primary btn-sm fw-semibold" onclick="openEditInspectionModal({{ insp.id }})" data-bs-dismiss="modal">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier l'inspection" %}
</button>
{% endif %}
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">{% translate "Fermer" %}</button>
</div>
</div>
</div>
</div>
{% endfor %}
{% if can_inspect %}
{% include "inspections/components/inspection_form_modal.html" %}
<script>
document.addEventListener('DOMContentLoaded', function() {
const triggerBtns = document.querySelectorAll('.btn-trigger-asset-inspection');
triggerBtns.forEach(btn => {
btn.addEventListener('click', function() {
const ctId = this.getAttribute('data-content-type-id');
const objId = this.getAttribute('data-object-id');
const code = this.getAttribute('data-target-code');
const name = this.getAttribute('data-target-name');
const type = this.getAttribute('data-target-type');
const status = this.getAttribute('data-target-status');
const modalCtInput = document.getElementById('insp_content_type_id');
const modalObjInput = document.getElementById('insp_object_id');
const modalTypeCode = document.getElementById('insp_target_code');
const modalTypeName = document.getElementById('insp_target_name');
const modalTypeBadge = document.getElementById('insp_target_type');
const modalStatusBadge = document.getElementById('insp_target_status');
if (modalCtInput) modalCtInput.value = ctId;
if (modalObjInput) modalObjInput.value = objId;
if (modalTypeCode) modalTypeCode.textContent = code;
if (modalTypeName) modalTypeName.textContent = name;
if (modalTypeBadge) modalTypeBadge.textContent = type;
if (modalStatusBadge) modalStatusBadge.textContent = status;
const modelName = (this.getAttribute('data-model-name') || '').toLowerCase();
const codeUpper = (code || '').toUpperCase();
const isRwiz = (modelName === 'naturerwiz' || codeUpper.startsWith('RWIZ'));
if (typeof window.toggleMissingResultChoice === 'function') {
window.toggleMissingResultChoice(isRwiz);
}
});
});
const form = document.getElementById('quickInspectionForm');
if (form && !form.dataset.ajaxHandlerAttached) {
form.dataset.ajaxHandlerAttached = 'true';
form.addEventListener('submit', function(e) {
e.preventDefault();
const submitBtn = document.getElementById('submitQuickInspectionBtn');
const spinner = document.getElementById('submitQuickInspectionSpinner');
const icon = document.getElementById('submitQuickInspectionIcon');
const alertBox = document.getElementById('quickInspectionAlert');
if (alertBox) {
alertBox.classList.add('d-none');
alertBox.textContent = '';
}
if (submitBtn) submitBtn.disabled = true;
if (spinner) spinner.classList.remove('d-none');
if (icon) icon.classList.add('d-none');
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(res => {
if (res.redirected) {
window.location.href = res.url;
return null;
}
return res.json();
})
.then(data => {
if (!data) return;
if (data.success) {
const modalEl = document.getElementById('quickInspectionModal');
const modal = bootstrap.Modal.getInstance(modalEl);
if (modal) modal.hide();
window.location.reload();
} else {
if (alertBox) {
{% translate "Erreur lors de l'enregistrement." as err_save_msg %}
alertBox.textContent = data.error || "{{ err_save_msg|escapejs }}";
alertBox.classList.remove('d-none');
}
if (submitBtn) submitBtn.disabled = false;
if (spinner) spinner.classList.add('d-none');
if (icon) icon.classList.remove('d-none');
}
})
.catch(err => {
console.error("Erreur lors de l'enregistrement de l'inspection:", err);
window.location.reload();
});
});
}
});
</script>
{% endif %}
<script>
(function() {
function loadLazyModalImages(modal) {
if (!modal || typeof modal.querySelectorAll !== 'function') return;
modal.querySelectorAll('img[data-src]').forEach(function(img) {
const dataSrc = img.getAttribute('data-src');
if (dataSrc) {
img.src = dataSrc;
img.removeAttribute('data-src');
}
});
}
document.addEventListener('show.bs.modal', function(e) {
loadLazyModalImages(e.target);
});
document.addEventListener('shown.bs.modal', function(e) {
loadLazyModalImages(e.target);
});
})();
window.handleToggleCancelAssetInspection = function(inspectionId, action) {
const confirmMsg = action === 'cancel'
? "{% filter escapejs %}{% translate 'Êtes-vous sûr de vouloir annuler cette inspection ?' %}{% endfilter %}"
: "{% filter escapejs %}{% translate 'Êtes-vous sûr de vouloir rétablir cette inspection ?' %}{% endfilter %}";
if (!confirm(confirmMsg)) return;
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, 10) === 'csrftoken=') {
cookieValue = decodeURIComponent(cookie.substring(10));
break;
}
}
}
const formData = new FormData();
formData.append('action', action);
fetch(`/inspections/api/${inspectionId}/toggle-cancel/`, {
method: 'POST',
headers: {
'X-CSRFToken': cookieValue
},
body: formData
})
.then(res => res.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert(data.error || "{% filter escapejs %}{% translate 'Une erreur est survenue.' %}{% endfilter %}");
}
})
.catch(err => {
console.error(err);
alert("{% filter escapejs %}{% translate 'Erreur de communication avec le serveur.' %}{% endfilter %}");
});
};
</script>
{% include "inspections/components/edit_inspection_modal.html" %}

View file

@ -1,265 +0,0 @@
{% load i18n %}
{# Interventions liées à l'asset #}
<div class="card mb-3" id="asset-interventions-card">
<div class="card-header bg-light d-flex align-items-center justify-content-between">
<h5 class="mb-0">
<i class="bi bi-wrench-adjustable me-2"></i>{% translate "Interventions liées" %}
{% if interventions %}
<span class="badge bg-secondary rounded-pill ms-1">{{ interventions|length }}</span>
{% endif %}
</h5>
{% if can_add_intervention %}
<button type="button" class="btn btn-outline-primary btn-sm" data-bs-toggle="modal" data-bs-target="#createInterventionForAssetModal">
<i class="bi bi-plus-lg me-1"></i>{% translate "Créer une intervention" %}
</button>
{% endif %}
</div>
<div class="card-body p-0">
{% if interventions %}
<div class="table-responsive">
<table class="table table-hover table-sm mb-0 align-middle">
<thead class="table-light">
<tr>
<th style="width: 12%;" class="ps-3">{% translate "Code" %}</th>
<th style="width: 25%;">{% translate "Titre" %}</th>
<th style="width: 18%;">{% translate "Symptôme" %}</th>
<th style="width: 15%;">{% translate "Statut" %}</th>
<th style="width: 10%;">{% translate "Priorité" %}</th>
<th style="width: 12%;">{% translate "Attribué à" %}</th>
<th style="width: 8%;" class="pe-3">{% translate "Date" %}</th>
</tr>
</thead>
<tbody>
{% for intervention in interventions %}
<tr>
<td class="ps-3">
<a href="{% url 'interventions:intervention_detail' intervention.id %}" class="fw-bold text-decoration-none">
{{ intervention.code }}
</a>
</td>
<td>
<a href="{% url 'interventions:intervention_detail' intervention.id %}" class="text-reset text-decoration-none">
{{ intervention.title }}
</a>
{% if intervention.maintain_type == 'corrective' %}
<span class="badge bg-danger ms-1" title="{% translate 'Maintenance corrective' %}">{% translate "Corrective" %}</span>
{% elif intervention.maintain_type == 'preventive' %}
<span class="badge bg-warning text-dark ms-1" title="{% translate 'Maintenance préventive' %}">{% translate "Préventive" %}</span>
{% elif intervention.maintain_type == 'ameliorative' %}
<span class="badge bg-success ms-1" title="{% translate 'Maintenance améliorative' %}">{% translate "Améliorative" %}</span>
{% endif %}
</td>
<td>
{% if intervention.symptom %}
{{ intervention.symptom.get_name }}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<span class="badge" style="background-color: {{ intervention.status_color_hex }}; color: {{ intervention.status_text_color_hex }};">
{{ intervention.get_status_display }}
</span>
</td>
<td>
{% if intervention.priority == '1' %}
<span class="badge bg-danger">{{ intervention.get_priority_display }}</span>
{% elif intervention.priority == '2' %}
<span class="badge bg-warning text-dark">{{ intervention.get_priority_display }}</span>
{% elif intervention.priority == '3' %}
<span class="badge bg-secondary">{{ intervention.get_priority_display }}</span>
{% elif intervention.priority == '4' %}
<span class="badge bg-light text-dark border">{{ intervention.get_priority_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ intervention.get_priority_display|default:"-" }}</span>
{% endif %}
</td>
<td>
{% if intervention.assigned_team %}
<span class="small">{{ intervention.assigned_team.name }}</span>
{% elif intervention.assigned_provider %}
<span class="small">{{ intervention.assigned_provider.name }}</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td class="pe-3">
<small class="text-muted" title="{% if intervention.end_time %}{% translate 'Date de réalisation' %}{% elif intervention.expected_end_time %}{% translate 'Échéance prévisionnelle' %}{% elif intervention.expected_begin_time %}{% translate 'Date de début prévisionnelle' %}{% else %}{% translate 'Date de création' %}{% endif %}">
{{ intervention.end_time|default:intervention.expected_end_time|default:intervention.expected_begin_time|default:intervention.creation_time|date:"d/m/Y" }}
</small>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-3 text-muted">
<i class="bi bi-info-circle me-1"></i>{% translate "Aucune intervention liée à cet asset." %}
</div>
{% endif %}
</div>
</div>
{% if can_add_intervention %}
<!-- Modal de création d'une intervention depuis l'asset -->
<div class="modal fade" id="createInterventionForAssetModal" tabindex="-1" aria-labelledby="createInterventionForAssetModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header bg-light">
<h5 class="modal-title" id="createInterventionForAssetModalLabel">
<i class="bi bi-wrench-adjustable me-2"></i>{% translate "Nouvelle intervention" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<form id="createInterventionForAssetForm" action="{% url 'assets:create_intervention_for_asset' asset_model asset.id %}" method="post">
{% csrf_token %}
<div class="modal-body">
<div id="createInterventionAlert" class="alert alert-danger d-none mb-3"></div>
<div class="mb-3">
<label class="form-label fw-semibold required">{% translate "Titre" %}</label>
<input type="text" name="title" class="form-control" placeholder="{% translate "Titre de l'intervention" %}" required>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Symptôme" %}</label>
<select name="symptom_id" id="asset_intervention_symptom_select" class="form-select">
<option value="">{% translate "-- Sélectionner un symptôme --" %}</option>
{% for s in symptoms %}
<option value="{{ s.id }}" data-name="{{ s.get_name }}" data-priority="{{ s.priority|default:'3' }}">{{ s.get_name }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold d-block">{% translate "Type de maintenance" %}</label>
<div class="btn-group w-100" role="group" aria-label="{% translate 'Type de maintenance' %}">
<input type="radio" class="btn-check" name="maintain_type" id="maintain_type_corrective" value="corrective" checked>
<label class="btn btn-outline-danger d-flex align-items-center justify-content-center gap-1 py-2" for="maintain_type_corrective">
<i class="bi bi-wrench"></i>
<span>{% translate "Corrective" %}</span>
</label>
<input type="radio" class="btn-check" name="maintain_type" id="maintain_type_preventive" value="preventive">
<label class="btn btn-outline-warning text-dark d-flex align-items-center justify-content-center gap-1 py-2" for="maintain_type_preventive">
<i class="bi bi-shield-check"></i>
<span>{% translate "Préventive" %}</span>
</label>
<input type="radio" class="btn-check" name="maintain_type" id="maintain_type_ameliorative" value="ameliorative">
<label class="btn btn-outline-success d-flex align-items-center justify-content-center gap-1 py-2" for="maintain_type_ameliorative">
<i class="bi bi-arrow-up-circle"></i>
<span>{% translate "Améliorative" %}</span>
</label>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Priorité" %}</label>
<select name="priority" id="asset_intervention_priority_select" class="form-select">
<option value="1">{% translate "Urgent" %}</option>
<option value="2">{% translate "Élevé" %}</option>
<option value="3" selected>{% translate "Normal" %}</option>
<option value="4">{% translate "Bas" %}</option>
</select>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">{% translate "Description / Remarques" %}</label>
<textarea name="init_description" class="form-control" rows="3" placeholder="{% translate 'Détails ou remarques globales...' %}"></textarea>
</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-primary" id="submitCreateInterventionBtn">
<span class="spinner-border spinner-border-sm me-1 d-none" id="submitCreateInterventionSpinner" role="status"></span>
<i class="bi bi-check-circle me-1" id="submitCreateInterventionIcon"></i>{% translate "Créer" %}
</button>
</div>
</form>
</div>
</div>
</div>
<script>
(function() {
const form = document.getElementById('createInterventionForAssetForm');
if (!form) return;
const symptomSelect = document.getElementById('asset_intervention_symptom_select');
const titleInput = form.querySelector('input[name="title"]');
const prioritySelect = document.getElementById('asset_intervention_priority_select');
let lastAutoTitle = '';
if (symptomSelect && titleInput) {
symptomSelect.addEventListener('change', function() {
const selectedOpt = symptomSelect.options[symptomSelect.selectedIndex];
if (selectedOpt && selectedOpt.value) {
const symName = selectedOpt.getAttribute('data-name') || selectedOpt.text.trim();
const symPriority = selectedOpt.getAttribute('data-priority');
if (!titleInput.value || titleInput.value === lastAutoTitle) {
titleInput.value = symName;
lastAutoTitle = symName;
}
if (symPriority && prioritySelect) {
prioritySelect.value = symPriority;
}
}
});
titleInput.addEventListener('input', function() {
if (titleInput.value !== lastAutoTitle) {
lastAutoTitle = '';
}
});
}
form.addEventListener('submit', function(e) {
e.preventDefault();
const alertDiv = document.getElementById('createInterventionAlert');
const submitBtn = document.getElementById('submitCreateInterventionBtn');
const spinner = document.getElementById('submitCreateInterventionSpinner');
const icon = document.getElementById('submitCreateInterventionIcon');
alertDiv.classList.add('d-none');
alertDiv.textContent = '';
submitBtn.disabled = true;
if (spinner) spinner.classList.remove('d-none');
if (icon) icon.classList.add('d-none');
const formData = new FormData(form);
fetch(form.action, {
method: 'POST',
body: formData,
headers: {
'X-Requested-With': 'XMLHttpRequest'
}
})
.then(response => response.json())
.then(data => {
if (data.success && data.redirect_url) {
window.location.href = data.redirect_url;
} else {
alertDiv.textContent = data.error || '{% translate "Une erreur est survenue." %}';
alertDiv.classList.remove('d-none');
submitBtn.disabled = false;
if (spinner) spinner.classList.add('d-none');
if (icon) icon.classList.remove('d-none');
}
})
.catch(err => {
alertDiv.textContent = '{% translate "Erreur réseau lors de la création." %}';
alertDiv.classList.remove('d-none');
submitBtn.disabled = false;
if (spinner) spinner.classList.add('d-none');
if (icon) icon.classList.remove('d-none');
});
});
})();
</script>
{% endif %}

View file

@ -1,194 +0,0 @@
{% load i18n %}
<!-- Modale de Saisie Rapide d'Inspection -->
<div class="modal fade" id="quickInspectionModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="quickInspectionModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-centered">
<div class="modal-content shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="quickInspectionModalLabel">
<i class="bi bi-search text-primary me-2"></i>{% translate "Nouvelle inspection" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<form id="quickInspectionForm" action="{% url 'assets:create_inspection_api' %}" method="post" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="content_type_id" id="insp_content_type_id">
<input type="hidden" name="object_id" id="insp_object_id">
<input type="hidden" name="nature" value="visual">
<input type="hidden" name="inspector_role" value="observer">
<div class="modal-body">
<!-- Target Card Preview -->
<div class="card mb-3 bg-light border-0">
<div class="card-body p-2 d-flex align-items-center justify-content-between">
<div>
<span class="badge bg-secondary mb-1" id="insp_target_type">Asset</span>
<h6 class="mb-0 fw-bold" id="insp_target_code">#12345</h6>
<small class="text-muted" id="insp_target_name">-</small>
</div>
<div class="text-end">
<span class="small text-muted d-block">{% translate "Statut actuel" %}</span>
<span class="badge bg-info text-dark" id="insp_target_status">-</span>
</div>
</div>
</div>
<div id="quickInspectionAlert" class="alert alert-danger d-none mb-3"></div>
<!-- Résultat de contrôle avec boutons colorés -->
<div class="mb-3" id="quickInspResultSection">
<label class="form-label fw-semibold d-block">{% translate "Résultat du contrôle" %} <span class="text-danger">*</span></label>
<div class="d-flex flex-column gap-2" role="group" aria-label="{% translate 'Résultat' %}">
<div class="row g-2">
<div class="col-6">
<input type="radio" class="btn-check" name="result_status" id="res_compliant" value="compliant">
<label class="btn btn-outline-success w-100 py-2 d-flex align-items-center justify-content-center gap-1" for="res_compliant">
<i class="bi bi-check-circle-fill"></i>
<span>{% translate "Conforme" %}</span>
</label>
</div>
<div class="col-6">
<input type="radio" class="btn-check" name="result_status" id="res_non_compliant" value="non_compliant">
<label class="btn btn-outline-warning text-dark w-100 py-2 d-flex align-items-center justify-content-center gap-1" for="res_non_compliant">
<i class="bi bi-exclamation-triangle-fill"></i>
<span>{% translate "Non conforme" %}</span>
</label>
</div>
</div>
<div class="row g-2">
<div class="col-4">
<input type="radio" class="btn-check" name="result_status" id="res_damaged" value="damaged">
<label class="btn btn-outline-danger w-100 py-2 d-flex align-items-center justify-content-center gap-1 small" for="res_damaged">
<i class="bi bi-x-circle-fill"></i>
<span>{% translate "Endommagé" %}</span>
</label>
</div>
<div class="col-4" id="res_container_missing">
<input type="radio" class="btn-check" name="result_status" id="res_missing" value="missing">
<label class="btn btn-outline-dark w-100 py-2 d-flex align-items-center justify-content-center gap-1 small" for="res_missing" id="res_label_missing">
<i class="bi bi-dash-circle-fill"></i>
<span>{% translate "Absent" %}</span>
</label>
</div>
<div class="col-4">
<input type="radio" class="btn-check" name="result_status" id="res_updated" value="updated">
<label class="btn btn-outline-info text-dark w-100 py-2 d-flex align-items-center justify-content-center gap-1 small" for="res_updated">
<i class="bi bi-pencil-square"></i>
<span>{% translate "Corrigé" %}</span>
</label>
</div>
</div>
</div>
</div>
<!-- Section Photos (Mode continu & Fichiers) -->
<div class="card border border-success-subtle mb-3" style="background-color: #e8f6f3;" id="quickInspPhotosSection">
<div class="card-header bg-transparent border-0 pb-0 pt-2 px-3">
<span class="fw-bold text-dark small"><i class="bi bi-camera me-1 text-teal"></i>{% translate "Photos & Fichiers" %}</span>
</div>
<div class="card-body p-3">
<div class="d-grid gap-2">
<button type="button" id="insp_btn_continuous_camera" class="btn btn-outline-secondary bg-white text-dark py-2 d-flex align-items-center justify-content-center gap-2 shadow-sm" style="border-color: #ced4da;">
<i class="bi bi-camera me-1"></i>
<span>{% translate "Prendre des photos (mode continu)" %}</span>
</button>
<button type="button" id="insp_btn_gallery_files" class="btn btn-outline-secondary bg-white text-dark py-2 d-flex align-items-center justify-content-center gap-2 shadow-sm" style="border-color: #ced4da;">
<i class="bi bi-folder2-open me-1"></i>
<span>{% translate "Photos, vidéos, fichiers" %}</span>
</button>
</div>
<!-- Inputs masqués -->
<input type="file" id="insp_camera_input" accept="image/*" capture="environment" class="d-none">
<input type="file" id="insp_gallery_input" accept="image/*,.pdf,.doc,.docx,.xls,.xlsx" multiple class="d-none">
<!-- Grille de prévisualisation des photos/fichiers accumulés -->
<div id="insp_photos_preview_container" class="row g-2 mt-2"></div>
</div>
</div>
<!-- Champs spécifiques au type d'asset -->
<div id="assetSpecificFieldsContainer">
{% if inspection_template %}
{% include inspection_template %}
{% endif %}
</div>
<!-- Option Création automatique d'une intervention -->
<div class="form-check form-switch mb-3 bg-light p-2 rounded ps-4" id="createInterventionSwitchContainer">
<input class="form-check-input ms-0 me-2" type="checkbox" name="create_intervention" value="true" id="createInterventionSwitch">
<label class="form-check-label small fw-semibold" for="createInterventionSwitch">
{% translate "Créer automatiquement une intervention en cas d'anomalie / dégât" %}
</label>
</div>
<!-- Remarques -->
<div class="mb-3" id="quickInspNotesSection">
<label class="form-label fw-semibold mb-1">{% translate "Remarques / Remarques du constat" %}</label>
<textarea name="notes" id="insp_notes" class="form-control" rows="2" placeholder="{% translate 'Saisir vos remarques ou détails...' %}"></textarea>
</div>
</div>
<div class="modal-footer bg-light">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-primary" id="submitQuickInspectionBtn">
<span class="spinner-border spinner-border-sm me-1 d-none" id="submitQuickInspectionSpinner"></span>
<i class="bi bi-check-lg me-1" id="submitQuickInspectionIcon"></i>
{% translate "Valider l'inspection" %}
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Modal caméra Canvas (mode continu pour inspection) -->
<div class="modal fade" id="inspCameraModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="inspCameraModalLabel" aria-hidden="true" style="z-index: 1060;">
<div class="modal-dialog modal-fullscreen-sm-down d-flex m-0 h-100 mx-auto">
<div class="modal-content bg-dark text-white d-flex flex-column">
<div class="modal-header border-0 flex-shrink-0 py-2">
<h5 class="modal-title fw-bold" id="inspCameraModalLabel">
<i class="bi bi-camera-video me-1"></i>{% translate "Caméra (Mode continu)" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}" id="inspCameraModalCloseBtn"></button>
</div>
<div class="modal-body d-flex flex-column p-2 flex-grow-1" style="overflow: hidden; min-height: 0;">
<div class="flex-grow-1 d-flex align-items-center justify-content-center" style="min-height: 0; overflow: hidden;">
<div class="position-relative w-100 h-100 d-flex align-items-center justify-content-center" style="border-radius: 0.5rem; overflow: hidden;">
<video id="inspCameraPreview" playsinline autoplay style="max-width: 100%; max-height: 100%; object-fit: contain;"></video>
<div id="inspCameraFlashOverlay" style="position: absolute; inset: 0; background: rgba(255, 255, 255, 0.9); opacity: 0; pointer-events: none; transition: opacity 120ms ease-out; z-index: 2;"></div>
<canvas id="inspCameraCanvas" class="d-none"></canvas>
</div>
</div>
<div id="inspCameraStatus" class="small text-center text-muted my-2 flex-shrink-0"></div>
<div class="d-flex justify-content-between align-items-center gap-2 mb-2 flex-shrink-0">
<button type="button" class="btn btn-outline-light flex-fill py-2" id="inspCameraStopBtn">
<i class="bi bi-x-circle me-1"></i>{% translate "Terminer" %}
</button>
<button type="button" class="btn btn-primary flex-fill py-2 fw-semibold" id="inspCameraCaptureBtn">
<i class="bi bi-camera-fill me-1"></i>{% translate "Capturer" %}
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Modale de Choix d'Asset (Zone dense / Sélection multiple) -->
<div class="modal fade" id="targetChooserModal" data-bs-backdrop="static" data-bs-keyboard="false" tabindex="-1" aria-labelledby="targetChooserModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content shadow">
<div class="modal-header bg-light">
<h5 class="modal-title fw-bold" id="targetChooserModalLabel">
<i class="bi bi-geo-fill text-primary me-2"></i>{% translate "Choisir un équipement à inspecter" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body p-2">
<p class="text-muted small px-2 mb-2">{% translate "Plusieurs équipements ont été détectés à cet emplacement. Veuillez sélectionner celui à inspecter :" %}</p>
<div class="list-group" id="targetChooserList"></div>
</div>
</div>
</div>
</div>

View file

@ -1,289 +0,0 @@
{% extends "assets/detail_generic.html" %}
{% load i18n %}
{% load static %}
{% load asset_tags %}
{% block head %}
{{ block.super }}
<style>
/* Adaptations mobiles avancées pour le template générique d'asset */
@media (max-width: 767.98px) {
#asset-detail-container {
padding-left: 0.5rem !important;
padding-right: 0.5rem !important;
}
/* En-tête mobile compact */
.generic-asset-mobile-header {
background-color: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 0.85rem;
padding: 0.85rem;
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.05);
margin-bottom: 0.75rem;
}
.generic-asset-mobile-header .asset-thematic-avatar {
width: 44px;
height: 44px;
border-radius: 0.75rem;
background-color: rgba(47, 62, 139, 0.1);
color: #0f172a;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.4rem;
flex-shrink: 0;
}
/* Barre d'onglets de navigation segmentée */
.mobile-asset-tabs-nav {
position: sticky;
top: 3.5rem;
z-index: 1015;
background: #ffffff;
border: 1px solid #cbd5e1;
border-radius: 0.75rem;
padding: 0.3rem;
display: flex;
gap: 0.35rem;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
box-shadow: 0 2px 6px rgba(15, 23, 42, 0.06);
margin-bottom: 0.85rem;
}
.mobile-asset-tabs-nav::-webkit-scrollbar {
display: none;
}
.mobile-tab-btn {
font-size: 0.82rem;
font-weight: 600;
padding: 0.4rem 0.75rem;
border-radius: 0.55rem;
border: none;
background: transparent;
color: #64748b;
transition: all 0.15s ease-in-out;
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.mobile-tab-btn.active {
background-color: #0f172a;
color: #ffffff;
box-shadow: 0 2px 4px rgba(47, 62, 139, 0.25);
}
/* Cartes et sections sur mobile */
#asset-detail-container .card {
border-radius: 0.85rem !important;
border: 1px solid #cbd5e1 !important;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.05);
margin-bottom: 0.75rem !important;
}
#asset-detail-container .card-header {
padding: 0.65rem 0.85rem;
border-top-left-radius: 0.85rem !important;
border-top-right-radius: 0.85rem !important;
}
#asset-detail-container .card-header h5 {
font-size: 0.95rem !important;
font-weight: 600;
}
#asset-detail-container .card-body {
padding: 0.75rem 0.85rem;
}
/* Tableaux tactiles */
#asset-detail-container .table th {
font-size: 0.84rem;
padding: 0.5rem 0.4rem;
width: 38% !important;
}
#asset-detail-container .table td {
font-size: 0.86rem;
padding: 0.5rem 0.4rem;
}
/* Barre d'édition CMS mobile */
#asset-cms-edit-bar.mobile-edit-bar {
top: 3.5rem;
z-index: 1020;
border-radius: 0.75rem;
border: 1px solid #b8c5d3;
}
/* Carte MapLibre sur mobile */
#asset-map {
height: 320px !important;
border-radius: 0 0 0.85rem 0.85rem;
}
}
</style>
{% endblock head %}
{# ── En-tête mobile readonly avec icône, thématique et badges ── #}
{% block asset_header_title_readonly %}
<div class="generic-asset-mobile-header">
<div class="d-flex align-items-center gap-2.5">
<div class="asset-thematic-avatar" {% if thematic.color %}style="background-color: {{ thematic.color }}18; color: {{ thematic.color }};"{% endif %}>
{% if thematic.icon %}
<i class="bi {{ thematic.icon }}"></i>
{% else %}
<i class="bi bi-pin-map"></i>
{% endif %}
</div>
<div class="flex-grow-1 text-truncate">
<div class="d-flex align-items-center gap-1.5 flex-wrap">
<span class="fw-bold text-dark fs-6" style="color: #0f172a;">{{ asset.code }}</span>
{% if asset.status == 'active' %}
<span class="badge bg-success">{{ asset.get_status_display }}</span>
{% elif asset.status == 'damaged' or asset.status == 'to_replace' %}
<span class="badge bg-warning text-dark">{{ asset.get_status_display }}</span>
{% else %}
<span class="badge bg-secondary">{{ asset.get_status_display|default:asset.status }}</span>
{% endif %}
</div>
<div class="small text-secondary text-truncate">
{% if thematic.name_fr %}
<span>{{ thematic.name_fr }}</span> &bull;
{% endif %}
<span>{{ asset_verbose_name }}</span>
{% if asset.name_fr %}
&mdash; <span class="fw-semibold text-dark">{{ asset.name_fr }}</span>
{% elif asset.model %}
&mdash; <span class="fw-semibold text-dark">{{ asset.model.get_name }}</span>
{% endif %}
</div>
</div>
</div>
</div>
{# Barre de navigation par onglets segmentés (Infos, Carte, Documents, Liens) #}
<div class="mobile-asset-tabs-nav" id="mobileAssetTabsNav">
<button type="button" class="mobile-tab-btn active" data-target-tab="tab-infos">
<i class="bi bi-card-checklist"></i>{% translate "Informations" %}
</button>
<button type="button" class="mobile-tab-btn" data-target-tab="tab-geo">
<i class="bi bi-geo-alt"></i>{% translate "Carte & Localisation" %}
</button>
<button type="button" class="mobile-tab-btn" data-target-tab="tab-docs">
<i class="bi bi-paperclip"></i>{% translate "Documents & Photos" %}
</button>
<button type="button" class="mobile-tab-btn" data-target-tab="tab-links">
<i class="bi bi-link-45deg"></i>{% translate "Assets Liés" %}
</button>
</div>
{% endblock asset_header_title_readonly %}
{# ── Script pour la gestion des onglets mobiles ── #}
{% block extra_scripts %}
{{ block.super }}
<script>
(function() {
function initMobileAssetTabs() {
const tabButtons = document.querySelectorAll('.mobile-tab-btn');
const container = document.getElementById('asset-detail-container');
if (!tabButtons.length || !container) return;
// Identification des cartes par catégorie
const mainCol = container.querySelector('.col-12, .col-md-8');
const sideCol = container.querySelector('.col-md-4, .col-12:last-child');
// Assigner les classes de section aux cartes
// 1. Informations
const infoCards = [
container.querySelector('.card:has(table)'),
document.getElementById('model-card'),
container.querySelector('.card:has(#observations-card)'),
].filter(Boolean);
// 2. Géolocalisation & Carte
const geoCards = [
container.querySelector('.card:has(#asset-map)'),
container.querySelector('.card:has(.asset-location-link)'),
].filter(Boolean);
// 3. Documents
const docCards = [
document.getElementById('documents-card') || container.querySelector('.card:has(#documents-list)') || container.querySelector('.card:has(.document-item)')
].filter(Boolean);
// 4. Assets Liés & Remplacements
const linkCards = [
container.querySelector('.card:has(.replacement-chain)'),
container.querySelector('.card:has(#associated-assets-list)'),
container.querySelector('.card:has(#linked-interventions-list)'),
].filter(Boolean);
// Initialiser les groupes
infoCards.forEach(c => c && c.classList.add('mobile-section-infos'));
geoCards.forEach(c => c && c.classList.add('mobile-section-geo'));
docCards.forEach(c => c && c.classList.add('mobile-section-docs'));
linkCards.forEach(c => c && c.classList.add('mobile-section-links'));
function showSection(targetTab) {
tabButtons.forEach(btn => {
if (btn.dataset.targetTab === targetTab) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
const allCustomSections = container.querySelectorAll(
'.mobile-section-infos, .mobile-section-geo, .mobile-section-docs, .mobile-section-links'
);
if (targetTab === 'tab-infos') {
allCustomSections.forEach(c => c.style.display = 'none');
infoCards.forEach(c => c.style.display = '');
// Si aucune carte taguée infos, tout afficher
if (!infoCards.length) allCustomSections.forEach(c => c.style.display = '');
} else if (targetTab === 'tab-geo') {
allCustomSections.forEach(c => c.style.display = 'none');
geoCards.forEach(c => c.style.display = '');
// Redimensionner la carte MapLibre pour s'adapter au conteneur
setTimeout(() => {
window.dispatchEvent(new Event('resize'));
}, 100);
} else if (targetTab === 'tab-docs') {
allCustomSections.forEach(c => c.style.display = 'none');
docCards.forEach(c => c.style.display = '');
} else if (targetTab === 'tab-links') {
allCustomSections.forEach(c => c.style.display = 'none');
linkCards.forEach(c => c.style.display = '');
}
}
tabButtons.forEach(btn => {
btn.addEventListener('click', () => {
showSection(btn.dataset.targetTab);
});
});
// Par défaut, afficher les infos
showSection('tab-infos');
// Si on active le mode édition, s'assurer que les champs éditables restent interactifs
document.addEventListener('assetEditModeChanged', (e) => {
if (e.detail && e.detail.editing) {
// En mode édition, toutes les cartes peuvent être consultées via les onglets
}
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initMobileAssetTabs);
} else {
initMobileAssetTabs();
}
})();
</script>
{% endblock extra_scripts %}
{% block script %}
{{ block.super }}
{% endblock script %}

View file

@ -1,38 +0,0 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% translate "Supprimer la zone de gestion" %} {{ zone.code }} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container py-5" style="max-width: 600px;">
<div class="card shadow border-danger">
<div class="card-header bg-danger text-white py-3">
<h5 class="card-title mb-0">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% translate "Confirmer la suppression" %}
</h5>
</div>
<div class="card-body p-4">
<p>{% translate "Êtes-vous certain de vouloir supprimer définitivement la zone de gestion suivante ?" %}</p>
<div class="alert alert-light border">
<h6 class="fw-bold mb-1">{{ zone.code }} - {{ zone.get_name }}</h6>
<div class="small text-muted">{{ zone.thematic.name }} {% if zone.zone_type %}| {{ zone.zone_type.get_name }}{% endif %}</div>
</div>
<p class="text-danger small">
<i class="bi bi-info-circle me-1"></i>{% translate "Cette action est irréversible. Les documents et associations liés à cette zone seront supprimés." %}
</p>
<form method="post" action="{% url 'assets:management_zone_delete' zone.pk %}">
{% csrf_token %}
<div class="d-flex justify-content-end gap-2 mt-4">
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-secondary">
{% translate "Annuler" %}
</a>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer définitivement" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endblock content %}

View file

@ -1,434 +0,0 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{{ zone.code }} - {{ zone.get_name }} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="/">{% translate "Accueil" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}">{% translate "Assets" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:management_zones_index' %}?thematic={{ zone.thematic.code }}">{% translate "Zones de gestion" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{{ zone.code }}</li>
</ol>
</nav>
<div class="d-flex gap-2">
<a href="{% url 'assets:management_zones_index' %}?thematic={{ zone.thematic.code }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la liste" %}
</a>
{% if can_edit %}
<a href="{% url 'assets:management_zone_edit' zone.pk %}" class="btn btn-outline-primary btn-sm">
<i class="bi bi-pencil me-1"></i>{% translate "Modifier" %}
</a>
<button type="button" class="btn btn-outline-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteZoneModal">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer" %}
</button>
{% endif %}
</div>
</div>
<!-- Zone Banner Card -->
<div class="card shadow-sm mb-4 border-start border-4" style="border-left-color: {{ zone.get_color }} !important;">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<div class="d-flex align-items-center gap-2 mb-2">
<span style="display:inline-block; width: 16px; height: 16px; border-radius: 4px; background-color: {{ zone.get_color }}; border: 1px solid rgba(0,0,0,0.25);"></span>
<h1 class="h4 mb-0 fw-bold">{{ zone.code }} - {{ zone.get_name }}</h1>
</div>
{% if zone.name_nl and zone.name_nl != zone.name_fr %}
<div class="text-muted small mb-2 fst-italic">{{ zone.name_nl }}</div>
{% endif %}
<div class="d-flex gap-2 flex-wrap">
<span class="badge bg-secondary-subtle text-secondary border">
<i class="bi bi-tag me-1"></i>{{ zone.thematic.name }}
</span>
{% if zone.zone_type %}
<span class="badge" style="background-color: {{ zone.zone_type.default_color }}; color: #fff;">
{{ zone.zone_type.get_name }}
</span>
{% endif %}
{% if zone.is_active %}
<span class="badge bg-success-subtle text-success border">
<i class="bi bi-check-circle me-1"></i>{% translate "Active" %}
</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border">
<i class="bi bi-dash-circle me-1"></i>{% translate "Inactive" %}
</span>
{% endif %}
{% if area_ha %}
<span class="badge bg-light text-dark border">
<i class="bi bi-rulers me-1"></i>{{ area_ha }} ha ({{ area_m2|floatformat:0 }} m²)
</span>
{% endif %}
</div>
</div>
<div class="col-md-4 text-md-end mt-3 mt-md-0">
{% if zone.target_frequency_days %}
<div class="d-inline-block text-center me-3 p-2 bg-light rounded border">
<span class="d-block text-muted small">{% translate "Fréquence cible" %}</span>
<span class="fs-5 fw-bold text-dark">{{ zone.target_frequency_days }} {% translate "j" %}</span>
</div>
{% endif %}
{% if zone.seasonality_notes %}
<div class="d-inline-block text-start p-2 bg-light rounded border">
<span class="d-block text-muted small">{% translate "Période" %}</span>
<span class="small fw-semibold text-dark">{{ zone.seasonality_notes }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Consignes et Points d'attention (Callout Banner) -->
{% if zone.instructions %}
<div class="card shadow-sm mb-4 border-warning border-start border-4">
<div class="card-body bg-warning bg-opacity-10">
<div class="d-flex align-items-start gap-2">
<i class="bi bi-exclamation-triangle-fill text-warning fs-4 mt-1"></i>
<div>
<h5 class="h6 fw-bold text-dark mb-1">{% translate "Consignes d'exploitation et points de vigilance" %}</h5>
<div class="text-dark" style="white-space: pre-line;">{{ zone.instructions }}</div>
</div>
</div>
</div>
</div>
{% endif %}
<!-- Main Grid: Informations & Carte -->
<div class="row g-4 mb-4">
<!-- Informations & Modalités -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light fw-bold">
<i class="bi bi-info-circle me-2 text-success"></i>{% translate "Détails & Caractéristiques" %}
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-4 text-muted">{% translate "Description" %}</dt>
<dd class="col-sm-8">{{ zone.description|default:_("Aucune description renseignée")|linebreaks }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Thématique" %}</dt>
<dd class="col-sm-8">{{ zone.thematic.name }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Type de zone" %}</dt>
<dd class="col-sm-8">{{ zone.zone_type.get_name|default:"-" }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Couleur de repérage" %}</dt>
<dd class="col-sm-8">
<div class="d-flex align-items-center gap-2">
<span style="display:inline-block; width:18px; height:18px; border-radius:3px; background-color: {{ zone.get_color }}; border: 1px solid rgba(0,0,0,0.25);"></span>
<code>{{ zone.get_color }}</code>
</div>
</dd>
<dt class="col-sm-4 text-muted">{% translate "Superficie estimée" %}</dt>
<dd class="col-sm-8">
{% if area_m2 %}
<strong>{{ area_ha }} hectares</strong> ({{ area_m2|floatformat:0 }} m²)
{% else %}
<span class="text-muted">{% translate "Périmètre non défini" %}</span>
{% endif %}
</dd>
<dt class="col-sm-4 text-muted">{% translate "Créé par" %}</dt>
<dd class="col-sm-8">{% if zone.created_by %}{{ zone.created_by.get_full_name|default:zone.created_by.username }}{% else %}-{% endif %}</dd>
<dt class="col-sm-4 text-muted">{% translate "Date de création" %}</dt>
<dd class="col-sm-8">{{ zone.created_at|date:"d/m/Y H:i" }}</dd>
<dt class="col-sm-4 text-muted">{% translate "Dernière mise à jour" %}</dt>
<dd class="col-sm-8">{{ zone.updated_at|date:"d/m/Y H:i" }}</dd>
</dl>
</div>
</div>
</div>
<!-- Carte du périmètre géographique -->
<div class="col-lg-6">
<div class="card shadow-sm h-100">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold">
<i class="bi bi-map me-2 text-success"></i>{% translate "Périmètre géographique" %}
</span>
{% if zone.geom %}
<span class="badge bg-success">{% translate "Géométrie définie" %}</span>
{% else %}
<span class="badge bg-secondary">{% translate "Aucun périmètre" %}</span>
{% endif %}
</div>
<div class="card-body p-0 position-relative">
<div id="detailZoneMap" style="height: 380px; width: 100%;"></div>
</div>
</div>
</div>
</div>
<!-- Documents & Arrêtés -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<span class="fw-bold">
<i class="bi bi-files me-2 text-success"></i>{% translate "Documents & Fiches techniques" %} ({{ documents.count }})
</span>
{% if can_edit %}
<button type="button" class="btn btn-sm btn-outline-success" data-bs-toggle="modal" data-bs-target="#uploadDocModal">
<i class="bi bi-upload me-1"></i>{% translate "Ajouter des documents" %}
</button>
{% endif %}
</div>
<div class="card-body p-0">
{% if documents %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase text-muted">
<tr>
<th>{% translate "Fichier" %}</th>
<th>{% translate "Date d'ajout" %}</th>
<th>{% translate "Ajouté par" %}</th>
<th class="text-end" style="width: 140px;">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<i class="bi bi-file-earmark-text text-primary fs-5"></i>
<div>
<a href="{{ doc.file.url }}" target="_blank" class="fw-semibold text-decoration-none">
{{ doc.file.name|slice:"25:"|default:doc.file.name }}
</a>
</div>
</div>
</td>
<td>{{ doc.upload_date|date:"d/m/Y H:i" }}</td>
<td>{% if doc.uploaded_by %}{{ doc.uploaded_by.get_full_name|default:doc.uploaded_by.username }}{% else %}-{% endif %}</td>
<td class="text-end">
<a href="{{ doc.file.url }}" target="_blank" class="btn btn-sm btn-outline-secondary" title="{% translate 'Télécharger' %}">
<i class="bi bi-download"></i>
</a>
{% if can_edit %}
<form method="post" action="{% url 'assets:management_zone_delete_document' zone.pk doc.pk %}" class="d-inline" onsubmit="return confirm('{% translate 'Supprimer ce document ?' %}');">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</button>
</form>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted small">
<i class="bi bi-folder2-open fs-2 d-block mb-2 text-muted"></i>
{% translate "Aucun document n'a été rattaché à cette zone de gestion." %}
</div>
{% endif %}
</div>
</div>
<!-- Équipements rattachés (Assets explicites) -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light">
<span class="fw-bold">
<i class="bi bi-layers me-2 text-success"></i>{% translate "Équipements rattachés explicitement" %} ({{ explicit_assets.count }})
</span>
</div>
<div class="card-body p-0">
{% if explicit_assets %}
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase text-muted">
<tr>
<th>{% translate "Équipement" %}</th>
<th>{% translate "Type" %}</th>
<th>{% translate "Statut de rattachement" %}</th>
<th>{% translate "Notes" %}</th>
</tr>
</thead>
<tbody>
{% for ea in explicit_assets %}
<tr>
<td>
<strong>{{ ea.asset|default:ea.object_id }}</strong>
</td>
<td>{{ ea.content_type.name }}</td>
<td>
{% if ea.is_excluded %}
<span class="badge bg-danger-subtle text-danger border">
<i class="bi bi-slash-circle me-1"></i>{% translate "Exclu de la zone" %}
</span>
{% else %}
<span class="badge bg-success-subtle text-success border">
<i class="bi bi-check-circle me-1"></i>{% translate "Inclus dans la zone" %}
</span>
{% endif %}
</td>
<td class="text-muted small">{{ ea.notes|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="p-4 text-center text-muted small">
<p class="mb-0">{% translate "Tous les équipements situés dans le périmètre géographique de la zone y sont automatiquement associés par intersection spatiale." %}</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Modal: Upload Documents -->
{% if can_edit and doc_form %}
<div class="modal fade" id="uploadDocModal" tabindex="-1" aria-labelledby="uploadDocModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'assets:management_zone_add_document' zone.pk %}" enctype="multipart/form-data">
{% csrf_token %}
<div class="modal-header">
<h5 class="modal-title" id="uploadDocModalLabel">
<i class="bi bi-upload me-2 text-success"></i>{% translate "Ajouter des documents" %}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label for="{{ doc_form.documents.id_for_label }}" class="form-label fw-semibold">
{% translate "Sélectionner des fichiers" %}
</label>
{{ doc_form.documents }}
<div class="form-text small">
{% translate "Formats acceptés : PDF, Word, Excel, images, etc. Vous pouvez sélectionner plusieurs fichiers simultanément." %}
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-success">
<i class="bi bi-check-lg me-1"></i>{% translate "Téléverser" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
<!-- Modal: Delete Zone Confirmation -->
{% if can_edit %}
<div class="modal fade" id="deleteZoneModal" tabindex="-1" aria-labelledby="deleteZoneModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<form method="post" action="{% url 'assets:management_zone_delete' zone.pk %}">
{% csrf_token %}
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteZoneModalLabel">
<i class="bi bi-exclamation-triangle-fill me-2"></i>{% translate "Confirmer la suppression" %}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>{% translate "Êtes-vous certain de vouloir supprimer la zone de gestion suivante ?" %}</p>
<div class="alert alert-light border">
<strong>{{ zone.code }}</strong> - {{ zone.get_name }}<br>
<small class="text-muted">{{ zone.thematic.name }} | {{ zone.zone_type.get_name|default:"-" }}</small>
</div>
<p class="text-danger small mb-0">
<i class="bi bi-info-circle me-1"></i>{% translate "Cette action est irréversible. Les documents et historiques liés seront supprimés." %}
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">{% translate "Annuler" %}</button>
<button type="submit" class="btn btn-danger">
<i class="bi bi-trash me-1"></i>{% translate "Supprimer définitivement" %}
</button>
</div>
</form>
</div>
</div>
</div>
{% endif %}
{% endblock content %}
{% block script %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const mapDiv = document.getElementById("detailZoneMap");
if (!mapDiv || typeof maplibregl === "undefined") return;
const zoneColor = "{{ zone.get_color }}";
const geojsonGeometry = {% if zone.geojson_geometry %}{{ zone.geojson_geometry|safe }}{% else %}null{% endif %};
const map = new maplibregl.Map({
container: mapDiv,
style: window.MAP_STYLE_LIGHT,
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
zoom: 12
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.on('load', function () {
if (!geojsonGeometry) {
return;
}
const feature = {
type: "Feature",
geometry: geojsonGeometry
};
map.addSource("detail-zone-source", {
type: "geojson",
data: feature
});
map.addLayer({
id: "detail-zone-fill",
type: "fill",
source: "detail-zone-source",
paint: {
"fill-color": zoneColor,
"fill-opacity": 0.30
}
});
map.addLayer({
id: "detail-zone-line",
type: "line",
source: "detail-zone-source",
paint: {
"line-color": zoneColor,
"line-width": 3,
"line-dasharray": [3, 1.5]
}
});
// Fit bounds to polygon
const bounds = new maplibregl.LngLatBounds();
const coords = geojsonGeometry.coordinates;
const addCoords = (arr) => {
if (typeof arr[0] === 'number') {
bounds.extend(arr);
} else {
arr.forEach(addCoords);
}
};
addCoords(coords);
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 45, maxZoom: 16 });
}
});
});
</script>
{% endblock script %}

View file

@ -1,568 +0,0 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{{ title }} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="/">{% translate "Accueil" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}">{% translate "Assets" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:management_zones_index' %}">{% translate "Zones de gestion" %}</a></li>
{% if not is_create and zone %}
<li class="breadcrumb-item"><a href="{% url 'assets:management_zone_detail' zone.pk %}">{{ zone.code }}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% translate "Modifier" %}</li>
{% else %}
<li class="breadcrumb-item active" aria-current="page">{% translate "Nouvelle zone" %}</li>
{% endif %}
</ol>
</nav>
<div>
{% if not is_create and zone %}
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Annuler" %}
</a>
{% else %}
<a href="{% url 'assets:management_zones_index' %}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left me-1"></i>{% translate "Annuler" %}
</a>
{% endif %}
</div>
</div>
<!-- Title -->
<div class="mb-4">
<h1 class="h3 mb-1">
{% if is_create %}
<i class="bi bi-plus-circle-fill text-success me-2"></i>{% translate "Créer une zone de gestion" %}
{% else %}
<i class="bi bi-pencil-square text-success me-2"></i>{% translate "Modifier la zone de gestion" %} {{ zone.code }}
{% endif %}
</h1>
<p class="text-muted mb-0">
{% translate "Définissez les paramètres d'entretien, les consignes et le périmètre géographique de la zone." %}
</p>
</div>
{% if form.errors %}
<div class="alert alert-danger alert-dismissible fade show mb-4" role="alert">
<i class="bi bi-exclamation-triangle-fill me-2"></i>
<strong>{% translate "Certains champs contiennent des erreurs." %}</strong>
{% translate "Veuillez vérifier les informations ci-dessous." %}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<form method="post" action="" class="needs-validation" id="managementZoneForm" novalidate>
{% csrf_token %}
{{ form.geom_geojson }}
<div class="row g-4">
<!-- Left Column: Form Fields -->
<div class="col-lg-6">
<!-- Informations générales -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-info-circle me-2 text-success"></i>{% translate "Informations générales" %}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-5">
<label for="{{ form.code.id_for_label }}" class="form-label fw-semibold">
{% translate "Code de la zone" %} <span class="text-danger">*</span>
</label>
{{ form.code }}
{% if form.code.errors %}<div class="text-danger small mt-1">{{ form.code.errors.0 }}</div>{% endif %}
<div class="form-text small">{% translate "Identifiant unique (ex: ZG-NAT-001)" %}</div>
</div>
<div class="col-md-7">
<label for="{{ form.thematic.id_for_label }}" class="form-label fw-semibold">
{% translate "Thématique" %} <span class="text-danger">*</span>
</label>
{{ form.thematic }}
{% if form.thematic.errors %}<div class="text-danger small mt-1">{{ form.thematic.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.name_fr.id_for_label }}" class="form-label fw-semibold">
{% translate "Nom (FR)" %} <span class="text-danger">*</span>
</label>
{{ form.name_fr }}
{% if form.name_fr.errors %}<div class="text-danger small mt-1">{{ form.name_fr.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-6">
<label for="{{ form.name_nl.id_for_label }}" class="form-label fw-semibold">
{% translate "Nom (NL)" %}
</label>
{{ form.name_nl }}
{% if form.name_nl.errors %}<div class="text-danger small mt-1">{{ form.name_nl.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-8">
<label for="{{ form.zone_type.id_for_label }}" class="form-label fw-semibold">
{% translate "Type de zone de gestion" %}
</label>
{{ form.zone_type }}
{% if form.zone_type.errors %}<div class="text-danger small mt-1">{{ form.zone_type.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-4">
<label for="{{ form.color.id_for_label }}" class="form-label fw-semibold">
{% translate "Couleur" %}
</label>
<div class="d-flex align-items-center gap-2">
{{ form.color }}
<span class="small text-muted" id="colorHexLabel"></span>
</div>
{% if form.color.errors %}<div class="text-danger small mt-1">{{ form.color.errors.0 }}</div>{% endif %}
</div>
<div class="col-12">
<div class="form-check form-switch mt-2">
{{ form.is_active }}
<label class="form-check-label fw-semibold ms-2" for="{{ form.is_active.id_for_label }}">
{% translate "Zone active (affichée sur les cartes d'exploitation)" %}
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Entretien, Calendrier & Consignes -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light fw-bold">
<i class="bi bi-calendar-check me-2 text-success"></i>{% translate "Modalités d'entretien & Consignes" %}
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-md-5">
<label for="{{ form.target_frequency_days.id_for_label }}" class="form-label fw-semibold">
{% translate "Fréquence cible (jours)" %}
</label>
<div class="input-group">
{{ form.target_frequency_days }}
<span class="input-group-text">{% translate "jours" %}</span>
</div>
<div class="form-text small">{% translate "Intervalle indicatif entre passages" %}</div>
{% if form.target_frequency_days.errors %}<div class="text-danger small mt-1">{{ form.target_frequency_days.errors.0 }}</div>{% endif %}
</div>
<div class="col-md-7">
<label for="{{ form.seasonality_notes.id_for_label }}" class="form-label fw-semibold">
{% translate "Saisonnalité / Calendrier" %}
</label>
{{ form.seasonality_notes }}
<div class="form-text small">{% translate "Ex: Fauche en sept; interdiction taille mars-juillet" %}</div>
{% if form.seasonality_notes.errors %}<div class="text-danger small mt-1">{{ form.seasonality_notes.errors.0 }}</div>{% endif %}
</div>
<div class="col-12">
<label for="{{ form.instructions.id_for_label }}" class="form-label fw-semibold text-warning-emphasis">
<i class="bi bi-exclamation-triangle me-1 text-warning"></i>{% translate "Consignes et points d'attention" %}
</label>
{{ form.instructions }}
<div class="form-text small">{% translate "Consignes cruciales : sécurité tramway, respect des zones humides, hauteur de coupe, etc." %}</div>
{% if form.instructions.errors %}<div class="text-danger small mt-1">{{ form.instructions.errors.0 }}</div>{% endif %}
</div>
<div class="col-12">
<label for="{{ form.description.id_for_label }}" class="form-label fw-semibold">
{% translate "Description générale" %}
</label>
{{ form.description }}
{% if form.description.errors %}<div class="text-danger small mt-1">{{ form.description.errors.0 }}</div>{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Interactive Map Polygon Drawer -->
<div class="col-lg-6">
<div class="card shadow-sm mb-4 sticky-top" style="top: 20px;">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div>
<i class="bi bi-bounding-box-circles me-2 text-success"></i>
<span class="fw-bold">{% translate "Périmètre géographique" %}</span>
<small class="text-muted fw-normal">({% translate "polygone" %})</small>
</div>
<div class="d-flex gap-1">
<button type="button" id="polyPickerDrawBtn" class="btn btn-sm btn-outline-success">
<i class="bi bi-pentagon me-1"></i>{% translate "Dessiner" %}
</button>
<button type="button" id="polyPickerFinishBtn" class="btn btn-sm btn-success d-none">
<i class="bi bi-check-circle me-1"></i>{% translate "Terminer le polygone" %}
</button>
<button type="button" id="polyPickerClearBtn" class="btn btn-sm btn-outline-danger" title="{% translate 'Effacer le polygone' %}">
<i class="bi bi-x-circle"></i>
</button>
</div>
</div>
<div class="card-body p-0 position-relative">
<div id="zonePickerMap" style="height: 480px; width: 100%;"></div>
</div>
<div class="card-footer bg-white border-top small text-muted d-flex justify-content-between align-items-center">
<span id="polyPickerHint">
{% translate "Cliquez sur « Dessiner » pour tracer les limites de la zone sur la carte." %}
</span>
<span id="polyStatusBadge" class="badge bg-secondary">
{% translate "Aucun tracé" %}
</span>
</div>
</div>
<!-- Form Submit Actions -->
<div class="d-flex justify-content-end gap-2 mb-4">
{% if not is_create and zone %}
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-secondary">
{% translate "Annuler" %}
</a>
{% else %}
<a href="{% url 'assets:management_zones_index' %}" class="btn btn-outline-secondary">
{% translate "Annuler" %}
</a>
{% endif %}
<button type="submit" class="btn btn-success px-4">
<i class="bi bi-check-lg me-1"></i>
{% if is_create %}{% translate "Créer la zone de gestion" %}{% else %}{% translate "Enregistrer les modifications" %}{% endif %}
</button>
</div>
</div>
</div>
</form>
</div>
{% endblock content %}
{% block script %}
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script>
document.addEventListener("DOMContentLoaded", function () {
const mapDiv = document.getElementById("zonePickerMap");
const geomInput = document.getElementById("id_geom_geojson");
const thematicSelect = document.getElementById("id_thematic");
const zoneTypeSelect = document.getElementById("id_zone_type");
const colorInput = document.getElementById("id_color");
const colorHexLabel = document.getElementById("colorHexLabel");
if (colorInput && colorHexLabel) {
colorHexLabel.textContent = colorInput.value;
colorInput.addEventListener("input", () => {
colorHexLabel.textContent = colorInput.value;
if (map && map.getLayer("poly-preview-fill")) {
map.setPaintProperty("poly-preview-fill", "fill-color", colorInput.value);
map.setPaintProperty("poly-preview-line", "line-color", colorInput.value);
}
});
}
// Dynamic zone types when thematic changes
if (thematicSelect && zoneTypeSelect) {
thematicSelect.addEventListener("change", async function () {
const thematicId = thematicSelect.value;
if (!thematicId) return;
try {
const resp = await fetch("{% url 'assets:management_zone_types_ajax' %}?thematic_id=" + encodeURIComponent(thematicId));
if (resp.ok) {
const data = await resp.json();
const currentVal = zoneTypeSelect.value;
zoneTypeSelect.innerHTML = '<option value="">-- {% translate "Sélectionner un type" %} --</option>';
(data.types || []).forEach(t => {
const opt = document.createElement("option");
opt.value = t.id;
opt.textContent = t.name;
if (String(t.id) === String(currentVal)) opt.selected = true;
zoneTypeSelect.appendChild(opt);
});
}
} catch (e) {
console.error("Error updating zone types:", e);
}
});
}
// Interactive Map Polygon Drawer
if (!mapDiv || typeof maplibregl === "undefined") return;
let drawing = false;
let vertices = [];
let vertexMarkers = [];
let map = null;
const drawBtn = document.getElementById("polyPickerDrawBtn");
const finishBtn = document.getElementById("polyPickerFinishBtn");
const clearBtn = document.getElementById("polyPickerClearBtn");
const hintEl = document.getElementById("polyPickerHint");
const badgeEl = document.getElementById("polyStatusBadge");
map = new maplibregl.Map({
container: mapDiv,
style: window.MAP_STYLE_LIGHT,
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
zoom: 12
});
// 1. Fond de plan & Orthophotos (LayerSwitcherControl)
if (typeof LayerSwitcherControl !== 'undefined') {
const layerSwitcher = new LayerSwitcherControl({
layers: [{
id: 'basemap',
name: "{% translate 'Fonds de plan & Orthophotos' %}",
icon: 'satellite',
group: 'basemap',
beforeLayer: function (mapInstance) {
if (mapInstance && mapInstance.getLayer('poly-preview-fill')) {
return 'poly-preview-fill';
}
return undefined;
},
layer: {
type: 'raster',
paint: { 'raster-opacity': 1 }
},
basemaps: (window.MAP_BACKGROUNDS && window.MAP_BACKGROUNDS.length > 0) ? window.MAP_BACKGROUNDS : undefined,
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 || '© 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: '© Digitaal Vlaanderen - Orthofoto 2023'
}
}
]
}]
});
map.addControl(layerSwitcher, 'top-right');
}
// 2. Navigation Control (Zoom +/- & Pitch/Bearing)
map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), 'top-right');
// 3. Outil de recherche d'adresse
if (typeof AddressSearchControl !== 'undefined') {
const searchCtrl = new AddressSearchControl({
autocompleteUrl: "{% url 'common:autocomplete_street' %}",
placeholder: "{% translate 'Rechercher une adresse…' %}"
});
map.addControl(searchCtrl, 'top-left');
}
function getColor() {
return colorInput ? colorInput.value : "#198754";
}
function initPolyLayers() {
if (!map.getSource("poly-preview")) {
map.addSource("poly-preview", {
type: "geojson",
data: { type: "Feature", geometry: { type: "Polygon", coordinates: [] } }
});
map.addLayer({
id: "poly-preview-fill",
type: "fill",
source: "poly-preview",
paint: {
"fill-color": getColor(),
"fill-opacity": 0.25
}
});
map.addLayer({
id: "poly-preview-line",
type: "line",
source: "poly-preview",
paint: {
"line-color": getColor(),
"line-width": 2.5
}
});
}
}
function clearMarkers() {
vertexMarkers.forEach(m => m.remove());
vertexMarkers = [];
}
function addVertexMarker(lng, lat, index) {
const el = document.createElement("div");
el.style.width = "10px";
el.style.height = "10px";
el.style.borderRadius = "50%";
el.style.backgroundColor = index === 0 ? "#dc3545" : "#198754";
el.style.border = "2px solid #ffffff";
el.style.boxShadow = "0 0 3px rgba(0,0,0,0.5)";
el.style.cursor = index === 0 && drawing && vertices.length >= 3 ? "pointer" : "default";
if (index === 0) {
el.title = "{% translate 'Cliquez pour clore le polygone' %}";
el.addEventListener("click", (e) => {
e.stopPropagation();
if (drawing && vertices.length >= 3) {
finishPolygon();
}
});
}
const marker = new maplibregl.Marker({ element: el }).setLngLat([lng, lat]).addTo(map);
vertexMarkers.push(marker);
}
function renderPoly(coords) {
initPolyLayers();
let geom;
if (coords.length >= 3) {
geom = {
type: "Polygon",
coordinates: [coords.concat([coords[0]])]
};
} else if (coords.length === 2) {
geom = {
type: "LineString",
coordinates: coords
};
} else {
geom = { type: "Point", coordinates: coords[0] || [0, 0] };
}
map.getSource("poly-preview").setData({ type: "Feature", geometry: geom });
}
function finishPolygon() {
if (vertices.length < 3) {
alert("{% translate 'Un polygone doit comporter au moins 3 sommets.' %}");
return;
}
drawing = false;
const closed = vertices.concat([vertices[0]]);
const polyGeojson = {
type: "Polygon",
coordinates: [closed]
};
if (geomInput) {
geomInput.value = JSON.stringify(polyGeojson);
}
renderPoly(vertices);
updateUI(false);
hintEl.textContent = "{% translate 'Polygone enregistré. Cliquez sur « Dessiner » pour le refaire.' %}";
badgeEl.className = "badge bg-success";
badgeEl.textContent = `{% translate 'Polygone défini' %} (${vertices.length} {% translate 'sommets' %})`;
}
function updateUI(isDrawing) {
drawBtn.classList.toggle("d-none", isDrawing);
finishBtn.classList.toggle("d-none", !isDrawing);
map.getCanvas().style.cursor = isDrawing ? "crosshair" : "default";
}
function showExistingPolygon(geojsonStr) {
if (!geojsonStr) return;
try {
const geom = JSON.parse(geojsonStr);
let coords = null;
if (geom.type === "Polygon") {
coords = geom.coordinates[0];
} else if (geom.type === "MultiPolygon") {
coords = geom.coordinates[0][0];
}
if (!coords || coords.length === 0) return;
// Strip closing duplicate vertex for marker tracking
const rawVertices = (coords[0][0] === coords[coords.length - 1][0] && coords[0][1] === coords[coords.length - 1][1])
? coords.slice(0, coords.length - 1)
: coords;
vertices = rawVertices;
clearMarkers();
vertices.forEach((c, idx) => addVertexMarker(c[0], c[1], idx));
renderPoly(vertices);
// Fit bounds
const bounds = new maplibregl.LngLatBounds();
coords.forEach(c => bounds.extend(c));
map.fitBounds(bounds, { padding: 50, maxZoom: 16 });
badgeEl.className = "badge bg-success";
badgeEl.textContent = `{% translate 'Polygone existant' %} (${vertices.length} {% translate 'sommets' %})`;
hintEl.textContent = "{% translate 'Polygone actuel affiché. Cliquez sur « Dessiner » pour le modifier.' %}";
} catch (e) {
console.warn("Could not parse existing geometry:", e);
}
}
map.on("load", function () {
initPolyLayers();
if (geomInput && geomInput.value) {
showExistingPolygon(geomInput.value);
}
});
map.on("click", function (e) {
if (!drawing) return;
const { lng, lat } = e.lngLat;
vertices.push([lng, lat]);
addVertexMarker(lng, lat, vertices.length - 1);
renderPoly(vertices);
hintEl.textContent = `{% translate 'Sommets posés' %} : ${vertices.length}. {% translate 'Cliquez sur le premier sommet ou sur « Terminer » pour clore.' %}`;
badgeEl.className = "badge bg-warning text-dark";
badgeEl.textContent = `{% translate 'Tracé en cours' %} (${vertices.length})`;
});
drawBtn.addEventListener("click", function (e) {
e.preventDefault();
drawing = true;
vertices = [];
clearMarkers();
if (geomInput) geomInput.value = "";
initPolyLayers();
map.getSource("poly-preview").setData({ type: "Feature", geometry: { type: "Polygon", coordinates: [] } });
updateUI(true);
hintEl.textContent = "{% translate 'Cliquez sur la carte pour poser chaque sommet.' %}";
badgeEl.className = "badge bg-warning text-dark";
badgeEl.textContent = "{% translate 'Tracé en cours' %}";
});
finishBtn.addEventListener("click", function (e) {
e.preventDefault();
finishPolygon();
});
clearBtn.addEventListener("click", function (e) {
e.preventDefault();
drawing = false;
vertices = [];
clearMarkers();
if (geomInput) geomInput.value = "";
initPolyLayers();
map.getSource("poly-preview").setData({ type: "Feature", geometry: { type: "Polygon", coordinates: [] } });
updateUI(false);
hintEl.textContent = "{% translate 'Périmètre effacé. Cliquez sur « Dessiner » pour recommencer.' %}";
badgeEl.className = "badge bg-secondary";
badgeEl.textContent = "{% translate 'Aucun tracé' %}";
});
});
</script>
{% endblock script %}

View file

@ -1,340 +0,0 @@
{% extends "base.html" %}
{% load static i18n %}
{% block title %}{% translate "Zones de gestion" %} | {{ product_name }}{% endblock %}
{% block content %}
<div class="container-fluid py-4">
<!-- Breadcrumb & Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-1">
<li class="breadcrumb-item"><a href="/">{% translate "Accueil" %}</a></li>
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}">{% translate "Assets" %}</a></li>
<li class="breadcrumb-item active" aria-current="page">{% translate "Zones de gestion" %}</li>
</ol>
</nav>
{% if can_create %}
<a href="{% url 'assets:management_zone_create' %}{% if selected_thematic_code %}?thematic={{ selected_thematic_code }}{% endif %}" class="btn btn-success">
<i class="bi bi-plus-lg me-1"></i>{% translate "Nouvelle zone de gestion" %}
</a>
{% endif %}
</div>
<!-- Title Bar -->
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<h1 class="h3 mb-1">
<i class="bi bi-diagram-3-fill text-success me-2"></i>{% translate "Zones de gestion" %}
</h1>
<p class="text-muted mb-0">
{% translate "Regroupements géographiques et fonctionnels d'équipements pour l'entretien récurrent, les plans de fauche et l'exploitation." %}
</p>
</div>
<div class="text-end">
<span class="badge bg-light text-dark border fs-6 px-3 py-2">
<strong>{{ total_count }}</strong> {% translate "zone(s) répertoriée(s)" %}
</span>
</div>
</div>
<!-- Filter Card -->
<div class="card shadow-sm mb-4">
<div class="card-body">
<form method="get" class="row g-3 align-items-end">
<!-- Search text -->
<div class="col-md-3">
<label class="form-label small text-muted">{% translate "Recherche" %}</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" name="q" class="form-control" placeholder="{% translate 'Code, nom, consignes...' %}" value="{{ search_query }}">
</div>
</div>
<!-- Thematic -->
<div class="col-md-3">
<label class="form-label small text-muted">{% translate "Thématique" %}</label>
<select name="thematic" id="filterThematic" class="form-select form-select-sm">
<option value="">-- {% translate "Toutes les thématiques" %} --</option>
{% for th in accessible_thematics %}
<option value="{{ th.code }}" {% if selected_thematic_code == th.code %}selected{% endif %}>
{{ th.name }}
</option>
{% endfor %}
</select>
</div>
<!-- Zone Type -->
<div class="col-md-3">
<label class="form-label small text-muted">{% translate "Type de zone" %}</label>
<select name="zone_type" id="filterZoneType" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les types" %} --</option>
{% for zt in available_types %}
<option value="{{ zt.id }}" {% if selected_type_id == zt.id|stringformat:"s" %}selected{% endif %}>
{{ zt.get_name }} ({{ zt.thematic.name }})
</option>
{% endfor %}
</select>
</div>
<!-- Status -->
<div class="col-md-2">
<label class="form-label small text-muted">{% translate "Statut" %}</label>
<select name="status" class="form-select form-select-sm">
<option value="">-- {% translate "Tous les statuts" %} --</option>
<option value="active" {% if selected_status == 'active' %}selected{% endif %}>{% translate "Actives uniquement" %}</option>
<option value="inactive" {% if selected_status == 'inactive' %}selected{% endif %}>{% translate "Inactives" %}</option>
</select>
</div>
<!-- Submit / Reset buttons -->
<div class="col-md-1 d-flex gap-1">
<button type="submit" class="btn btn-sm btn-primary w-100" title="{% translate 'Filtrer' %}">
<i class="bi bi-funnel"></i>
</button>
<a href="{% url 'assets:management_zones_index' %}" class="btn btn-sm btn-outline-secondary" title="{% translate 'Réinitialiser' %}">
<i class="bi bi-x-lg"></i>
</a>
</div>
</form>
</div>
</div>
<!-- Map Overview Card -->
<div class="card shadow-sm mb-4">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
<span class="fw-semibold small">
<i class="bi bi-map me-1 text-success"></i>{% translate "Aperçu cartographique des périmètres" %}
</span>
<small class="text-muted" id="mapLoadingStatus">{% translate "Chargement de la carte..." %}</small>
</div>
<div class="card-body p-0">
<div id="zonesOverviewMap" style="height: 320px; width: 100%;"></div>
</div>
</div>
<!-- Zones Table Card -->
<div class="card shadow-sm">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead class="table-light small text-uppercase text-muted">
<tr>
<th style="width: 140px;">{% translate "Code" %}</th>
<th>{% translate "Nom de la zone" %}</th>
<th>{% translate "Thématique" %}</th>
<th>{% translate "Type de zone" %}</th>
<th>{% translate "Fréquence cible" %}</th>
<th>{% translate "Saisonnalité" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end" style="width: 160px;">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for item in zones_list %}
{% with zone=item.zone can_edit=item.can_edit %}
<tr>
<td>
<div class="d-flex align-items-center gap-2">
<span style="display:inline-block; width: 14px; height: 14px; border-radius: 3px; background-color: {{ zone.get_color }}; border: 1px solid rgba(0,0,0,0.2); flex-shrink: 0;"></span>
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="fw-bold text-decoration-none">
{{ zone.code }}
</a>
</div>
</td>
<td>
<div class="fw-semibold">{{ zone.get_name }}</div>
{% if zone.instructions %}
<div class="text-muted small text-truncate" style="max-width: 320px;" title="{{ zone.instructions }}">
<i class="bi bi-exclamation-triangle text-warning me-1"></i>{{ zone.instructions }}
</div>
{% endif %}
</td>
<td>
<span class="badge bg-secondary-subtle text-secondary border">
{{ zone.thematic.name }}
</span>
</td>
<td>
{% if zone.zone_type %}
<span class="badge" style="background-color: {{ zone.zone_type.default_color }}; color: #fff;">
{{ zone.zone_type.get_name }}
</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if zone.target_frequency_days %}
<span class="badge bg-info-subtle text-info border">
<i class="bi bi-arrow-repeat me-1"></i>{{ zone.target_frequency_days }} {% translate "jours" %}
</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if zone.seasonality_notes %}
<span class="small text-muted"><i class="bi bi-calendar3 me-1"></i>{{ zone.seasonality_notes }}</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if zone.is_active %}
<span class="badge bg-success-subtle text-success border">{% translate "Active" %}</span>
{% else %}
<span class="badge bg-danger-subtle text-danger border">{% translate "Inactive" %}</span>
{% endif %}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="{% url 'assets:management_zone_detail' zone.pk %}" class="btn btn-outline-primary" title="{% translate 'Détails' %}">
<i class="bi bi-eye"></i>
</a>
{% if can_edit %}
<a href="{% url 'assets:management_zone_edit' zone.pk %}" class="btn btn-outline-secondary" title="{% translate 'Modifier' %}">
<i class="bi bi-pencil"></i>
</a>
<a href="{% url 'assets:management_zone_delete' zone.pk %}" class="btn btn-outline-danger" title="{% translate 'Supprimer' %}">
<i class="bi bi-trash"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endwith %}
{% empty %}
<tr>
<td colspan="8" class="text-center py-5 text-muted">
<i class="bi bi-diagram-3 fs-1 d-block mb-2 text-muted"></i>
<p class="mb-2">{% translate "Aucune zone de gestion ne correspond aux critères sélectionnés." %}</p>
{% if can_create %}
<a href="{% url 'assets:management_zone_create' %}{% if selected_thematic_code %}?thematic={{ selected_thematic_code }}{% endif %}" class="btn btn-sm btn-success">
<i class="bi bi-plus-lg me-1"></i>{% translate "Créer une première zone de gestion" %}
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block script %}
<script>
document.addEventListener("DOMContentLoaded", function () {
const mapDiv = document.getElementById("zonesOverviewMap");
if (!mapDiv || typeof maplibregl === "undefined") return;
const thematicParam = "{{ selected_thematic_code|default:'' }}";
const geojsonUrl = "{% url 'assets:management_zones_geojson' %}" + (thematicParam ? ("?thematic=" + encodeURIComponent(thematicParam)) : "");
const map = new maplibregl.Map({
container: mapDiv,
style: window.MAP_STYLE_LIGHT,
center: window.MAP_DEFAULT_CENTER || [4.3572, 50.8477],
zoom: 11
});
map.addControl(new maplibregl.NavigationControl(), 'top-right');
map.on('load', async function () {
try {
const resp = await fetch(geojsonUrl, {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
});
if (!resp.ok) {
document.getElementById("mapLoadingStatus").textContent = "";
return;
}
const data = await resp.json();
const features = data.features || [];
document.getElementById("mapLoadingStatus").textContent = `${features.length} {% translate "périmètre(s) affiché(s)" %}`;
map.addSource('management-zones-overview', {
type: 'geojson',
data: data
});
map.addLayer({
id: 'management-zones-fill',
type: 'fill',
source: 'management-zones-overview',
paint: {
'fill-color': ['coalesce', ['get', 'color'], '#198754'],
'fill-opacity': 0.25
}
});
map.addLayer({
id: 'management-zones-line',
type: 'line',
source: 'management-zones-overview',
paint: {
'line-color': ['coalesce', ['get', 'color'], '#198754'],
'line-width': 2,
'line-dasharray': [3, 1.5]
}
});
const popup = new maplibregl.Popup({ closeButton: true, closeOnClick: true, maxWidth: '300px' });
map.on('click', 'management-zones-fill', function (e) {
if (!e.features || !e.features[0]) return;
const p = e.features[0].properties;
const detailUrl = "/assets/management-zones/" + p.id + "/";
const html = `
<div style="font-size: 12px; line-height: 1.4;">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:4px;">
<span style="display:inline-block; width:12px; height:12px; border-radius:3px; background-color:${p.color || '#198754'};"></span>
<strong>${p.code}</strong>
</div>
<div class="fw-semibold mb-1">${p.name}</div>
${p.zone_type_name ? `<span class="badge bg-secondary mb-1">${p.zone_type_name}</span>` : ''}
${p.target_frequency_days ? `<div class="text-muted small"><i class="bi bi-arrow-repeat me-1"></i>${p.target_frequency_days} {% translate "jours" %}</div>` : ''}
<div class="mt-2 text-end">
<a href="${detailUrl}" class="btn btn-sm btn-outline-primary py-0 px-2" style="font-size: 11px;">
{% translate "Consulter la fiche" %} &rarr;
</a>
</div>
</div>
`;
popup.setLngLat(e.lngLat).setHTML(html).addTo(map);
});
map.on('mouseenter', 'management-zones-fill', () => { map.getCanvas().style.cursor = 'pointer'; });
map.on('mouseleave', 'management-zones-fill', () => { map.getCanvas().style.cursor = ''; });
// Fit bounds to features
if (features.length > 0) {
const bounds = new maplibregl.LngLatBounds();
features.forEach(f => {
const coords = f.geometry?.coordinates;
if (!coords) return;
const addCoords = (arr) => {
if (typeof arr[0] === 'number') {
bounds.extend(arr);
} else {
arr.forEach(addCoords);
}
};
addCoords(coords);
});
if (!bounds.isEmpty()) {
map.fitBounds(bounds, { padding: 40, maxZoom: 15 });
}
}
} catch (err) {
console.error("Error loading management zones overview map:", err);
document.getElementById("mapLoadingStatus").textContent = "";
}
});
});
</script>
{% endblock script %}

View file

@ -1,705 +0,0 @@
{% extends "base.html" %}
{% load static %}
{% load i18n %}
{% block extra_head %}
<style>
.nature-kpi-card {
border-radius: 10px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
border: 1px solid #e2e8f0;
background: #ffffff;
}
.nature-kpi-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.08);
}
.nature-kpi-icon {
width: 44px;
height: 44px;
min-width: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.3rem;
}
.nature-tab-content {
background: #ffffff;
border: 1px solid #dee2e6;
border-top: none;
border-radius: 0 0 8px 8px;
padding: 1rem;
}
#map {
width: 100%;
height: calc(100vh - 230px);
min-height: 520px;
border-radius: 8px;
}
.map-card-wrapper {
position: sticky;
top: 1rem;
}
.map-layer-pill {
font-size: 0.78rem;
cursor: pointer;
user-select: none;
}
</style>
{% endblock extra_head %}
{% block content %}
<div class="container-fluid py-3">
<!-- Breadcrumb & Navigation -->
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<nav aria-label="breadcrumb">
<ol class="breadcrumb mb-0">
<li class="breadcrumb-item"><a href="{% url 'assets:index' %}?thematic=nature" class="text-decoration-none">{% translate "Nature" %}</a></li>
<li class="breadcrumb-item active fw-semibold" aria-current="page">{{ section.code }}</li>
</ol>
</nav>
<div class="d-flex gap-2">
<a href="{% url 'assets:index' %}?thematic=nature" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-left me-1"></i>{% translate "Retour à la carte" %}
</a>
{% if section.lon and section.lat %}
<a href="https://www.google.com/maps?cbll={{ section.lat|floatformat:'6u'}},{{ section.lon|floatformat:'6u'}}&cbp=12,0,0,0,5&layer=c&panoid="
target="_blank" class="btn btn-sm btn-outline-primary" style="text-decoration: none;">
<img src="{% static 'common/google-street-view-logo.png' %}" alt="{% translate 'Street View' %}" height="18" class="me-1">
{% translate "View in Google Street View" %}
</a>
{% endif %}
</div>
</div>
<!-- Header Card -->
<div class="card shadow-sm mb-4 border-0" style="background: linear-gradient(to right, #f8fafc, #ffffff); border-left: 5px solid #16a34a !important;">
<div class="card-body p-3 p-md-4">
<div class="row align-items-center g-3">
<div class="col-lg-8">
<div class="d-flex align-items-center gap-3">
<div class="rounded-circle bg-success-subtle p-2 d-flex align-items-center justify-content-center text-success" style="width: 52px; height: 52px; min-width: 52px;">
<i class="bi bi-tree-fill" style="font-size: 1.8rem;"></i>
</div>
<div>
<div class="d-flex align-items-center gap-2 flex-wrap">
<h1 class="h4 mb-0 fw-bold text-dark">
{{ section.code }} <span class="text-muted fw-normal">&mdash; {{ section.get_name }}</span>
</h1>
</div>
<div class="d-flex align-items-center gap-2 mt-2 flex-wrap text-muted small">
{% if section.municipality %}
<span><i class="bi bi-geo-alt-fill text-danger me-1"></i>{{ section.municipality }}</span>
<span>&bull;</span>
{% endif %}
<span>
<strong>{% translate "Zone:" %}</strong>
{% if section.zone %}
<span class="badge bg-secondary-subtle text-secondary border">Zone {{ section.zone }}</span>
{% else %}
<span class="text-muted">{% translate "Non définie" %}</span>
{% endif %}
</span>
<span>&bull;</span>
<span>
<strong>{% translate "Autoroute:" %}</strong>
{% if section.is_highway %}
<span class="badge bg-primary-subtle text-primary border">{% translate "Oui" %}</span>
{% else %}
<span class="badge bg-light text-secondary border">{% translate "Non" %}</span>
{% endif %}
</span>
{% if section.category %}
<span>&bull;</span>
<span><i class="bi bi-tag me-1"></i>{{ section.category.get_name }}</span>
{% endif %}
</div>
</div>
</div>
{% if section.parent_location or section.child_locations.exists %}
<div class="mt-2 pt-2 border-top d-flex gap-3 flex-wrap small text-muted">
{% if section.parent_location %}
<div>
<i class="bi bi-diagram-2 me-1"></i>{% translate "Section parente :" %}
<a href="{{ section.parent_location.get_absolute_url }}" class="fw-semibold text-decoration-none">
{{ section.parent_location.code }} - {{ section.parent_location.get_name }}
</a>
</div>
{% endif %}
{% if section.child_locations.exists %}
<div>
<i class="bi bi-diagram-3 me-1"></i>{% translate "Sous-sections :" %}
{% for child in section.child_locations.all %}
<a href="{{ child.get_absolute_url }}" class="badge bg-light text-dark border text-decoration-none me-1">
{{ child.code }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
{% endif %}
</div>
<div class="col-lg-4 text-lg-end">
<div class="d-inline-flex gap-2 flex-wrap justify-content-lg-end">
<div class="badge bg-light text-dark border p-2 text-start">
<span class="d-block text-muted" style="font-size: 0.72rem;">{% translate "Total équipements" %}</span>
<span class="fw-bold fs-6">{{ total_assets_count }}</span>
</div>
<div class="badge bg-light text-dark border p-2 text-start">
<span class="d-block text-muted" style="font-size: 0.72rem;">{% translate "Interventions" %}</span>
<span class="fw-bold fs-6 text-primary">{{ interventions|length }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- KPI Metric Cards Grid -->
<div class="row g-3 mb-4">
<!-- 1. Nombre d'arbres -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-success-subtle text-success">
<i class="bi bi-tree-fill"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Nombre d'arbres:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ trees_count }}</div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ trees_active_count }} {% translate "actifs" %}
{% if trees_remarkable_count %}
&bull; <span class="text-warning fw-semibold"><i class="bi bi-star-fill text-warning"></i> {{ trees_remarkable_count }} {% translate "remarquables" %}</span>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- 2. Superficie totale surfaces vertes -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-primary-subtle text-primary">
<i class="bi bi-grid-fill"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Surfaces végétales:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ total_green_surface|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ green_surfaces_count }} {% translate "surfaces au total" %}
</div>
</div>
</div>
</div>
</div>
<!-- 3. Superficie massif arboré -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-warning-subtle text-warning-emphasis">
<i class="bi bi-flower1"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Superficie massif arboré:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ surface_massif_arbore|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ massif_arbore_count }} {% translate "massifs arborés" %}
</div>
</div>
</div>
</div>
</div>
<!-- 4. Superficie pré fleuri -->
<div class="col-sm-6 col-xl-3">
<div class="card h-100 nature-kpi-card shadow-sm p-3">
<div class="d-flex align-items-center gap-3">
<div class="nature-kpi-icon bg-info-subtle text-info-emphasis">
<i class="bi bi-flower2"></i>
</div>
<div>
<div class="text-muted small fw-semibold">{% translate "Superficie pré fleuri:" %}</div>
<div class="fs-4 fw-bold text-dark">{{ surface_pre_fleuri|floatformat:1 }} <span class="fs-6 fw-normal text-muted">m²</span></div>
<div class="text-muted" style="font-size: 0.75rem;">
{{ pre_fleuri_count }} {% translate "prés fleuris" %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Typology breakdown (if available) -->
{% if surfaces_by_type %}
<div class="card shadow-sm mb-4 border-0">
<div class="card-body p-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<span class="small fw-bold text-secondary text-uppercase"><i class="bi bi-pie-chart-fill me-1"></i>{% translate "Répartition des surfaces végétales" %}</span>
<span class="small text-muted">{{ total_green_surface|floatformat:1 }} m²</span>
</div>
<!-- Progress bar -->
<div class="progress mb-2" style="height: 10px; border-radius: 6px;">
{% for st in surfaces_by_type %}
<div class="progress-bar" role="progressbar"
style="width: {{ st.percent }}%; background-color: {{ st.color }};"
title="{{ st.name }}: {{ st.total_area|floatformat:1 }} m² ({{ st.percent }}%)"
aria-valuenow="{{ st.percent }}" aria-valuemin="0" aria-valuemax="100"></div>
{% endfor %}
</div>
<!-- Badges -->
<div class="d-flex flex-wrap gap-2 mt-2">
{% for st in surfaces_by_type %}
<span class="badge bg-light text-dark border d-inline-flex align-items-center gap-1 py-1 px-2" style="font-size: 0.8rem;">
<span class="d-inline-block rounded-circle" style="width: 8px; height: 8px; background-color: {{ st.color }};"></span>
<span class="fw-semibold">{{ st.name }}</span>:
<span class="text-muted">{{ st.total_area|floatformat:1 }} m² ({{ st.count }})</span>
</span>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Main Content Layout (Split View: Tabs/Tables on Left, Interactive Map on Right) -->
<div class="row g-4">
<!-- Left Column: Tabs & Tables -->
<div class="col-lg-7 col-xl-7">
<!-- Tabs Navigation -->
<ul class="nav nav-tabs" id="natureLocationTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active fw-semibold" id="surfaces-tab" data-bs-toggle="tab" data-bs-target="#surfaces-pane" type="button" role="tab">
<i class="bi bi-grid-fill text-success me-1"></i>{% translate "Surfaces vertes" %}
<span class="badge bg-success-subtle text-success ms-1">{{ green_surfaces_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="trees-tab" data-bs-toggle="tab" data-bs-target="#trees-pane" type="button" role="tab">
<i class="bi bi-tree-fill text-success me-1"></i>{% translate "Arbres" %}
<span class="badge bg-success-subtle text-success ms-1">{{ trees_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="equipments-tab" data-bs-toggle="tab" data-bs-target="#equipments-pane" type="button" role="tab">
<i class="bi bi-flower3 text-info me-1"></i>{% translate "Arbustes & Bancs" %}
<span class="badge bg-secondary-subtle text-secondary ms-1">{{ shrubs_count|add:benches_count }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="interventions-tab" data-bs-toggle="tab" data-bs-target="#interventions-pane" type="button" role="tab">
<i class="bi bi-tools text-primary me-1"></i>{% translate "Interventions" %}
<span class="badge bg-primary-subtle text-primary ms-1">{{ interventions|length }}</span>
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link fw-semibold" id="documents-tab" data-bs-toggle="tab" data-bs-target="#documents-pane" type="button" role="tab">
<i class="bi bi-files text-secondary me-1"></i>{% translate "Documents" %}
<span class="badge bg-secondary-subtle text-secondary ms-1">{{ documents|length }}</span>
</button>
</li>
</ul>
<!-- Tabs Content -->
<div class="tab-content nature-tab-content shadow-sm" id="natureLocationTabsContent">
<!-- TAB 1: Surfaces végétalisées -->
<div class="tab-pane fade show active" id="surfaces-pane" role="tabpanel" aria-labelledby="surfaces-tab">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Typologie / Modèle" %}</th>
<th class="text-end">{% translate "Superficie" %}</th>
<th>{% translate "Pente / Tonte" %}</th>
<th>{% translate "Bulbes" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for gs in green_surfaces %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturegreensurface' gs.id %}" class="text-decoration-none">
{{ gs.code }}
</a>
</td>
<td>
<span class="d-inline-block rounded-circle me-1" style="width: 8px; height: 8px; background-color: {{ gs.model.color|default:'#4caf50' }};"></span>
{{ gs.model.get_name|default:"-" }}
</td>
<td class="text-end fw-semibold">
{% if gs.surface_area %}{{ gs.surface_area|floatformat:1 }} m²{% else %}<span class="text-muted">-</span>{% endif %}
</td>
<td>
<span class="small text-muted">
{% if gs.slope %}{{ gs.slope }}%{% else %}-{% endif %} /
{% if gs.mower_frenquency %}{{ gs.mower_frenquency }}x/an{% else %}-{% endif %}
</span>
</td>
<td>
{% if gs.bulb_types %}
<span class="badge bg-info-subtle text-info border">{{ gs.bulb_types|length }} bulbe(s)</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
<span class="badge {% if gs.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ gs.get_status_display }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if gs.lon and gs.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ gs.lon|stringformat:'f' }}, {{ gs.lat|stringformat:'f' }}, '{{ gs.code }}')" title="{% translate 'Centrer sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturegreensurface' gs.id %}" class="btn btn-outline-primary" title="{% translate 'Détail' %}">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="bi bi-grid mb-2 d-block fs-3 text-muted"></i>
{% translate "Aucune surface végétalisée enregistrée sur cette voirie." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 2: Arbres -->
<div class="tab-pane fade" id="trees-pane" role="tabpanel" aria-labelledby="trees-tab">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Essence / Modèle" %}</th>
<th>{% translate "Stade" %}</th>
<th>{% translate "Vitalité" %}</th>
<th>{% translate "Risque" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for tree in trees %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturetree' tree.id %}" class="text-decoration-none">
{{ tree.code }}
</a>
{% if tree.is_remarkable %}
<i class="bi bi-star-fill text-warning ms-1" title="{% translate 'Arbre remarquable' %}"></i>
{% endif %}
</td>
<td>
<div>{{ tree.model.get_name|default:"-" }}</div>
{% if tree.model.scientific_name %}
<div class="small text-muted fst-italic">{{ tree.model.scientific_name }}</div>
{% endif %}
</td>
<td>
<span class="small">{{ tree.get_development_stage_display|default:"-" }}</span>
</td>
<td>
{% if tree.vitality == 'strong' %}
<span class="badge bg-success-subtle text-success border">{% translate "Forte" %}</span>
{% elif tree.vitality == 'normal' %}
<span class="badge bg-info-subtle text-info border">{% translate "Normale" %}</span>
{% elif tree.vitality == 'weak' %}
<span class="badge bg-warning-subtle text-warning-emphasis border">{% translate "Faible" %}</span>
{% elif tree.vitality == 'declining' or tree.vitality == 'dead' %}
<span class="badge bg-danger">{{ tree.get_vitality_display }}</span>
{% else %}
<span class="text-muted small">-</span>
{% endif %}
</td>
<td>
{% if tree.risk_level == 'high' or tree.risk_level == 'imminent_danger' %}
<span class="badge bg-danger">{{ tree.get_risk_level_display }}</span>
{% elif tree.risk_level == 'moderate' %}
<span class="badge bg-warning text-dark">{{ tree.get_risk_level_display }}</span>
{% else %}
<span class="small text-muted">{{ tree.get_risk_level_display|default:"Faible" }}</span>
{% endif %}
</td>
<td>
<span class="badge {% if tree.status == 'active' %}bg-success{% else %}bg-secondary{% endif %}">
{{ tree.get_status_display }}
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if tree.lon and tree.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ tree.lon|stringformat:'f' }}, {{ tree.lat|stringformat:'f' }}, '{{ tree.code }}')" title="{% translate 'Centrer sur la carte' %}">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturetree' tree.id %}" class="btn btn-outline-primary" title="{% translate 'Détail' %}">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" class="text-center text-muted py-4">
<i class="bi bi-tree mb-2 d-block fs-3 text-muted"></i>
{% translate "Aucun arbre enregistré sur cette voirie." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 3: Arbustes & Bancs -->
<div class="tab-pane fade" id="equipments-pane" role="tabpanel" aria-labelledby="equipments-tab">
<!-- Arbustes -->
<h6 class="fw-bold mb-2 text-dark"><i class="bi bi-flower3 text-success me-1"></i>{% translate "Arbustes" %} ({{ shrubs_count }})</h6>
<div class="table-responsive mb-4">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "Hauteur" %}</th>
<th>{% translate "Taillé" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for shrub in shrubs %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'natureshrub' shrub.id %}" class="text-decoration-none">
{{ shrub.code }}
</a>
</td>
<td>{{ shrub.model.get_name|default:"-" }}</td>
<td>{% if shrub.height %}{{ shrub.height }} m{% else %}-{% endif %}</td>
<td>
{% if shrub.trimmed %}<span class="badge bg-success-subtle text-success">{% translate "Oui" %}</span>{% else %}<span class="badge bg-light text-secondary">{% translate "Non" %}</span>{% endif %}
</td>
<td><span class="badge bg-secondary">{{ shrub.get_status_display }}</span></td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if shrub.lon and shrub.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ shrub.lon|stringformat:'f' }}, {{ shrub.lat|stringformat:'f' }}, '{{ shrub.code }}')">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'natureshrub' shrub.id %}" class="btn btn-outline-primary">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-2 small">{% translate "Aucun arbuste répertorié." %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Bancs / Mobilier -->
<h6 class="fw-bold mb-2 text-dark"><i class="bi bi-inbox text-warning me-1"></i>{% translate "Mobilier & Bancs" %} ({{ benches_count }})</h6>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0" style="font-size: 0.9rem;">
<thead class="table-light">
<tr>
<th>{% translate "Code" %}</th>
<th>{% translate "Modèle" %}</th>
<th>{% translate "Matériau" %}</th>
<th>{% translate "Dimensions" %}</th>
<th>{% translate "Statut" %}</th>
<th class="text-end">{% translate "Actions" %}</th>
</tr>
</thead>
<tbody>
{% for bench in benches %}
<tr>
<td class="fw-bold">
<a href="{% url 'assets:nature_assets_detail' 'naturebench' bench.id %}" class="text-decoration-none">
{{ bench.code }}
</a>
</td>
<td>{{ bench.model.get_name|default:"-" }}</td>
<td>{{ bench.material|default:"-" }}</td>
<td>
{% if bench.length or bench.width or bench.height %}
{{ bench.length|default:"-" }} x {{ bench.width|default:"-" }} x {{ bench.height|default:"-" }} m
{% else %}
-
{% endif %}
</td>
<td><span class="badge bg-secondary">{{ bench.get_status_display }}</span></td>
<td class="text-end">
<div class="btn-group btn-group-sm">
{% if bench.lon and bench.lat %}
<button type="button" class="btn btn-outline-success" onclick="zoomToAsset({{ bench.lon|stringformat:'f' }}, {{ bench.lat|stringformat:'f' }}, '{{ bench.code }}')">
<i class="bi bi-geo-alt"></i>
</button>
{% endif %}
<a href="{% url 'assets:nature_assets_detail' 'naturebench' bench.id %}" class="btn btn-outline-primary">
<i class="bi bi-arrow-right"></i>
</a>
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="6" class="text-center text-muted py-2 small">{% translate "Aucun banc ou mobilier répertorié." %}</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 4: Interventions -->
<div class="tab-pane fade" id="interventions-pane" role="tabpanel" aria-labelledby="interventions-tab">
<div class="table-responsive">
<table id="interventions-table" class="table table-hover align-middle mb-0"
data-pagination="true"
data-page-size="25">
<thead class="table-light">
<tr>
<th data-field="actions" class="text-center" style="width: 50px;"></th>
<th data-field="code">{% translate "Code" %}</th>
<th data-field="title">{% translate "Titre" %}</th>
<th data-field="begin_date">{% translate "Début" %}</th>
<th data-field="status">{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for interv in interventions %}
<tr>
<td class="text-center">
<a href="{% url 'interventions:interventions_index' %}{{ interv.id }}" class="btn btn-sm btn-light detail-btn" title="{% translate 'Détail' %}">
<i class="bi bi-info-circle"></i>
</a>
</td>
<td class="fw-bold">{{ interv.code }}</td>
<td>{{ interv.title }}</td>
<td>{{ interv.begin_time|date:"d-m-Y H:i"|default:"-" }}</td>
<td>
<span class="badge bg-secondary">{{ interv.get_status_display }}</span>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted py-4">
<i class="bi bi-tools mb-2 d-block fs-3 text-muted"></i>
{% translate "Aucune intervention enregistrée sur cette localisation ou ses aménagements." %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- TAB 5: Documents -->
<div class="tab-pane fade" id="documents-pane" role="tabpanel" aria-labelledby="documents-tab">
{% if documents %}
<div class="row g-3">
{% for document in documents %}
<div class="col-6 col-sm-4 col-md-3">
<div class="card h-100 shadow-sm border p-2 text-center">
<a href="{{ document.url }}" target="_blank" class="text-decoration-none text-dark">
<div class="img-thumbnail d-flex align-items-center justify-content-center mx-auto mb-2" style="width: 100%; height: 90px; overflow: hidden; background: #f8fafc;">
{% if document.thumbnail_url %}
<img src="{{ document.thumbnail_url }}" alt="{{ document.title }}" style="max-height: 100%; max-width: 100%; object-fit: contain;">
{% else %}
<i class="bi bi-file-earmark-text fs-2 text-secondary"></i>
{% endif %}
</div>
<div class="fw-semibold small text-truncate" title="{{ document.title }}">{{ document.title }}</div>
{% if document.tag %}
<span class="badge bg-primary-subtle text-primary border my-1" style="font-size: 0.7rem;">{{ document.tag }}</span>
{% endif %}
{% if document.date %}
<div class="text-muted" style="font-size: 0.7rem;">{{ document.date|date:"d/m/Y" }}</div>
{% endif %}
</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center text-muted py-5">
<i class="bi bi-folder2-open mb-2 d-block fs-2 text-muted"></i>
{% translate "Aucun document associé à cette localisation." %}
</div>
{% endif %}
</div>
</div>
</div>
<!-- Right Column: Interactive Map -->
<div class="col-lg-5 col-xl-5">
<div class="card shadow-sm border-0 map-card-wrapper">
<div class="card-header bg-white py-2 px-3 d-flex align-items-center justify-content-between flex-wrap gap-2 border-bottom">
<span class="fw-bold text-dark d-flex align-items-center">
<i class="bi bi-geo-alt-fill text-success me-2"></i>{% translate "Carte & Aménagements" %}
</span>
<!-- Map Layer Controls / Legend -->
<div class="d-flex align-items-center gap-2 flex-wrap">
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-surfaces" checked onchange="toggleNatureLayer('surfaces', this.checked)">
<label class="form-check-label" for="toggle-surfaces"><span class="badge bg-success" style="width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Surfaces" %}</label>
</div>
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-trees" checked onchange="toggleNatureLayer('trees', this.checked)">
<label class="form-check-label" for="toggle-trees"><span class="badge bg-success" style="border-radius: 50%; width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Arbres" %}</label>
</div>
<div class="form-check form-check-inline m-0 map-layer-pill">
<input class="form-check-input" type="checkbox" id="toggle-equipments" checked onchange="toggleNatureLayer('shrubs', this.checked); toggleNatureLayer('benches', this.checked)">
<label class="form-check-label" for="toggle-equipments"><span class="badge bg-warning" style="border-radius: 50%; width: 8px; height: 8px; padding: 0; display: inline-block;"></span> {% translate "Autres" %}</label>
</div>
</div>
</div>
<div class="card-body p-0 position-relative">
<div id="map"></div>
</div>
</div>
</div>
</div>
</div>
{% endblock content %}
{% block script %}
{{ feature_lon|json_script:"lon_value" }}
{{ feature_lat|json_script:"lat_value" }}
{{ section_geojson|json_script:"section_geojson_value" }}
{{ assets_geojson|json_script:"assets_geojson_value" }}
<script>
const feature_lon = JSON.parse(document.getElementById("lon_value").textContent);
const feature_lat = JSON.parse(document.getElementById("lat_value").textContent);
const section_geojson = JSON.parse(document.getElementById("section_geojson_value").textContent);
const assets_geojson = JSON.parse(document.getElementById("assets_geojson_value").textContent);
</script>
<script src="{% static 'common/turf.min.js' %}"></script>
<script src="{% static 'common/layer-switcher-control.js' %}"></script>
<script src="{% static 'assets/nature_location_detail.js' %}"></script>
{% endblock script %}

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more