Initial commit of clean Core

This commit is contained in:
Karl Determe 2026-07-22 14:48:40 +02:00
commit 8c83ff764c
1924 changed files with 559868 additions and 0 deletions

43
.dockerignore Normal file
View file

@ -0,0 +1,43 @@
# Git
.git
.gitignore
# Environments and Virtualenvs
venv/
venv_old/
.env
.env.local
.env.production
.env.development
.env.staging
# Python
**/__pycache__/
**/*.pyc
**/*.pyo
**/*.pyd
.Python
**/*.egg-info/
.eggs/
.pytest_cache/
# Databases
*.sqlite3
# Django media & static
streetup/media/
streetup/private_media/
streetup/static/
streetup/django.log
*.log
# VS Code / Editor config
.vscode/
.idea/
*.swp
*~
# Docker files
Dockerfile
docker-compose.yml
.dockerignore

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

42
.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
.env
.venv
venv/
venv_old/
env/
node_modules/
.well-known/
*.log
*.md
!streetup/common/help_docs/
!streetup/common/help_docs/**/*.md
media/
private_media/
/static/
android/
backups/
exports/
external_files/
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
streetup/.env
scripts/
carl_export/
carl_export.*
streetup/static/
deploy.sh
generated_passwords.txt
streetup/generated_passwords.txt
scratch/
Caddyfile
Caddyfile.prod
.vscode/

111
Dockerfile Normal file
View file

@ -0,0 +1,111 @@
# Utilisation de l'image officielle Miniforge (basée sur Debian)
FROM condaforge/miniforge3:26.3.2-3
# Éviter l'écriture de fichiers .pyc et forcer le buffering immédiat des logs
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
WORKDIR /app
# Installation des paquets système requis pour le runtime (ex: OpenGL et GLib pour OpenCV/YOLO)
RUN apt-get update && apt-get install -y --no-install-recommends \
gnupg \
ca-certificates \
curl \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg \
&& (. /etc/os-release && echo "deb http://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main" > /etc/apt/sources.list.d/pgdg.list) \
&& apt-get update && apt-get install -y --no-install-recommends \
libgl1 \
libglib2.0-0 \
fonts-dejavu \
fonts-liberation \
postgresql-client-18 \
rsync \
openssh-client \
&& rm -rf /var/lib/apt/lists/*
# 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 streetup-env -y -c conda-forge \
python=3.12 \
gdal \
geos \
proj \
poppler \
&& mamba clean -afy
# Activation par défaut de l'environnement Conda dans le conteneur
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/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
COPY requirements/ /app/requirements/
RUN pip install --no-cache-dir -r requirements/base.txt && \
if [ "$INSTALL_AI_LIBS" = "true" ]; then \
pip install --no-cache-dir -r requirements/ai.txt; \
fi
# 1. Copie des répertoires volumineux et statiques (très bonne mise en cache)
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 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 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 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 streetup/*.py /app/
COPY streetup/generated_passwords.txt* /app/
# Exposer le port par défaut
EXPOSE 8000
# Entrypoint de production : Gunicorn avec workers/timeout configurables via variables d'environnement
# GUNICORN_WORKERS (défaut: 4) et GUNICORN_TIMEOUT (défaut: 120) peuvent être surchargés dans le docker-compose
# En développement, "command: python manage.py runserver..." dans docker-compose.yml court-circuite cet entrypoint
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh
CMD ["/docker-entrypoint.sh"]

859
doc/DOCUMENTATION.html Normal file
View file

@ -0,0 +1,859 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Documentation StreetUp</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
color: #333;
background-color: #f5f5f5;
}
.container {
background-color: white;
padding: 40px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
h1 {
color: #2c3e50;
border-bottom: 3px solid #3498db;
padding-bottom: 10px;
margin-top: 30px;
}
h2 {
color: #34495e;
border-bottom: 2px solid #ecf0f1;
padding-bottom: 8px;
margin-top: 25px;
}
h3 {
color: #546e7a;
margin-top: 20px;
}
h4, h5, h6 {
color: #607d8b;
margin-top: 15px;
}
code {
background-color: #f4f4f4;
padding: 2px 6px;
border-radius: 3px;
font-family: "Courier New", Courier, monospace;
color: #e74c3c;
}
pre {
background-color: #2d2d2d;
color: #f8f8f2;
padding: 15px;
border-radius: 5px;
overflow-x: auto;
margin: 15px 0;
}
pre code {
background-color: transparent;
color: inherit;
padding: 0;
}
table {
border-collapse: collapse;
width: 100%;
margin: 20px 0;
}
table tr {
border-bottom: 1px solid #ddd;
}
table td, table th {
padding: 12px;
text-align: left;
}
table tr:nth-child(even) {
background-color: #f9f9f9;
}
table tr:first-child {
background-color: #3498db;
color: white;
font-weight: bold;
}
ul {
margin: 10px 0;
padding-left: 30px;
}
li {
margin: 5px 0;
}
hr {
border: none;
border-top: 2px solid #ecf0f1;
margin: 30px 0;
}
a {
color: #3498db;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
strong {
color: #2c3e50;
}
.toc {
background-color: #ecf0f1;
padding: 20px;
border-radius: 5px;
margin: 20px 0;
}
</style>
</head>
<body>
<div class="container">
<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>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>
<li><strong>Ouvrages d'art</strong> (Structures - tunnels, ponts, viaducs)</li>
<li><strong>Espaces verts</strong> (Nature)</li>
<li><strong>Centres de contrôle</strong> (Control Centers)</li>
<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="">streetup/
├── streetup/ # Configuration principale Django
│ ├── settings.py # Paramètres de l'application
│ ├── urls.py # Routes principales
│ └── wsgi.py # Point d'entrée WSGI
│
├── common/ # Module commun (thématiques, utilisateurs, documents)
├── assets/ # Gestion des actifs (équipements de voirie)
├── interventions/ # Gestion des interventions de maintenance
├── contracts/ # Gestion des contrats et commandes
├── projects/ # Gestion des projets
├── observations/ # Signalements et observations terrain
├── controls/ # Contrôles et rapports de chantier
├── documents/ # Gestion documentaire avancée
├── stock/ # Gestion du stock et des entrepôts
├── api/ # API REST
├── home/ # Page d'accueil
└── mobile/ # Interface mobile
</code></pre></p><p>---</p><p><h2>Modules Django</h2></p><p><h3>1. Module <code>common</code> - Données communes</h3></p><p>Le module <code>common</code> contient les modèles partagés par l'ensemble de l'application.</p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>Thematic</code></td><td>Thématiques (feux, éclairage, signalisation, etc.)</td></tr>
<tr><td><code>Role</code></td><td>Rôles utilisateurs (manager, technicien, admin)</td></tr>
<tr><td><code>UserConfig</code></td><td>Configuration utilisateur et permissions</td></tr>
<tr><td><code>UserThematics</code></td><td>Accès utilisateur par thématique</td></tr>
<tr><td><code>UserContractAccess</code></td><td>Accès utilisateur par contrat</td></tr>
<tr><td><code>Document</code></td><td>Classe abstraite pour les documents</td></tr>
<tr><td><code>Municipality</code></td><td>Communes</td></tr>
<tr><td><code>AdrStreet</code></td><td>Noms de rues</td></tr>
<tr><td><code>AdrPoint</code></td><td>Points d'adresse</td></tr></p><p><h4>Liste des thématiques</h4></p><p><tr><td>Nom (FR)</td><td>Nom (NL)</td><td>Code</td><td>Icône Bootstrap</td><td>Couleur</td></tr>
<tr><td>----------</td><td>----------</td><td>------</td><td>-----------------</td><td>---------</td></tr>
<tr><td>Centres de contrôles</td><td>Control centers</td><td><code>controlcenters</code></td><td>bi-building-gear</td><td>#000000</td></tr>
<tr><td>Eclairage public</td><td>Openbare verlichting</td><td><code>publiclighting</code></td><td>bi-lightbulb</td><td>#000000</td></tr>
<tr><td>Feux tricolores</td><td>Verkeerslichten</td><td><code>trafficlights</code></td><td>bi-stoplights</td><td>#000000</td></tr>
<tr><td>ITS</td><td>ITS</td><td><code>its</code></td><td>bi-lightning</td><td>#000000</td></tr>
<tr><td>Nature</td><td>Natuur</td><td><code>nature</code></td><td>bi-suit-club</td><td>#00ff00</td></tr>
<tr><td>Ouvrages d'art</td><td>Kunstwerken</td><td><code>structures</code></td><td>bi-rainbow</td><td>#000000</td></tr>
<tr><td>Patrimoine artistique</td><td>Artistiek erfgoed</td><td><code>artworks</code></td><td>bi-bank</td><td>#0000aa</td></tr>
<tr><td>Roads</td><td>Roads</td><td><code>roads</code></td><td>bi-fullscreen-exit</td><td>#000000</td></tr>
<tr><td>Signalisation</td><td>Signalisatie</td><td><code>sign</code></td><td>bi-signpost</td><td>#000000</td></tr></p><p><h3>2. Module <code>assets</code> - Gestion des actifs</h3></p><p>Le module <code>assets</code> gère tous les équipements et infrastructures de voirie, organisés par sous-domaine.</p><p><h4>Classes abstraites de base</h4></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>AbstractLocation</code></td><td>Localisation abstraite (code, nom, coordonnées)</td></tr>
<tr><td><code>AbstractAssetModel</code></td><td>Modèle d'équipement (marque, catégorie)</td></tr>
<tr><td><code>AbstractAsset</code></td><td>Équipement abstrait (statut, dates d'installation)</td></tr>
<tr><td><code>AbstractGeoAsset</code></td><td>Équipement géolocalisé</td></tr>
<tr><td><code>AssetCategory</code></td><td>Catégorie d'équipement</td></tr></p><p><h4>Feux de signalisation (Traffic Lights)</h4></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>TrafficLightIntersection</code></td><td>Carrefour à feux</td></tr>
<tr><td><code>TrafficLightPole</code></td><td>Poteau de feu</td></tr>
<tr><td><code>TrafficLightPoleModel</code></td><td>Modèle de poteau</td></tr>
<tr><td><code>TrafficLightLantern</code></td><td>Lanterne</td></tr>
<tr><td><code>TrafficLightLanternModel</code></td><td>Modèle de lanterne</td></tr>
<tr><td><code>TrafficLightCable</code></td><td>Câble</td></tr>
<tr><td><code>TrafficLightCableModel</code></td><td>Modèle de câble</td></tr>
<tr><td><code>TrafficLightController</code></td><td>Contrôleur</td></tr>
<tr><td><code>TrafficLightControllerModel</code></td><td>Modèle de contrôleur</td></tr>
<tr><td><code>TrafficLightDetector</code></td><td>Détecteur</td></tr>
<tr><td><code>TrafficLightDetectorModel</code></td><td>Modèle de détecteur</td></tr>
<tr><td><code>TrafficLightContract</code></td><td>Contrat lié à un carrefour</td></tr>
<tr><td><code>TrafficLightIntersectionDocument</code></td><td>Document associé</td></tr></p><p><h4>Éclairage public (Public Lighting)</h4></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>PublicLightingStreet</code></td><td>Rue d'éclairage public</td></tr>
<tr><td><code>PublicLightingPole</code></td><td>Poteau d'éclairage</td></tr>
<tr><td><code>PublicLightingPoleModel</code></td><td>Modèle de poteau</td></tr>
<tr><td><code>PublicLightingLightFixture</code></td><td>Luminaire</td></tr>
<tr><td><code>PublicLightingLightFixtureModel</code></td><td>Modèle de luminaire</td></tr>
<tr><td><code>PublicLightingStreetDocument</code></td><td>Document associé</td></tr></p><p><h4>Signalisation routière (Road Signs)</h4></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>SignStreet</code></td><td>Rue pour la signalisation</td></tr>
<tr><td><code>SignPole</code></td><td>Poteau de signalisation</td></tr>
<tr><td><code>SignPoleModel</code></td><td>Modèle de poteau</td></tr>
<tr><td><code>SignPanel</code></td><td>Panneau de signalisation</td></tr>
<tr><td><code>SignPanelModel</code></td><td>Modèle de panneau</td></tr>
<tr><td><code>SignPanelType</code></td><td>Type de panneau</td></tr>
<tr><td><code>SignStreetDocument</code></td><td>Document associé</td></tr></p><p><h4>Ouvrages d'art (Structures)</h4></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>Structure</code></td><td>Ouvrage d'art (tunnel, pont, viaduc...)</td></tr>
<tr><td><code>StructureLocation</code></td><td>Localisation dans l'ouvrage</td></tr>
<tr><td><code>StructureGeoAsset</code></td><td>Équipement géolocalisé dans l'ouvrage</td></tr>
<tr><td><code>StructureAssetModel</code></td><td>Modèle d'équipement</td></tr>
<tr><td><code>StructureContract</code></td><td>Contrat lié à l'ouvrage</td></tr>
<tr><td><code>StructureDocument</code></td><td>Document associé</td></tr></p><p><h4>Nature / Espaces verts</h4></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>NatureLocation</code></td><td>Zone naturelle</td></tr>
<tr><td><code>NatureGreenSurface</code></td><td>Surface végétale</td></tr>
<tr><td><code>NatureTree</code></td><td>Arbre</td></tr>
<tr><td><code>NatureAssetModel</code></td><td>Modèle d'équipement nature</td></tr>
<tr><td><code>NatureGreenSurfaceDocument</code></td><td>Document associé</td></tr></p><p><h4>Centres de contrôle (Control Centers)</h4></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>ControlCenter</code></td><td>Centre de contrôle</td></tr>
<tr><td><code>ControlCenterLocation</code></td><td>Localisation dans le centre de contrôle</td></tr>
<tr><td><code>ControlCenterGeoAsset</code></td><td>Équipement géolocalisé</td></tr>
<tr><td><code>ControlCenterAssetModel</code></td><td>Modèle d'équipement</td></tr>
<tr><td><code>ControlCenterContract</code></td><td>Contrat lié au centre</td></tr>
<tr><td><code>ControlCenterGeoAssetContract</code></td><td>Contrat lié à un équipement</td></tr>
<tr><td><code>ControlCenterDocument</code></td><td>Document du centre</td></tr>
<tr><td><code>ControlCenterAssetDocument</code></td><td>Document d'équipement</td></tr></p><p><h4>Patrimoine artistique (Artworks)</h4></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>ArtworkStreet</code></td><td>Rue pour le patrimoine artistique</td></tr>
<tr><td><code>ArtworkMonument</code></td><td>Monument (statue, sculpture, structure...)</td></tr>
<tr><td><code>ArtworkFountain</code></td><td>Fontaine artistique</td></tr>
<tr><td><code>ArtworkFountainDocument</code></td><td>Document de fontaine</td></tr>
<tr><td><code>ArtworkMonumentDocument</code></td><td>Document de monument</td></tr></p><p><h4>ITS - Systèmes de transport intelligents (Intelligent Transport Systems)</h4></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>ITSLocation</code></td><td>Localisation ITS</td></tr>
<tr><td><code>ITSGeoAsset</code></td><td>Équipement ITS géolocalisé</td></tr>
<tr><td><code>ITSAssetModel</code></td><td>Modèle d'équipement ITS</td></tr>
<tr><td><code>ITSLocationDocument</code></td><td>Document de localisation</td></tr>
<tr><td><code>ITSLocationContract</code></td><td>Contrat lié à une localisation</td></tr>
<tr><td><code>ITSGeoAssetContract</code></td><td>Contrat lié à un équipement</td></tr></p><p><h4>Chaussées, trottoirs et mobilier urbain (Roads)</h4></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>RoadStreet</code></td><td>Rue</td></tr>
<tr><td><code>Roadway</code></td><td>Chaussée</td></tr>
<tr><td><code>RoadwayModel</code></td><td>Modèle de chaussée</td></tr>
<tr><td><code>RoadwayFunction</code></td><td>Fonctions de la chaussée (piéton, vélo, transport)</td></tr>
<tr><td><code>RoadSidewalk</code></td><td>Trottoir</td></tr>
<tr><td><code>RoadSidewalkModel</code></td><td>Modèle de trottoir</td></tr>
<tr><td><code>RoadFurniture</code></td><td>Mobilier urbain</td></tr>
<tr><td><code>RoadFurnitureModel</code></td><td>Modèle de mobilier urbain</td></tr>
<tr><td><code>RoadStreetDocument</code></td><td>Document associé</td></tr></p><p><h3>3. Module <code>interventions</code> - Gestion des interventions</h3></p><p>Ce module gère tout le cycle de vie des interventions de maintenance.</p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>Intervention</code></td><td>Intervention (corrective, préventive, améliorative)</td></tr>
<tr><td><code>InterventionSequence</code></td><td>Numérotation automatique des interventions</td></tr>
<tr><td><code>InterventionTimeLine</code></td><td>Historique des événements</td></tr>
<tr><td><code>InterventionPlanificationTimeLine</code></td><td>Planification</td></tr>
<tr><td><code>InterventionRealizationTimeLine</code></td><td>Réalisation</td></tr>
<tr><td><code>InterventionOccupation</code></td><td>Temps de travail</td></tr>
<tr><td><code>InterventionNote</code></td><td>Notes et commentaires</td></tr>
<tr><td><code>InterventionDocument</code></td><td>Documents associés</td></tr>
<tr><td><code>InterventionAsset</code></td><td>Lien vers les équipements</td></tr>
<tr><td><code>InterventionLocation</code></td><td>Lien vers les localisations</td></tr>
<tr><td><code>InterventionLink</code></td><td>Liens entre interventions</td></tr>
<tr><td><code>InterventionContractPost</code></td><td>Postes de contrat utilisés</td></tr>
<tr><td><code>InterventionPostPeriodQuantity</code></td><td>Quantités validées par période</td></tr>
<tr><td><code>Symptom</code></td><td>Symptôme/problème signalé</td></tr>
<tr><td><code>SymptomRecommendation</code></td><td>Recommandation associée au symptôme</td></tr>
<tr><td><code>ReflexSheet</code></td><td>Fiche réflexe</td></tr>
<tr><td><code>SourceCategory</code></td><td>Source de l'intervention</td></tr></p><p><h4>Opérations de maintenance préventive</h4></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>OperationTemplate</code></td><td>Template d'opérations</td></tr>
<tr><td><code>OperationTemplateItem</code></td><td>Item du template</td></tr>
<tr><td><code>Operation</code></td><td>Opération à réaliser</td></tr>
<tr><td><code>OperationStatusSet</code></td><td>Ensemble de statuts possibles</td></tr>
<tr><td><code>OperationStatus</code></td><td>Statut d'opération</td></tr>
<tr><td><code>MeasurementDefinition</code></td><td>Définition de mesure</td></tr></p><p><h3>4. Module <code>contracts</code> - Gestion des contrats</h3></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>Company</code></td><td>Société/Organisme</td></tr>
<tr><td><code>CompanyTeam</code></td><td>Équipe</td></tr>
<tr><td><code>CompanyMember</code></td><td>Membre d'équipe</td></tr>
<tr><td><code>Contract</code></td><td>Contrat de maintenance</td></tr>
<tr><td><code>ContractPost</code></td><td>Poste de contrat (bordereau de prix)</td></tr>
<tr><td><code>ContractOrder</code></td><td>Commande</td></tr>
<tr><td><code>ContractOrderQuote</code></td><td>Devis</td></tr>
<tr><td><code>ContractOrderQuoteItem</code></td><td>Ligne de devis</td></tr>
<tr><td><code>ContractOrderDocument</code></td><td>Document de commande</td></tr>
<tr><td><code>ContractOrderProgressState</code></td><td>État d'avancement</td></tr>
<tr><td><code>ContractOrderProgressStateItem</code></td><td>Item d'état d'avancement</td></tr></p><p><h3>5. Module <code>projects</code> - Gestion des projets</h3></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>Project</code></td><td>Projet</td></tr>
<tr><td><code>ProjectSequence</code></td><td>Numérotation automatique</td></tr>
<tr><td><code>ProjectPhase</code></td><td>Phase de projet</td></tr>
<tr><td><code>ProjectMember</code></td><td>Membre du projet</td></tr>
<tr><td><code>ProjectIntervention</code></td><td>Intervention liée au projet</td></tr>
<tr><td><code>ProjectDocument</code></td><td>Document du projet</td></tr></p><p><h3>6. Module <code>observations</code> - Observations terrain</h3></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>Observation</code></td><td>Observation/signalement</td></tr>
<tr><td><code>ObservationPhoto</code></td><td>Photo de l'observation</td></tr>
<tr><td><code>ObservationComment</code></td><td>Commentaire</td></tr></p><p><h3>7. Module <code>controls</code> - Contrôles et rapports</h3></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>Control</code></td><td>Contrôle/rapport de chantier</td></tr>
<tr><td><code>Category</code></td><td>Catégorie de contrôle</td></tr>
<tr><td><code>ControlCheck</code></td><td>Point de contrôle</td></tr>
<tr><td><code>DocumentCheck</code></td><td>Vérification documentaire</td></tr>
<tr><td><code>CheckList</code></td><td>Liste de contrôle</td></tr>
<tr><td><code>CheckListControlCheck</code></td><td>Item de checklist</td></tr>
<tr><td><code>CheckListDocumentCheck</code></td><td>Document de checklist</td></tr>
<tr><td><code>ControlReport</code></td><td>Rapport généré</td></tr>
<tr><td><code>ControlReferencePlan</code></td><td>Plan de référence</td></tr>
<tr><td><code>Remark</code></td><td>Remarque</td></tr>
<tr><td><code>RemarkImage</code></td><td>Image de remarque</td></tr>
<tr><td><code>RemarkTag</code></td><td>Tag de remarque</td></tr>
<tr><td><code>ControlReportTemplate</code></td><td>Template de rapport</td></tr>
<tr><td><code>ControlQuantity</code></td><td>Quantité dans une remarque</td></tr></p><p><h3>8. Module <code>documents</code> - Gestion documentaire avancée</h3></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>DocumentType</code></td><td>Type de document</td></tr>
<tr><td><code>DocumentTag</code></td><td>Tag de document</td></tr>
<tr><td><code>DocumentFolder</code></td><td>Dossier</td></tr>
<tr><td><code>ManagedDocument</code></td><td>Document géré</td></tr>
<tr><td><code>DocumentVersion</code></td><td>Version de document</td></tr>
<tr><td><code>DocumentValidation</code></td><td>Validation de document</td></tr>
<tr><td><code>DocumentShare</code></td><td>Partage de document</td></tr>
<tr><td><code>DocumentFolderShare</code></td><td>Partage de dossier</td></tr>
<tr><td><code>DocumentAccessLink</code></td><td>Lien d'accès</td></tr>
<tr><td><code>DocumentFolderAccessLink</code></td><td>Lien d'accès dossier</td></tr>
<tr><td><code>DocumentAttachment</code></td><td>Pièce jointe</td></tr>
<tr><td><code>DocumentFolderAttachment</code></td><td>Attachement de dossier</td></tr></p><p><h3>9. Module <code>stock</code> - Gestion du stock</h3></p><p><tr><td>Modèle</td><td>Description</td></tr>
<tr><td>--------</td><td>-------------</td></tr>
<tr><td><code>Warehouse</code></td><td>Entrepôt</td></tr>
<tr><td><code>WarehouseLocation</code></td><td>Emplacement dans l'entrepôt</td></tr>
<tr><td><code>Category</code></td><td>Catégorie de produit</td></tr>
<tr><td><code>Product</code></td><td>Produit</td></tr>
<tr><td><code>Stock</code></td><td>Quantité en stock</td></tr>
<tr><td><code>StockMovement</code></td><td>Mouvement de stock</td></tr>
<tr><td><code>Inventory</code></td><td>Inventaire</td></tr>
<tr><td><code>InventoryItem</code></td><td>Ligne d'inventaire</td></tr>
<tr><td><code>Supplier</code></td><td>Fournisseur</td></tr>
<tr><td><code>PurchaseOrder</code></td><td>Commande d'achat</td></tr>
<tr><td><code>PurchaseOrderItem</code></td><td>Ligne de commande</td></tr>
<tr><td><code>StockAlert</code></td><td>Alerte de stock</td></tr>
<tr><td><code>PreparationOrder</code></td><td>Ordre de préparation</td></tr>
<tr><td><code>PreparationOrderItem</code></td><td>Ligne de préparation</td></tr></p><p>---</p><p><h2>Schéma des modèles et relations</h2></p><p><h3>Diagramme des relations principales</h3></p><p><pre><code class="">┌─────────────────────────────────────────────────────────────────────────────────────┐
│ THÉMATIQUES │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Feux de │ │ Éclairage │ │Signalisation│ │ Ouvrages │ │
│ │signalisation│ │ public │ │ routière │ │ d'art │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │Intersection │ │ Street │ │ Street │ │ Structure │ │
│ │ (Location) │ │ (Location) │ │ (Location) │ │ (Location) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │ │
│ ▼ ▼ ▼ ▼ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ Pôles │ │ Pôles │ │ Pôles │ │ Assets │ │
│ │ Lanternes │ │ Luminaires │ │ Panneaux │ │ │ │
│ │ Câbles │ │ │ │ │ │ │ │
│ │ Contrôleurs │ │ │ │ │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
└─────────┼─────────────────┼─────────────────┼─────────────────┼──────────────────────┘
│ │ │ │
└────────────┬────┴─────────────────┴─────────────────┘
│
▼
┌───────────────────────┐
│ INTERVENTIONS │
│ │
│ - Correctives │
│ - Préventives │
│ - Amélioratives │
└───────────┬───────────┘
│
┌───────────┴───────────┐
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ CONTRATS │ │ PROJETS │
│ │ │ │
│ - Commandes │ │ - Phases │
│ - Postes │ │ - Membres │
│ - Devis │ │ - Documents │
└────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ OBSERVATIONS │
│ │
│ - Photos │
│ - Commentaires │
└─────────────────┘
</code></pre></p><p><h3>Relations détaillées par domaine</h3></p><p><h4>1. Relations Thematic (Thématiques)</h4></p><p><pre><code class="">Thematic
│
├──► AssetCategory (1:N)
│ │
│ └──► Assets, Symptoms, etc.
│
├──► Project (M:N via thematics)
│
├──► Control (M:N via thematics)
│
├──► DocumentFolder (M:N)
│
├──► Intervention (1:N)
│
├──► Observation (1:N)
│
├──► Symptom (1:N)
│
└──► OperationTemplate (1:N)
</code></pre></p><p><h4>2. Relations Company (Société)</h4></p><p><pre><code class="">Company
│
├──► Contract (1:N) ───► ContractPost (1:N)
│ │ │
│ │ └──► InterventionContractPost
│ │
│ ├──► ContractOrder (1:N)
│ │ │
│ │ ├──► ContractOrderQuote
│ │ │ │
│ │ │ └──► ContractOrderQuoteItem (1:N)
│ │ │
│ │ ├──► ContractOrderDocument (1:N)
│ │ │
│ │ └──► ContractOrderProgressState (1:N)
│ │
│ └──► Intervention (1:N)
│
├──► CompanyTeam (1:N)
│ │
│ └──► CompanyMember (1:N)
│ │
│ └──► Intervention (assigned_member)
│ │
│ └──► InterventionOccupation (1:N)
│
└──► Symptom (1:N - provider)
</code></pre></p><p><h4>3. Relations Intervention</h4></p><p><pre><code class="">Intervention
│
├──► InterventionTimeLine (1:N) - Historique des statuts
│
├──► InterventionPlanificationTimeLine (1:N) - Planification
│
├──► InterventionRealizationTimeLine (1:N) - Réalisation
│
├──► InterventionOccupation (1:N) - Temps de travail
│
├──► InterventionNote (1:N) - Notes
│
├──► InterventionDocument (1:N) - Documents
│
├──► InterventionContractPost (1:N) - Postes de contrat
│ │
│ └──► InterventionPostPeriodQuantity (1:N)
│
├──► InterventionAsset (1:N) - GenericForeignKey vers assets
│
├──► InterventionLocation (1:N) - GenericForeignKey vers locations
│
├──► InterventionLink (M:N) - Liens entre interventions
│
├──► Operation (1:N) - Opérations préventives
│
├──► Observation (1:N) - Observations liées
│
└──► Control (1:N) - Contrôles liés
</code></pre></p><p><h4>4. Relations Operations (Maintenance préventive)</h4></p><p><pre><code class="">OperationTemplate
│
├──► OperationTemplateItem (1:N)
│ │
│ ├──► OperationStatusSet (N:1)
│ │
│ └──► MeasurementDefinition (N:1)
│
└──► Intervention.equipment_operation_templates (M:N)</p><p>Operation
│
├──► Intervention (N:1)
│
├──► OperationStatusSet (N:1)
│
├──► OperationStatus (N:1)
│
├──► MeasurementDefinition (N:1)
│
└──► Asset (GenericForeignKey)
</code></pre></p><p><h4>5. Relations Traffic Lights (Feux de signalisation)</h4></p><p><pre><code class="">TrafficLightIntersection
│
├──► TrafficLightPole (1:N)
│ │
│ ├──► TrafficLightLantern (1:N)
│ │
│ └──► TrafficLightDetector (1:N)
│
├──► TrafficLightCable (1:N)
│
├──► TrafficLightController (1:N)
│
├──► TrafficLightIntersectionDocument (1:N)
│
├──► TrafficLightContract (1:N)
│
└──► Municipality (N:1)
</code></pre></p><p><h4>6. Relations Project (Projets)</h4></p><p><pre><code class="">Project
│
├──► ProjectPhase (1:N)
│ │
│ ├──► ProjectIntervention (1:N)
│ │
│ └──► Control (1:N)
│
├──► ProjectMember (1:N) ───► User
│
├──► ProjectDocument (1:N)
│
├──► Thematic (M:N)
│
└──► ContractOrder (1:N via project)
</code></pre></p><p><h4>7. Relations Control (Contrôles)</h4></p><p><pre><code class="">Control
│
├──► ControlCheck (1:N)
│
├──► DocumentCheck (1:N)
│
├──► ControlReport (1:N)
│
├──► ControlReferencePlan (1:N)
│ │
│ └──► Remark (1:N)
│ │
│ ├──► RemarkImage (1:N)
│ │
│ ├──► RemarkTag (1:N)
│ │
│ └──► ControlQuantity (1:N)
│
├──► ControlReportTemplate (1:N)
│
├──► Project (N:1)
│
├──► ProjectPhase (N:1)
│
└──► Intervention (N:1)
</code></pre></p><p><h4>8. Relations Documents</h4></p><p><pre><code class="">ManagedDocument
│
├──► DocumentVersion (1:N)
│ │
│ └──► DocumentValidation (1:N)
│
├──► DocumentType (M:N)
│
├──► DocumentTag (M:N)
│
├──► DocumentFolder (M:N)
│
├──► DocumentShare (1:N) ───► User
│
├──► DocumentAccessLink (1:N)
│
└──► DocumentAttachment (1:N) - GenericForeignKey</p><p>DocumentFolder
│
├──► DocumentFolderShare (1:N) ───► User
│
├──► DocumentFolderAccessLink (1:N)
│
├──► DocumentFolderAttachment (1:N) - GenericForeignKey
│
├──► Thematic (M:N)
│
└──► parent_folders (M:N - self-referential)
</code></pre></p><p><h4>9. Relations Stock</h4></p><p><pre><code class="">Warehouse
│
├──► WarehouseLocation (1:N)
│ │
│ ├──► Stock (1:N)
│ │
│ ├──► StockMovement (N:1 source/destination)
│ │
│ └──► InventoryItem (N:1)
│
├──► Inventory (1:N)
│ │
│ └──► InventoryItem (1:N)
│
├──► PurchaseOrder (1:N)
│ │
│ └──► PurchaseOrderItem (1:N)
│
└──► StockAlert (1:N)</p><p>Product
│
├──► Category (N:1)
│
├──► Stock (1:N)
│
├──► StockMovement (1:N)
│
├──► PurchaseOrderItem (1:N)
│
├──► InventoryItem (1:N)
│
├──► StockAlert (1:N)
│
└──► PreparationOrderItem (1:N)
</code></pre></p><p>---</p><p><h2>Détail des modèles par module</h2></p><p><h3>Statuts d'intervention</h3></p><p><pre><code class="python">STATUS_CHOICES = {
'in_preparation': 'En préparation', # Ordre: 1
'to_be_approved': 'A approuver', # Ordre: 2
'to_be_planned': 'A planifier', # Ordre: 5
'to_be_processed': 'A traiter', # Ordre: 11
'in_progress': 'Démarré', # Ordre: 21
'on_pause': 'En pause', # Ordre: 22
'finished': 'Terminé', # Ordre: 25
'processed': 'A valider', # Ordre: 31
'to_be_corrected': 'A corriger', # Ordre: 41
'corrected': 'Corrigé', # Ordre: 45
'validated': 'Validé', # Ordre: 51
'invoiced': 'Facturé', # Ordre: 61
'closed': 'Finalisé', # Ordre: 71
'canceled': 'Annulé', # Ordre: 200
}
</code></pre></p><p><h3>Types de maintenance</h3></p><p><pre><code class="python">MAINTAIN_CHOICES = [
('corrective', 'Maintenance corrective'),
('preventive', 'Maintenance préventive'),
('ameliorative', 'Maintenance améliorative'),
]
</code></pre></p><p><h3>Types d'opération</h3></p><p><pre><code class="python">TYPE_CHOICES = [
('fixing', 'Dépannage'),
('repair', 'Réparation'),
('replace', 'Remplacement'),
('care', 'Entretien'),
('control', 'Contrôle'),
('inspection', 'Inspection'),
('build', 'Construction'),
]
</code></pre></p><p><h3>Priorités</h3></p><p><pre><code class="python">PRIORITY_CHOICES = {
'1': 'Urgent',
'2': 'Elevé',
'3': 'Normal',
'4': 'Bas',
}
</code></pre></p><p>---</p><p><h2>Flux de données</h2></p><p><h3>Flux de création d'intervention</h3></p><p><pre><code class="">┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Observation │────►│ Symptom │────►│ Intervention│
│ (terrain) │ │ (problème) │ │ (création) │
└─────────────┘ └─────────────┘ └──────┬──────┘
│
┌──────────────────────────┼──────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Contrat │ │ Location │ │ Company │
│ (liaison) │ │ (lieu) │ │ (assignation)│
└─────────────┘ └─────────────┘ └─────────────┘
</code></pre></p><p><h3>Flux de maintenance préventive</h3></p><p><pre><code class="">┌─────────────────┐
│OperationTemplate│
│ (templates) │
└────────┬────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐
│TemplateItems │────►│ Intervention │
│ (opérations) │ │ (préventive) │
└─────────────────┘ └────────┬────────┘
│
▼
┌─────────────────┐
│ Operations │
│ (à effectuer) │
└────────┬────────┘
│
┌────────────┼────────────┐
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Status │ │ Mesure │ │ Asset │
│(validation)│ │ (valeur) │ │ (lié) │
└───────────┘ └───────────┘ └───────────┘
</code></pre></p><p><h3>Flux de gestion des contrats</h3></p><p><pre><code class="">┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Company │────►│ Contract │────►│ ContractPost│
│ (entreprise)│ │ (marché) │ │ (bordereau) │
└─────────────┘ └──────┬──────┘ └──────┬──────┘
│ │
▼ │
┌─────────────┐ │
│ContractOrder│ │
│ (commande) │ │
└──────┬──────┘ │
│ │
┌────────────┼────────────┐ │
│ │ │ │
▼ ▼ ▼ │
┌───────────┐ ┌───────────┐ ┌───────────┐
│ Quote │ │ Documents │ │ Progress │
│ (devis) │ │ │ │ State │
└───────────┘ └───────────┘ └───────────┘
│
▼
┌─────────────┐
│Intervention │
│ContractPost │◄──────┘
└─────────────┘
</code></pre></p><p>---</p><p><h2>Diagrammes</h2></p><p><h3>Architecture globale</h3></p><p><pre><code class="">┌─────────────────────────────────────────────────────────────────────────────────────┐
│ STREETUP │
│ Système de gestion des actifs de voirie │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌───────────────────────────────────────────────────────────────────────────────┐ │
│ │ COUCHE PRÉSENTATION │ │
│ │ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │
│ │ │ Web UI │ │ Mobile UI │ │ API REST │ │ Reports │ │ │
│ │ │ (Django) │ │ (Django) │ │ (Django) │ │ (PDF) │ │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────────────────────────────────────────────────────────┐ │
│ │ COUCHE MÉTIER │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐│ │
│ │ │ Assets │ │Interven- │ │ Contracts│ │ Projects │ │ Controls │ │ Stock ││ │
│ │ │ │ │ tions │ │ │ │ │ │ │ │ ││ │
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘│ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
│ │ │Observa- │ │Documents │ │ Common │ │ │
│ │ │ tions │ │ │ │ │ │ │
│ │ └──────────┘ └──────────┘ └──────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────────────────────────────────────────────────────────────┐ │
│ │ COUCHE DONNÉES │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────────────┐ │ │
│ │ │ PostgreSQL + PostGIS │ │ │
│ │ │ │ │ │
│ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
│ │ │ │ Users │ │Thematics│ │ Assets │ │Interven-│ │Contracts│ │ │ │
│ │ │ │ │ │ │ │ + Geo │ │ tions │ │ │ │ │ │
│ │ │ └─────────┘ └─────────┘ └─────────┘ └─────────┘ └─────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────┘
</code></pre></p><p><h3>Hiérarchie des classes d'actifs</h3></p><p><pre><code class=""> AbstractLocation
│
┌───────────────────────┼───────────────────────┐
│ │ │
▼ ▼ ▼
TrafficLightIntersection PublicLightingStreet SignStreet
│ │ │
│ │ │
▼ ▼ ▼
AbstractAsset AbstractAsset AbstractAsset
│ │ │
├─► AbstractGeoAsset ├─► AbstractGeoAsset ├─► AbstractGeoAsset
│ │ │ │ │ │
│ ▼ │ ▼ │ ▼
│ TrafficLightPole │ PLPole/Fixture │ SignPole/Panel
│ │ │
└─► TrafficLightLantern │ │
└─► TrafficLightCable │ │
└─► TrafficLightController │
│ │
StructureLocation NatureLocation
│ │
▼ ▼
StructureGeoAsset NatureTree/Surface
</code></pre></p><p><h3>Cycle de vie d'une intervention</h3></p><p><pre><code class="">┌─────────────────┐
│ in_preparation │ ◄─── Création initiale
│ (En prép.) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ to_be_approved │ ◄─── Soumission pour approbation
│ (A approuver) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ to_be_planned │ ◄─── Approuvé, à planifier
│ (A planifier) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ to_be_processed │ ◄─── Planifié, à traiter
│ (A traiter) │
└────────┬────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐
│ in_progress │────►│ on_pause │
│ (Démarré) │◄────│ (En pause) │
└────────┬────────┘ └─────────────────┘
│
▼
┌─────────────────┐
│ finished │ ◄─── Travaux terminés
│ (Terminé) │
└────────┬────────┘
│
▼
┌─────────────────┐ ┌─────────────────┐
│ processed │────►│ to_be_corrected │
│ (A valider) │◄────│ (A corriger) │
└────────┬────────┘ └────────┬────────┘
│ │
│ ┌────────▼────────┐
│ │ corrected │
│ │ (Corrigé) │
│ └────────┬────────┘
│ │
▼◄──────────────────────┘
┌─────────────────┐
│ validated │ ◄─── Validation finale
│ (Validé) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ invoiced │ ◄─── Facturation
│ (Facturé) │
└────────┬────────┘
│
▼
┌─────────────────┐
│ closed │ ◄─── Clôture définitive
│ (Finalisé) │
└─────────────────┘</p><p> │
├───────────────────────────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ canceled │ ◄─── Peut être │ (Tout statut │
│ (Annulé) │ depuis │ précédent) │
└─────────────────┘ └─────────────────┘
</code></pre></p><p>---</p><p><h2>Notes techniques</h2></p><p><h3>Base de données</h3></p><p><ul>
<li><strong>PostgreSQL</strong> avec extension <strong>PostGIS</strong> pour les données géospatiales</li>
<li>Système de coordonnées: <strong>SRID 3812</strong> (Belgian Lambert 2008)</li>
<li>Support des géométries: Point, LineString, Polygon, MultiPolygon</li>
</ul></p><p><h3>Authentification et permissions</h3></p><p><ul>
<li>Système de rôles via <code>common.Role</code></li>
<li>Configuration utilisateur via <code>common.UserConfig</code></li>
<li>Accès par thématique via <code>UserThematics</code></li>
<li>Accès par contrat via <code>UserContractAccess</code></li>
</ul></p><p><h3>Gestion des fichiers</h3></p><p><ul>
<li>Documents stockés avec stockage privé (<code>PrivateMediaStorage</code>)</li>
<li>Génération automatique de vignettes</li>
<li>Support des PDFs et images</li>
</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>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
5. <strong>Gestion documentaire</strong> : Système complet de versionnement et de partage de documents
6. <strong>Intégration</strong> : API REST pour l'intégration avec d'autres systèmes
</div>
</body>
</html>

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

73
docker-compose.yml Normal file
View file

@ -0,0 +1,73 @@
services:
db:
image: postgis/postgis:18-3.6
container_name: streetup_db
restart: unless-stopped
ports:
- "5434:5432" # Mappé sur le port 5434 de l'hôte pour éviter tout conflit avec PostgreSQL local
environment:
POSTGRES_DB: streetup
POSTGRES_USER: admin
POSTGRES_PASSWORD: replace_by_your_pwd
volumes:
- 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 admin -d streetup" ]
interval: 10s
timeout: 5s
retries: 5
web:
build:
context: .
dockerfile: Dockerfile
args:
# Mettre "true" pour installer ultralytics, OpenCV et PyTorch dans l'image
INSTALL_AI_LIBS: "false"
container_name: streetup_web
restart: unless-stopped
command: python manage.py runserver 0.0.0.0:8000
ports:
- "8000:8000"
volumes:
- ./streetup:/app # Volume mount pour le hot-reload local
- streetup_media:/app/media
- streetup_private_media:/app/private_media
env_file:
- ./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/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:
db:
condition: service_healthy
caddy:
image: caddy:latest
container_name: streetup_caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- streetup_caddy_data:/data
- streetup_caddy_config:/config
depends_on:
- web
volumes:
streetup_db_data:
streetup_media:
streetup_private_media:
streetup_caddy_data:
streetup_caddy_config:

11
docker-entrypoint.sh Normal file
View file

@ -0,0 +1,11 @@
#!/bin/bash
# Point d'entrée du conteneur web en production.
# En développement, ce script est court-circuité par la directive
# "command: python manage.py runserver ..." dans docker-compose.yml.
set -e
exec gunicorn streetup.wsgi:application \
--bind 0.0.0.0:8000 \
--workers "${GUNICORN_WORKERS:-4}" \
--threads "${GUNICORN_THREADS:-2}" \
--timeout "${GUNICORN_TIMEOUT:-120}"

77
panoramax.env.example Normal file
View file

@ -0,0 +1,77 @@
# ==============================================================================
# Configuration d'environnement pour Panoramax (GeoVisio)
# ==============================================================================
# Copiez ce fichier sous le nom '.env' (dans le dossier /srv/panoramax/) sur votre
# serveur de production et renseignez les valeurs appropriées.
# ==============================================================================
# ------------------------------------------------------------------------------
# 1. Sécurité et Base de données
# ------------------------------------------------------------------------------
# Le mot de passe de la base de données PostGIS dédiée de Panoramax.
# Doit correspondre à la variable de la base de données pour que l'API et
# le worker puissent s'y connecter.
DB_PASSWORD=choisissez_un_mot_de_passe_robuste
# Clé secrète de Flask utilisée pour signer les sessions et jetons (tokens).
# Requis pour générer le jeton d'administration permanent.
# Générez une clé robuste avec : openssl rand -hex 32
FLASK_SECRET_KEY=choisissez_une_cle_secrete_aleatoire
# ------------------------------------------------------------------------------
# 2. Stockage S3 Privé (OVHcloud Roubaix - rbx)
# ------------------------------------------------------------------------------
# Identifiants de l'utilisateur ayant le rôle 'ObjectStore operator' sur OVHcloud.
# IMPORTANT : Le bucket S3 doit être configuré en ACCÈS PRIVÉ (non public).
AWS_ACCESS_KEY_ID=votre_access_key_id_ovh
AWS_SECRET_ACCESS_KEY=votre_secret_access_key_ovh
# URL d'accès PyFilesystem vers le stockage S3 d'OVHcloud (Roubaix).
# Remplacez 'nom-du-bucket' par le nom de votre bucket S3 privé créé sur OVHcloud.
# Notez que le endpoint_url doit être encodé pour l'URL (https%3A%2F%2Fs3.rbx.io.cloud.ovh.net)
FS_URL=
# ------------------------------------------------------------------------------
# 3. URLs de l'API et Performance (Option B : Liens Privés + Signature)
# ------------------------------------------------------------------------------
# URL publique principale de l'API Panoramax.
API_PUBLIC_URL=
# Avec l'Option B (Sécurité maximale), nous désactivons le service direct de photos
# publiques par Panoramax. Nous laissons ces variables commentées.
#
# C'est StreetUp (Django) qui utilisera les mêmes identifiants AWS ci-dessus pour
# générer des URLs de lecture S3 temporaires signées (S3 Presigned URLs)
# valables 30 minutes (1800 secondes) pour les utilisateurs connectés.
#
# API_PERMANENT_PICTURES_PUBLIC_URL=
# API_DERIVATES_PICTURES_PUBLIC_URL=
# Stratégie de génération des vignettes/tuiles.
# 'PREPROCESS' force le picture-worker à précalculer et envoyer toutes les vignettes
# sur le S3 dès le versement de la photo par StreetUp.
PICTURE_PROCESS_DERIVATES_STRATEGY=PREPROCESS
# ------------------------------------------------------------------------------
# 4. Authentification & Sécurité d'écriture
# ------------------------------------------------------------------------------
# Désactivation de l'authentification OAuth externe (laissé vide).
# Aucun utilisateur externe ne pourra se connecter via navigateur.
OAUTH_PROVIDER=
# Force l'authentification obligatoire pour tous les téléversements (uploads).
# Seul StreetUp, muni du jeton d'administration généré en console, pourra verser
# des images et créer des séquences après avoir validé leur qualité.
API_FORCE_AUTH_ON_UPLOAD=true
# Visibilité par défaut pour les nouvelles images ('anyone', 'logged-only' ou 'owner-only').
# Par défaut, nous utilisons 'anyone' (le catalogue de métadonnées reste accessible
# au public), et nous protégeons l'accès direct aux images (S3) au niveau du proxy.
API_DEFAULT_PICTURE_VISIBILITY=anyone
# ------------------------------------------------------------------------------
# 5. Floutage (Anonymisation) des visages et plaques
# ------------------------------------------------------------------------------
# Renseignez l'URL du service de floutage (SGBlur) exécuté localement.
# Si cette variable est commentée ou vide, le floutage automatique est désactivé.
API_BLUR_URL=

3
requirements/ai.txt Normal file
View file

@ -0,0 +1,3 @@
# Les librairies IA en local (ultralytics, PyTorch, etc.) ont été supprimées.
# L'application utilise désormais un appel à un microservice externe pour la détection.
# Ce fichier reste vide ou pour d'éventuelles dépendances futures du client d'API.

44
requirements/base.txt Normal file
View file

@ -0,0 +1,44 @@
asgiref==3.11.0
certifi==2025.11.12
charset-normalizer==3.4.4
crispy-bootstrap5==2025.6
Django==5.2.8
djangorestframework==3.16.1
djangorestframework-simplejwt==5.5.1
django-crispy-forms==2.5
et_xmlfile==2.0.0
geopandas==1.1.1
idna==3.11
numpy==2.3.5
openpyxl==3.1.5
opencv-python-headless>=4.10.0
packaging==25.0
python-docx==1.2.0
pandas==2.3.3
pdf2image==1.17.0
pillow==12.0.0
psycopg==3.2.13
psycopg-binary==3.2.13
pyogrio==0.12.1
pyproj==3.7.2
python-dateutil==2.9.0.post0
python-dotenv==1.2.1
pytz==2025.2
requests==2.32.5
shapely==2.1.2
six==1.17.0
sqlparse==0.5.3
tzdata==2025.2
urllib3==2.5.0
fpdf2==2.8.5
qrcode[pil]==8.2
bleach==6.1.0
num2words==0.5.12
httpx==0.28.1
rapidfuzz==3.14.5
aiohttp
gunicorn>=23.0.0
ezdxf==1.4.4
matplotlib==3.10.9
boto3>=1.34.0
markdown

82
streetup/.env.example Normal file
View file

@ -0,0 +1,82 @@
DEBUG=True
SECRET_KEY='...'
ALLOWED_HOSTS=
CSRF_TRUSTED_ORIGINS=
ROOT_URL=
# Configuration de la base de données
# - Pour un usage local (hors Docker) : DB_HOST=127.0.0.1, DB_PORT=5432
DB_HOST=
DB_PORT=
DB_NAME=
DB_USER=
DB_PASSWORD=
# Librairies GIS (cartographiques)
# - Windows (exemple OSGeo4W) :
# GDAL_LIBRARY_PATH='C:\OSGeo4W\\bin\gdal309.dll'
# GEOS_LIBRARY_PATH='C:\OSGeo4W\\bin\geos_c.dll'
# - Linux (Conda local) :
# GDAL_LIBRARY_PATH=/chemin/vers/miniconda3/envs/.../lib/libgdal.so
# GEOS_LIBRARY_PATH=/chemin/vers/miniconda3/envs/.../lib/libgeos_c.so
# - Docker (Miniforge/Conda interne - défini par défaut dans le Dockerfile) :
# 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
EMAIL_HOST=
EMAIL_PORT=
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
LOG_FILE_PATH=".../django.log"
PYGEOAPI_BASE_URL=
# Backup settings
BACKUP_ENABLED=false
BACKUP_DIR=
BACKUP_RETENTION_DAYS=30
BACKUP_TIMEOUT_SECONDS=3600
BACKUP_SSH_KEY=
# MinIO / S3 Backup settings
BACKUP_MINIO_ENABLED=
BACKUP_MINIO_ENDPOINT_URL=
BACKUP_MINIO_BUCKET=
BACKUP_MINIO_REGION=
BACKUP_MINIO_ACCESS_KEY=
BACKUP_MINIO_SECRET_KEY=
# Cartography Styles and Tiles (e.g. MapTiler or OpenFreeMap)
MAP_STYLE_LIGHT=
MAP_STYLE_STANDARD=
MAP_RASTER_TILE_URL=
MAP_ATTRIBUTION=
# Panoramax API Configuration
PANORAMAX_API_URL=
PANORAMAX_API_KEY=
# AWS S3 Settings for Panoramax private bucket redirects (Option B)
PANORAMAX_AWS_ACCESS_KEY_ID=
PANORAMAX_AWS_SECRET_ACCESS_KEY=
PANORAMAX_AWS_S3_ENDPOINT_URL=
PANORAMAX_AWS_S3_BUCKET=
PANORAMAX_AWS_S3_PREFIX=
# Organization Brand Configuration
PRODUCT_NAME=
ORGANIZATION_NAME=
ORGANIZATION_NAME_FR=
ORGANIZATION_NAME_NL=
ORGANIZATION_LOGO_HEADER=
ORGANIZATION_LOGO_PNG=
ORGANIZATION_LOGO_SVG=
ORGANIZATION_PRIMARY_COLOR_HEX=

View file

185
streetup/analytics/admin.py Normal file
View file

@ -0,0 +1,185 @@
"""
Interface d'administration pour les modèles Analytics.
"""
from django.contrib import admin
from django.utils.html import format_html
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from .models import PageView, UserSession, DailyPageViewSummary
@admin.register(PageView)
class PageViewAdmin(admin.ModelAdmin):
"""Interface d'administration pour les vues de pages."""
list_display = [
'timestamp', 'user_link', 'path', 'method',
'response_time_colored', 'ip_address'
]
list_filter = [
'method', 'timestamp',
('user', admin.RelatedOnlyFieldListFilter),
]
search_fields = ['path', 'user__username', 'ip_address', 'full_url']
readonly_fields = [
'user', 'session_key', 'path', 'full_url', 'method',
'timestamp', 'ip_address', 'user_agent', 'referer', 'response_time_ms'
]
date_hierarchy = 'timestamp'
ordering = ['-timestamp']
# Pagination
list_per_page = 50
def user_link(self, obj):
"""Lien vers la page de l'utilisateur."""
if obj.user:
url = reverse('admin:auth_user_change', args=[obj.user.id])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
return _("Anonyme")
user_link.short_description = _("Utilisateur")
def response_time_colored(self, obj):
"""Affiche le temps de réponse avec une couleur selon la performance."""
if obj.response_time_ms is None:
return '-'
# Définir les seuils de couleur
if obj.response_time_ms < 100:
color = 'green'
elif obj.response_time_ms < 500:
color = 'orange'
else:
color = 'red'
return format_html(
'<span style="color: {}; font-weight: bold;">{} ms</span>',
color,
obj.response_time_ms
)
response_time_colored.short_description = _("Temps de réponse")
response_time_colored.admin_order_field = 'response_time_ms'
def has_add_permission(self, request):
"""Désactiver l'ajout manuel."""
return False
def has_change_permission(self, request, obj=None):
"""Désactiver la modification."""
return False
@admin.register(UserSession)
class UserSessionAdmin(admin.ModelAdmin):
"""Interface d'administration pour les sessions utilisateurs."""
list_display = [
'login_time', 'user_link', 'duration_formatted',
'page_views_count', 'status_colored', 'ip_address'
]
list_filter = [
'is_active', 'login_time',
('user', admin.RelatedOnlyFieldListFilter),
]
search_fields = ['user__username', 'session_key', 'ip_address']
readonly_fields = [
'user', 'session_key', 'login_time', 'last_activity',
'logout_time', 'ip_address', 'user_agent', 'page_views_count',
'is_active', 'duration_formatted'
]
date_hierarchy = 'login_time'
ordering = ['-login_time']
# Pagination
list_per_page = 50
def user_link(self, obj):
"""Lien vers la page de l'utilisateur."""
url = reverse('admin:auth_user_change', args=[obj.user.id])
return format_html('<a href="{}">{}</a>', url, obj.user.username)
user_link.short_description = _("Utilisateur")
def status_colored(self, obj):
"""Affiche le statut avec une couleur."""
if obj.is_active:
return format_html(
'<span style="color: green; font-weight: bold;">● {}</span>',
_("Active")
)
else:
return format_html(
'<span style="color: gray;">○ {}</span>',
_("Terminée")
)
status_colored.short_description = _("Statut")
status_colored.admin_order_field = 'is_active'
def duration_formatted(self, obj):
"""Affiche la durée de la session formatée."""
seconds = obj.duration_seconds
if seconds < 60:
return f"{int(seconds)}s"
elif seconds < 3600:
minutes = int(seconds / 60)
return f"{minutes}min"
else:
hours = int(seconds / 3600)
minutes = int((seconds % 3600) / 60)
return f"{hours}h {minutes}min"
duration_formatted.short_description = _("Durée")
def has_add_permission(self, request):
"""Désactiver l'ajout manuel."""
return False
def has_change_permission(self, request, obj=None):
"""Désactiver la modification."""
return False
@admin.register(DailyPageViewSummary)
class DailyPageViewSummaryAdmin(admin.ModelAdmin):
"""Interface d'administration pour les résumés quotidiens."""
list_display = [
'date', 'total_page_views', 'unique_users',
'unique_sessions', 'total_sessions_started',
'avg_response_colored',
]
list_filter = ['date']
search_fields = ['date']
readonly_fields = [
'date', 'total_page_views', 'unique_users', 'unique_sessions',
'unique_ips', 'avg_response_time_ms', 'max_response_time_ms',
'top_paths', 'top_users', 'method_counts',
'total_sessions_started', 'created_at', 'updated_at',
]
date_hierarchy = 'date'
ordering = ['-date']
list_per_page = 50
def avg_response_colored(self, obj):
"""Affiche le temps de réponse moyen avec couleur."""
if obj.avg_response_time_ms is None:
return '-'
val = obj.avg_response_time_ms
if val < 100:
color = 'green'
elif val < 500:
color = 'orange'
else:
color = 'red'
return format_html(
'<span style="color: {}; font-weight: bold;">{} ms</span>',
color,
f'{val:.0f}',
)
avg_response_colored.short_description = _("Temps de rép. moyen")
avg_response_colored.admin_order_field = 'avg_response_time_ms'
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False

View file

@ -0,0 +1,7 @@
from django.apps import AppConfig
class AnalyticsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'analytics'
verbose_name = 'Analytics & Monitoring'

View file

@ -0,0 +1,204 @@
"""
Commande Django pour agréger les statistiques de pages vues
et purger les données détaillées de plus d'un mois.
Workflow:
1. Pour chaque jour ayant des PageView de plus de 30 jours,
calculer les agrégats et créer/mettre à jour un DailyPageViewSummary.
2. Supprimer les PageView et UserSession de plus de 30 jours.
Usage:
python manage.py cleanup_analytics
python manage.py cleanup_analytics --days 60
python manage.py cleanup_analytics --dry-run
"""
from datetime import timedelta
from django.core.management.base import BaseCommand
from django.db.models import Count, Avg, Max, Q
from django.db.models.functions import TruncDate
from django.utils import timezone
from analytics.models import PageView, UserSession, DailyPageViewSummary
class Command(BaseCommand):
help = (
"Agrège les statistiques quotidiennes de pages vues dans "
"DailyPageViewSummary puis purge les enregistrements détaillés "
"de PageView et UserSession de plus de N jours (défaut: 30)."
)
def add_arguments(self, parser):
parser.add_argument(
"--days",
type=int,
default=30,
help="Nombre de jours de rétention des données détaillées (défaut: 30).",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Affiche ce qui serait fait sans modifier la base de données.",
)
def handle(self, *args, **options):
retention_days = options["days"]
dry_run = options["dry_run"]
cutoff = timezone.now() - timedelta(days=retention_days)
cutoff_date = cutoff.date()
if dry_run:
self.stdout.write(self.style.WARNING("[DRY-RUN] Aucune modification ne sera effectuée.\n"))
self.stdout.write(
self.style.WARNING(
f"\n=== Nettoyage analytics — rétention {retention_days} jours "
f"(avant le {cutoff_date}) ===\n"
)
)
# ── 1. Agrégation ─────────────────────────────────────────────
old_page_views = PageView.objects.filter(timestamp__lt=cutoff)
total_old = old_page_views.count()
if total_old == 0:
self.stdout.write("Aucune PageView à agréger.\n")
else:
self.stdout.write(f"{total_old} PageView(s) à agréger et purger.\n")
self._aggregate_page_views(old_page_views, dry_run)
# ── 2. Purge des PageView ─────────────────────────────────────
if not dry_run and total_old > 0:
deleted_pv, _ = old_page_views.delete()
self.stdout.write(self.style.SUCCESS(f" ✓ {deleted_pv} PageView(s) supprimée(s).\n"))
# ── 3. Purge des UserSession inactives ────────────────────────
old_sessions = UserSession.objects.filter(
last_activity__lt=cutoff,
is_active=False,
)
total_sessions = old_sessions.count()
if total_sessions == 0:
self.stdout.write("Aucune UserSession inactive à purger.\n")
else:
if dry_run:
self.stdout.write(f" [DRY-RUN] {total_sessions} UserSession(s) inactives seraient supprimées.\n")
else:
deleted_us, _ = old_sessions.delete()
self.stdout.write(self.style.SUCCESS(f" ✓ {deleted_us} UserSession(s) inactives supprimées.\n"))
self.stdout.write(self.style.SUCCESS("\n=== Nettoyage terminé ===\n"))
# ──────────────────────────────────────────────────────────────────
# Méthodes internes
# ──────────────────────────────────────────────────────────────────
def _aggregate_page_views(self, queryset, dry_run):
"""
Calcule les résumés quotidiens à partir du queryset fourni
et crée / met à jour les DailyPageViewSummary correspondants.
"""
days = (
queryset
.annotate(day=TruncDate("timestamp"))
.values("day")
.annotate(count=Count("id"))
.order_by("day")
)
for entry in days:
day = entry["day"]
day_qs = queryset.filter(timestamp__date=day)
summary_data = self._compute_daily_summary(day, day_qs)
if dry_run:
self.stdout.write(
f" [DRY-RUN] {day} : {summary_data['total_page_views']} vues, "
f"{summary_data['unique_users']} utilisateurs uniques"
)
else:
DailyPageViewSummary.objects.update_or_create(
date=day,
defaults=summary_data,
)
self.stdout.write(
self.style.SUCCESS(
f" ✓ Résumé {day} : {summary_data['total_page_views']} vues, "
f"{summary_data['unique_users']} utilisateurs uniques"
)
)
def _compute_daily_summary(self, day, day_qs):
"""Calcule les métriques agrégées pour un jour donné."""
# Compteurs de base
total_page_views = day_qs.count()
unique_users = (
day_qs.filter(user__isnull=False)
.values("user")
.distinct()
.count()
)
unique_sessions = (
day_qs.exclude(session_key="")
.values("session_key")
.distinct()
.count()
)
unique_ips = (
day_qs.filter(ip_address__isnull=False)
.values("ip_address")
.distinct()
.count()
)
# Temps de réponse
response_stats = day_qs.filter(
response_time_ms__isnull=False
).aggregate(
avg=Avg("response_time_ms"),
mx=Max("response_time_ms"),
)
avg_response = response_stats["avg"]
max_response = response_stats["mx"]
# Top 20 pages
top_paths = dict(
day_qs.values_list("path")
.annotate(cnt=Count("id"))
.order_by("-cnt")[:20]
)
# Top 20 utilisateurs
top_users = dict(
day_qs.filter(user__isnull=False)
.values_list("user__username")
.annotate(cnt=Count("id"))
.order_by("-cnt")[:20]
)
# Répartition par méthode HTTP
method_counts = dict(
day_qs.values_list("method")
.annotate(cnt=Count("id"))
.order_by("-cnt")
)
# Sessions démarrées ce jour-là
total_sessions_started = UserSession.objects.filter(
login_time__date=day
).count()
return {
"total_page_views": total_page_views,
"unique_users": unique_users,
"unique_sessions": unique_sessions,
"unique_ips": unique_ips,
"avg_response_time_ms": round(avg_response, 2) if avg_response else None,
"max_response_time_ms": max_response,
"top_paths": top_paths,
"top_users": top_users,
"method_counts": method_counts,
"total_sessions_started": total_sessions_started,
}

View file

@ -0,0 +1,196 @@
"""
Middleware pour le tracking des visites de pages et des sessions utilisateurs.
Ce middleware enregistre automatiquement les visites de pages et met à jour
les informations de session pour le monitoring de l'application.
"""
import time
import logging
from django.utils import timezone
from django.contrib.auth.signals import user_logged_in, user_logged_out
# Import tardif pour éviter les dépendances circulaires
# Ces imports sont effectués au niveau du module mais après la configuration Django
from analytics.models import PageView, UserSession
class AnalyticsMiddleware:
"""
Middleware qui capture les visites de pages pour le monitoring.
Pour chaque requête, enregistre:
- La page visitée
- L'utilisateur (si authentifié)
- La session
- Le timestamp
- Les métadonnées (IP, user agent, etc.)
"""
# URLs à ignorer pour ne pas polluer les statistiques
IGNORED_PATHS = [
'/static/',
'/media/',
'/private-media/',
'/favicon.ico',
'/__debug__/',
'/admin/jsi18n/', # Traductions JS de l'admin
]
# Méthodes HTTP à tracker (ignorer les HEAD, OPTIONS, etc.)
TRACKED_METHODS = ['GET', 'POST']
def __init__(self, get_response):
self.get_response = get_response
# Connecter les signaux pour tracker les connexions/déconnexions
user_logged_in.connect(self._on_user_login)
user_logged_out.connect(self._on_user_logout)
def __call__(self, request):
# Vérifier si cette requête doit être trackée
if not self._should_track(request):
return self.get_response(request)
# Enregistrer le temps de début
start_time = time.time()
# Traiter la requête
response = self.get_response(request)
# Calculer le temps de réponse
response_time_ms = int((time.time() - start_time) * 1000)
# Enregistrer la visite de page (de manière asynchrone pour ne pas ralentir la réponse)
try:
self._record_page_view(request, response_time_ms)
except Exception as e:
# En cas d'erreur, ne pas faire planter la requête
logger = logging.getLogger('analytics')
logger.error(f"Erreur lors de l'enregistrement de la page view: {e}")
return response
def _should_track(self, request):
"""Détermine si la requête doit être trackée."""
# Ignorer certains chemins
for path in self.IGNORED_PATHS:
if request.path.startswith(path):
return False
# Ignorer certaines méthodes HTTP
if request.method not in self.TRACKED_METHODS:
return False
# Ignorer les requêtes AJAX de type polling/refresh
if request.META.get('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest':
# On peut choisir de tracker ou non les requêtes AJAX
# Pour l'instant, on les ignore pour ne pas polluer les stats
return False
return True
def _get_client_ip(self, request):
"""Récupère l'adresse IP du client."""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0].strip()
else:
ip = request.META.get('REMOTE_ADDR')
return ip
def _get_user_agent(self, request):
"""Récupère le user agent en le tronquant si nécessaire."""
user_agent = request.META.get('HTTP_USER_AGENT')
return user_agent[:2000] if user_agent else None
def _get_referer(self, request):
"""Récupère le referer en le tronquant si nécessaire."""
referer = request.META.get('HTTP_REFERER')
return referer[:2048] if referer else None
def _record_page_view(self, request, response_time_ms):
"""Enregistre la visite de page dans la base de données."""
# Créer l'enregistrement de page view
page_view = PageView.objects.create(
user=request.user if request.user.is_authenticated else None,
session_key=request.session.session_key or '',
path=request.path,
full_url=request.build_absolute_uri(),
method=request.method,
ip_address=self._get_client_ip(request),
user_agent=self._get_user_agent(request),
referer=self._get_referer(request),
response_time_ms=response_time_ms,
)
# Mettre à jour la session utilisateur si l'utilisateur est authentifié
if request.user.is_authenticated and request.session.session_key:
self._update_user_session(request)
def _update_user_session(self, request):
"""Met à jour les informations de session utilisateur."""
session_key = request.session.session_key
if not session_key:
return
try:
# Récupérer ou créer la session
user_session, created = UserSession.objects.get_or_create(
session_key=session_key,
defaults={
'user': request.user,
'login_time': timezone.now(),
'last_activity': timezone.now(),
'ip_address': self._get_client_ip(request),
'user_agent': self._get_user_agent(request),
}
)
# Mettre à jour l'activité et le compteur de pages vues
if not created:
user_session.last_activity = timezone.now()
user_session.page_views_count += 1
user_session.save(update_fields=['last_activity', 'page_views_count'])
except Exception as e:
# En cas d'erreur (par exemple, clé de session dupliquée), ignorer
logger = logging.getLogger('analytics')
logger.warning(f"Erreur lors de la mise à jour de la session: {e}")
def _on_user_login(self, sender, request, user, **kwargs):
"""
Signal handler appelé quand un utilisateur se connecte.
Crée une nouvelle session utilisateur.
"""
# S'assurer que la session a une clé
if not request.session.session_key:
request.session.create()
session_key = request.session.session_key
# Créer ou mettre à jour la session
UserSession.objects.update_or_create(
session_key=session_key,
defaults={
'user': user,
'login_time': timezone.now(),
'last_activity': timezone.now(),
'ip_address': self._get_client_ip(request),
'user_agent': self._get_user_agent(request),
'is_active': True,
}
)
def _on_user_logout(self, sender, request, user, **kwargs):
"""
Signal handler appelé quand un utilisateur se déconnecte.
Marque la session comme inactive.
"""
if request.session.session_key:
try:
user_session = UserSession.objects.get(
session_key=request.session.session_key
)
user_session.mark_inactive()
except UserSession.DoesNotExist:
pass

View file

@ -0,0 +1,84 @@
# Generated by Django 5.2.8 on 2026-05-21 13:51
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='DailyPageViewSummary',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateField(db_index=True, unique=True, verbose_name='Date')),
('total_page_views', models.IntegerField(default=0, verbose_name='Total pages vues')),
('unique_users', models.IntegerField(default=0, verbose_name='Utilisateurs uniques')),
('unique_sessions', models.IntegerField(default=0, verbose_name='Sessions uniques')),
('unique_ips', models.IntegerField(default=0, verbose_name='IPs uniques')),
('avg_response_time_ms', models.FloatField(blank=True, null=True, verbose_name='Temps de réponse moyen (ms)')),
('max_response_time_ms', models.IntegerField(blank=True, null=True, verbose_name='Temps de réponse max (ms)')),
('top_paths', models.JSONField(blank=True, default=dict, help_text='Les 20 pages les plus visitées avec leur nombre de vues', verbose_name='Top pages visitées')),
('top_users', models.JSONField(blank=True, default=dict, help_text='Les 20 utilisateurs les plus actifs avec leur nombre de vues', verbose_name='Top utilisateurs')),
('method_counts', models.JSONField(blank=True, default=dict, help_text='Nombre de requêtes par méthode HTTP (GET, POST, ...)', verbose_name='Répartition par méthode HTTP')),
('total_sessions_started', models.IntegerField(default=0, verbose_name='Sessions démarrées')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Créé le')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Mis à jour le')),
],
options={
'verbose_name': 'Résumé quotidien',
'verbose_name_plural': 'Résumés quotidiens',
'ordering': ['-date'],
},
),
migrations.CreateModel(
name='PageView',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(db_index=True, max_length=40, verbose_name='Clé de session')),
('path', models.CharField(db_index=True, max_length=2048, verbose_name='Chemin URL')),
('full_url', models.CharField(blank=True, max_length=4096, null=True, verbose_name='URL complète')),
('method', models.CharField(default='GET', max_length=10, verbose_name='Méthode HTTP')),
('timestamp', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Date et heure')),
('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='Adresse IP')),
('user_agent', models.TextField(blank=True, null=True, verbose_name='User Agent')),
('referer', models.CharField(blank=True, max_length=2048, null=True, verbose_name='Page précédente')),
('response_time_ms', models.IntegerField(blank=True, help_text='Durée de traitement de la requête en millisecondes', null=True, verbose_name='Temps de réponse (ms)')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='page_views', to=settings.AUTH_USER_MODEL, verbose_name='Utilisateur')),
],
options={
'verbose_name': 'Vue de page',
'verbose_name_plural': 'Vues de pages',
'ordering': ['-timestamp'],
'indexes': [models.Index(fields=['user', 'timestamp'], name='analytics_p_user_id_9d5969_idx'), models.Index(fields=['session_key', 'timestamp'], name='analytics_p_session_982099_idx'), models.Index(fields=['path', 'timestamp'], name='analytics_p_path_51c7b7_idx'), models.Index(fields=['timestamp'], name='analytics_p_timesta_835321_idx')],
},
),
migrations.CreateModel(
name='UserSession',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('session_key', models.CharField(db_index=True, max_length=40, unique=True, verbose_name='Clé de session')),
('login_time', models.DateTimeField(db_index=True, verbose_name='Heure de connexion')),
('last_activity', models.DateTimeField(db_index=True, verbose_name='Dernière activité')),
('logout_time', models.DateTimeField(blank=True, null=True, verbose_name='Heure de déconnexion')),
('ip_address', models.GenericIPAddressField(blank=True, null=True, verbose_name='Adresse IP')),
('user_agent', models.TextField(blank=True, null=True, verbose_name='User Agent')),
('page_views_count', models.IntegerField(default=0, verbose_name='Nombre de pages vues')),
('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Session active')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='analytics_sessions', to=settings.AUTH_USER_MODEL, verbose_name='Utilisateur')),
],
options={
'verbose_name': 'Session utilisateur',
'verbose_name_plural': 'Sessions utilisateurs',
'ordering': ['-login_time'],
'indexes': [models.Index(fields=['user', 'login_time'], name='analytics_u_user_id_eec74f_idx'), models.Index(fields=['is_active', 'last_activity'], name='analytics_u_is_acti_794ed6_idx')],
},
),
]

View file

@ -0,0 +1,260 @@
"""
Modèles pour le monitoring de l'utilisation de l'application.
Ce module fournit des modèles pour tracker:
- Les visites de pages (PageView)
- Les sessions utilisateurs (UserSession)
- Les résumés quotidiens (DailyPageViewSummary)
- Les statistiques d'utilisation
"""
from django.db import models, transaction
from django.contrib.auth.models import User
from django.conf import settings
from django.utils import timezone
class PageView(models.Model):
"""
Enregistre chaque visite de page par un utilisateur.
Permet de tracker les pages visitées et la fréquence d'utilisation.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
null=True,
blank=True,
verbose_name="Utilisateur",
related_name="page_views"
)
session_key = models.CharField(
max_length=40,
db_index=True,
verbose_name="Clé de session"
)
path = models.CharField(
max_length=2048,
verbose_name="Chemin URL",
db_index=True
)
full_url = models.CharField(
max_length=4096,
blank=True,
null=True,
verbose_name="URL complète"
)
method = models.CharField(
max_length=10,
default='GET',
verbose_name="Méthode HTTP"
)
timestamp = models.DateTimeField(
auto_now_add=True,
verbose_name="Date et heure",
db_index=True
)
ip_address = models.GenericIPAddressField(
null=True,
blank=True,
verbose_name="Adresse IP"
)
user_agent = models.TextField(
blank=True,
null=True,
verbose_name="User Agent"
)
referer = models.CharField(
max_length=2048,
blank=True,
null=True,
verbose_name="Page précédente"
)
response_time_ms = models.IntegerField(
null=True,
blank=True,
verbose_name="Temps de réponse (ms)",
help_text="Durée de traitement de la requête en millisecondes"
)
class Meta:
verbose_name = "Vue de page"
verbose_name_plural = "Vues de pages"
ordering = ["-timestamp"]
indexes = [
models.Index(fields=['user', 'timestamp']),
models.Index(fields=['session_key', 'timestamp']),
models.Index(fields=['path', 'timestamp']),
models.Index(fields=['timestamp']),
]
def __str__(self):
user_str = self.user.username if self.user else "Anonyme"
return f"{user_str} - {self.path} - {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}"
class UserSession(models.Model):
"""
Enrichit les informations de session Django avec des métadonnées supplémentaires.
Permet de tracker les sessions actives et l'historique de connexion.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
verbose_name="Utilisateur",
related_name="analytics_sessions"
)
session_key = models.CharField(
max_length=40,
unique=True,
db_index=True,
verbose_name="Clé de session"
)
login_time = models.DateTimeField(
verbose_name="Heure de connexion",
db_index=True
)
last_activity = models.DateTimeField(
verbose_name="Dernière activité",
db_index=True
)
logout_time = models.DateTimeField(
null=True,
blank=True,
verbose_name="Heure de déconnexion"
)
ip_address = models.GenericIPAddressField(
null=True,
blank=True,
verbose_name="Adresse IP"
)
user_agent = models.TextField(
blank=True,
null=True,
verbose_name="User Agent"
)
page_views_count = models.IntegerField(
default=0,
verbose_name="Nombre de pages vues"
)
is_active = models.BooleanField(
default=True,
verbose_name="Session active",
db_index=True
)
class Meta:
verbose_name = "Session utilisateur"
verbose_name_plural = "Sessions utilisateurs"
ordering = ["-login_time"]
indexes = [
models.Index(fields=['user', 'login_time']),
models.Index(fields=['is_active', 'last_activity']),
]
def __str__(self):
status = "Active" if self.is_active else "Terminée"
return f"{self.user.username} - {self.login_time.strftime('%Y-%m-%d %H:%M')} - {status}"
@property
def duration(self):
"""Retourne la durée de la session."""
end_time = self.logout_time if self.logout_time else self.last_activity
return end_time - self.login_time
@property
def duration_seconds(self):
"""Retourne la durée de la session en secondes."""
return self.duration.total_seconds()
def mark_inactive(self):
"""Marque la session comme inactive de manière atomique."""
with transaction.atomic():
# Utiliser select_for_update pour éviter les race conditions
session = UserSession.objects.select_for_update().filter(pk=self.pk).first()
if session and session.is_active:
session.is_active = False
if not session.logout_time:
session.logout_time = timezone.now()
session.save(update_fields=['is_active', 'logout_time'])
# Mettre à jour l'instance actuelle
self.is_active = session.is_active
self.logout_time = session.logout_time
class DailyPageViewSummary(models.Model):
"""
Résumé quotidien des statistiques de pages vues.
Ce modèle stocke les agrégats par jour pour conserver des statistiques
d'utilisation même après la purge des données détaillées de PageView.
"""
date = models.DateField(
unique=True,
db_index=True,
verbose_name="Date"
)
total_page_views = models.IntegerField(
default=0,
verbose_name="Total pages vues"
)
unique_users = models.IntegerField(
default=0,
verbose_name="Utilisateurs uniques"
)
unique_sessions = models.IntegerField(
default=0,
verbose_name="Sessions uniques"
)
unique_ips = models.IntegerField(
default=0,
verbose_name="IPs uniques"
)
avg_response_time_ms = models.FloatField(
null=True,
blank=True,
verbose_name="Temps de réponse moyen (ms)"
)
max_response_time_ms = models.IntegerField(
null=True,
blank=True,
verbose_name="Temps de réponse max (ms)"
)
top_paths = models.JSONField(
default=dict,
blank=True,
verbose_name="Top pages visitées",
help_text="Les 20 pages les plus visitées avec leur nombre de vues"
)
top_users = models.JSONField(
default=dict,
blank=True,
verbose_name="Top utilisateurs",
help_text="Les 20 utilisateurs les plus actifs avec leur nombre de vues"
)
method_counts = models.JSONField(
default=dict,
blank=True,
verbose_name="Répartition par méthode HTTP",
help_text="Nombre de requêtes par méthode HTTP (GET, POST, ...)"
)
total_sessions_started = models.IntegerField(
default=0,
verbose_name="Sessions démarrées"
)
created_at = models.DateTimeField(
auto_now_add=True,
verbose_name="Créé le"
)
updated_at = models.DateTimeField(
auto_now=True,
verbose_name="Mis à jour le"
)
class Meta:
verbose_name = "Résumé quotidien"
verbose_name_plural = "Résumés quotidiens"
ordering = ["-date"]
def __str__(self):
return f"{self.date} — {self.total_page_views} vues, {self.unique_users} utilisateurs"

View file

@ -0,0 +1,291 @@
{% extends "base.html" %}
{% load i18n %}
{% load static %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
.stat-card {
border-left: 4px solid #2b3c91;
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-5px);
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
.stat-number {
font-size: 2.5rem;
font-weight: bold;
color: #2b3c91;
}
.stat-label {
color: #6c757d;
font-size: 0.9rem;
}
.chart-container {
position: relative;
height: 400px;
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid mt-4">
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center">
<h1><i class="bi bi-graph-up me-2"></i>{% translate "Tableau de bord Analytics" %}</h1>
<div class="btn-group" role="group">
<a href="?days=7" class="btn btn-sm {% if days == 7 %}btn-primary{% else %}btn-outline-primary{% endif %}">7j</a>
<a href="?days=30" class="btn btn-sm {% if days == 30 %}btn-primary{% else %}btn-outline-primary{% endif %}">30j</a>
<a href="?days=90" class="btn btn-sm {% if days == 90 %}btn-primary{% else %}btn-outline-primary{% endif %}">90j</a>
</div>
</div>
</div>
</div>
<!-- Statistiques principales -->
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body">
<div class="stat-number">{{ stats.total_users }}</div>
<div class="stat-label">{% translate "Utilisateurs totaux" %}</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body">
<div class="stat-number">{{ stats.active_users }}</div>
<div class="stat-label">{% translate "Utilisateurs actifs" %} ({{ days }}j)</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body">
<div class="stat-number">{{ stats.total_sessions }}</div>
<div class="stat-label">{% translate "Sessions totales" %} ({{ days }}j)</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card stat-card h-100">
<div class="card-body">
<div class="stat-number">{{ stats.sessions_per_user }}</div>
<div class="stat-label">{% translate "Sessions par utilisateur" %}</div>
</div>
</div>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-md-4">
<div class="card stat-card h-100">
<div class="card-body">
<div class="stat-number">{{ stats.total_page_views }}</div>
<div class="stat-label">{% translate "Pages vues" %} ({{ days }}j)</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card h-100">
<div class="card-body">
<div class="stat-number">{{ stats.active_sessions }}</div>
<div class="stat-label">{% translate "Sessions actives" %}</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card stat-card h-100">
<div class="card-body">
<div class="stat-number">{{ stats.avg_response_time|floatformat:0 }} ms</div>
<div class="stat-label">{% translate "Temps de réponse moyen" %}</div>
</div>
</div>
</div>
</div>
<!-- Graphiques -->
<div class="row g-3 mb-4">
<div class="col-lg-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{% translate "Activité quotidienne" %}</h5>
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="dailyActivityChart"></canvas>
</div>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">{% translate "Activité horaire (24h)" %}</h5>
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="hourlyActivityChart"></canvas>
</div>
</div>
</div>
</div>
</div>
<!-- Pages et utilisateurs populaires -->
<div class="row g-3 mb-4">
<div class="col-lg-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{% translate "Pages les plus visitées" %}</h5>
<a href="{% url 'analytics:page_views_list' %}" class="btn btn-sm btn-outline-primary">{% translate "Voir tout" %}</a>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% translate "Page" %}</th>
<th class="text-end">{% translate "Visites" %}</th>
</tr>
</thead>
<tbody>
{% for page in top_pages %}
<tr>
<td><code>{{ page.path }}</code></td>
<td class="text-end"><strong>{{ page.count }}</strong></td>
</tr>
{% empty %}
<tr>
<td colspan="2" class="text-center text-muted">{% translate "Aucune donnée" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">{% translate "Utilisateurs les plus actifs" %}</h5>
<a href="{% url 'analytics:sessions_list' %}" class="btn btn-sm btn-outline-primary">{% translate "Voir tout" %}</a>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% translate "Utilisateur" %}</th>
<th class="text-end">{% translate "Vues" %}</th>
<th class="text-end">{% translate "Sessions" %}</th>
</tr>
</thead>
<tbody>
{% for user in top_users %}
<tr>
<td>{{ user.user__username }}</td>
<td class="text-end"><strong>{{ user.views }}</strong></td>
<td class="text-end">{{ user.sessions }}</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-center text-muted">{% translate "Aucune donnée" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script>
const chartData = {{ chart_data|safe }};
const hourlyChartData = {{ hourly_chart_data|safe }};
// Graphique activité quotidienne
const dailyCtx = document.getElementById('dailyActivityChart').getContext('2d');
new Chart(dailyCtx, {
type: 'line',
data: {
labels: chartData.labels,
datasets: [
{
label: '{% translate "Vues de pages" %}',
data: chartData.views,
borderColor: 'rgb(43, 60, 145)',
backgroundColor: 'rgba(43, 60, 145, 0.1)',
tension: 0.4
},
{
label: '{% translate "Utilisateurs" %}',
data: chartData.users,
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.4
},
{
label: '{% translate "Sessions" %}',
data: chartData.sessions,
borderColor: 'rgb(255, 159, 64)',
backgroundColor: 'rgba(255, 159, 64, 0.1)',
tension: 0.4
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
// Graphique activité horaire
const hourlyCtx = document.getElementById('hourlyActivityChart').getContext('2d');
new Chart(hourlyCtx, {
type: 'bar',
data: {
labels: hourlyChartData.labels,
datasets: [{
label: '{% translate "Vues" %}',
data: hourlyChartData.values,
backgroundColor: 'rgba(43, 60, 145, 0.7)',
borderColor: 'rgb(43, 60, 145)',
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true
}
}
}
});
</script>
{% endblock %}

View file

@ -0,0 +1,48 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="container-fluid mt-4">
<div class="row mb-4">
<div class="col-12">
<h1><i class="bi bi-file-earmark-text me-2"></i>{% translate "Vues de pages" %}</h1>
<a href="{% url 'analytics:dashboard' %}" class="btn btn-outline-secondary mb-3">
<i class="bi bi-arrow-left"></i> {% translate "Retour au tableau de bord" %}
</a>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% translate "Date/Heure" %}</th>
<th>{% translate "Utilisateur" %}</th>
<th>{% translate "Page" %}</th>
<th>{% translate "Méthode" %}</th>
<th>{% translate "Temps" %}</th>
</tr>
</thead>
<tbody>
{% for pv in page_views %}
<tr>
<td>{{ pv.timestamp|date:"d/m/Y H:i:s" }}</td>
<td>{% if pv.user %}{{ pv.user.username }}{% else %}{% translate "Anonyme" %}{% endif %}</td>
<td><code>{{ pv.path }}</code></td>
<td><span class="badge bg-info">{{ pv.method }}</span></td>
<td>{% if pv.response_time_ms %}{{ pv.response_time_ms }} ms{% else %}-{% endif %}</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-center text-muted">{% translate "Aucune vue de page trouvée" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends "base.html" %}
{% load i18n %}
{% block content %}
<div class="container-fluid mt-4">
<div class="row mb-4">
<div class="col-12">
<h1><i class="bi bi-clock-history me-2"></i>{% translate "Sessions utilisateurs" %}</h1>
<a href="{% url 'analytics:dashboard' %}" class="btn btn-outline-secondary mb-3">
<i class="bi bi-arrow-left"></i> {% translate "Retour au tableau de bord" %}
</a>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>{% translate "Utilisateur" %}</th>
<th>{% translate "Connexion" %}</th>
<th>{% translate "Dernière activité" %}</th>
<th>{% translate "Durée" %}</th>
<th>{% translate "Pages vues" %}</th>
<th>{% translate "Statut" %}</th>
</tr>
</thead>
<tbody>
{% for session in sessions %}
<tr>
<td>{{ session.user.username }}</td>
<td>{{ session.login_time|date:"d/m/Y H:i" }}</td>
<td>{{ session.last_activity|date:"d/m/Y H:i" }}</td>
<td>{{ session.duration }}</td>
<td>{{ session.page_views_count }}</td>
<td>
{% if session.is_active %}
<span class="badge bg-success">{% translate "Active" %}</span>
{% else %}
<span class="badge bg-secondary">{% translate "Terminée" %}</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-center text-muted">{% translate "Aucune session trouvée" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}

286
streetup/analytics/tests.py Normal file
View file

@ -0,0 +1,286 @@
"""
Tests pour le système Analytics.
"""
from django.test import TestCase, Client, RequestFactory
from django.contrib.auth.models import User
from django.utils import timezone
from datetime import timedelta
from .models import PageView, UserSession
from .middleware import AnalyticsMiddleware
class PageViewModelTest(TestCase):
"""Tests pour le modèle PageView."""
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
def test_create_page_view(self):
"""Test de création d'une vue de page."""
page_view = PageView.objects.create(
user=self.user,
session_key='test_session_123',
path='/test/page/',
method='GET',
ip_address='127.0.0.1',
response_time_ms=100
)
self.assertEqual(page_view.user, self.user)
self.assertEqual(page_view.path, '/test/page/')
self.assertEqual(page_view.response_time_ms, 100)
self.assertIsNotNone(page_view.timestamp)
def test_page_view_str(self):
"""Test de la représentation string d'une PageView."""
page_view = PageView.objects.create(
user=self.user,
session_key='test_session_123',
path='/test/page/',
)
str_repr = str(page_view)
self.assertIn('testuser', str_repr)
self.assertIn('/test/page/', str_repr)
def test_page_view_without_user(self):
"""Test de création d'une vue de page sans utilisateur (anonyme)."""
page_view = PageView.objects.create(
user=None,
session_key='anonymous_session',
path='/public/page/',
)
self.assertIsNone(page_view.user)
str_repr = str(page_view)
self.assertIn('Anonyme', str_repr)
class UserSessionModelTest(TestCase):
"""Tests pour le modèle UserSession."""
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
def test_create_user_session(self):
"""Test de création d'une session utilisateur."""
now = timezone.now()
session = UserSession.objects.create(
user=self.user,
session_key='test_session_456',
login_time=now,
last_activity=now,
ip_address='127.0.0.1',
is_active=True
)
self.assertEqual(session.user, self.user)
self.assertEqual(session.session_key, 'test_session_456')
self.assertTrue(session.is_active)
self.assertIsNone(session.logout_time)
def test_session_duration(self):
"""Test du calcul de la durée d'une session."""
now = timezone.now()
later = now + timedelta(hours=2)
session = UserSession.objects.create(
user=self.user,
session_key='test_session_789',
login_time=now,
last_activity=later,
is_active=True
)
duration = session.duration
self.assertEqual(duration.total_seconds(), 7200) # 2 heures
self.assertEqual(session.duration_seconds, 7200)
def test_mark_inactive(self):
"""Test de marquage d'une session comme inactive."""
session = UserSession.objects.create(
user=self.user,
session_key='test_session_abc',
login_time=timezone.now(),
last_activity=timezone.now(),
is_active=True
)
self.assertTrue(session.is_active)
self.assertIsNone(session.logout_time)
session.mark_inactive()
session.refresh_from_db()
self.assertFalse(session.is_active)
self.assertIsNotNone(session.logout_time)
def test_session_str(self):
"""Test de la représentation string d'une UserSession."""
session = UserSession.objects.create(
user=self.user,
session_key='test_session_def',
login_time=timezone.now(),
last_activity=timezone.now(),
is_active=True
)
str_repr = str(session)
self.assertIn('testuser', str_repr)
self.assertIn('Active', str_repr)
class AnalyticsMiddlewareTest(TestCase):
"""Tests pour le middleware Analytics."""
def setUp(self):
self.factory = RequestFactory()
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
self.middleware = AnalyticsMiddleware(lambda r: None)
def test_should_track_normal_request(self):
"""Test que les requêtes normales sont trackées."""
request = self.factory.get('/test/page/')
self.assertTrue(self.middleware._should_track(request))
def test_should_not_track_static_files(self):
"""Test que les fichiers statiques ne sont pas trackés."""
request = self.factory.get('/static/css/style.css')
self.assertFalse(self.middleware._should_track(request))
def test_should_not_track_media_files(self):
"""Test que les fichiers media ne sont pas trackés."""
request = self.factory.get('/media/images/photo.jpg')
self.assertFalse(self.middleware._should_track(request))
def test_should_not_track_favicon(self):
"""Test que le favicon n'est pas tracké."""
request = self.factory.get('/favicon.ico')
self.assertFalse(self.middleware._should_track(request))
def test_should_not_track_head_method(self):
"""Test que les requêtes HEAD ne sont pas trackées."""
request = self.factory.head('/test/page/')
self.assertFalse(self.middleware._should_track(request))
def test_should_track_post_method(self):
"""Test que les requêtes POST sont trackées."""
request = self.factory.post('/test/form/')
self.assertTrue(self.middleware._should_track(request))
def test_get_client_ip(self):
"""Test de récupération de l'adresse IP du client."""
# IP directe
request = self.factory.get('/')
request.META['REMOTE_ADDR'] = '192.168.1.100'
ip = self.middleware._get_client_ip(request)
self.assertEqual(ip, '192.168.1.100')
# IP via proxy (X-Forwarded-For)
request = self.factory.get('/')
request.META['HTTP_X_FORWARDED_FOR'] = '203.0.113.1, 192.168.1.1'
ip = self.middleware._get_client_ip(request)
self.assertEqual(ip, '203.0.113.1')
class AnalyticsViewsTest(TestCase):
"""Tests pour les vues Analytics."""
def setUp(self):
self.client = Client()
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
self.superuser = User.objects.create_superuser(
username='superuser',
password='testpass123'
)
def test_dashboard_requires_superuser(self):
"""Test que le dashboard nécessite les droits superuser."""
# Utilisateur non authentifié
response = self.client.get('/analytics/')
self.assertEqual(response.status_code, 302) # Redirection vers login
# Utilisateur authentifié mais non superuser
self.client.login(username='testuser', password='testpass123')
response = self.client.get('/analytics/')
self.assertEqual(response.status_code, 302) # Redirection
# Utilisateur superuser
self.client.login(username='superuser', password='testpass123')
response = self.client.get('/analytics/')
self.assertEqual(response.status_code, 200) # Accès autorisé
def test_dashboard_displays_stats(self):
"""Test que le dashboard affiche les statistiques."""
# Créer quelques données de test
PageView.objects.create(
user=self.user,
session_key='session1',
path='/test/',
)
self.client.login(username='superuser', password='testpass123')
response = self.client.get('/analytics/')
self.assertEqual(response.status_code, 200)
self.assertIn('stats', response.context)
self.assertIn('chart_data', response.context)
def test_dashboard_days_filter(self):
"""Test du filtre de période du dashboard."""
self.client.login(username='superuser', password='testpass123')
# 7 jours
response = self.client.get('/analytics/?days=7')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['days'], 7)
# 30 jours
response = self.client.get('/analytics/?days=30')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context['days'], 30)
class AnalyticsIntegrationTest(TestCase):
"""Tests d'intégration du système Analytics."""
def setUp(self):
self.user = User.objects.create_user(
username='testuser',
password='testpass123'
)
def test_page_view_tracking_flow(self):
"""Test du flux complet de tracking d'une page view."""
# Se connecter
client = Client()
client.login(username='testuser', password='testpass123')
# Naviguer sur une page (le middleware devrait créer une PageView)
# Note: En environnement de test, le middleware peut ne pas être actif
# Ce test vérifie plutôt la structure
# Vérifier qu'on peut créer manuellement une PageView
initial_count = PageView.objects.count()
PageView.objects.create(
user=self.user,
session_key=client.session.session_key or 'test_session',
path='/test/page/',
)
final_count = PageView.objects.count()
self.assertEqual(final_count, initial_count + 1)

View file

@ -0,0 +1,14 @@
"""
URLs pour l'application Analytics.
"""
from django.urls import path
from . import views
app_name = 'analytics'
urlpatterns = [
path('', views.dashboard, name='dashboard'),
path('sessions/', views.sessions_list, name='sessions_list'),
path('page-views/', views.page_views_list, name='page_views_list'),
]

224
streetup/analytics/views.py Normal file
View file

@ -0,0 +1,224 @@
"""
Vues pour le tableau de bord d'analytics.
"""
from django.shortcuts import render
from django.contrib.auth.decorators import user_passes_test
from django.utils import timezone
from django.db.models import Count, Avg, Q, F
from django.db.models.functions import TruncDate, TruncHour
from datetime import timedelta
import json
from .models import PageView, UserSession
from django.contrib.auth.models import User
def superuser_required(view_func):
"""Décorateur qui vérifie que l'utilisateur est un superadmin."""
decorated_view = user_passes_test(
lambda u: u.is_active and u.is_superuser,
login_url='admin:login'
)(view_func)
return decorated_view
@superuser_required
def dashboard(request):
"""
Vue principale du tableau de bord d'analytics.
Affiche les statistiques d'utilisation de l'application.
"""
# Période à analyser (par défaut: 30 derniers jours)
days = int(request.GET.get('days', 30))
start_date = timezone.now() - timedelta(days=days)
# Statistiques générales
stats = {
'total_users': User.objects.filter(is_active=True).count(),
'active_users': _get_active_users_count(start_date),
'total_sessions': UserSession.objects.filter(login_time__gte=start_date).count(),
'active_sessions': UserSession.objects.filter(is_active=True).count(),
'total_page_views': PageView.objects.filter(timestamp__gte=start_date).count(),
'avg_response_time': PageView.objects.filter(
timestamp__gte=start_date,
response_time_ms__isnull=False
).aggregate(avg=Avg('response_time_ms'))['avg'] or 0,
}
# Calculer les sessions par utilisateur (éviter la division par zéro)
stats['sessions_per_user'] = (
round(stats['total_sessions'] / stats['active_users'], 2)
if stats['active_users'] > 0
else 0
)
# Pages les plus visitées
top_pages = PageView.objects.filter(
timestamp__gte=start_date
).values('path').annotate(
count=Count('id')
).order_by('-count')[:10]
# Utilisateurs les plus actifs
top_users = PageView.objects.filter(
timestamp__gte=start_date,
user__isnull=False
).values('user__username', 'user__id').annotate(
views=Count('id'),
sessions=Count('session_key', distinct=True)
).order_by('-views')[:10]
# Activité par jour
daily_activity = PageView.objects.filter(
timestamp__gte=start_date
).annotate(
date=TruncDate('timestamp')
).values('date').annotate(
views=Count('id'),
users=Count('user', distinct=True),
sessions=Count('session_key', distinct=True)
).order_by('date')
# Préparer les données pour les graphiques
chart_data = {
'labels': [item['date'].strftime('%d/%m') for item in daily_activity],
'views': [item['views'] for item in daily_activity],
'users': [item['users'] for item in daily_activity],
'sessions': [item['sessions'] for item in daily_activity],
}
# Activité par heure (dernières 24h)
yesterday = timezone.now() - timedelta(days=1)
hourly_activity = PageView.objects.filter(
timestamp__gte=yesterday
).annotate(
hour=TruncHour('timestamp')
).values('hour').annotate(
count=Count('id')
).order_by('hour')
hourly_chart_data = {
'labels': [item['hour'].strftime('%H:%M') for item in hourly_activity],
'values': [item['count'] for item in hourly_activity],
}
context = {
'stats': stats,
'top_pages': top_pages,
'top_users': top_users,
'chart_data': json.dumps(chart_data),
'hourly_chart_data': json.dumps(hourly_chart_data),
'days': days,
}
return render(request, 'analytics/dashboard.html', context)
@superuser_required
def sessions_list(request):
"""
Liste détaillée des sessions utilisateurs.
"""
# Paramètres de filtrage
days = int(request.GET.get('days', 30))
user_id = request.GET.get('user')
active_only = request.GET.get('active_only', False)
start_date = timezone.now() - timedelta(days=days)
# Requête de base
sessions = UserSession.objects.filter(
login_time__gte=start_date
).select_related('user')
# Filtres
if user_id:
sessions = sessions.filter(user_id=user_id)
if active_only:
sessions = sessions.filter(is_active=True)
# Ordre
sessions = sessions.order_by('-login_time')
# Pagination (simple, 100 par page)
sessions = sessions[:100]
# Liste des utilisateurs pour le filtre
users = User.objects.filter(
analytics_sessions__login_time__gte=start_date
).distinct().order_by('username')
context = {
'sessions': sessions,
'users': users,
'selected_user_id': user_id,
'active_only': active_only,
'days': days,
}
return render(request, 'analytics/sessions_list.html', context)
@superuser_required
def page_views_list(request):
"""
Liste détaillée des vues de pages.
"""
# Paramètres de filtrage
days = int(request.GET.get('days', 7)) # Par défaut 7 jours car il peut y avoir beaucoup de données
user_id = request.GET.get('user')
path = request.GET.get('path')
start_date = timezone.now() - timedelta(days=days)
# Requête de base
page_views = PageView.objects.filter(
timestamp__gte=start_date
).select_related('user')
# Filtres
if user_id:
page_views = page_views.filter(user_id=user_id)
if path:
page_views = page_views.filter(path__icontains=path)
# Ordre
page_views = page_views.order_by('-timestamp')
# Pagination (simple, 100 par page)
page_views = page_views[:100]
# Liste des utilisateurs et chemins pour les filtres
users = User.objects.filter(
page_views__timestamp__gte=start_date
).distinct().order_by('username')
popular_paths = PageView.objects.filter(
timestamp__gte=start_date
).values('path').annotate(
count=Count('id')
).order_by('-count')[:20]
context = {
'page_views': page_views,
'users': users,
'popular_paths': popular_paths,
'selected_user_id': user_id,
'selected_path': path,
'days': days,
}
return render(request, 'analytics/page_views_list.html', context)
def _get_active_users_count(start_date):
"""
Compte le nombre d'utilisateurs actifs depuis start_date.
Un utilisateur est considéré actif s'il a au moins une page view.
"""
return User.objects.filter(
page_views__timestamp__gte=start_date
).distinct().count()

0
streetup/api/__init__.py Normal file
View file

92
streetup/api/admin.py Normal file
View file

@ -0,0 +1,92 @@
# admin.py
from django import forms
from django.utils import timezone
from django.utils.html import escape, mark_safe
from django.contrib import admin
from django.contrib.admin.widgets import AdminSplitDateTime
from .models import PersonalAccessToken, _sha256 # _sha256 dispo dans models.py
import secrets
_HTML5_FMT = "%Y-%m-%dT%H:%M" # sans secondes ni timezone (exigence de datetime-local)
class PersonalAccessTokenForm(forms.ModelForm):
# Non obligatoire: on le remplit nous-mêmes si vide
token_hash = forms.CharField(required=False, widget=forms.TextInput(attrs={"readonly": "readonly"}))
# Aide à la saisie: durée si expires_at non remplit
expires_at = forms.DateTimeField(
required=False,
input_formats=[_HTML5_FMT], # pour le POST
widget=forms.DateTimeInput(
attrs={"type": "datetime-local"},
format=_HTML5_FMT, # pour l’affichage initial
),
)
ttl_days = forms.IntegerField(required=False, min_value=1, initial=30, help_text="Utilisé si 'expires_at' est vide.")
class Meta:
model = PersonalAccessToken
fields = ("user", "name", "collections", "expires_at", "revoked", "token_hash")
widgets = {
# JSONField par défaut convient; sinon remplacer par un Textarea
# "collections": forms.Textarea(attrs={"rows": 2}),
}
def clean(self):
cleaned = super().clean()
expires_at = cleaned.get("expires_at")
ttl_days = cleaned.get("ttl_days")
if not expires_at:
# Si pas de date fournie, calcule via ttl_days (ou 30 par défaut)
days = ttl_days or 30
cleaned["expires_at"] = timezone.now() + timezone.timedelta(days=days)
return cleaned
@admin.register(PersonalAccessToken)
class PersonalAccessTokenAdmin(admin.ModelAdmin):
form = PersonalAccessTokenForm
list_display = ("user", "name", "expires_at", "revoked", "created_at")
list_filter = ("revoked",)
search_fields = ("user__username", "user__email", "name")
readonly_fields = ("token_hash",) # visible mais non éditable
actions = ["revoke_tokens"]
@admin.action(description="Révoquer les tokens sélectionnés")
def revoke_tokens(self, request, queryset):
updated = queryset.update(revoked=True)
self.message_user(request, f"{updated} token(s) révoqué(s).")
def save_model(self, request, obj, form, change):
"""
À la création, si token_hash est vide:
- Génère un raw token 'pat_...'
- Hash en SHA-256 (même logique que le modèle)
- Sauve l'objet
- Affiche le raw token une seule fois dans un message admin
"""
raw_token_to_display = None
if not change and not obj.token_hash:
# Génère un raw token (même préfixe que la méthode issue)
raw = "pat_" + secrets.token_urlsafe(32)
obj.token_hash = _sha256(raw) # même algo que le modèle
raw_token_to_display = raw
# Remise en forme des collections: set trié, comme dans issue()
if obj.collections:
obj.collections = sorted(set(obj.collections))
# Si l'admin n'a pas rempli expires_at, le form l'a calculé via ttl_days
super().save_model(request, obj, form, change)
if raw_token_to_display:
# Affiche le raw token (copiable) + l'expiration
message = f"Token généré pour {obj.user}: {raw_token_to_display}\nExpire le {obj.expires_at:%Y-%m-%d %H:%M}"
self.message_user(
request,
mark_safe("<pre style='white-space:pre-wrap'>"+escape(message)+"</pre>")
)

6
streetup/api/apps.py Normal file
View file

@ -0,0 +1,6 @@
from django.apps import AppConfig
class ApiConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'api'

124
streetup/api/auth.py Normal file
View file

@ -0,0 +1,124 @@
from django.contrib.auth import get_user_model
from django.utils import timezone
from django.http import HttpRequest
from rest_framework.authentication import SessionAuthentication, get_authorization_header
from .models import PersonalAccessToken
# === AUTH HYBRIDE (JWT court OU PAT opaque) ===
# - JWT: via djangorestframework-simplejwt (RS256)
# - PAT: tokens opaques en DB (lookup + expiration + revoke)
User = get_user_model()
# Import JWTAuthentication uniquement si simplejwt est installé/configuré
try:
from rest_framework_simplejwt.authentication import JWTAuthentication
_jwt_auth = JWTAuthentication()
except Exception: # pragma: no cover
_jwt_auth = None
def collections_for(user) -> list[str]:
"""
Mapping des permissions Django -> collections accessibles via token.
Inclut les collections pygeoapi et les endpoints de reporting.
"""
collections = ["technical_galleries"]
# Accès reporting : superuser ou can_access_view('reporting')
try:
from common.models import UserConfig
user_config = UserConfig.objects.get(user=user)
if user_config.can_access_view("reporting"):
collections.append("reporting")
except Exception:
pass
if user.is_superuser:
if "reporting" not in collections:
collections.append("reporting")
return collections
class HybridTokenAuthentication(SessionAuthentication):
"""
Authentifie les requêtes API de 3 façons:
1) Session (cookie) — via SessionAuthentication (hérité)
2) Authorization: Bearer <JWT | PAT>
3) ?token=<JWT | PAT>
Remplit request.collections (list[str]) quand un JWT/PAT est utilisé.
"""
def authenticate(self, request: HttpRequest):
# 1) D'abord, si un header Bearer / query ?token est présent, tenter JWT/PAT
raw_token: str | None = None
auth = get_authorization_header(request).decode("utf-8")
if auth.startswith("Bearer "):
raw_token = auth.split(" ", 1)[1]
else:
raw_token = request.GET.get("token")
# Si pas trouvé dans header/query, tenter depuis le chemin: /api/geo/pat_xxx/...
if not raw_token:
path = request.path or ""
# On cherche un segment 'pat_' immédiatement après /api/geo/
# Exemple: /api/geo/pat_abCDe.../collections/technical_galleries/items
try:
after_geo = path.split("/geo/", 1)[1] # peut lever IndexError
first_seg = after_geo.split("/", 1)[0]
if first_seg.startswith("pat_"):
raw_token = first_seg
except Exception:
pass
if raw_token:
# Essai JWT
if _jwt_auth is not None:
try:
validated = _jwt_auth.get_validated_token(raw_token)
user = _jwt_auth.get_user(validated)
request.collections = validated.get("collections", [])
return (user, validated)
except Exception:
pass
# Essai PAT
pat = PersonalAccessToken.lookup(raw_token)
if pat is None:
# Échec explicite si un token a été fourni mais est invalide
return None
request.collections = pat.collections
return (pat.user, None)
# 2) Sinon, retomber sur la session (auth interactive)
result = super().authenticate(request)
if result is not None:
# Pour les vues geo, on peut projeter les collections depuis la session
user, x = result
request.collections = collections_for(user)
return result
# === JWT issue utilitaire ===
# (JWT court, claims minimales, signé RS256 via SimpleJWT)
try:
from rest_framework_simplejwt.tokens import AccessToken
def mint_jwt(user, lifetime_minutes: int = 20) -> str:
t = AccessToken()
t["sub"] = str(user.pk)
t["aud"] = "pygeoapi"
t["collections"] = collections_for(user)
t.set_exp(from_time=timezone.now(), lifetime=timezone.timedelta(minutes=int(lifetime_minutes)))
return str(t)
except Exception: # pragma: no cover
def mint_jwt(user, lifetime_minutes: int = 20) -> str: # fallback clair
raise RuntimeError("SimpleJWT n'est pas installé/configuré")

View file

@ -0,0 +1,33 @@
# Generated by Django 5.2.8 on 2026-05-21 13:51
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PersonalAccessToken',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, max_length=120)),
('token_hash', models.CharField(db_index=True, max_length=64, unique=True)),
('collections', models.JSONField(default=list)),
('expires_at', models.DateTimeField()),
('revoked', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='personal_access_tokens', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ('-created_at',),
},
),
]

View file

61
streetup/api/models.py Normal file
View file

@ -0,0 +1,61 @@
from __future__ import annotations
import hashlib
import secrets
from django.conf import settings
from django.contrib.auth import get_user_model
from django.db import models
from django.utils import timezone
User = get_user_model()
def _sha256(s: str) -> str:
return hashlib.sha256(s.encode("utf-8")).hexdigest()
class PersonalAccessToken(models.Model):
"""
Token opaque (PAT) stocké haché pour liens publics ou intégrations.
Révocable instantanément + expiration.
"""
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="personal_access_tokens")
name = models.CharField(max_length=120, blank=True)
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
collections = models.JSONField(default=list) # ex: ["technical-galleries", "assets"]
expires_at = models.DateTimeField()
revoked = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
ordering = ("-created_at",)
def __str__(self) -> str:
return f"PAT({self.user}, {self.name or 'unnamed'})"
@property
def is_expired(self) -> bool:
return timezone.now() >= self.expires_at
@property
def is_active(self) -> bool:
return (not self.revoked) and (not self.is_expired)
@classmethod
def issue(cls, *, user, collections: list[str], ttl_hours: int = 720, name: str = "share link") -> tuple[str, "PersonalAccessToken"]:
raw = "pat_" + secrets.token_urlsafe(32)
obj = cls.objects.create(
user=user,
name=name,
token_hash=_sha256(raw),
collections=sorted(set(collections)),
expires_at=timezone.now() + timezone.timedelta(hours=int(ttl_hours)),
)
return raw, obj
@classmethod
def lookup(cls, raw: str) -> "PersonalAccessToken | None":
try:
obj = cls.objects.get(token_hash=_sha256(raw))
return obj if obj.is_active else None
except cls.DoesNotExist:
return None

3
streetup/api/tests.py Normal file
View file

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

30
streetup/api/urls.py Normal file
View file

@ -0,0 +1,30 @@
from django.urls import path, re_path, include
from .views import (
GeoProxyView,
InterventionListCreateView,
InterventionRetrieveUpdateDestroyView,
IssueJWTView,
SharePATView,
)
app_name = "zapi"
urlpatterns = [
# API root (optionnel: une vue d'info/health)
# path("", APIRootView.as_view(), name="root"),
# ---- Assets -> proxy pygeoapi ----
# /api/geo/... (catch-all)
re_path(r"^geo/.*$", GeoProxyView.as_view(), name="geo-proxy"),
# ---- Interventions (DRF) ----
path("interventions/", InterventionListCreateView.as_view(), name="intervention-list"),
path("interventions/<int:pk>/", InterventionRetrieveUpdateDestroyView.as_view(), name="intervention-detail"),
# ---- Reporting ----
path("reporting/", include("reporting.urls")),
# ---- Tokens utilitaires ----
path("tokens/jwt/", IssueJWTView.as_view(), name="issue-jwt"),
path("tokens/share/", SharePATView.as_view(), name="share-pat"),
]

534
streetup/api/views.py Normal file
View file

@ -0,0 +1,534 @@
from __future__ import annotations
import os
from typing import Any, Optional
import re
from html import parser as html_parser
import httpx
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode, unquote
from rest_framework import generics, permissions, serializers, status
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from django.http import HttpResponse
from .models import PersonalAccessToken
from .auth import HybridTokenAuthentication, collections_for, mint_jwt
# === CONFIG ===
PYGEOAPI_BASE_URL = os.environ.get("PYGEOAPI_BASE_URL")
# === VUES TOKENS ===
class IssueJWTView(APIView):
permission_classes = [IsAuthenticated]
authentication_classes = [HybridTokenAuthentication]
def post(self, request, *args, **kwargs):
token = mint_jwt(request.user, lifetime_minutes=int(request.data.get("lifetime_minutes", 20)))
return Response({"access_token": token, "token_type": "Bearer"})
class SharePATView(APIView):
permission_classes = [IsAuthenticated]
authentication_classes = [HybridTokenAuthentication]
def post(self, request, *args, **kwargs):
req_cols = request.data.get("collections", [])
ttl_h = int(request.data.get("ttl_hours", 720))
allowed = sorted(set(collections_for(request.user)) & set(req_cols)) if req_cols else collections_for(request.user)
raw, obj = PersonalAccessToken.issue(
user=request.user,
collections=allowed,
ttl_hours=ttl_h,
name=request.data.get("name", "share link"),
)
base = request.build_absolute_uri("/api/geo/").rstrip("/")
url_path = f"{base}/{raw}/" # <-- forme /api/geo/pat_xxx/
url_query = request.build_absolute_uri("/api/geo/").rstrip("/") + f"?token={raw}"
return Response({
"token": raw,
"url_path": url_path,
"url_query": url_query,
"expires_at": obj.expires_at,
})
# === PROXY GEO -> PYGEOAPI ===
class GeoProxyView(APIView):
"""
Passerelle /api/geo/... -> pygeoapi.
- Auth: HybridTokenAuthentication (JWT, PAT, ou Session)
- AutZ: filtrage par collection si l'URL cible le détail d'une collection
"""
permission_classes = [IsAuthenticated]
authentication_classes = [HybridTokenAuthentication]
def _strip_prefix(self, full_path: str) -> str:
# /api/geo/... -> /collections/... (chemin attendu par pygeoapi)
path = full_path
if path.startswith("/api"):
path = path[len("/api"):]
if path.startswith("/geo"):
path = path[len("/geo"):]
if not path.startswith("/"):
path = "/" + path
# Si le 1er segment est un PAT (pat_...), on l'enlève du chemin
parts = [p for p in path.split("/") if p]
if parts and parts[0].startswith("pat_"):
parts = parts[1:]
path = "/" + "/".join(parts)
return path
def _check_collection(self, url_or_path: str, allowed: list[str] | None) -> tuple[bool, str | None]:
"""
Retourne:
(True, None) -> accès OK (pas une URL de collection spécifique)
(True, cid) -> accès OK à la collection cid
(False, cid) -> accès refusé à la collection cid
"""
# 1) Extraire le path pur (ignore ?f=json, #hash, etc.)
s = urlsplit(url_or_path)
path = s.path or url_or_path
path = unquote(path)
# 2) Trouver /collections/ n'importe où dans le path
needle = "/collections/"
i = path.lower().find(needle)
if i == -1:
# Pas une URL /collections/{cid} -> OK
return True, None
# 3) Récupérer le segment cid (ce qui suit immédiatement /collections/)
after = path[i + len(needle):]
if not after:
# /collections (listing) -> OK
return True, None
cid = after.split("/", 1)[0]
if not cid:
# /collections/ sans id -> traiter comme listing
return True, None
# 4) Vérifier l'autorisation (si filtrage actif)
if allowed is None:
return True, cid
return (cid in allowed, cid)
def request_upstream(self, request, upstream: str, *, path: str, allowed: list[str] | None = None):
headers = {k: v for k, v in request.headers.items() if k.lower() not in {"host", "content-length"}}
try:
r = httpx.request(
method=request.method,
url=upstream,
headers=headers,
params=request.GET,
content=(getattr(request, "body", None) if request.method in {"POST", "PUT", "PATCH"} else None),
timeout=15.0,
)
except httpx.RequestError as exc:
return Response({"detail": f"Upstream error: {exc}"}, status=status.HTTP_502_BAD_GATEWAY)
# --- NEW: renvoi correct selon le Content-Type ---
content_type = (r.headers.get("content-type") or "").split(";")[0].strip().lower()
pat = _extract_pat_from_request(request)
mode = _choose_pat_mode(request)
allowed_set: set[str] | None
if allowed is None:
allowed_set = None
else:
allowed_set = {c for c in allowed if c}
path_no_query = path.split("?", 1)[0]
path_for_filter = (path_no_query.rstrip("/") or "/") if path_no_query else "/"
# Headers Location (redirections)
def _copy_headers(resp):
# Ne pas copier Content-Encoding pour éviter la double compression (Django GZipMiddleware + pygeoapi)
for h in ("Content-Disposition", "Cache-Control", "ETag", "Last-Modified"):
if h in r.headers:
resp[h] = r.headers[h]
loc = r.headers.get("Location")
if loc and pat:
resp["Location"] = _rewrite_api_geo_everywhere(loc, pat, mode=mode)
return resp
# JSON
try:
data = r.json()
if allowed_set is not None and path_for_filter == "/collections" and isinstance(data, dict):
data = _filter_collections_json(data, allowed_set)
def _walk(o):
if isinstance(o, dict):
return {k: _walk(v) for k, v in o.items()}
if isinstance(o, list):
return [_walk(v) for v in o]
if isinstance(o, str) and "/api/geo" in o:
return _rewrite_api_geo_everywhere(o, pat, mode=mode)
return o
data = _walk(data)
resp = Response(data, status=r.status_code)
return _copy_headers(resp)
except ValueError:
pass
# HTML / texte
if content_type in {"text/html", "text/plain", "application/xml", "text/xml"}:
text = r.text
if allowed_set is not None and path_for_filter == "/collections":
text = _filter_collections_html(text, allowed_set)
if pat and "/api/geo" in text:
text = _rewrite_api_geo_everywhere(text, pat, mode=mode)
resp = HttpResponse(text, status=r.status_code, content_type=r.headers.get("content-type"))
return _copy_headers(resp)
# Binaire inchangé
resp = HttpResponse(r.content, status=r.status_code, content_type=r.headers.get("content-type"))
return _copy_headers(resp)
def get(self, request, *args, **kwargs):
path = self._strip_prefix(request.get_full_path())
ok, cid = self._check_collection(path, getattr(request, "collections", []))
if not ok:
return Response({"detail": f"Forbidden collection: {cid}"}, status=status.HTTP_403_FORBIDDEN)
upstream = f"{PYGEOAPI_BASE_URL}{path}"
return self.request_upstream(request, upstream, path=path, allowed=getattr(request, "collections", None))
# Optionnel: POST/PUT/PATCH/DELETE si tu as des opérations d'écriture pygeoapi
def post(self, request, *args, **kwargs):
path = self._strip_prefix(request.get_full_path())
ok, cid = self._check_collection(path, getattr(request, "collections", []))
if not ok:
return Response({"detail": f"Forbidden collection: {cid}"}, status=status.HTTP_403_FORBIDDEN)
upstream = f"{PYGEOAPI_BASE_URL}{path}"
return self.request_upstream(request, upstream, path=path, allowed=getattr(request, "collections", None))
# === DRF pur ===
from interventions.models import Intervention
class InterventionSerializer(serializers.ModelSerializer):
class Meta:
model = Intervention
fields = ("code","title","status","creation_time")
class InterventionListCreateView(generics.ListCreateAPIView):
"""
GET /api/interventions/?status=to_be_processed&status=in_progress
POST /api/interventions/
"""
permission_classes = [IsAuthenticated]
authentication_classes = [HybridTokenAuthentication]
# TODO: remplacer par le queryset réel:
queryset = []
serializer_class = InterventionSerializer
def get_queryset(self):
qs = super().get_queryset()
statuses = self.request.query_params.getlist("status")
if statuses:
# TODO: adapter au champ status réel
# qs = qs.filter(status__in=statuses)
pass
return qs
class InterventionRetrieveUpdateDestroyView(generics.RetrieveUpdateDestroyAPIView):
permission_classes = [IsAuthenticated]
authentication_classes = [HybridTokenAuthentication]
# TODO: remplacer par le queryset réel:
queryset = []
serializer_class = InterventionSerializer
def _extract_pat_from_request(request):
auth = request.headers.get("Authorization", "")
if auth.lower().startswith("bearer pat_"):
return auth.split(None, 1)[1].strip()
tok = request.GET.get("token")
if tok and tok.startswith("pat_"):
return tok
path = request.path or ""
try:
after_geo = path.split("/geo/", 1)[1]
seg0 = after_geo.split("/", 1)[0]
if seg0.startswith("pat_"):
return seg0
except Exception:
pass
return None
def _choose_pat_mode(request) -> str:
# Forçage manuel
forced = request.GET.get("_pat_mode")
if forced in {"path", "query"}:
return forced
# Si PAT en query -> réécritures en query, sinon path
if request.GET.get("token", "").startswith("pat_"):
return "query"
return "path"
# Capture TOUTE l’URL /api/geo… (absolue ou relative), y compris ?query et #fragment,
# mais en évitant le cas où un /pat_… est déjà juste après /api/geo/
_PATTERN_API_GEO_URL = re.compile(
r'((?:(?:https?://|//)[^/\'"\s>]+)?' # schéma + host éventuel
r'/api/geo' # base
r'(?:/(?!pat_[^/]+)[^\'"\s>]*)?' # suite de chemin (sans /pat_… juste après)
r'(?:\?[^#\'"\s>]*)?' # query facultative
r'(?:#[^\'"\s>]*)?' # fragment facultatif
r')'
)
def _rewrite_api_geo_everywhere(text: str, pat: str, mode: str = "path") -> str:
"""
Réécrit toutes les URLs /api/geo… :
- mode="path" -> injecte /pat_xxx/ après /api/geo
- mode="query" -> ajoute ?token=pat_xxx (ou &token=…)
Ne double pas si déjà présent (/pat_… ou token=…).
"""
if not pat or not text:
return text
def _repl(m):
url = m.group(1)
parts = urlsplit(url)
# parts.path commence par /api/geo…
after = parts.path.split("/api/geo", 1)[1] # "", "/...", etc.
if mode == "path":
# si déjà /pat_… juste après /api/geo, ne rien faire
if after.startswith("/") and after[1:].startswith("pat_"):
return url
new_path = parts.path.replace("/api/geo", f"/api/geo/{pat}", 1)
return urlunsplit((parts.scheme, parts.netloc, new_path, parts.query, parts.fragment))
# mode "query"
# si token déjà présent, ne rien faire
q = parse_qsl(parts.query or "", keep_blank_values=True)
if any(k == "token" for k, _ in q):
return url
q.append(("token", pat))
new_query = urlencode(q, doseq=True)
return urlunsplit((parts.scheme, parts.netloc, parts.path, new_query, parts.fragment))
return _PATTERN_API_GEO_URL.sub(_repl, text)
def _filter_collections_json(data: dict[str, Any], allowed: set[str]) -> dict[str, Any]:
"""Filtre la réponse JSON /collections selon les collections autorisées."""
collections = data.get("collections")
if isinstance(collections, list):
filtered = []
for item in collections:
if not isinstance(item, dict):
continue
cid = item.get("id")
if cid in allowed:
filtered.append(item)
data["collections"] = filtered
links = data.get("links")
if isinstance(links, list):
filtered_links = []
for link in links:
if not isinstance(link, dict):
filtered_links.append(link)
continue
href = link.get("href", "")
cid = _extract_collection_id_from_href(str(href))
if cid is None or cid in allowed:
filtered_links.append(link)
data["links"] = filtered_links
return data
def _extract_collection_id_from_href(href: str) -> str | None:
if not href:
return None
ref = href.strip()
if not ref:
return None
# Normalise la partie chemin
path = ref
if "?" in path:
path = path.split("?", 1)[0]
if "#" in path:
path = path.split("#", 1)[0]
if "collections/" in path:
segment = path.split("collections/", 1)[1]
else:
segment = path
segment = segment.lstrip("./")
while segment.startswith("../"):
segment = segment[3:]
segment = segment.lstrip("/")
if not segment:
return None
cid = segment.split("/", 1)[0]
if not cid:
return None
# on évite de considérer des liens génériques (items, schema, etc.)
if cid in {"items", "query", "schema", "coverage", "tiles", "map"}:
return None
return cid
# -------------------------------------------------------------------
# HTML Filtering
# -------------------------------------------------------------------
_COLLECTION_HREF_RE = re.compile(r"/collections/([^/?#]+)", re.I)
class _CollectionHTMLFilter(html_parser.HTMLParser):
CONTAINER_TAGS = {"tr", "li", "article", "div", "section"}
# HTML void elements (no end tag is emitted by the parser)
VOID_TAGS = {
"area", "base", "br", "col", "embed", "hr", "img", "input",
"link", "meta", "param", "source", "track", "wbr"
}
def __init__(self, allowed: Optional[set[str]]):
super().__init__(convert_charrefs=False)
self.allowed = allowed
self.stack = [{"tag": None, "buffer": [], "ids": set(), "container": False}]
def _push(self, tag: Optional[str], container: bool = False):
self.stack.append({"tag": tag, "buffer": [], "ids": set(), "container": container})
def _pop(self):
return self.stack.pop()
def _append(self, s: str):
self.stack[-1]["buffer"].append(s)
def get_html(self) -> str:
return "".join(self.stack[0]["buffer"])
def handle_decl(self, decl: str):
self._append(f"<!{decl}>")
def handle_starttag(self, tag: str, attrs: list[tuple[str, Optional[str]]]):
t = tag.lower()
# Build attributes
if attrs:
a = " ".join(
f'{k}="{(v or "").replace("\"", "&quot;")}"' if v is not None else f"{k}"
for k, v in attrs
)
open_tag = f"<{tag} {a}>"
else:
open_tag = f"<{tag}>"
# Void elements: append and DO NOT push (no end tag will arrive)
if t in self.VOID_TAGS:
self._append(open_tag)
return
# Non-void: push a new frame and append the start tag inside that frame
container = t in self.CONTAINER_TAGS
self._push(tag, container)
self._append(open_tag)
# Detect collection IDs on <a href=".../collections/{id}">
if t == "a":
href = None
for k, v in attrs:
if k.lower() == "href":
href = v or ""
break
if href:
m = _COLLECTION_HREF_RE.search(href)
if m:
cid = m.group(1)
# mark current and nearest container
self.stack[-1]["ids"].add(cid)
for frame in reversed(self.stack):
if frame["container"]:
frame["ids"].add(cid)
break
def handle_endtag(self, tag: str):
t = tag.lower()
# If the stack top isn't the same tag, avoid popping (robustness)
if not self.stack or self.stack[-1]["tag"] is None or self.stack[-1]["tag"].lower() != t:
# Still emit the closing tag for best-effort rendering
self._append(f"</{tag}>")
return
# Close normally
self._append(f"</{tag}>")
frame = self._pop()
if frame["container"] and self.allowed is not None:
ids = frame["ids"]
keep = (not ids) or any(cid in self.allowed for cid in ids)
if not keep:
# Drop the entire container block
return
# Keep the content (opening+children+closing) by appending to parent
self._append("".join(frame["buffer"]))
def handle_startendtag(self, tag: str, attrs: list[tuple[str, Optional[str]]]):
# Treat as a void-like tag
if attrs:
a = " ".join(
f'{k}="{(v or "").replace("\"", "&quot;")}"' if v is not None else f"{k}"
for k, v in attrs
)
self._append(f"<{tag} {a} />")
else:
self._append(f"<{tag} />")
def handle_data(self, data: str):
self._append(data)
def handle_comment(self, data: str):
self._append(f"<!--{data}-->")
def handle_entityref(self, name: str):
self._append(f"&{name};")
def handle_charref(self, name: str):
self._append(f"&#{name};")
def _filter_collections_html(html: str, allowed: Optional[set[str]]) -> str:
if not html:
return "<!DOCTYPE html>\n"
if allowed is None:
out = html
if "<!DOCTYPE" not in out[:300].upper():
out = "<!DOCTYPE html>\n" + out
return out
parser = _CollectionHTMLFilter(allowed)
parser.feed(html)
parser.close()
print(parser.stack)
out = parser.get_html()
if "<!DOCTYPE" not in out[:300].upper():
out = "<!DOCTYPE html>\n" + out
return out

View file

653
streetup/assets/admin.py Normal file
View file

@ -0,0 +1,653 @@
from django.contrib.gis import admin
from django.contrib.contenttypes.admin import GenericTabularInline
from django import forms
from documents.models import DocumentFolder, DocumentFolderAttachment
from .models import TrafficLightIntersection, TrafficLightIntersectionDocument
from .models import TrafficLightPoleModel, TrafficLightPole, TrafficLightCableModel, TrafficLightCable, TrafficLightElectricalCabinet, TrafficLightElectricalCabinetModel
from .models import TrafficLightLanternModel, TrafficLightLantern, TrafficLightControllerModel, TrafficLightController, TrafficLightDetector, TrafficLightDetectorModel
from .models import TrafficLightContract, TrafficLightRadarModel, TrafficLightRadar
from .models import (
TrafficLightRadarPoleModel, TrafficLightRadarPole, TrafficLightElectricalCabinetContentModel,
TrafficLightElectricalCabinetContent, TrafficLightAccessoryModel, TrafficLightAccessory,
TrafficLightProgramming, TrafficLightControllerHardware, TrafficLightControllerLogFile,
TrafficLightNetworkHardware, TrafficLightVoltageHardware
)
from .models import SignStreet
from .models import AssetCategory, AssetHistory
from .models import GreenSurfacesComment, GreenSurfacesCommentPhoto
from .models import CleanGlassContainer, CleanGlassContainerModel, CleanLitterBin, CleanLitterBinModel, CleanLocation, CleanLocationContract
class AssetAdmin(admin.ModelAdmin):
def get_search_fields(self, request):
search_fields = list(super().get_search_fields(request) or [])
model_fields = {f.name for f in self.model._meta.get_fields()}
for field in ["code", "name_fr", "name_nl"]:
if field in model_fields and field not in search_fields:
search_fields.append(field)
return search_fields
def get_list_filter(self, request):
list_filter = list(super().get_list_filter(request) or [])
model_fields = {f.name for f in self.model._meta.get_fields()}
if "model" in model_fields and "model" not in list_filter:
list_filter.append("model")
return list_filter
class AssetGISAdmin(admin.GISModelAdmin):
gis_widget_kwargs = {
'attrs': {
"default_lon" : 4.3517, # Bruxelles
"default_lat" : 50.8503,
"default_zoom" : 12
},
}
def get_search_fields(self, request):
search_fields = list(super().get_search_fields(request) or [])
model_fields = {f.name for f in self.model._meta.get_fields()}
for field in ["code", "name_fr", "name_nl"]:
if field in model_fields and field not in search_fields:
search_fields.append(field)
return search_fields
def get_list_filter(self, request):
list_filter = list(super().get_list_filter(request) or [])
model_fields = {f.name for f in self.model._meta.get_fields()}
if "model" in model_fields and "model" not in list_filter:
list_filter.append("model")
return list_filter
@admin.register(TrafficLightContract)
class TrafficLightContractAdmin(AssetAdmin):
search_fields = ("intersection__code", "contract__contract_number")
list_display = ("intersection", "contract")
list_filter = ("contract",)
class TrafficLightContractAdminInline(admin.TabularInline):
model = TrafficLightContract
extra = 1
@admin.register(TrafficLightIntersection)
class TrafficLightIntersectionAdmin(AssetAdmin):
search_fields = ("code", "name_fr", "name_nl")
list_display = ("code", "name_fr", "municipality")
list_filter = ("municipality",)
inlines = [TrafficLightContractAdminInline,]
admin.site.register(TrafficLightIntersectionDocument, AssetAdmin)
admin.site.register(TrafficLightPoleModel, AssetAdmin)
admin.site.register(TrafficLightPole, AssetAdmin)
admin.site.register(TrafficLightCableModel, AssetAdmin)
admin.site.register(TrafficLightCable, AssetAdmin)
admin.site.register(TrafficLightLanternModel, AssetAdmin)
admin.site.register(TrafficLightLantern, AssetAdmin)
admin.site.register(TrafficLightControllerModel, AssetAdmin)
admin.site.register(TrafficLightDetector, AssetAdmin)
admin.site.register(TrafficLightDetectorModel, AssetAdmin)
admin.site.register(TrafficLightElectricalCabinetModel, AssetAdmin)
admin.site.register(TrafficLightElectricalCabinet, AssetAdmin)
admin.site.register(TrafficLightRadarModel, AssetAdmin)
admin.site.register(TrafficLightRadar, AssetAdmin)
admin.site.register(TrafficLightRadarPoleModel, AssetAdmin)
admin.site.register(TrafficLightRadarPole, AssetAdmin)
admin.site.register(TrafficLightElectricalCabinetContentModel, AssetAdmin)
admin.site.register(TrafficLightElectricalCabinetContent, AssetAdmin)
admin.site.register(TrafficLightAccessoryModel, AssetAdmin)
admin.site.register(TrafficLightAccessory, AssetAdmin)
admin.site.register(TrafficLightProgramming, AssetAdmin)
admin.site.register(TrafficLightControllerHardware, AssetAdmin)
admin.site.register(TrafficLightControllerLogFile, AssetAdmin)
admin.site.register(TrafficLightNetworkHardware, AssetAdmin)
admin.site.register(TrafficLightVoltageHardware, AssetAdmin)
@admin.register(TrafficLightController)
class TrafficLightControllerAdmin(AssetAdmin):
search_fields = ("intersection__code",)
list_display = ("intersection", "connected_to_control_center", "controller_maintenance_contract")
list_filter = ("connected_to_control_center", "controller_maintenance_contract")
@admin.register(AssetCategory)
class AssetCategoryAdmin(AssetAdmin):
search_fields = ("code", "name_fr", "name_nl")
list_display = ("code", "get_name", "thematic", "parent")
list_filter = ("thematic", "parent")
autocomplete_fields = ("parent",)
filter_horizontal = ("allowed_models",)
change_list_template = "admin/assets/assetcategory/change_list.html"
def get_form(self, request, obj=None, **kwargs):
form = super().get_form(request, obj, **kwargs)
if "parent" in form.base_fields:
# Limiter les choix du parent aux catégories sans parent (1 niveau max)
form.base_fields["parent"].queryset = AssetCategory.objects.filter(parent__isnull=True)
return form
def get_urls(self):
from django.urls import path as urlpath
urls = super().get_urls()
custom_urls = [
urlpath(
"import-excel/",
self.admin_site.admin_view(self.import_excel_view),
name="assets_assetcategory_import_excel",
),
urlpath(
"download-template/",
self.admin_site.admin_view(self.download_template_view),
name="assets_assetcategory_download_template",
),
]
return custom_urls + urls
# ------------------------------------------------------------------
def import_excel_view(self, request):
import io
import tempfile
import os
from django.contrib import messages
from django.shortcuts import render, redirect
from django.urls import reverse
from assets.management.commands.import_assets_from_excel import Command as ImportCommand
from assets.management.commands.import_assets_from_excel import COLUMNS, COLUMN_HINTS
column_guide = [
{"key": key, "desc": COLUMN_HINTS.get(key, "")}
for key, _label in COLUMNS
]
context = {
**self.admin_site.each_context(request),
"title": "Import d'assets depuis Excel",
"opts": self.model._meta,
"column_guide": column_guide,
}
if request.method == "POST":
uploaded = request.FILES.get("excel_file")
if not uploaded:
messages.error(request, "Aucun fichier sélectionné.")
return render(request, "admin/assets/assetcategory/import_excel.html", context)
if not uploaded.name.endswith(".xlsx"):
messages.error(request, "Seuls les fichiers .xlsx sont acceptés.")
return render(request, "admin/assets/assetcategory/import_excel.html", context)
dry_run = "dry_run" in request.POST
update = "update" in request.POST
# Save file to a temp path so the command can open it
suffix = ".xlsx"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
for chunk in uploaded.chunks():
tmp.write(chunk)
tmp_path = tmp.name
cmd = ImportCommand()
# Capture stdout / stderr via StringIO-compatible wrapper
class _Writer:
def __init__(self):
self.lines = []
def write(self, msg):
self.lines.append(str(msg))
def flush(self):
pass
out = _Writer()
err = _Writer()
cmd.stdout = out
cmd.stderr = err
import_error = None
try:
cmd._import(tmp_path, dry_run=dry_run, update=update)
except Exception as e:
import_error = str(e)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
output_lines = out.lines + err.lines
context["output_lines"] = output_lines
context["dry_run"] = dry_run
context["import_error"] = import_error
if import_error:
messages.error(request, f"Erreur lors de l'import : {import_error}")
elif dry_run:
messages.warning(request, "Dry-run terminé – aucune donnée modifiée.")
else:
messages.success(request, "Import terminé avec succès.")
return render(request, "admin/assets/assetcategory/import_excel.html", context)
return render(request, "admin/assets/assetcategory/import_excel.html", context)
# ------------------------------------------------------------------
def download_template_view(self, request):
import io
from django.http import HttpResponse
from assets.management.commands.import_assets_from_excel import generate_template
buf = io.BytesIO()
# generate_template expects a file path, so use a temp file
import tempfile
import os
with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp:
tmp_path = tmp.name
try:
generate_template(tmp_path)
with open(tmp_path, "rb") as f:
data = f.read()
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
response = HttpResponse(
data,
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
response["Content-Disposition"] = 'attachment; filename="assets_import_template.xlsx"'
return response
@admin.register(AssetHistory)
class AssetHistoryAdmin(AssetAdmin):
list_display = ("timestamp", "content_type", "object_id", "user", "field_name", "action_type")
list_filter = ("action_type", "content_type", "timestamp")
search_fields = ("user__username", "field_name", "object_id")
readonly_fields = ("content_type", "object_id", "user", "timestamp", "field_name", "old_value", "new_value", "action_type")
date_hierarchy = "timestamp"
def has_add_permission(self, request):
# Prevent manual creation
return False
def has_change_permission(self, request, obj=None):
# Read-only
return False
# ============================================================================
# Public Lighting
# ============================================================================
from .models import (
PublicLightingStreet, PublicLightingStreetDocument, PublicLightingPoleModel,
PublicLightingPole, PublicLightingLightFixtureModel, PublicLightingLightFixture,
PublicLightingElectricalCabinetModel, PublicLightingElectricalCabinet
)
admin.site.register(PublicLightingStreet, AssetAdmin)
admin.site.register(PublicLightingStreetDocument, AssetAdmin)
admin.site.register(PublicLightingPoleModel, AssetAdmin)
admin.site.register(PublicLightingPole, AssetAdmin)
admin.site.register(PublicLightingLightFixtureModel, AssetAdmin)
admin.site.register(PublicLightingLightFixture, AssetAdmin)
admin.site.register(PublicLightingElectricalCabinetModel, AssetAdmin)
admin.site.register(PublicLightingElectricalCabinet, AssetAdmin)
# ============================================================================
# Artworks
# ============================================================================
from .models import (
ArtworkStreet, ArtworkMonument, ArtworkFountain,
ArtworkFountainDocument, ArtworkMonumentDocument
)
admin.site.register(ArtworkStreet, AssetAdmin)
admin.site.register(ArtworkMonument, AssetAdmin)
admin.site.register(ArtworkFountain, AssetAdmin)
admin.site.register(ArtworkFountainDocument, AssetAdmin)
admin.site.register(ArtworkMonumentDocument, AssetAdmin)
from .models import Structure, StructureLocation, StructureDocument, StructureAssetModel, StructureGeoAsset, StructureContract
def make_folder_attachment_inline(thematic_code):
"""
Factory pour créer un inline DocumentFolderAttachment filtré par thématique.
Args:
thematic_code: Le code de la thématique (ex: 'structures', 'control_centers')
"""
class FolderAttachmentForm(forms.ModelForm):
class Meta:
model = DocumentFolderAttachment
fields = ['folder', 'is_default']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Filtrer les dossiers par thématique
self.fields['folder'].queryset = DocumentFolder.objects.filter(
thematics__code=thematic_code
).distinct().order_by('name')
class DocumentFolderAttachmentInline(GenericTabularInline):
model = DocumentFolderAttachment
form = FolderAttachmentForm
extra = 1
fields = ('folder', 'is_default')
verbose_name = "Dossier documentaire"
verbose_name_plural = "Dossiers documentaires"
return DocumentFolderAttachmentInline
# Inlines spécifiques par thématique
StructureFolderAttachmentInline = make_folder_attachment_inline('structures')
ControlCenterFolderAttachmentInline = make_folder_attachment_inline('control_centers')
TrafficLightFolderAttachmentInline = make_folder_attachment_inline('traffic_lights')
@admin.register(Structure)
class StructureAdmin(AssetAdmin):
search_fields = ("code", "name_fr", "name_nl")
list_display = ("code", "name_fr", "structure_type")
list_filter = ("structure_type",)
inlines = [StructureFolderAttachmentInline]
@admin.register(StructureLocation)
class StructureLocationAdmin(AssetAdmin):
search_fields = ("code", "name_fr", "name_nl")
admin.site.register(StructureDocument, AssetAdmin)
admin.site.register(StructureAssetModel, AssetAdmin)
admin.site.register(StructureGeoAsset, AssetAdmin)
admin.site.register(StructureContract, AssetAdmin)
from .models import ControlCenter, ControlCenterLocation, ControlCenterDocument, ControlCenterAssetModel, ControlCenterGeoAsset, ControlCenterContract, ControlCenterGeoAssetContract, ControlCenterAssetDocument
@admin.register(ControlCenter)
class ControlCenterAdmin(AssetAdmin):
search_fields = ("code", "name_fr", "name_nl")
inlines = [ControlCenterFolderAttachmentInline]
@admin.register(ControlCenterLocation)
class ControlCenterLocationAdmin(AssetAdmin):
search_fields = ("code", "name_fr", "name_nl")
admin.site.register(ControlCenterDocument, AssetAdmin)
admin.site.register(ControlCenterAssetModel, AssetAdmin)
admin.site.register(ControlCenterGeoAsset, AssetAdmin)
admin.site.register(ControlCenterContract, AssetAdmin)
admin.site.register(ControlCenterGeoAssetContract, AssetAdmin)
admin.site.register(ControlCenterAssetDocument, AssetAdmin)
@admin.register(SignStreet)
class SignStreetAdmin(AssetAdmin):
search_fields = ("code", "name_fr", "name_nl")
list_display = ("code", "name_fr", "name_nl", "municipality")
list_filter = ("municipality",)
# ============================================================================
# Sign
# ============================================================================
from .models import (
SignStreetDocument, SignPoleModel, SignPole, SignPanelModel,
SignPanelType, SignPanel, ComplementaryPoliceRegulation, SignParkingSpot
)
admin.site.register(SignStreetDocument, AssetAdmin)
admin.site.register(SignPoleModel, AssetAdmin)
admin.site.register(SignPole, AssetAdmin)
admin.site.register(SignPanelModel, AssetAdmin)
admin.site.register(SignPanelType, AssetAdmin)
admin.site.register(SignPanel, AssetAdmin)
admin.site.register(ComplementaryPoliceRegulation, AssetAdmin)
admin.site.register(SignParkingSpot, AssetAdmin)
from .models import ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract
admin.site.register(ITSAssetModel, AssetAdmin)
@admin.register(ITSGeoAsset)
class ITSGeoAssetAdmin(AssetAdmin):
search_fields = ("code", "model__name_fr", "location__code")
list_display = ("code", "model", "location")
list_filter = ("model", "location")
@admin.register(ITSGeoAssetContract)
class ITSGeoAssetContractAdmin(AssetAdmin):
search_fields = ("asset__code", "contract__contract_number")
list_display = ("asset", "contract")
list_filter = ("contract",)
from .models import ITSLocation, ITSLocationDocument, ITSLocationContract
admin.site.register(ITSLocation, AssetAdmin)
admin.site.register(ITSLocationDocument, AssetAdmin)
admin.site.register(ITSLocationContract, AssetAdmin)
from .models import NatureAssetModel, NatureRWIAsset, NatureRWIAssetDocument
from .models import (
NatureLocation, NatureGreenSurface, NatureGreenSurfaceDocument,
NatureTree, NatureShrub, NatureBench, NatureRWIZ
)
admin.site.register(NatureAssetModel, AssetAdmin)
admin.site.register(NatureLocation, AssetAdmin)
admin.site.register(NatureGreenSurface, AssetAdmin)
admin.site.register(NatureGreenSurfaceDocument, AssetAdmin)
admin.site.register(NatureTree, AssetAdmin)
admin.site.register(NatureShrub, AssetAdmin)
admin.site.register(NatureBench, AssetAdmin)
admin.site.register(NatureRWIZ, AssetAdmin)
class CustomNatureGeoAdmin(AssetGISAdmin):
gis_widget_kwargs = {
'attrs': {
"default_lon" : 4.3517, # Bruxelles
"default_lat" : 50.8503,
"default_zoom" : 12
},
}
@admin.register(NatureRWIAsset)
class NatureRWIAssetAdmin(CustomNatureGeoAdmin):
list_display = ("code", "code_name", "structure_type", "geometry_type", "project_manager", "condition")
list_filter = ("structure_type", "geometry_type", "project_manager", "condition", "construction_status")
search_fields = ("code", "external_id", "administrator", "comment")
change_list_template = "admin/assets/naturerwiasset/change_list.html"
def get_fields(self, request, obj=None):
fields = list(super().get_fields(request, obj))
if obj and obj.geometry_type != 'point':
if 'has_filter' in fields:
fields.remove('has_filter')
return fields
def get_urls(self):
from django.urls import path as urlpath
urls = super().get_urls()
custom_urls = [
urlpath(
"import-rwi/",
self.admin_site.admin_view(self.import_rwi_view),
name="assets_naturerwiasset_import_rwi",
),
]
return custom_urls + urls
def import_rwi_view(self, request):
import io
import os
import tempfile
from django.core.management import call_command
from django.contrib import messages
from django.shortcuts import render
context = {
**self.admin_site.each_context(request),
"title": "Importation du GeoPackage RWI",
"opts": self.model._meta,
}
if request.method == "POST":
dry_run = "dry_run" in request.POST
gpkg_file = request.FILES.get("gpkg_file")
if not gpkg_file:
messages.error(request, "Aucun fichier GeoPackage sélectionné.")
return render(request, "admin/assets/naturerwiasset/import_rwi.html", context)
if not gpkg_file.name.endswith(".gpkg"):
messages.error(request, "Seuls les fichiers .gpkg sont acceptés.")
return render(request, "admin/assets/naturerwiasset/import_rwi.html", context)
# Save uploaded file to a temporary file
with tempfile.NamedTemporaryFile(suffix=".gpkg", delete=False) as tmp:
for chunk in gpkg_file.chunks():
tmp.write(chunk)
tmp_path = tmp.name
stdout_stream = io.StringIO()
stderr_stream = io.StringIO()
import_error = None
try:
if dry_run:
call_command("import_rwi", tmp_path, "--dry-run", stdout=stdout_stream, stderr=stderr_stream)
else:
call_command("import_rwi", tmp_path, stdout=stdout_stream, stderr=stderr_stream)
except Exception as e:
import_error = str(e)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
output_lines = (stdout_stream.getvalue() + stderr_stream.getvalue()).split("\n")
context["output_lines"] = output_lines
context["dry_run"] = dry_run
context["import_error"] = import_error
if import_error:
messages.error(request, f"Erreur lors de l'importation : {import_error}")
elif dry_run:
messages.warning(request, "Simulation d'importation (dry-run) terminée – aucune donnée n'a été modifiée.")
else:
messages.success(request, "Importation du GeoPackage RWI terminée avec succès !")
return render(request, "admin/assets/naturerwiasset/import_rwi.html", context)
return render(request, "admin/assets/naturerwiasset/import_rwi.html", context)
admin.site.register(NatureRWIAssetDocument, AssetAdmin)
# ============================================================================
# Green Surfaces Comments Admin
# ============================================================================
class GreenSurfacesCommentPhotoInline(admin.TabularInline):
model = GreenSurfacesCommentPhoto
extra = 1
fields = ('photo', 'heading', 'order')
readonly_fields = ('created_at',)
@admin.register(GreenSurfacesComment)
class GreenSurfacesCommentAdmin(AssetAdmin):
list_display = ('id', 'comment_preview', 'created_by', 'created_at', 'address')
list_filter = ('created_at', 'created_by')
search_fields = ('comment', 'address', 'created_by__username')
readonly_fields = ('created_at', 'updated_at', 'geom')
inlines = [GreenSurfacesCommentPhotoInline]
fieldsets = (
('Commentaire', {
'fields': ('comment', 'created_by')
}),
('Localisation', {
'fields': ('latitude', 'longitude', 'geom', 'address')
}),
('Métadonnées', {
'fields': ('created_at', 'updated_at')
}),
)
def comment_preview(self, obj):
return obj.comment[:50] + '...' if len(obj.comment) > 50 else obj.comment
comment_preview.short_description = 'Commentaire'
@admin.register(GreenSurfacesCommentPhoto)
class GreenSurfacesCommentPhotoAdmin(AssetAdmin):
list_display = ('id', 'comment', 'heading', 'order', 'created_at')
list_filter = ('created_at',)
search_fields = ('comment__comment',)
readonly_fields = ('created_at',)
# ============================================================================
# Roads
# ============================================================================
from .models import (
RoadStreet, RoadStreetDocument, RoadwayModel, Roadway,
RoadSidewalkModel, RoadSidewalk, RoadFurnitureModel, RoadFurniture,
RoadwayFunction, RoadConcreteBlockSet, RoadDrainModel, RoadDrain
)
admin.site.register(RoadStreet, AssetAdmin)
admin.site.register(RoadStreetDocument, AssetAdmin)
admin.site.register(RoadwayModel, AssetAdmin)
admin.site.register(Roadway, AssetAdmin)
admin.site.register(RoadSidewalkModel, AssetAdmin)
admin.site.register(RoadSidewalk, AssetAdmin)
admin.site.register(RoadFurnitureModel, AssetAdmin)
admin.site.register(RoadFurniture, AssetAdmin)
admin.site.register(RoadwayFunction, AssetAdmin)
admin.site.register(RoadConcreteBlockSet, AssetAdmin)
admin.site.register(RoadDrainModel, AssetAdmin)
admin.site.register(RoadDrain, AssetAdmin)
@admin.register(CleanLocation)
class CleanLocationAdmin(AssetAdmin):
search_fields = ("code", "name_fr", "name_nl")
list_display = ("code", "name_fr", "name_nl", "municipality")
list_filter = ("municipality",)
admin.site.register(CleanLitterBinModel, AssetAdmin)
admin.site.register(CleanLitterBin, AssetAdmin)
admin.site.register(CleanGlassContainerModel, AssetAdmin)
admin.site.register(CleanGlassContainer, AssetAdmin)
admin.site.register(CleanLocationContract, AssetAdmin)
from .models import CleanLitterBinPhoto, CleanLitterBinObservation
admin.site.register(CleanLitterBinPhoto, AssetAdmin)
admin.site.register(CleanLitterBinObservation, AssetAdmin)

49
streetup/assets/apps.py Normal file
View file

@ -0,0 +1,49 @@
from django.apps import AppConfig
class AssetsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'assets'
def ready(self):
"""Import signals when app is ready"""
import assets.signals
# Register private file guards
from common.private_files.registry import register
# Guard for green surfaces comment photos
def guard_green_surfaces_comment_photo(obj, user):
"""
Allow access to comment photos for users with 'nature' thematic access.
"""
from assets.models.nature import GreenSurfacesCommentPhoto
from common.models import UserThematics
if not isinstance(obj, GreenSurfacesCommentPhoto):
return False
if not user.is_authenticated:
return False
# Check if user has access to 'nature' thematic
user_thematics = UserThematics.objects.filter(
user_config__user=user,
thematic__code='nature'
).exists()
return user_thematics
register('assets', 'GreenSurfacesCommentPhoto', 'photo', guard_green_surfaces_comment_photo)
# Guard for traffic light controller log files
def guard_tl_controller_log(obj, user):
from common.models import UserThematics
if not user.is_authenticated:
return False
return UserThematics.objects.filter(
user_config__user=user,
thematic__code='trafficlights'
).exists()
register('assets', 'TrafficLightControllerLogFile', 'file', guard_tl_controller_log)

273
streetup/assets/forms.py Normal file
View file

@ -0,0 +1,273 @@
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.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',
},
}

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -0,0 +1,579 @@
# 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-05-23 14:00+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"
#: assets/static/assets/asset_add_modal.js:52
#: assets/static/assets/asset_location_edit.js:59
#: assets/static/assets/unified_index.js:115
#: assets/static/assets/unified_index.js:168
#: assets/static/assets/unified_index.js:224
msgid "Chargement..."
msgstr "Loading..."
#: assets/static/assets/asset_add_modal.js:56
#: assets/static/assets/asset_add_modal.js:426
#: assets/static/assets/unified_index.js:114
#: assets/static/assets/unified_index.js:220
#: assets/static/assets/unified_index.js:262
msgid "Sélectionner d'abord une catégorie..."
msgstr "Select a category first..."
#: assets/static/assets/asset_add_modal.js:65
#: assets/static/assets/unified_index.js:230
msgid "Sélectionner un modèle..."
msgstr "Select a model..."
#: assets/static/assets/asset_add_modal.js:77
#: assets/static/assets/unified_index.js:243
msgid "Aucun modèle disponible"
msgstr "No model available"
#: assets/static/assets/asset_add_modal.js:82
#: assets/static/assets/asset_location_edit.js:102
#: assets/static/assets/unified_index.js:134
#: assets/static/assets/unified_index.js:205
#: assets/static/assets/unified_index.js:247
msgid "Erreur de chargement"
msgstr "Loading error"
#: assets/static/assets/asset_add_modal.js:90
#: assets/static/assets/unified_index.js:848
#: assets/static/assets/unified_index.js:935
msgid "Création..."
msgstr "Creating..."
#: assets/static/assets/asset_add_modal.js:146
#: assets/static/assets/asset_add_modal.js:350
msgid "Erreur lors de la création"
msgstr "Error during creation"
#: assets/static/assets/asset_add_modal.js:156
#: assets/static/assets/asset_add_modal.js:359
msgid "Erreur de connexion"
msgstr "Connection error"
#: assets/static/assets/asset_add_modal.js:221
msgid "Sélection de template d'opérations"
msgstr "Operation template selection"
#: assets/static/assets/asset_add_modal.js:223
msgid "Fermer"
msgstr "Close"
#: assets/static/assets/asset_add_modal.js:226
msgid "Choisissez le(s) template(s) d'opérations à appliquer à cet équipement."
msgstr "Choose the operation template(s) to apply to this asset."
#: assets/static/assets/asset_add_modal.js:228
msgid "Choisir le(s) template(s) d'opérations :"
msgstr "Choose operation template(s):"
#: assets/static/assets/asset_add_modal.js:242
msgid "Tout sélectionner"
msgstr "Select all"
#: assets/static/assets/asset_add_modal.js:245
msgid "Tout désélectionner"
msgstr "Deselect all"
#: assets/static/assets/asset_add_modal.js:253
msgid "Ignorer et continuer"
msgstr "Ignore and continue"
#: assets/static/assets/asset_add_modal.js:256
msgid "Créer les opérations"
msgstr "Create operations"
#: assets/static/assets/asset_add_modal.js:317
msgid "Création en cours..."
msgstr "Creating..."
#: 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."
#: assets/static/assets/asset_location_edit.js:76
#: assets/static/assets/unified_index.js:177
msgid "Sélectionner une localisation..."
msgstr "Select location..."
#: 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."
#: assets/static/assets/asset_location_edit.js:92
#: assets/static/assets/unified_index.js:196
msgid "Aucune localisation disponible"
msgstr "No location available"
#: assets/static/assets/asset_location_edit.js:94
msgid "Aucun résultat pour cette recherche."
msgstr "No results for this search."
#: assets/static/assets/asset_location_edit.js:104
msgid "Erreur lors du chargement des localisations"
msgstr "Error during loading locations"
#: assets/static/assets/asset_location_edit.js:117
msgid "Veuillez sélectionner une localisation"
msgstr "Please select a location"
#: assets/static/assets/asset_location_edit.js:124
msgid "Mise à jour..."
msgstr "Updating..."
#: assets/static/assets/asset_location_edit.js:149
msgid "Localisation mise à jour"
msgstr "Location updated"
#: assets/static/assets/asset_location_edit.js:154
#: assets/static/assets/asset_location_edit.js:161
msgid "Erreur lors de la mise à jour"
msgstr "Error during updating"
#: assets/static/assets/assets_index.js:568
#: assets/static/assets/assets_index.js:592
#: assets/static/assets/unified_index.js:499
msgid "Code"
msgstr "Code"
#: assets/static/assets/assets_index.js:594
#: assets/static/assets/green-surfaces-comments.js:953
#: assets/static/assets/unified_index.js:510
msgid "Statut"
msgstr "Status"
#: 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"
#: 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"
#: assets/static/assets/assets_index.js:880
msgid "Sélectionner l'asset"
msgstr "Select asset"
#: assets/static/assets/assets_index.js:886
msgid "Désélectionner l'asset"
msgstr "Deselect asset"
#: assets/static/assets/assets_index.js:997
#: assets/static/assets/assets_index.js:1068
msgid "Erreur lors de la récupération des données."
msgstr "Error while retrieving data."
#: assets/static/assets/assets_index.js:1100
msgid ""
"Sélectionnez des localisations ou lancez une recherche pour afficher les "
"assets."
msgstr ""
#: assets/static/assets/assets_index.js:1102
msgid "Aucun asset ne correspond aux critères de recherche."
msgstr "No asset matches the search criteria."
#: assets/static/assets/assets_index.js:1309
#: assets/static/assets/unified_index.js:2571
#: assets/static/assets/unified_index.js:3330
#: assets/static/assets/unified_index.js:3398
msgid "Sans catégorie"
msgstr "Uncategorized"
#: assets/static/assets/assets_index.js:1330
#: assets/static/assets/unified_index.js:3424
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"
#: assets/static/assets/assets_index.js:1338
#: assets/static/assets/unified_index.js:3429
msgid "(sélection)"
msgstr "(selection)"
#: assets/static/assets/assets_index.js:1339
#: assets/static/assets/unified_index.js:3429
msgid "(table)"
msgstr "(table)"
#: assets/static/assets/assets_index.js:1350
#: assets/static/assets/unified_index.js:3434
msgid "Aucune donnée disponible"
msgstr "No data available"
#: assets/static/assets/assets_index.js:1383
#: assets/static/assets/unified_index.js:3465
msgid "au total"
msgstr "in total"
#: assets/static/assets/assets_index.js:1389
#: assets/static/assets/unified_index.js:509
#: assets/static/assets/unified_index.js:3471
msgid "Modèle"
msgstr "Model"
#: assets/static/assets/assets_index.js:1390
#: assets/static/assets/unified_index.js:3472
msgid "Nombre"
msgstr "Number"
#: assets/static/assets/assets_index.js:1391
#: assets/static/assets/unified_index.js:3473
msgid "Longueur (m)"
msgstr "Length (m)"
#: assets/static/assets/assets_index.js:1392
#: assets/static/assets/unified_index.js:3474
msgid "Surface (m²)"
msgstr "Area (m²)"
#: assets/static/assets/assets_index.js:1396
#: assets/static/assets/unified_index.js:3477
msgid "Aucun modèle"
msgstr "No model"
#: assets/static/assets/assets_index.js:1400
#: assets/static/assets/unified_index.js:3480
msgid "Total catégorie"
msgstr "Category total"
#: assets/static/assets/green-surfaces-comments.js:473
#: assets/static/assets/green-surfaces-comments.js:902
msgid "Nouveau commentaire"
msgstr "New comment"
#: assets/static/assets/green-surfaces-comments.js:480
msgid "Textes rapides"
msgstr "Quick texts"
#: assets/static/assets/green-surfaces-comments.js:482
#: assets/static/assets/green-surfaces-comments.js:483
#: assets/static/assets/green-surfaces-comments.js:956
msgid "Mémo"
msgstr "Memo"
#: assets/static/assets/green-surfaces-comments.js:485
#: assets/static/assets/green-surfaces-comments.js:486
msgid "Point d'attention"
msgstr "Point of attention"
#: assets/static/assets/green-surfaces-comments.js:488
#: assets/static/assets/green-surfaces-comments.js:489
#: assets/static/assets/green-surfaces-comments.js:962
msgid "À faire"
msgstr "To do"
#: assets/static/assets/green-surfaces-comments.js:491
#: assets/static/assets/green-surfaces-comments.js:492
#: assets/static/assets/green-surfaces-comments.js:967
msgid "OK"
msgstr "OK"
#: assets/static/assets/green-surfaces-comments.js:494
#: assets/static/assets/green-surfaces-comments.js:495
#: assets/static/assets/green-surfaces-comments.js:970
msgid "Problème"
msgstr "Problem"
#: assets/static/assets/green-surfaces-comments.js:501
#: assets/static/assets/green-surfaces-comments.js:977
msgid "Commentaire"
msgstr "Comment"
#: assets/static/assets/green-surfaces-comments.js:502
msgid "Votre commentaire..."
msgstr "Your comment..."
#: assets/static/assets/green-surfaces-comments.js:506
#: assets/static/assets/green-surfaces-comments.js:990
msgid "Photos"
msgstr "Photos"
#: assets/static/assets/green-surfaces-comments.js:509
msgid "Prendre des photos (mode continu)"
msgstr "Take photos (continuous mode)"
#: assets/static/assets/green-surfaces-comments.js:512
msgid "Sélectionner des fichiers"
msgstr "Select files"
#: assets/static/assets/green-surfaces-comments.js:521
msgid "Position"
msgstr "Position"
#: assets/static/assets/green-surfaces-comments.js:525
msgid "Position actuelle sera utilisée"
msgstr "Current position will be used"
#: assets/static/assets/green-surfaces-comments.js:528
#: assets/static/assets/green-surfaces-comments.js:985
msgid "Repositionner"
msgstr "Reposition"
#: assets/static/assets/green-surfaces-comments.js:536
#: assets/static/assets/green-surfaces-comments.js:1008
msgid "Annuler"
msgstr "Cancel"
#: assets/static/assets/green-surfaces-comments.js:538
#: assets/static/assets/green-surfaces-comments.js:1005
msgid "Enregistrer"
msgstr "Save"
#: assets/static/assets/green-surfaces-comments.js:570
msgid "Caméra"
msgstr "Camera"
#: assets/static/assets/green-surfaces-comments.js:585
#: assets/static/assets/green-surfaces-comments.js:1127
msgid "Terminer"
msgstr "Finish"
#: assets/static/assets/green-surfaces-comments.js:588
#: assets/static/assets/green-surfaces-comments.js:1124
msgid "Capturer"
msgstr "Capture"
#: assets/static/assets/green-surfaces-comments.js:959
msgid "Attention"
msgstr "Warning"
#: assets/static/assets/green-surfaces-comments.js:977
msgid "(optionnel)"
msgstr "(optional)"
#: assets/static/assets/green-surfaces-comments.js:978
msgid "Entrez un commentaire optionnel..."
msgstr "Enter an optional comment..."
#: assets/static/assets/green-surfaces-comments.js:982
msgid "Localisation"
msgstr "Location"
#: assets/static/assets/green-surfaces-comments.js:983
msgid "Calcul en cours..."
msgstr "Calculating..."
#: assets/static/assets/green-surfaces-comments.js:993
msgid "Appareil photo"
msgstr "Camera"
#: assets/static/assets/green-surfaces-comments.js:996
msgid "Sélectionner fichiers"
msgstr "Select files"
#: assets/static/assets/unified_index.js:121
#: assets/static/assets/unified_index.js:258
msgid "Sélectionner une catégorie..."
msgstr "Select a category..."
#: assets/static/assets/unified_index.js:130
msgid "Aucune catégorie disponible"
msgstr "No category available"
#: assets/static/assets/unified_index.js:170
msgid "Sélectionnez la localisation liée à ce nouvel asset."
msgstr "Select the location linked to this new asset."
#: assets/static/assets/unified_index.js:200
msgid "Aucune localisation disponible pour créer cet asset."
msgstr "No location available to create this asset."
#: assets/static/assets/unified_index.js:209
msgid "Impossible de charger les localisations pour cette thématique."
msgstr "Unable to load locations for this topic."
#: assets/static/assets/unified_index.js:294
msgid "Créer un asset"
msgstr "Create an asset"
#: assets/static/assets/unified_index.js:308
msgid "Le code est généré automatiquement."
msgstr "The code is generated automatically."
#: assets/static/assets/unified_index.js:444
#: assets/static/assets/unified_index.js:449
msgid "Erreur lors du chargement du formulaire."
msgstr "Error while loading the form."
#: assets/static/assets/unified_index.js:466
msgid "Créer une localisation"
msgstr "Create a location"
#: assets/static/assets/unified_index.js:467
#: assets/static/assets/unified_index.js:506
msgid "Type"
msgstr "Type"
#: assets/static/assets/unified_index.js:490
msgid "Zoom"
msgstr "Zoom"
#: assets/static/assets/unified_index.js:505
msgid "Nom"
msgstr "Name"
#: assets/static/assets/unified_index.js:507
msgid "Thématique"
msgstr "Topic"
#: assets/static/assets/unified_index.js:508
msgid "Catégorie"
msgstr "Category"
#: assets/static/assets/unified_index.js:783
#: assets/static/assets/unified_index.js:784
msgid "Sélectionner une thématique..."
msgstr "Select a topic..."
#: assets/static/assets/unified_index.js:783
#: assets/static/assets/unified_index.js:784
msgid "Aucune thématique disponible"
msgstr "No topic available"
#: assets/static/assets/unified_index.js:791
msgid "Vous n'avez pas de droits de création"
msgstr "You do not have creation rights"
#: assets/static/assets/unified_index.js:809
msgid "Sélectionnez une thématique pour créer un asset."
msgstr "Select a topic to create an asset."
#: assets/static/assets/unified_index.js:821
msgid "Sélectionnez une thématique pour créer une localisation."
msgstr "Select a topic to create a location."
#: assets/static/assets/unified_index.js:840
msgid "Sélectionnez une localisation avant de créer cet asset."
msgstr "Select a location before creating this asset."
#: assets/static/assets/unified_index.js:871
msgid "Type d'asset introuvable pour ce modèle."
msgstr "Asset type not found for this model."
#: assets/static/assets/unified_index.js:901
msgid "Asset créé."
msgstr "Asset created."
#: assets/static/assets/unified_index.js:912
#: assets/static/assets/unified_index.js:959
msgid "Erreur lors de la création."
msgstr "Error during creation."
#: assets/static/assets/unified_index.js:916
#: assets/static/assets/unified_index.js:963
msgid "Erreur de connexion."
msgstr "Connection error."
#: assets/static/assets/unified_index.js:952
msgid "Localisation créée."
msgstr "Location created."
#: assets/static/assets/unified_index.js:1359
msgid "Veuillez sélectionner au moins un asset."
msgstr "Please select at least one asset."
#: assets/static/assets/unified_index.js:1423
msgid ""
"Trop de données à afficher. Ajoutez des filtres ou réduisez le périmètre "
"pour charger le tableau et la carte."
msgstr ""
#: assets/static/assets/unified_index.js:1671
#: assets/static/assets/unified_index.js:1702
msgid "Voir dans le tableau"
msgstr "View in table"
#: assets/static/assets/unified_index.js:1711
msgid "autre(s) asset(s)"
msgstr "other asset(s)"
#: assets/static/assets/unified_index.js:1715
msgid "autre(s) localisation(s)"
msgstr "other location(s)"
#: assets/static/assets/unified_index.js:1720
msgid "Assets trouvés"
msgstr "Assets found"
#: assets/static/assets/unified_index.js:1728
msgid "Localisations trouvées"
msgstr "Locations found"
#: assets/static/assets/unified_index.js:2209
msgid "Vue initiale (Bruxelles)"
msgstr "Initial view (Brussels)"
#: assets/static/assets/unified_index.js:2349
msgid "sélectionné(s)"
msgstr "selected"
#: assets/static/assets/unified_index.js:2889
msgid "Aucun type sélectionné"
msgstr "No type selected"
#: assets/static/assets/unified_index.js:2893
msgid "Aucun type disponible."
msgstr "No type available."
#: assets/static/assets/unified_index.js:3034
#: assets/static/assets/unified_index.js:3204
#: assets/static/assets/unified_index.js:3262
msgid "Sélectionnez d'abord une ou plusieurs thématiques."
msgstr "Select one or more topics first."
#: assets/static/assets/unified_index.js:3104
msgid "Tous"
msgstr "All"
#: assets/static/assets/unified_index.js:3269
msgid "Sélectionnez d'abord une ou plusieurs catégories."
msgstr "Select one or more categories first."
#: assets/static/assets/unified_index.js:3285
msgid "Chargement des modèles..."
msgstr "Loading models..."
#: assets/static/assets/unified_index.js:3296
msgid "Aucun modèle disponible pour les catégories sélectionnées."
msgstr "No model available for the selected categories."
#: assets/static/assets/unified_index.js:3307
msgid "Afficher les modèles"
msgstr "Show models"
#: assets/static/assets/unified_index.js:3308
msgid "Replier les modèles"
msgstr "Collapse models"
#: assets/static/assets/unified_index.js:3313
msgid ""
"Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
msgstr ""
#~ msgid "Erreur lors de la liaison"
#~ msgstr "Error during linking"
#~ msgid "Erreur lors de la désélection"
#~ msgstr "Error during deselection"

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -0,0 +1,593 @@
# 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-05-23 14:00+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"
#: assets/static/assets/asset_add_modal.js:52
#: assets/static/assets/asset_location_edit.js:59
#: assets/static/assets/unified_index.js:115
#: assets/static/assets/unified_index.js:168
#: assets/static/assets/unified_index.js:224
msgid "Chargement..."
msgstr "Chargement..."
#: assets/static/assets/asset_add_modal.js:56
#: assets/static/assets/asset_add_modal.js:426
#: assets/static/assets/unified_index.js:114
#: assets/static/assets/unified_index.js:220
#: assets/static/assets/unified_index.js:262
msgid "Sélectionner d'abord une catégorie..."
msgstr "Sélectionner d'abord une catégorie..."
#: assets/static/assets/asset_add_modal.js:65
#: assets/static/assets/unified_index.js:230
msgid "Sélectionner un modèle..."
msgstr "Sélectionner un modèle..."
#: assets/static/assets/asset_add_modal.js:77
#: assets/static/assets/unified_index.js:243
msgid "Aucun modèle disponible"
msgstr "Aucun modèle disponible"
#: assets/static/assets/asset_add_modal.js:82
#: assets/static/assets/asset_location_edit.js:102
#: assets/static/assets/unified_index.js:134
#: assets/static/assets/unified_index.js:205
#: assets/static/assets/unified_index.js:247
msgid "Erreur de chargement"
msgstr "Erreur de chargement"
#: assets/static/assets/asset_add_modal.js:90
#: assets/static/assets/unified_index.js:848
#: assets/static/assets/unified_index.js:935
msgid "Création..."
msgstr "Création..."
#: assets/static/assets/asset_add_modal.js:146
#: assets/static/assets/asset_add_modal.js:350
msgid "Erreur lors de la création"
msgstr "Erreur lors de la création"
#: assets/static/assets/asset_add_modal.js:156
#: assets/static/assets/asset_add_modal.js:359
msgid "Erreur de connexion"
msgstr "Erreur de connexion"
#: assets/static/assets/asset_add_modal.js:221
msgid "Sélection de template d'opérations"
msgstr "Sélection de template d'opérations"
#: assets/static/assets/asset_add_modal.js:223
msgid "Fermer"
msgstr "Fermer"
#: assets/static/assets/asset_add_modal.js:226
msgid "Choisissez le(s) template(s) d'opérations à appliquer à cet équipement."
msgstr "Choisissez le(s) template(s) d'opérations à appliquer à cet équipement."
#: assets/static/assets/asset_add_modal.js:228
msgid "Choisir le(s) template(s) d'opérations :"
msgstr "Choisir le(s) template(s) d'opérations :"
#: assets/static/assets/asset_add_modal.js:242
msgid "Tout sélectionner"
msgstr "Tout sélectionner"
#: assets/static/assets/asset_add_modal.js:245
msgid "Tout désélectionner"
msgstr "Tout désélectionner"
#: assets/static/assets/asset_add_modal.js:253
msgid "Ignorer et continuer"
msgstr "Ignorer et continuer"
#: assets/static/assets/asset_add_modal.js:256
msgid "Créer les opérations"
msgstr "Créer les opérations"
#: assets/static/assets/asset_add_modal.js:317
msgid "Création en cours..."
msgstr "Création en cours..."
#: 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."
#: assets/static/assets/asset_location_edit.js:76
#: assets/static/assets/unified_index.js:177
msgid "Sélectionner une localisation..."
msgstr "Sélectionner une localisation..."
#: 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."
#: assets/static/assets/asset_location_edit.js:92
#: assets/static/assets/unified_index.js:196
msgid "Aucune localisation disponible"
msgstr "Aucune localisation disponible"
#: assets/static/assets/asset_location_edit.js:94
msgid "Aucun résultat pour cette recherche."
msgstr "Aucun résultat pour cette recherche."
#: assets/static/assets/asset_location_edit.js:104
msgid "Erreur lors du chargement des localisations"
msgstr "Erreur lors du chargement des localisations"
#: assets/static/assets/asset_location_edit.js:117
msgid "Veuillez sélectionner une localisation"
msgstr "Veuillez sélectionner une localisation"
#: assets/static/assets/asset_location_edit.js:124
msgid "Mise à jour..."
msgstr "Mise à jour..."
#: assets/static/assets/asset_location_edit.js:149
msgid "Localisation mise à jour"
msgstr "Localisation mise à jour"
#: assets/static/assets/asset_location_edit.js:154
#: assets/static/assets/asset_location_edit.js:161
msgid "Erreur lors de la mise à jour"
msgstr "Erreur lors de la mise à jour"
#: assets/static/assets/assets_index.js:568
#: assets/static/assets/assets_index.js:592
#: assets/static/assets/unified_index.js:499
msgid "Code"
msgstr "Code"
#: assets/static/assets/assets_index.js:594
#: assets/static/assets/green-surfaces-comments.js:953
#: assets/static/assets/unified_index.js:510
msgid "Statut"
msgstr "Statut"
#: 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"
#: 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"
#: assets/static/assets/assets_index.js:880
msgid "Sélectionner l'asset"
msgstr "Sélectionner l'asset"
#: assets/static/assets/assets_index.js:886
msgid "Désélectionner l'asset"
msgstr "Désélectionner l'asset"
#: assets/static/assets/assets_index.js:997
#: 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."
#: 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."
#: 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."
#: assets/static/assets/assets_index.js:1309
#: assets/static/assets/unified_index.js:2571
#: assets/static/assets/unified_index.js:3330
#: assets/static/assets/unified_index.js:3398
msgid "Sans catégorie"
msgstr "Sans catégorie"
#: assets/static/assets/assets_index.js:1330
#: assets/static/assets/unified_index.js:3424
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"
#: assets/static/assets/assets_index.js:1338
#: assets/static/assets/unified_index.js:3429
msgid "(sélection)"
msgstr "(sélection)"
#: assets/static/assets/assets_index.js:1339
#: assets/static/assets/unified_index.js:3429
msgid "(table)"
msgstr "(table)"
#: assets/static/assets/assets_index.js:1350
#: assets/static/assets/unified_index.js:3434
msgid "Aucune donnée disponible"
msgstr "Aucune donnée disponible"
#: assets/static/assets/assets_index.js:1383
#: assets/static/assets/unified_index.js:3465
msgid "au total"
msgstr "au total"
#: assets/static/assets/assets_index.js:1389
#: assets/static/assets/unified_index.js:509
#: assets/static/assets/unified_index.js:3471
msgid "Modèle"
msgstr "Modèle"
#: assets/static/assets/assets_index.js:1390
#: assets/static/assets/unified_index.js:3472
msgid "Nombre"
msgstr "Nombre"
#: assets/static/assets/assets_index.js:1391
#: assets/static/assets/unified_index.js:3473
msgid "Longueur (m)"
msgstr "Longueur (m)"
#: assets/static/assets/assets_index.js:1392
#: assets/static/assets/unified_index.js:3474
msgid "Surface (m²)"
msgstr "Surface (m²)"
#: assets/static/assets/assets_index.js:1396
#: assets/static/assets/unified_index.js:3477
msgid "Aucun modèle"
msgstr "Aucun modèle"
#: assets/static/assets/assets_index.js:1400
#: assets/static/assets/unified_index.js:3480
msgid "Total catégorie"
msgstr "Total catégorie"
#: assets/static/assets/green-surfaces-comments.js:473
#: assets/static/assets/green-surfaces-comments.js:902
msgid "Nouveau commentaire"
msgstr "Nouveau commentaire"
#: assets/static/assets/green-surfaces-comments.js:480
msgid "Textes rapides"
msgstr "Textes rapides"
#: assets/static/assets/green-surfaces-comments.js:482
#: assets/static/assets/green-surfaces-comments.js:483
#: assets/static/assets/green-surfaces-comments.js:956
msgid "Mémo"
msgstr "Mémo"
#: assets/static/assets/green-surfaces-comments.js:485
#: assets/static/assets/green-surfaces-comments.js:486
msgid "Point d'attention"
msgstr "Point d'attention"
#: assets/static/assets/green-surfaces-comments.js:488
#: assets/static/assets/green-surfaces-comments.js:489
#: assets/static/assets/green-surfaces-comments.js:962
msgid "À faire"
msgstr "À faire"
#: assets/static/assets/green-surfaces-comments.js:491
#: assets/static/assets/green-surfaces-comments.js:492
#: assets/static/assets/green-surfaces-comments.js:967
msgid "OK"
msgstr "OK"
#: assets/static/assets/green-surfaces-comments.js:494
#: assets/static/assets/green-surfaces-comments.js:495
#: assets/static/assets/green-surfaces-comments.js:970
msgid "Problème"
msgstr "Problème"
#: assets/static/assets/green-surfaces-comments.js:501
#: assets/static/assets/green-surfaces-comments.js:977
msgid "Commentaire"
msgstr "Commentaire"
#: assets/static/assets/green-surfaces-comments.js:502
msgid "Votre commentaire..."
msgstr "Votre commentaire..."
#: assets/static/assets/green-surfaces-comments.js:506
#: assets/static/assets/green-surfaces-comments.js:990
msgid "Photos"
msgstr "Photos"
#: assets/static/assets/green-surfaces-comments.js:509
msgid "Prendre des photos (mode continu)"
msgstr "Prendre des photos (mode continu)"
#: assets/static/assets/green-surfaces-comments.js:512
msgid "Sélectionner des fichiers"
msgstr "Sélectionner des fichiers"
#: assets/static/assets/green-surfaces-comments.js:521
msgid "Position"
msgstr "Position"
#: assets/static/assets/green-surfaces-comments.js:525
msgid "Position actuelle sera utilisée"
msgstr "Position actuelle sera utilisée"
#: assets/static/assets/green-surfaces-comments.js:528
#: assets/static/assets/green-surfaces-comments.js:985
msgid "Repositionner"
msgstr "Repositionner"
#: assets/static/assets/green-surfaces-comments.js:536
#: assets/static/assets/green-surfaces-comments.js:1008
msgid "Annuler"
msgstr "Annuler"
#: assets/static/assets/green-surfaces-comments.js:538
#: assets/static/assets/green-surfaces-comments.js:1005
msgid "Enregistrer"
msgstr "Enregistrer"
#: assets/static/assets/green-surfaces-comments.js:570
msgid "Caméra"
msgstr "Caméra"
#: assets/static/assets/green-surfaces-comments.js:585
#: assets/static/assets/green-surfaces-comments.js:1127
msgid "Terminer"
msgstr "Terminer"
#: assets/static/assets/green-surfaces-comments.js:588
#: assets/static/assets/green-surfaces-comments.js:1124
msgid "Capturer"
msgstr "Capturer"
#: assets/static/assets/green-surfaces-comments.js:959
msgid "Attention"
msgstr "Attention"
#: assets/static/assets/green-surfaces-comments.js:977
msgid "(optionnel)"
msgstr "(optionnel)"
#: assets/static/assets/green-surfaces-comments.js:978
msgid "Entrez un commentaire optionnel..."
msgstr "Entrez un commentaire optionnel..."
#: assets/static/assets/green-surfaces-comments.js:982
msgid "Localisation"
msgstr "Localisation"
#: assets/static/assets/green-surfaces-comments.js:983
msgid "Calcul en cours..."
msgstr "Calcul en cours..."
#: assets/static/assets/green-surfaces-comments.js:993
msgid "Appareil photo"
msgstr "Appareil photo"
#: assets/static/assets/green-surfaces-comments.js:996
msgid "Sélectionner fichiers"
msgstr "Sélectionner fichiers"
#: assets/static/assets/unified_index.js:121
#: assets/static/assets/unified_index.js:258
msgid "Sélectionner une catégorie..."
msgstr "Sélectionner une catégorie..."
#: assets/static/assets/unified_index.js:130
msgid "Aucune catégorie disponible"
msgstr "Aucune catégorie disponible"
#: assets/static/assets/unified_index.js:170
msgid "Sélectionnez la localisation liée à ce nouvel asset."
msgstr "Sélectionnez la localisation liée à ce nouvel asset."
#: assets/static/assets/unified_index.js:200
msgid "Aucune localisation disponible pour créer cet asset."
msgstr "Aucune localisation disponible pour créer cet asset."
#: assets/static/assets/unified_index.js:209
msgid "Impossible de charger les localisations pour cette thématique."
msgstr "Impossible de charger les localisations pour cette thématique."
#: assets/static/assets/unified_index.js:294
msgid "Créer un asset"
msgstr "Créer un asset"
#: assets/static/assets/unified_index.js:308
msgid "Le code est généré automatiquement."
msgstr "Le code est généré automatiquement."
#: assets/static/assets/unified_index.js:444
#: assets/static/assets/unified_index.js:449
msgid "Erreur lors du chargement du formulaire."
msgstr "Erreur lors du chargement du formulaire."
#: assets/static/assets/unified_index.js:466
msgid "Créer une localisation"
msgstr "Créer une localisation"
#: assets/static/assets/unified_index.js:467
#: assets/static/assets/unified_index.js:506
msgid "Type"
msgstr "Type"
#: assets/static/assets/unified_index.js:490
msgid "Zoom"
msgstr "Zoom"
#: assets/static/assets/unified_index.js:505
msgid "Nom"
msgstr "Nom"
#: assets/static/assets/unified_index.js:507
msgid "Thématique"
msgstr "Thématique"
#: assets/static/assets/unified_index.js:508
msgid "Catégorie"
msgstr "Catégorie"
#: assets/static/assets/unified_index.js:783
#: assets/static/assets/unified_index.js:784
msgid "Sélectionner une thématique..."
msgstr "Sélectionner une thématique..."
#: assets/static/assets/unified_index.js:783
#: assets/static/assets/unified_index.js:784
msgid "Aucune thématique disponible"
msgstr "Aucune thématique disponible"
#: assets/static/assets/unified_index.js:791
msgid "Vous n'avez pas de droits de création"
msgstr "Vous n'avez pas de droits de création"
#: assets/static/assets/unified_index.js:809
msgid "Sélectionnez une thématique pour créer un asset."
msgstr "Sélectionnez une thématique pour créer un asset."
#: assets/static/assets/unified_index.js:821
msgid "Sélectionnez une thématique pour créer une localisation."
msgstr "Sélectionnez une thématique pour créer une localisation."
#: assets/static/assets/unified_index.js:840
msgid "Sélectionnez une localisation avant de créer cet asset."
msgstr "Sélectionnez une localisation avant de créer cet asset."
#: assets/static/assets/unified_index.js:871
msgid "Type d'asset introuvable pour ce modèle."
msgstr "Type d'asset introuvable pour ce modèle."
#: assets/static/assets/unified_index.js:901
msgid "Asset créé."
msgstr "Asset créé."
#: assets/static/assets/unified_index.js:912
#: assets/static/assets/unified_index.js:959
msgid "Erreur lors de la création."
msgstr "Erreur lors de la création."
#: assets/static/assets/unified_index.js:916
#: assets/static/assets/unified_index.js:963
msgid "Erreur de connexion."
msgstr "Erreur de connexion."
#: assets/static/assets/unified_index.js:952
msgid "Localisation créée."
msgstr "Localisation créée."
#: assets/static/assets/unified_index.js:1359
msgid "Veuillez sélectionner au moins un asset."
msgstr "Veuillez sélectionner au moins un asset."
#: assets/static/assets/unified_index.js:1423
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."
#: assets/static/assets/unified_index.js:1671
#: assets/static/assets/unified_index.js:1702
msgid "Voir dans le tableau"
msgstr "Voir dans le tableau"
#: assets/static/assets/unified_index.js:1711
msgid "autre(s) asset(s)"
msgstr "autre(s) asset(s)"
#: assets/static/assets/unified_index.js:1715
msgid "autre(s) localisation(s)"
msgstr "autre(s) localisation(s)"
#: assets/static/assets/unified_index.js:1720
msgid "Assets trouvés"
msgstr "Assets trouvés"
#: assets/static/assets/unified_index.js:1728
msgid "Localisations trouvées"
msgstr "Localisations trouvées"
#: assets/static/assets/unified_index.js:2209
msgid "Vue initiale (Bruxelles)"
msgstr "Vue initiale (Bruxelles)"
#: assets/static/assets/unified_index.js:2349
msgid "sélectionné(s)"
msgstr "sélectionné(s)"
#: assets/static/assets/unified_index.js:2889
msgid "Aucun type sélectionné"
msgstr "Aucun type sélectionné"
#: assets/static/assets/unified_index.js:2893
msgid "Aucun type disponible."
msgstr "Aucun type disponible."
#: assets/static/assets/unified_index.js:3034
#: assets/static/assets/unified_index.js:3204
#: assets/static/assets/unified_index.js:3262
msgid "Sélectionnez d'abord une ou plusieurs thématiques."
msgstr "Sélectionnez d'abord une ou plusieurs thématiques."
#: assets/static/assets/unified_index.js:3104
msgid "Tous"
msgstr "Tous"
#: assets/static/assets/unified_index.js:3269
msgid "Sélectionnez d'abord une ou plusieurs catégories."
msgstr "Sélectionnez d'abord une ou plusieurs catégories."
#: assets/static/assets/unified_index.js:3285
msgid "Chargement des modèles..."
msgstr "Chargement des modèles..."
#: assets/static/assets/unified_index.js:3296
msgid "Aucun modèle disponible pour les catégories sélectionnées."
msgstr "Aucun modèle disponible pour les catégories sélectionnées."
#: assets/static/assets/unified_index.js:3307
msgid "Afficher les modèles"
msgstr "Afficher les modèles"
#: assets/static/assets/unified_index.js:3308
msgid "Replier les modèles"
msgstr "Replier les modèles"
#: assets/static/assets/unified_index.js:3313
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."
#~ msgid "Erreur lors de la liaison"
#~ msgstr "Erreur lors de la liaison"
#~ msgid "Erreur lors de la désélection"
#~ msgstr "Erreur lors de la désélection"
# Asset Template Selection Modal
#~ msgid "Aucun template correspondant"
#~ msgstr "Aucun template correspondant"
#~ msgid ""
#~ "Le modèle ou la catégorie de cet équipement ne correspond à aucun "
#~ "template d'opérations automatique."
#~ msgstr ""
#~ "Le modèle ou la catégorie de cet équipement ne correspond à aucun "
#~ "template d'opérations automatique."
#~ msgid "-- Sélectionner un template --"
#~ msgstr "-- Sélectionner un template --"

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -0,0 +1,595 @@
# 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-05-23 14:00+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"
#: assets/static/assets/asset_add_modal.js:52
#: assets/static/assets/asset_location_edit.js:59
#: assets/static/assets/unified_index.js:115
#: assets/static/assets/unified_index.js:168
#: assets/static/assets/unified_index.js:224
msgid "Chargement..."
msgstr "Laden..."
#: assets/static/assets/asset_add_modal.js:56
#: assets/static/assets/asset_add_modal.js:426
#: assets/static/assets/unified_index.js:114
#: assets/static/assets/unified_index.js:220
#: assets/static/assets/unified_index.js:262
msgid "Sélectionner d'abord une catégorie..."
msgstr "Selecteer eerst een categorie..."
#: assets/static/assets/asset_add_modal.js:65
#: assets/static/assets/unified_index.js:230
msgid "Sélectionner un modèle..."
msgstr "Selecteer een model..."
#: assets/static/assets/asset_add_modal.js:77
#: assets/static/assets/unified_index.js:243
msgid "Aucun modèle disponible"
msgstr "Geen model beschikbaar"
#: assets/static/assets/asset_add_modal.js:82
#: assets/static/assets/asset_location_edit.js:102
#: assets/static/assets/unified_index.js:134
#: assets/static/assets/unified_index.js:205
#: assets/static/assets/unified_index.js:247
msgid "Erreur de chargement"
msgstr "Fout bij het laden"
#: assets/static/assets/asset_add_modal.js:90
#: assets/static/assets/unified_index.js:848
#: assets/static/assets/unified_index.js:935
msgid "Création..."
msgstr "Bezig met aanmaken..."
#: assets/static/assets/asset_add_modal.js:146
#: assets/static/assets/asset_add_modal.js:350
msgid "Erreur lors de la création"
msgstr "Fout bij het aanmaken"
#: assets/static/assets/asset_add_modal.js:156
#: assets/static/assets/asset_add_modal.js:359
msgid "Erreur de connexion"
msgstr "Verbindingsfout"
#: assets/static/assets/asset_add_modal.js:221
msgid "Sélection de template d'opérations"
msgstr "Selectie van bewerkingssjabloon"
#: assets/static/assets/asset_add_modal.js:223
msgid "Fermer"
msgstr "Sluiten"
#: assets/static/assets/asset_add_modal.js:226
msgid "Choisissez le(s) template(s) d'opérations à appliquer à cet équipement."
msgstr ""
"Kies de bewerkingssjabloon(s) die op deze uitrusting moeten worden toegepast."
#: assets/static/assets/asset_add_modal.js:228
msgid "Choisir le(s) template(s) d'opérations :"
msgstr "Kies de bewerkingssjabloon(s) :"
#: assets/static/assets/asset_add_modal.js:242
msgid "Tout sélectionner"
msgstr "Alles selecteren"
#: assets/static/assets/asset_add_modal.js:245
msgid "Tout désélectionner"
msgstr "Alles deselecteren"
#: assets/static/assets/asset_add_modal.js:253
msgid "Ignorer et continuer"
msgstr "Negeren en doorgaan"
#: assets/static/assets/asset_add_modal.js:256
msgid "Créer les opérations"
msgstr "Bewerkingen aanmaken"
#: assets/static/assets/asset_add_modal.js:317
msgid "Création en cours..."
msgstr "Bezig met aanmaken..."
#: 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."
#: assets/static/assets/asset_location_edit.js:76
#: assets/static/assets/unified_index.js:177
msgid "Sélectionner une localisation..."
msgstr "Selecteer een locatie..."
#: 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."
#: assets/static/assets/asset_location_edit.js:92
#: assets/static/assets/unified_index.js:196
msgid "Aucune localisation disponible"
msgstr "Geen locatie beschikbaar"
#: assets/static/assets/asset_location_edit.js:94
msgid "Aucun résultat pour cette recherche."
msgstr "Geen resultaten voor deze zoekopdracht."
#: assets/static/assets/asset_location_edit.js:104
msgid "Erreur lors du chargement des localisations"
msgstr "Fout bij het laden van locaties"
#: assets/static/assets/asset_location_edit.js:117
msgid "Veuillez sélectionner une localisation"
msgstr "Selecteer een locatie"
#: assets/static/assets/asset_location_edit.js:124
msgid "Mise à jour..."
msgstr "Bezig met bijwerken..."
#: assets/static/assets/asset_location_edit.js:149
msgid "Localisation mise à jour"
msgstr "Locatie bijgewerkt"
#: assets/static/assets/asset_location_edit.js:154
#: assets/static/assets/asset_location_edit.js:161
msgid "Erreur lors de la mise à jour"
msgstr "Fout bij het bijwerken"
#: assets/static/assets/assets_index.js:568
#: assets/static/assets/assets_index.js:592
#: assets/static/assets/unified_index.js:499
msgid "Code"
msgstr "Code"
#: assets/static/assets/assets_index.js:594
#: assets/static/assets/green-surfaces-comments.js:953
#: assets/static/assets/unified_index.js:510
msgid "Statut"
msgstr "Status"
#: 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"
#: 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"
#: assets/static/assets/assets_index.js:880
msgid "Sélectionner l'asset"
msgstr "Asset selecteren"
#: assets/static/assets/assets_index.js:886
msgid "Désélectionner l'asset"
msgstr "Asset deselecteren"
#: assets/static/assets/assets_index.js:997
#: 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."
#: assets/static/assets/assets_index.js:1100
msgid ""
"Sélectionnez des localisations ou lancez une recherche pour afficher les "
"assets."
msgstr ""
#: 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."
#: assets/static/assets/assets_index.js:1309
#: assets/static/assets/unified_index.js:2571
#: assets/static/assets/unified_index.js:3330
#: assets/static/assets/unified_index.js:3398
msgid "Sans catégorie"
msgstr "Zonder categorie"
#: assets/static/assets/assets_index.js:1330
#: assets/static/assets/unified_index.js:3424
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"
#: assets/static/assets/assets_index.js:1338
#: assets/static/assets/unified_index.js:3429
msgid "(sélection)"
msgstr "(selectie)"
#: assets/static/assets/assets_index.js:1339
#: assets/static/assets/unified_index.js:3429
msgid "(table)"
msgstr "(tabel)"
#: assets/static/assets/assets_index.js:1350
#: assets/static/assets/unified_index.js:3434
msgid "Aucune donnée disponible"
msgstr "Geen gegevens beschikbaar"
#: assets/static/assets/assets_index.js:1383
#: assets/static/assets/unified_index.js:3465
msgid "au total"
msgstr "in totaal"
#: assets/static/assets/assets_index.js:1389
#: assets/static/assets/unified_index.js:509
#: assets/static/assets/unified_index.js:3471
msgid "Modèle"
msgstr "Model"
#: assets/static/assets/assets_index.js:1390
#: assets/static/assets/unified_index.js:3472
msgid "Nombre"
msgstr "Nummer"
#: assets/static/assets/assets_index.js:1391
#: assets/static/assets/unified_index.js:3473
msgid "Longueur (m)"
msgstr "Lengte (m)"
#: assets/static/assets/assets_index.js:1392
#: assets/static/assets/unified_index.js:3474
msgid "Surface (m²)"
msgstr "Oppervlakte (m²)"
#: assets/static/assets/assets_index.js:1396
#: assets/static/assets/unified_index.js:3477
msgid "Aucun modèle"
msgstr "Geen model"
#: assets/static/assets/assets_index.js:1400
#: assets/static/assets/unified_index.js:3480
msgid "Total catégorie"
msgstr "Totaal per categorie"
#: assets/static/assets/green-surfaces-comments.js:473
#: assets/static/assets/green-surfaces-comments.js:902
msgid "Nouveau commentaire"
msgstr "Nieuwe opmerking"
#: assets/static/assets/green-surfaces-comments.js:480
msgid "Textes rapides"
msgstr "Snelle teksten"
#: assets/static/assets/green-surfaces-comments.js:482
#: assets/static/assets/green-surfaces-comments.js:483
#: assets/static/assets/green-surfaces-comments.js:956
msgid "Mémo"
msgstr "Memo"
#: assets/static/assets/green-surfaces-comments.js:485
#: assets/static/assets/green-surfaces-comments.js:486
msgid "Point d'attention"
msgstr "Aandachtspunt"
#: assets/static/assets/green-surfaces-comments.js:488
#: assets/static/assets/green-surfaces-comments.js:489
#: assets/static/assets/green-surfaces-comments.js:962
msgid "À faire"
msgstr "Te doen"
#: assets/static/assets/green-surfaces-comments.js:491
#: assets/static/assets/green-surfaces-comments.js:492
#: assets/static/assets/green-surfaces-comments.js:967
msgid "OK"
msgstr "OK"
#: assets/static/assets/green-surfaces-comments.js:494
#: assets/static/assets/green-surfaces-comments.js:495
#: assets/static/assets/green-surfaces-comments.js:970
msgid "Problème"
msgstr "Probleem"
#: assets/static/assets/green-surfaces-comments.js:501
#: assets/static/assets/green-surfaces-comments.js:977
msgid "Commentaire"
msgstr "Opmerking"
#: assets/static/assets/green-surfaces-comments.js:502
msgid "Votre commentaire..."
msgstr "Uw opmerking..."
#: assets/static/assets/green-surfaces-comments.js:506
#: assets/static/assets/green-surfaces-comments.js:990
msgid "Photos"
msgstr "Foto's"
#: assets/static/assets/green-surfaces-comments.js:509
msgid "Prendre des photos (mode continu)"
msgstr "Foto's maken (continue modus)"
#: assets/static/assets/green-surfaces-comments.js:512
msgid "Sélectionner des fichiers"
msgstr "Bestanden selecteren"
#: assets/static/assets/green-surfaces-comments.js:521
msgid "Position"
msgstr "Positie"
#: assets/static/assets/green-surfaces-comments.js:525
msgid "Position actuelle sera utilisée"
msgstr "Huidige positie zal worden gebruikt"
#: assets/static/assets/green-surfaces-comments.js:528
#: assets/static/assets/green-surfaces-comments.js:985
msgid "Repositionner"
msgstr "Opnieuw positioneren"
#: assets/static/assets/green-surfaces-comments.js:536
#: assets/static/assets/green-surfaces-comments.js:1008
msgid "Annuler"
msgstr "Annuleren"
#: assets/static/assets/green-surfaces-comments.js:538
#: assets/static/assets/green-surfaces-comments.js:1005
msgid "Enregistrer"
msgstr "Opslaan"
#: assets/static/assets/green-surfaces-comments.js:570
msgid "Caméra"
msgstr "Camera"
#: assets/static/assets/green-surfaces-comments.js:585
#: assets/static/assets/green-surfaces-comments.js:1127
msgid "Terminer"
msgstr "Voltooien"
#: assets/static/assets/green-surfaces-comments.js:588
#: assets/static/assets/green-surfaces-comments.js:1124
msgid "Capturer"
msgstr "Opnemen"
#: assets/static/assets/green-surfaces-comments.js:959
msgid "Attention"
msgstr "Let op"
#: assets/static/assets/green-surfaces-comments.js:977
msgid "(optionnel)"
msgstr "(optioneel)"
#: assets/static/assets/green-surfaces-comments.js:978
msgid "Entrez un commentaire optionnel..."
msgstr "Voer een optionele opmerking in..."
#: assets/static/assets/green-surfaces-comments.js:982
msgid "Localisation"
msgstr "Locatie"
#: assets/static/assets/green-surfaces-comments.js:983
msgid "Calcul en cours..."
msgstr "Bezig met berekenen..."
#: assets/static/assets/green-surfaces-comments.js:993
msgid "Appareil photo"
msgstr "Camera"
#: assets/static/assets/green-surfaces-comments.js:996
msgid "Sélectionner fichiers"
msgstr "Bestanden selecteren"
#: assets/static/assets/unified_index.js:121
#: assets/static/assets/unified_index.js:258
msgid "Sélectionner une catégorie..."
msgstr "Selecteer een categorie..."
#: assets/static/assets/unified_index.js:130
msgid "Aucune catégorie disponible"
msgstr "Geen categorie beschikbaar"
#: assets/static/assets/unified_index.js:170
msgid "Sélectionnez la localisation liée à ce nouvel asset."
msgstr "Selecteer de locatie gekoppeld aan deze nieuwe asset."
#: assets/static/assets/unified_index.js:200
msgid "Aucune localisation disponible pour créer cet asset."
msgstr "Geen locatie beschikbaar om deze asset aan te maken."
#: assets/static/assets/unified_index.js:209
msgid "Impossible de charger les localisations pour cette thématique."
msgstr "Kan locaties voor dit thema niet laden."
#: assets/static/assets/unified_index.js:294
msgid "Créer un asset"
msgstr "Een asset aanmaken"
#: assets/static/assets/unified_index.js:308
msgid "Le code est généré automatiquement."
msgstr "De code wordt automatisch gegenereerd."
#: assets/static/assets/unified_index.js:444
#: assets/static/assets/unified_index.js:449
msgid "Erreur lors du chargement du formulaire."
msgstr "Fout bij het laden van het formulier."
#: assets/static/assets/unified_index.js:466
msgid "Créer une localisation"
msgstr "Een locatie aanmaken"
#: assets/static/assets/unified_index.js:467
#: assets/static/assets/unified_index.js:506
msgid "Type"
msgstr "Type"
#: assets/static/assets/unified_index.js:490
msgid "Zoom"
msgstr "Zoom"
#: assets/static/assets/unified_index.js:505
msgid "Nom"
msgstr "Naam"
#: assets/static/assets/unified_index.js:507
msgid "Thématique"
msgstr "Thema"
#: assets/static/assets/unified_index.js:508
msgid "Catégorie"
msgstr "Categorie"
#: assets/static/assets/unified_index.js:783
#: assets/static/assets/unified_index.js:784
msgid "Sélectionner une thématique..."
msgstr "Selecteer een thema..."
#: assets/static/assets/unified_index.js:783
#: assets/static/assets/unified_index.js:784
msgid "Aucune thématique disponible"
msgstr "Geen thema beschikbaar"
#: assets/static/assets/unified_index.js:791
msgid "Vous n'avez pas de droits de création"
msgstr "U heeft geen rechten om aan te maken"
#: assets/static/assets/unified_index.js:809
msgid "Sélectionnez une thématique pour créer un asset."
msgstr "Selecteer een thema om een asset aan te maken."
#: assets/static/assets/unified_index.js:821
msgid "Sélectionnez une thématique pour créer une localisation."
msgstr "Selecteer een thema om een locatie aan te maken."
#: assets/static/assets/unified_index.js:840
msgid "Sélectionnez une localisation avant de créer cet asset."
msgstr "Selecteer een locatie voordat u deze asset aanmaakt."
#: assets/static/assets/unified_index.js:871
msgid "Type d'asset introuvable pour ce modèle."
msgstr "Assettype niet gevonden voor dit model."
#: assets/static/assets/unified_index.js:901
msgid "Asset créé."
msgstr "Asset aangemaakt."
#: assets/static/assets/unified_index.js:912
#: assets/static/assets/unified_index.js:959
msgid "Erreur lors de la création."
msgstr "Fout bij het aanmaken."
#: assets/static/assets/unified_index.js:916
#: assets/static/assets/unified_index.js:963
msgid "Erreur de connexion."
msgstr "Verbindingsfout."
#: assets/static/assets/unified_index.js:952
msgid "Localisation créée."
msgstr "Locatie aangemaakt."
#: assets/static/assets/unified_index.js:1359
msgid "Veuillez sélectionner au moins un asset."
msgstr "Selecteer ten minste één asset."
#: assets/static/assets/unified_index.js:1423
msgid ""
"Trop de données à afficher. Ajoutez des filtres ou réduisez le périmètre "
"pour charger le tableau et la carte."
msgstr ""
#: assets/static/assets/unified_index.js:1671
#: assets/static/assets/unified_index.js:1702
msgid "Voir dans le tableau"
msgstr "Bekijk in de tabel"
#: assets/static/assets/unified_index.js:1711
msgid "autre(s) asset(s)"
msgstr "andere asset(s)"
#: assets/static/assets/unified_index.js:1715
msgid "autre(s) localisation(s)"
msgstr "andere locatie(s)"
#: assets/static/assets/unified_index.js:1720
msgid "Assets trouvés"
msgstr "Gevonden assets"
#: assets/static/assets/unified_index.js:1728
msgid "Localisations trouvées"
msgstr "Gevonden locaties"
#: assets/static/assets/unified_index.js:2209
msgid "Vue initiale (Bruxelles)"
msgstr "Eerste weergave (Brussel)"
#: assets/static/assets/unified_index.js:2349
msgid "sélectionné(s)"
msgstr "geselecteerd"
#: assets/static/assets/unified_index.js:2889
msgid "Aucun type sélectionné"
msgstr "Geen type geselecteerd"
#: assets/static/assets/unified_index.js:2893
msgid "Aucun type disponible."
msgstr "Geen type beschikbaar."
#: assets/static/assets/unified_index.js:3034
#: assets/static/assets/unified_index.js:3204
#: assets/static/assets/unified_index.js:3262
msgid "Sélectionnez d'abord une ou plusieurs thématiques."
msgstr "Selecteer eerst een of meer thema's."
#: assets/static/assets/unified_index.js:3104
msgid "Tous"
msgstr "Alle"
#: assets/static/assets/unified_index.js:3269
msgid "Sélectionnez d'abord une ou plusieurs catégories."
msgstr "Selecteer eerst een of meer categorieën."
#: assets/static/assets/unified_index.js:3285
msgid "Chargement des modèles..."
msgstr "Modellen laden..."
#: assets/static/assets/unified_index.js:3296
msgid "Aucun modèle disponible pour les catégories sélectionnées."
msgstr "Geen model beschikbaar voor de geselecteerde categorieën."
#: assets/static/assets/unified_index.js:3307
msgid "Afficher les modèles"
msgstr "Modellen tonen"
#: assets/static/assets/unified_index.js:3308
msgid "Replier les modèles"
msgstr "Modellen inklappen"
#: assets/static/assets/unified_index.js:3313
msgid ""
"Bloc replié. Cliquez sur \"Afficher les modèles\" pour voir les options."
msgstr ""
#~ msgid "Erreur lors de la liaison"
#~ msgstr "Fout bij het koppelen"
#~ msgid "Erreur lors de la désélection"
#~ msgstr "Fout bij het deselecteren"
# Asset Template Selection Modal
#~ msgid "Aucun template correspondant"
#~ msgstr "Geen overeenkomstige sjabloon"
#~ msgid ""
#~ "Le modèle ou la catégorie de cet équipement ne correspond à aucun "
#~ "template d'opérations automatique."
#~ msgstr ""
#~ "Het model of de categorie van deze uitrusting komt niet overeen met een "
#~ "automatisch bewerkingssjabloon."
#~ msgid "-- Sélectionner un template --"
#~ msgstr "-- Selecteer een sjabloon --"

View file

View file

@ -0,0 +1,199 @@
"""
Management command to query assets by status at a specific date.
Usage: python manage.py assets_at_date <asset_model> <status> <date>
Examples:
python manage.py assets_at_date signpole active 2025-01-15
python manage.py assets_at_date assets.signpole active 2025-01-15 # Specify app
python manage.py assets_at_date sign.signpole active 2025-01-15 # Specify app
"""
from django.core.management.base import BaseCommand
from django.contrib.contenttypes.models import ContentType
from django.utils.dateparse import parse_datetime, parse_date
from django.utils import timezone
from datetime import datetime
from assets.models import AssetHistory
class Command(BaseCommand):
help = 'Query assets by status at a specific date (supports app_label.model_name format)'
def add_arguments(self, parser):
parser.add_argument(
'asset_model',
type=str,
help='The asset model, optionally with app label (e.g., signpole or assets.signpole)'
)
parser.add_argument(
'status',
type=str,
nargs='?',
default=None,
help='Status to filter by (e.g., active, removed) - optional'
)
parser.add_argument(
'date',
type=str,
help='Date in ISO format (e.g., 2025-01-15 or 2025-01-15T12:00:00)'
)
def handle(self, *args, **options):
asset_model_input = options['asset_model']
status_filter = options.get('status')
date_str = options['date']
# Parse date
try:
target_date = parse_datetime(date_str)
if not target_date:
parsed_date = parse_date(date_str)
if not parsed_date:
raise ValueError("Invalid date format")
target_date = datetime.combine(parsed_date, datetime.min.time())
target_date = timezone.make_aware(target_date)
except Exception as e:
self.stdout.write(
self.style.ERROR(f"Invalid date format: {e}")
)
return
# Parse asset model (may include app_label)
if '.' in asset_model_input:
app_label, model_name = asset_model_input.split('.', 1)
content_types = ContentType.objects.filter(app_label=app_label, model=model_name.lower())
else:
model_name = asset_model_input.lower()
content_types = list(ContentType.objects.filter(model=model_name))
# Handle multiple matches
if not content_types:
self.stdout.write(
self.style.ERROR(f"Model '{asset_model_input}' not found")
)
return
if len(content_types) > 1:
self.stdout.write(
self.style.WARNING(f"Multiple models found for '{asset_model_input}':")
)
for ct in content_types:
self.stdout.write(f" - {ct.app_label}.{ct.model}")
self.stdout.write(
self.style.WARNING("Using the first one. Specify with 'app_label.model' to choose.\n")
)
content_type = content_types[0]
asset_model_class = content_type.model_class()
self.stdout.write(
self.style.SUCCESS(
f"\nAssets of type '{content_type.app_label}.{content_type.model}' on {target_date.date()}"
)
)
if status_filter:
self.stdout.write(f" Status: {status_filter}\n")
# Get all assets created by the date
created_by_date = list(AssetHistory.objects.filter(
content_type=content_type,
action_type='create',
timestamp__lte=target_date
).values_list('object_id', flat=True).distinct())
if not created_by_date:
self.stdout.write(self.style.WARNING(" No assets created by this date"))
return
# Get all deletions in one query
deleted_ids = set(AssetHistory.objects.filter(
content_type=content_type,
object_id__in=created_by_date,
action_type='delete',
timestamp__lte=target_date
).values_list('object_id', flat=True))
# Get last status for each asset in one query using subquery
from django.db.models import OuterRef, Subquery
latest_status = AssetHistory.objects.filter(
content_type=content_type,
object_id=OuterRef('object_id'),
field_name='status',
timestamp__lte=target_date
).order_by('-timestamp').values('new_value')[:1]
statuses = {}
status_entries = AssetHistory.objects.filter(
content_type=content_type,
object_id__in=created_by_date,
field_name='status',
timestamp__lte=target_date
).values('object_id').annotate(
last_status=Subquery(latest_status)
).values('object_id', 'last_status')
for entry in status_entries:
statuses[entry['object_id']] = entry['last_status']
# Get all assets in one query for faster access
assets_dict = {asset.id: asset for asset in asset_model_class.objects.filter(pk__in=created_by_date)}
# Build results
results = []
for asset_id in created_by_date:
# Check if deleted
if asset_id in deleted_ids:
status = 'DELETED'
else:
# Get status from pre-fetched data
status = statuses.get(asset_id)
if not status:
# Fallback to current asset status
asset_obj = assets_dict.get(asset_id)
if asset_obj:
status = getattr(asset_obj, 'status', None)
if status_filter and status != status_filter:
continue
# Get asset details from pre-fetched dict
asset = assets_dict.get(asset_id)
if asset:
results.append({
'id': asset_id,
'code': asset.code,
'status': status,
'name': asset.get_name() if hasattr(asset, 'get_name') else str(asset)
})
else:
results.append({
'id': asset_id,
'code': f'ID: {asset_id}',
'status': status,
'name': '(Deleted)'
})
# Display results
if not results:
self.stdout.write(
self.style.WARNING(
f" No assets found with the given criteria"
)
)
return
self.stdout.write(f" Found {len(results)} asset(s):\n")
for item in results:
status_color = {
'active': self.style.SUCCESS,
'removed': self.style.ERROR,
'damaged': self.style.WARNING,
'DELETED': self.style.ERROR,
}.get(item['status'], self.style.WARNING)
status_display = status_color(f"[{item['status']}]")
self.stdout.write(
f" • {item['code']:20} {status_display:20} {item['name']}"
)
self.stdout.write(self.style.SUCCESS(f"\n Total: {len(results)} asset(s)\n"))

View file

@ -0,0 +1,165 @@
from collections import defaultdict
from datetime import datetime
import re
from openpyxl import Workbook
from django.core.management.base import BaseCommand
from assets.models import NatureGreenSurface
ILLEGAL_CHAR_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]")
def sanitize(value):
if isinstance(value, str):
return ILLEGAL_CHAR_RE.sub("", value)
return value
def autosize_columns(ws):
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
value = "" if cell.value is None else str(cell.value)
if len(value) > max_length:
max_length = len(value)
ws.column_dimensions[column_letter].width = min(max(12, max_length + 2), 60)
class Command(BaseCommand):
help = (
"Exporte les assets NatureGreenSurface dans un fichier XLSX avec "
"une feuille de donnees brutes et une feuille de synthese par type de surface."
)
def add_arguments(self, parser):
parser.add_argument(
"--output",
type=str,
default=None,
help="Chemin du fichier XLSX de sortie.",
)
parser.add_argument(
"--include-removed",
action="store_true",
default=False,
help="Inclut les surfaces avec le statut 'removed'.",
)
def handle(self, *args, **options):
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_path = options["output"] or f"greensurfaces_export_{timestamp}.xlsx"
queryset = NatureGreenSurface.objects.select_related("model", "location").order_by("code")
if not options["include_removed"]:
queryset = queryset.exclude(status="removed")
wb = Workbook()
# Feuille 1: donnees brutes
ws_raw = wb.active
ws_raw.title = "Donnees"
raw_headers = [
"ID",
"Code",
"Nom FR",
"Nom NL",
"Statut",
"Type surface (code)",
"Type surface (nom FR)",
"Type surface (nom NL)",
"Categorie (code)",
"Categorie (nom FR)",
"Categorie (nom NL)",
"Localisation (code)",
"Localisation (nom FR)",
"Localisation (nom NL)",
"Superficie",
"Pente",
"Densite",
"Largeur",
"Longueur",
"Longitude",
"Latitude",
]
ws_raw.append(raw_headers)
summary = defaultdict(lambda: {"count": 0, "surface_sum": 0.0})
for surface in queryset:
model = surface.model
category = model.category if model and model.category else None
location = surface.location
model_key = (
model.code if model and model.code else "SANS_TYPE",
model.name_fr if model and model.name_fr else "Sans type",
model.name_nl if model and model.name_nl else "",
)
surface_area = float(surface.surface_area or 0)
summary[model_key]["count"] += 1
summary[model_key]["surface_sum"] += surface_area
ws_raw.append(
[
surface.id,
sanitize(surface.code),
sanitize(surface.name_fr),
sanitize(surface.name_nl),
sanitize(surface.status),
sanitize(model.code if model else None),
sanitize(model.name_fr if model else None),
sanitize(model.name_nl if model else None),
sanitize(category.code if category else None),
sanitize(category.name_fr if category else None),
sanitize(category.name_nl if category else None),
sanitize(location.code if location else None),
sanitize(location.name_fr if location else None),
sanitize(location.name_nl if location else None),
surface.surface_area,
surface.slope,
surface.density,
surface.width,
surface.length,
surface.lon,
surface.lat,
]
)
autosize_columns(ws_raw)
# Feuille 2: synthese
ws_summary = wb.create_sheet(title="Synthese")
summary_headers = [
"Type surface (code)",
"Type surface (nom FR)",
"Type surface (nom NL)",
"Nombre",
"Superficie totale",
]
ws_summary.append(summary_headers)
for (model_code, model_name_fr, model_name_nl), values in sorted(
summary.items(), key=lambda item: (item[0][1], item[0][0])
):
ws_summary.append(
[
sanitize(model_code),
sanitize(model_name_fr),
sanitize(model_name_nl),
values["count"],
round(values["surface_sum"], 2),
]
)
ws_summary.append(["TOTAL", "", "", f"=SUM(D2:D{ws_summary.max_row})", f"=SUM(E2:E{ws_summary.max_row})"])
autosize_columns(ws_summary)
wb.save(output_path)
self.stdout.write(self.style.SUCCESS(f"Export termine: {output_path}"))
self.stdout.write(self.style.SUCCESS(f"Nombre de surfaces exportees: {queryset.count()}"))

View file

@ -0,0 +1,909 @@
"""
Commande Django pour importer des assets depuis un fichier Excel.
La commande lit un fichier .xlsx dont les colonnes sont :
thématique | nom_modèle_objet | code | nom_fr | nom_nl |
code_catégorie | code_modèle | code_localisation | geojson |
lon | lat | statut | codes_parents | codes_enfants |
marque | date_installation | structure
Usage :
python manage.py import_assets_from_excel chemin/vers/fichier.xlsx
python manage.py import_assets_from_excel chemin/vers/fichier.xlsx --dry-run
python manage.py import_assets_from_excel chemin/vers/fichier.xlsx --update
python manage.py import_assets_from_excel --generate-template [--template-output assets_template.xlsx]
"""
import ast
import json
import re
from datetime import datetime
import openpyxl
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.geos import GEOSGeometry, Point
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils import timezone
from assets.models import (
AbstractAsset,
AbstractGeoAsset,
AbstractLocation,
AssetCategory,
ASSET_STATUS_CHOICES,
)
from common.models import Thematic
# ---------------------------------------------------------------------------
# Column definitions (normalized header → Excel label)
# ---------------------------------------------------------------------------
COLUMNS = [
("thématique", "Thématique (code)"),
("nom_modèle_objet", "Nom du modèle d'objet (ex: structuregeoasset)"),
("code", "Code"),
("nom_fr", "Nom FR"),
("nom_nl", "Nom NL"),
("code_catégorie", "Code de la catégorie"),
("code_modèle", "Code du modèle"),
("code_localisation", "Code de la localisation"),
("geojson", "GeoJSON"),
("lon", "Lon"),
("lat", "Lat"),
("statut", "Statut"),
("codes_parents", "Codes des assets parents ( [code1, code2] )"),
("codes_enfants", "Codes des assets enfants ( [code1, code2] )"),
("marque", "Marque"),
("date_installation", "Date d'installation (YYYY-MM-DD)"),
("structure", "Structure (code)"),
]
HEADER_KEYS = [col[0] for col in COLUMNS]
COLUMN_HINTS = {
"thématique": "Code de la thématique, ex: structures, trafficlights, controlcenters…",
"nom_modèle_objet": "Nom du modèle Django en minuscules, ex: structuregeoasset, itsgeoasset, controlcentergeoasset",
"code": "Code unique de l'asset",
"nom_fr": "Nom en français",
"nom_nl": "Naam in het Nederlands",
"code_catégorie": "Code de l'AssetCategory",
"code_modèle": "Code du modèle d'asset (StructureAssetModel.code, etc.)",
"code_localisation": "Code de la localisation (StructureLocation.code, ITSLocation.code…)",
"geojson": 'GeoJSON valide (type Point/Polygon…), ex: {"type":"Point","coordinates":[4.35,50.85]}',
"lon": "Longitude (WGS84) – facultatif si geojson fourni",
"lat": "Latitude (WGS84) – facultatif si geojson fourni",
"statut": "active | planned | damaged | to_replace | removed | to_be_approved",
"codes_parents": 'Codes des assets parents entre crochets, ex: [CODE1, CODE2]',
"codes_enfants": 'Codes des assets enfants entre crochets, ex: [CODE3]',
"marque": "Marque / fabricant",
"date_installation": "Date d'installation au format YYYY-MM-DD",
"structure": "Code de l'objet Structure (uniquement pour StructureGeoAsset)",
}
EXAMPLE_ROW = {
"thématique": "structures",
"nom_modèle_objet": "structuregeoasset",
"code": "STR-001",
"nom_fr": "Pont de l'exemple",
"nom_nl": "Voorbeeldbrug",
"code_catégorie": "CAT_STRUCTURE",
"code_modèle": "MOD_PONT",
"code_localisation": "LOC_001",
"geojson": '{"type":"Point","coordinates":[4.3517,50.8503]}',
"lon": "4.3517",
"lat": "50.8503",
"statut": "active",
"codes_parents": "[STR-000]",
"codes_enfants": "[STR-002, STR-003]",
"marque": "Fabricant SA",
"date_installation": "2024-01-15",
"structure": "OA-001",
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _normalize_key(s: str) -> str:
"""Lowercases and strips a string for column matching."""
return s.strip().lower().replace(" ", "_").replace("'", "_").replace("(", "").replace(")", "")
def _parse_codes_list(raw: str) -> list[str]:
"""Parse '[CODE1, CODE2]' or 'CODE1,CODE2' into a list of strings."""
if not raw:
return []
raw = str(raw).strip()
if raw.startswith("["):
# Remove brackets and split
inner = raw.strip("[]")
parts = [p.strip() for p in inner.split(",") if p.strip()]
return parts
return [p.strip() for p in raw.split(",") if p.strip()]
def _parse_date(raw) -> datetime | None:
"""Try to parse a date string (YYYY-MM-DD or DD/MM/YYYY)."""
if not raw:
return None
if isinstance(raw, datetime):
return raw
raw = str(raw).strip()
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%Y/%m/%d"):
try:
return datetime.strptime(raw, fmt)
except ValueError:
continue
return None
def _get_model_class(model_name: str):
"""Return the Django model class for a given model name (case-insensitive)."""
model_name_lower = model_name.strip().lower()
cts = ContentType.objects.filter(model=model_name_lower)
if not cts.exists():
return None
# Prefer assets app
cts_sorted = sorted(cts, key=lambda ct: (0 if ct.app_label == "assets" else 1))
for ct in cts_sorted:
mc = ct.model_class()
if mc is not None:
return mc
return None
def _find_asset_by_code(code: str):
"""Search all AbstractAsset subclasses for an asset with the given code."""
for model in apps.get_models():
if model._meta.abstract:
continue
try:
if not issubclass(model, AbstractAsset):
continue
except TypeError:
continue
obj = model.objects.filter(code=code).first()
if obj:
return obj
return None
def _find_location_by_code(model_class, code: str):
"""
Find a location instance for an asset model class by code.
Looks at FK fields that point to AbstractLocation subclasses.
"""
for field in model_class._meta.get_fields():
if not hasattr(field, "related_model") or field.related_model is None:
continue
if not hasattr(field, "column"):
continue # skip reverse relations
try:
if issubclass(field.related_model, AbstractLocation):
location = field.related_model.objects.filter(code=code).first()
if location:
return field.name, location
except TypeError:
continue
return None, None
def _find_model_instance_by_code(model_class, code: str):
"""
Find an AssetModel instance for an asset class by code.
Looks for the 'model' FK field.
"""
try:
model_field = model_class._meta.get_field("model")
related_model = model_field.related_model
return related_model.objects.filter(code=code).first()
except Exception:
return None
def _set_geom_from_geojson(asset, geojson_str: str):
"""Parse a GeoJSON string and set asset.geom (in SRID 3812)."""
try:
geom_data = json.loads(geojson_str)
if geom_data.get("type") == "Feature":
geom_json = json.dumps(geom_data.get("geometry", {}))
else:
geom_json = json.dumps(geom_data)
geom = GEOSGeometry(geom_json, srid=4326)
geom.transform(3812)
asset.geom = geom
# Update lon/lat from geometry centroid
geom_wgs84 = geom.transform(4326, clone=True)
if geom_wgs84.geom_type == "Point":
asset.lon = geom_wgs84.x
asset.lat = geom_wgs84.y
else:
centroid = geom_wgs84.centroid
asset.lon = centroid.x
asset.lat = centroid.y
return True
except Exception as e:
return False
def _set_geom_from_lon_lat(asset, lon: float, lat: float):
"""Set asset.geom (in SRID 3812) from WGS84 lon/lat."""
pt = Point(lon, lat, srid=4326)
pt.transform(3812)
asset.geom = pt
asset.lon = lon
asset.lat = lat
def _set_parent_fk(asset, parent_asset, model_class, excluded=None):
"""
Find the FK field on 'asset' (model_class) that points to the type of
'parent_asset' and set it.
Returns (True, field_name) on success or (False, tried_field_names) on failure.
"""
if excluded is None:
excluded = {"model", "category", "replaced_by", "marked_as_removed_by", "updated_by",
"marked_as_removed_by"}
parent_class = type(parent_asset)
tried = []
for field in model_class._meta.get_fields():
if not hasattr(field, "related_model") or field.related_model is None:
continue
if not hasattr(field, "column"):
continue # skip reverse relations
if field.name in excluded:
continue
# Only consider FK fields pointing to AbstractAsset subclasses
try:
if not issubclass(field.related_model, AbstractAsset):
continue
except TypeError:
continue
tried.append(f"{field.name}→{field.related_model.__name__}")
try:
if issubclass(parent_class, field.related_model):
setattr(asset, field.name, parent_asset)
return True, field.name
except TypeError:
continue
return False, tried
def _set_child_fk(child_asset, current_asset, excluded=None):
"""
Find the FK field on 'child_asset' that points to the type of 'current_asset'
and set it on the child.
Returns (True, field_name) on success or (False, tried_field_names) on failure.
"""
if excluded is None:
excluded = {"model", "category", "replaced_by", "marked_as_removed_by", "updated_by",
"marked_as_removed_by"}
current_class = type(current_asset)
child_class = type(child_asset)
tried = []
for field in child_class._meta.get_fields():
if not hasattr(field, "related_model") or field.related_model is None:
continue
if not hasattr(field, "column"):
continue
if field.name in excluded:
continue
# Only consider FK fields pointing to AbstractAsset subclasses
try:
if not issubclass(field.related_model, AbstractAsset):
continue
except TypeError:
continue
tried.append(f"{field.name}→{field.related_model.__name__}")
try:
if issubclass(current_class, field.related_model):
setattr(child_asset, field.name, current_asset)
return True, field.name
except TypeError:
continue
return False, tried
# ---------------------------------------------------------------------------
# Excel template generation
# ---------------------------------------------------------------------------
def generate_template(output_path: str):
"""Generate a nicely formatted Excel template file."""
wb = openpyxl.Workbook()
ws_data = wb.active
ws_data.title = "Assets"
# --- Styles ---
header_fill = PatternFill("solid", fgColor="1F4E79")
hint_fill = PatternFill("solid", fgColor="D6E4F0")
example_fill = PatternFill("solid", fgColor="E2EFDA")
header_font = Font(bold=True, color="FFFFFF", size=11)
hint_font = Font(italic=True, color="555555", size=9)
example_font = Font(color="375623", size=10)
wrap = Alignment(wrap_text=True, vertical="top")
thin_border_side = Side(border_style="thin", color="AAAAAA")
thin_border = Border(
left=thin_border_side, right=thin_border_side,
top=thin_border_side, bottom=thin_border_side,
)
# Row 1 – Column keys (hidden row used for mapping)
for col_idx, (key, label) in enumerate(COLUMNS, start=1):
cell = ws_data.cell(row=1, column=col_idx, value=key)
cell.font = Font(size=8, color="999999")
cell.fill = PatternFill("solid", fgColor="F2F2F2")
# Row 2 – Human-readable headers
for col_idx, (key, label) in enumerate(COLUMNS, start=1):
cell = ws_data.cell(row=2, column=col_idx, value=label)
cell.font = header_font
cell.fill = header_fill
cell.alignment = wrap
cell.border = thin_border
# Row 3 – Hints
for col_idx, key in enumerate(HEADER_KEYS, start=1):
cell = ws_data.cell(row=3, column=col_idx, value=COLUMN_HINTS.get(key, ""))
cell.font = hint_font
cell.fill = hint_fill
cell.alignment = wrap
cell.border = thin_border
# Row 4 – Example
for col_idx, key in enumerate(HEADER_KEYS, start=1):
cell = ws_data.cell(row=4, column=col_idx, value=EXAMPLE_ROW.get(key, ""))
cell.font = example_font
cell.fill = example_fill
cell.alignment = wrap
cell.border = thin_border
# Row 5+ – Empty data rows
for row in range(5, 25):
for col_idx in range(1, len(COLUMNS) + 1):
cell = ws_data.cell(row=row, column=col_idx, value="")
cell.border = thin_border
cell.alignment = Alignment(vertical="top")
# Column widths
COLUMN_WIDTHS = {
1: 20, # thématique
2: 35, # nom_modèle_objet
3: 20, # code
4: 30, # nom_fr
5: 30, # nom_nl
6: 25, # code_catégorie
7: 25, # code_modèle
8: 25, # code_localisation
9: 50, # geojson
10: 12, # lon
11: 12, # lat
12: 18, # statut
13: 35, # codes_parents
14: 35, # codes_enfants
15: 25, # marque
16: 22, # date_installation
17: 20, # structure
}
for col_idx, width in COLUMN_WIDTHS.items():
ws_data.column_dimensions[get_column_letter(col_idx)].width = width
ws_data.row_dimensions[1].height = 14
ws_data.row_dimensions[2].height = 30
ws_data.row_dimensions[3].height = 50
ws_data.row_dimensions[4].height = 25
# Freeze panes below row 4 and after column A
ws_data.freeze_panes = "B5"
# --- Second sheet: reference values ---
ws_ref = wb.create_sheet("Référence")
ref_header = Font(bold=True, color="FFFFFF")
ref_fill = PatternFill("solid", fgColor="2E75B6")
ws_ref["A1"] = "Valeurs valides pour le champ 'statut'"
ws_ref["A1"].font = ref_header
ws_ref["A1"].fill = ref_fill
ws_ref["A1"].alignment = Alignment(horizontal="center")
ws_ref.merge_cells("A1:B1")
ws_ref["A2"] = "Clé"
ws_ref["B2"] = "Libellé"
ws_ref["A2"].font = Font(bold=True)
ws_ref["B2"].font = Font(bold=True)
status_labels = {
"to_be_approved": "À approuver",
"planned": "Planifié",
"active": "Actif",
"damaged": "Endommagé",
"to_replace": "À remplacer",
"removed": "Enlevé",
}
for row_idx, (k, v) in enumerate(status_labels.items(), start=3):
ws_ref.cell(row=row_idx, column=1, value=k)
ws_ref.cell(row=row_idx, column=2, value=v)
row_after_status = 3 + len(status_labels) + 2
ws_ref.cell(row=row_after_status, column=1, value="Exemples de modèles d'objets (nom_modèle_objet)").font = Font(bold=True)
ws_ref.merge_cells(f"A{row_after_status}:B{row_after_status}")
examples = [
("structuregeoasset", "StructureGeoAsset – Ouvrages d'art"),
("itsgeoasset", "ITSGeoAsset – ITS"),
("controlcentergeoasset", "ControlCenterGeoAsset – Centres de contrôle"),
("trafficlightpole", "TrafficLightPole – Feux de circulation (poteaux)"),
("trafficlightlantern", "TrafficLightLantern – Lanternes"),
("trafficlightcontroller","TrafficLightController – Régulateurs"),
("trafficlightdetector", "TrafficLightDetector – Détecteurs"),
("trafficlightcabinet", "TrafficLightElectricalCabinet – Armoires"),
("publiclightingpole", "PublicLightingPole – Éclairage public (mâts)"),
("publiclightinglightfixture", "PublicLightingLightFixture – Luminaires"),
("naturetree", "NatureTree – Arbres"),
("natureshrub", "NatureShrub – Arbustes"),
("naturebench", "NatureBench – Bancs"),
("naturegreensurface", "NatureGreenSurface – Surfaces vertes"),
("roadfurniture", "RoadFurniture – Mobilier routier"),
]
for row_idx, (model, desc) in enumerate(examples, start=row_after_status + 1):
ws_ref.cell(row=row_idx, column=1, value=model)
ws_ref.cell(row=row_idx, column=2, value=desc)
ws_ref.column_dimensions["A"].width = 35
ws_ref.column_dimensions["B"].width = 50
wb.save(output_path)
return output_path
# ---------------------------------------------------------------------------
# Main command
# ---------------------------------------------------------------------------
class Command(BaseCommand):
help = (
"Importe des assets depuis un fichier Excel (.xlsx). "
"Utilisez --generate-template pour créer le fichier modèle."
)
def add_arguments(self, parser):
parser.add_argument(
"excel_file",
nargs="?",
type=str,
help="Chemin vers le fichier Excel à importer.",
)
parser.add_argument(
"--dry-run",
action="store_true",
default=False,
help="Simule l'import sans écrire dans la base de données.",
)
parser.add_argument(
"--update",
action="store_true",
default=False,
help="Met à jour les assets existants si le code existe déjà.",
)
parser.add_argument(
"--generate-template",
action="store_true",
default=False,
help="Génère un fichier Excel modèle.",
)
parser.add_argument(
"--template-output",
type=str,
default="assets_import_template.xlsx",
help="Chemin de sortie du fichier modèle (défaut: assets_import_template.xlsx).",
)
# ------------------------------------------------------------------
def handle(self, *args, **options):
# --- Template generation mode ---
if options["generate_template"]:
output = options["template_output"]
path = generate_template(output)
self.stdout.write(self.style.SUCCESS(f"✓ Template généré: {path}"))
return
# --- Import mode ---
excel_file = options.get("excel_file")
if not excel_file:
raise CommandError(
"Veuillez fournir le chemin du fichier Excel, "
"ou utilisez --generate-template pour créer le modèle."
)
dry_run = options["dry_run"]
update = options["update"]
if dry_run:
self.stdout.write(self.style.WARNING("⚠ Mode dry-run activé – aucune donnée ne sera enregistrée."))
self._import(excel_file, dry_run=dry_run, update=update)
# ------------------------------------------------------------------
def _import(self, excel_file: str, dry_run: bool, update: bool):
try:
wb = openpyxl.load_workbook(excel_file, data_only=True)
except FileNotFoundError:
raise CommandError(f"Fichier introuvable: {excel_file}")
except Exception as e:
raise CommandError(f"Impossible d'ouvrir le fichier: {e}")
ws = wb.active
# --- Detect header row ---
header_row_idx, col_map, data_start_row = self._detect_header(ws)
if header_row_idx is None:
raise CommandError(
"Impossible de trouver la ligne d'en-tête. "
"Assurez-vous que la ligne 1 ou 2 contient les clés de colonnes."
)
self.stdout.write(f"→ En-tête détectée à la ligne {header_row_idx}, données à partir de la ligne {data_start_row}.")
# Collect data rows (skip header, hint and example rows)
data_rows = []
for row in ws.iter_rows(min_row=data_start_row, values_only=True):
# Skip completely empty rows
if all(cell is None or str(cell).strip() == "" for cell in row):
continue
data_rows.append(row)
self.stdout.write(f"→ {len(data_rows)} ligne(s) à traiter.")
# Two-pass approach
# Pass 1: create / update assets
# Pass 2: resolve parent/child FK links
created_assets = {} # row_index → asset instance
rows_data = [] # store parsed row dicts
errors = []
stats = {"created": 0, "updated": 0, "skipped": 0, "errors": 0}
# Consume data_rows into parsed dicts, skip hint row(s) written by template
parsed_rows = []
for row in data_rows:
row_dict = {}
for key, col_idx in col_map.items():
value = row[col_idx] if col_idx < len(row) else None
row_dict[key] = value if value is not None else ""
# Skip if 'code' is empty
if not str(row_dict.get("code", "")).strip():
continue
parsed_rows.append(row_dict)
self.stdout.write(f"→ {len(parsed_rows)} ligne(s) valides (avec code).")
with transaction.atomic():
# ------ PASS 1: create / update assets ------
for line_num, row_dict in enumerate(parsed_rows, start=1):
try:
asset, action = self._process_row(row_dict, update=update, dry_run=dry_run)
if asset is not None:
created_assets[line_num] = asset
rows_data.append((line_num, row_dict))
stats[action] += 1
self.stdout.write(
f" Ligne {line_num}: [{action.upper()}] code={row_dict['code']}"
)
else:
stats["skipped"] += 1
self.stdout.write(
self.style.WARNING(f" Ligne {line_num}: [IGNORÉ] code={row_dict['code']}")
)
except Exception as e:
stats["errors"] += 1
msg = f" Ligne {line_num}: [ERREUR] code={row_dict.get('code', '?')} – {e}"
self.stderr.write(self.style.ERROR(msg))
errors.append(msg)
# ------ PASS 2: parent/child links ------
for line_num, row_dict in rows_data:
asset = created_assets.get(line_num)
if asset is None:
continue
model_class = type(asset)
save_needed = False
# Parent links
parent_codes = _parse_codes_list(str(row_dict.get("codes_parents", "")))
for pcode in parent_codes:
parent = _find_asset_by_code(pcode)
if parent is None:
self.stderr.write(
f" Ligne {line_num}: [LIEN] parent '{pcode}' introuvable dans la base – lien ignoré."
)
continue
if not dry_run:
# Direction 1 : FK sur le nouvel asset → parent
ok, tried_fields = _set_parent_fk(asset, parent, model_class)
if ok:
save_needed = True
self.stdout.write(
f" Ligne {line_num}: [LIEN] parent '{pcode}' ({type(parent).__name__}) lié via FK."
)
else:
# Direction 2 (fallback) : FK sur le parent → nouvel asset
ok2, tried_fields2 = _set_parent_fk(parent, asset, type(parent))
if ok2:
parent.save()
self.stdout.write(
f" Ligne {line_num}: [LIEN] parent '{pcode}' ({type(parent).__name__}) "
f"lié via FK inverse (FK sur le parent)."
)
else:
all_tried = (tried_fields or []) + (tried_fields2 or [])
self.stderr.write(
f" Ligne {line_num}: [LIEN] impossible de lier le parent '{pcode}' "
f"({type(parent).__name__}) – aucun champ FK compatible trouvé "
f"(champs vérifiés: {all_tried or 'aucun'})."
)
else:
self.stdout.write(
f" Ligne {line_num}: [LIEN dry-run] parent '{pcode}' trouvé ({type(parent).__name__})."
)
if save_needed and not dry_run:
asset.save()
save_needed = False
# Child links
child_codes = _parse_codes_list(str(row_dict.get("codes_enfants", "")))
for ccode in child_codes:
child = _find_asset_by_code(ccode)
if child is None:
self.stderr.write(
f" Ligne {line_num}: [LIEN] enfant '{ccode}' introuvable dans la base – lien ignoré."
)
continue
if not dry_run:
# Direction 1 : FK sur l'enfant → nouvel asset
ok, tried_fields = _set_child_fk(child, asset)
if ok:
child.save()
self.stdout.write(
f" Ligne {line_num}: [LIEN] enfant '{ccode}' ({type(child).__name__}) lié via FK."
)
else:
# Direction 2 (fallback) : FK sur le nouvel asset → enfant
ok2, tried_fields2 = _set_child_fk(asset, child)
if ok2:
asset.save()
self.stdout.write(
f" Ligne {line_num}: [LIEN] enfant '{ccode}' ({type(child).__name__}) "
f"lié via FK inverse (FK sur l'asset importé)."
)
else:
all_tried = (tried_fields or []) + (tried_fields2 or [])
self.stderr.write(
f" Ligne {line_num}: [LIEN] impossible de lier l'enfant '{ccode}' "
f"({type(child).__name__}) – aucun champ FK compatible trouvé "
f"(champs vérifiés: {all_tried or 'aucun'})."
)
else:
self.stdout.write(
f" Ligne {line_num}: [LIEN dry-run] enfant '{ccode}' trouvé ({type(child).__name__})."
)
if dry_run:
# Roll back so nothing is persisted
transaction.set_rollback(True)
# --- Summary ---
self.stdout.write("")
self.stdout.write(self.style.SUCCESS("=== Résumé de l'import ==="))
self.stdout.write(f" Créés : {stats['created']}")
self.stdout.write(f" Mis à jour: {stats['updated']}")
self.stdout.write(f" Ignorés : {stats['skipped']}")
self.stdout.write(f" Erreurs : {stats['errors']}")
if errors:
self.stdout.write("")
self.stdout.write(self.style.ERROR("Détail des erreurs:"))
for err in errors:
self.stdout.write(self.style.ERROR(err))
if dry_run:
self.stdout.write(self.style.WARNING("(dry-run: aucune donnée enregistrée)"))
# ------------------------------------------------------------------
def _detect_header(self, ws):
"""
Detect which row contains the column headers and where data starts.
Supports two layouts:
- Row 1: machine-key row (from our template) → data starts at row 5
- Row 1 or 2: human-readable labels → data starts the row after
Returns (header_row_idx [1-based], col_map {key: 0-based col index},
data_start_row [1-based]).
"""
# Build reverse mapping: normalised label → key
label_to_key = {_normalize_key(label): key for key, label in COLUMNS}
HEADER_KEYS_SET = set(HEADER_KEYS)
for row_idx in range(1, 4):
row_values = [
str(cell.value).strip() if cell.value is not None else ""
for cell in ws[row_idx]
]
normalized = [_normalize_key(v) for v in row_values]
matches_key = sum(1 for n in normalized if n in HEADER_KEYS_SET)
matches_label = sum(1 for n in normalized if n in label_to_key)
if matches_key >= 3:
col_map = {}
for col_idx, norm in enumerate(normalized):
if norm in HEADER_KEYS_SET:
col_map[norm] = col_idx
# If ALL values are exact machine keys, this is our template row 1.
# In that case skip rows 2-4 (human labels, hints, example).
non_empty = [n for n in normalized if n]
is_template_machine_row = non_empty and all(n in HEADER_KEYS_SET for n in non_empty)
data_start = 5 if is_template_machine_row else row_idx + 1
return row_idx, col_map, data_start
if matches_label >= 3:
col_map = {}
for col_idx, norm in enumerate(normalized):
if norm in label_to_key:
col_map[label_to_key[norm]] = col_idx
return row_idx, col_map, row_idx + 1
return None, {}, None
# ------------------------------------------------------------------
def _process_row(self, row_dict: dict, update: bool, dry_run: bool):
"""
Process a single Excel row and create/update the asset.
Returns (asset_instance, action_string) or (None, 'skipped').
"""
code = str(row_dict.get("code", "")).strip()
if not code:
return None, "skipped"
model_name = str(row_dict.get("nom_modèle_objet", "")).strip().lower()
if not model_name:
raise ValueError("La colonne 'nom_modèle_objet' est requise.")
model_class = _get_model_class(model_name)
if model_class is None:
raise ValueError(f"Modèle d'objet inconnu: '{model_name}'")
# Check for existing asset
existing = model_class.objects.filter(code=code).first()
if existing and not update:
return None, "skipped"
asset = existing if existing else model_class()
action = "updated" if existing else "created"
# --- Thematic (informational, used for validation only) ---
thematic_code = str(row_dict.get("thématique", "")).strip()
if thematic_code and not dry_run:
try:
Thematic.objects.get(code=thematic_code)
except Thematic.DoesNotExist:
raise ValueError(f"Thématique inconnue: '{thematic_code}'")
# --- Basic fields ---
asset.code = code
nom_fr = str(row_dict.get("nom_fr", "")).strip()
if nom_fr and hasattr(asset, "name_fr"):
asset.name_fr = nom_fr
nom_nl = str(row_dict.get("nom_nl", "")).strip()
if nom_nl and hasattr(asset, "name_nl"):
asset.name_nl = nom_nl
marque = str(row_dict.get("marque", "")).strip()
if marque and hasattr(asset, "brand"):
asset.brand = marque
# --- Status ---
statut = str(row_dict.get("statut", "")).strip()
if statut:
valid_keys = list(ASSET_STATUS_CHOICES.keys())
if statut in valid_keys:
asset.status = statut
else:
raise ValueError(
f"Statut invalide: '{statut}'. Valeurs acceptées: {valid_keys}"
)
else:
if not existing:
asset.status = "active"
# --- Installation date ---
date_raw = row_dict.get("date_installation", "")
parsed_date = _parse_date(date_raw)
if parsed_date and hasattr(asset, "installation_date"):
from django.utils import timezone as dj_tz
if dj_tz.is_aware(parsed_date):
asset.installation_date = parsed_date
else:
asset.installation_date = dj_tz.make_aware(parsed_date)
# --- Category ---
cat_code = str(row_dict.get("code_catégorie", "")).strip()
if cat_code and hasattr(asset, "category"):
try:
asset.category = AssetCategory.objects.get(code=cat_code)
except AssetCategory.DoesNotExist:
raise ValueError(f"Catégorie inconnue: '{cat_code}'")
# --- Asset model object (e.g. StructureAssetModel) ---
model_code = str(row_dict.get("code_modèle", "")).strip()
if model_code and hasattr(model_class, "_meta"):
model_instance = _find_model_instance_by_code(model_class, model_code)
if model_instance is None:
raise ValueError(f"Modèle d'asset introuvable: code='{model_code}'")
asset.model = model_instance
elif not existing:
# Check if 'model' field is required
try:
model_field = model_class._meta.get_field("model")
if not model_field.null and not model_field.blank:
raise ValueError(
"Le champ 'code_modèle' est requis pour ce type d'asset."
)
except Exception:
pass
# --- Location ---
loc_code = str(row_dict.get("code_localisation", "")).strip()
if loc_code:
field_name, location = _find_location_by_code(model_class, loc_code)
if location:
setattr(asset, field_name, location)
else:
raise ValueError(f"Localisation introuvable: code='{loc_code}'")
# --- Structure ---
structure_code = str(row_dict.get("structure", "")).strip()
if structure_code and hasattr(asset, "structure"):
from assets.models.structures import Structure
try:
asset.structure = Structure.objects.get(code=structure_code)
except Structure.DoesNotExist:
raise ValueError(f"Structure introuvable: code='{structure_code}'")
# --- Geometry ---
geojson_raw = str(row_dict.get("geojson", "")).strip()
lon_raw = row_dict.get("lon", "")
lat_raw = row_dict.get("lat", "")
if geojson_raw and hasattr(asset, "geom"):
ok = _set_geom_from_geojson(asset, geojson_raw)
if not ok:
raise ValueError(f"GeoJSON invalide: '{geojson_raw[:80]}'")
# Also store raw geojson string
if hasattr(asset, "geojson"):
asset.geojson = geojson_raw
elif lon_raw != "" and lat_raw != "" and hasattr(asset, "geom"):
try:
lon = float(str(lon_raw).replace(",", "."))
lat = float(str(lat_raw).replace(",", "."))
_set_geom_from_lon_lat(asset, lon, lat)
if hasattr(asset, "geojson"):
asset.geojson = json.dumps({
"type": "Point",
"coordinates": [lon, lat],
})
except ValueError:
raise ValueError(f"Lon/lat invalide: lon='{lon_raw}', lat='{lat_raw}'")
if not dry_run:
asset.save()
return asset, action

View file

@ -0,0 +1,446 @@
"""
Commande Django pour créer des objets RoadConcreteBlockSet
à partir des observations d'inventaire de blocs en béton.
Les observations concernées sont celles qui contiennent une réponse
de type choice_multiple dont la valeur correspond à un type de bloc
(concrete_1m, concrete_2m, red_white). C'est le même critère que
celui utilisé par maps/views.py::concrete_blocks_geojson.
Mapping des fonctions (valeurs checklist → choix du modèle) :
works → construction
security → antiterrorist
parking → parking_neutralization
protection → protection
deposit → unidentified
circulation → unidentified
test → unidentified
Usage :
python manage.py import_concrete_blocks_from_observations
python manage.py import_concrete_blocks_from_observations --dry-run
python manage.py import_concrete_blocks_from_observations --update
"""
import os
from django.contrib.contenttypes.models import ContentType
from django.contrib.gis.gdal import CoordTransform, SpatialReference
from django.contrib.gis.geos import GEOSGeometry, Point
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from assets.models.roads import RoadConcreteBlockSet
from documents.models import DocumentAttachment, DocumentTag, DocumentVersion, ManagedDocument
from observations.models import Observation, ObservationChecklistResponse, ObservationPhoto
# ---------------------------------------------------------------------------
# Constants – keep in sync with maps/views.py
# ---------------------------------------------------------------------------
BLOCK_TYPE_VALUES = ['concrete_1m', 'concrete_2m', 'red_white']
FUNCTION_VALUES = ['works', 'security', 'parking', 'test', 'deposit', 'circulation', 'protection']
# Map observation checklist function keys → RoadConcreteBlockSet.main_function choices
FUNCTION_TO_CB = {
'protection': 'protection',
'security': 'antiterrorist',
'parking': 'parking_neutralization',
'works': 'construction',
'deposit': 'unidentified',
'circulation': 'unidentified',
'test': 'unidentified',
'antiterrorist': 'antiterrorist', # in case of direct checklist response with this value
'parking_neutralization': 'parking_neutralization', # idem
'construction': 'construction', # idem
'unidentified': 'unidentified', # idem
'road_safety': 'road_safety', # idem
'public_space': 'public_space', # idem
}
# Coordinate transform helpers (built once)
_srs_4326 = SpatialReference(4326)
_srs_3812 = SpatialReference(3812)
_ct_to_3812 = CoordTransform(_srs_4326, _srs_3812)
_ct_to_4326 = CoordTransform(_srs_3812, _srs_4326)
def _json_contains_any(field, values):
"""Q expression: JSON array field contains at least one of the given values."""
q = Q()
for v in values:
q |= Q(**{f'{field}__contains': [v]})
return q
def _make_geom_3812(obs):
"""
Return a GEOS Point in SRID 3812 for *obs*, or None if coordinates
are unavailable. Prefers obs.geom (already in 3812) over lat/lon.
"""
if obs.geom:
return obs.geom # PointField srid=3812
if obs.latitude is not None and obs.longitude is not None:
pt = Point(obs.longitude, obs.latitude, srid=4326)
pt.transform(_ct_to_3812)
return pt
return None
def _make_geojson(geom_3812):
"""Return a GeoJSON string (WGS84) for the given Point in SRID 3812."""
if geom_3812 is None:
return None
wgs84 = geom_3812.clone()
wgs84.transform(_ct_to_4326)
return wgs84.geojson
def _make_lon_lat(obs, geom_3812):
"""Return (lon, lat) in WGS84 from observation fields or geom."""
if obs.longitude is not None and obs.latitude is not None:
return obs.longitude, obs.latitude
if geom_3812 is not None:
wgs84 = geom_3812.clone()
wgs84.transform(_ct_to_4326)
return wgs84.x, wgs84.y
return None, None
def _obs_code_to_asset_code(obs):
"""Derive a unique asset code from the observation code."""
return f'CBX-{obs.code}' if obs.code else f'CBX-OBS{obs.pk:05d}'
# ---------------------------------------------------------------------------
# Fetch observation data (mirrors concrete_blocks_geojson logic)
# ---------------------------------------------------------------------------
def _fetch_observation_data():
"""
Return a dict keyed by observation id, each value being a dict with:
obs – Observation instance
block_types – list of matching block-type keys
functions – list of matching function keys
block_count – float or None
"""
# Observations that have at least one block-type checklist response
obs_ids = list(
ObservationChecklistResponse.objects
.filter(_json_contains_any('value_choices', BLOCK_TYPE_VALUES))
.values_list('observation_id', flat=True)
.distinct()
)
if not obs_ids:
return {}
observations = {
obs.pk: obs
for obs in Observation.objects.filter(pk__in=obs_ids)
}
# Block-type checklist responses
bt_responses = (
ObservationChecklistResponse.objects
.filter(observation_id__in=obs_ids)
.filter(_json_contains_any('value_choices', BLOCK_TYPE_VALUES))
.values('observation_id', 'value_choices')
)
# Function checklist responses
fn_responses = (
ObservationChecklistResponse.objects
.filter(observation_id__in=obs_ids)
.filter(_json_contains_any('value_choices', FUNCTION_VALUES))
.values('observation_id', 'value_choices')
)
# Block count (numeric checklist item)
cnt_responses = (
ObservationChecklistResponse.objects
.filter(
observation_id__in=obs_ids,
checklist_item__item_type='number',
value_number__isnull=False,
)
.values('observation_id', 'value_number')
)
result = {obs_id: {'obs': obs, 'block_types': [], 'functions': [], 'block_count': None}
for obs_id, obs in observations.items()}
for r in bt_responses:
obs_id = r['observation_id']
for v in (r['value_choices'] or []):
if v in BLOCK_TYPE_VALUES and v not in result[obs_id]['block_types']:
result[obs_id]['block_types'].append(v)
for r in fn_responses:
obs_id = r['observation_id']
for v in (r['value_choices'] or []):
if v in FUNCTION_VALUES and v not in result[obs_id]['functions']:
result[obs_id]['functions'].append(v)
for r in cnt_responses:
obs_id = r['observation_id']
if result[obs_id]['block_count'] is None:
result[obs_id]['block_count'] = float(r['value_number'])
return result
# ---------------------------------------------------------------------------
# Management command
# ---------------------------------------------------------------------------
class Command(BaseCommand):
help = (
"Crée des objets RoadConcreteBlockSet à partir des observations "
"d'inventaire de blocs en béton (lecture des réponses de checklist)."
)
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
default=False,
help="Afficher les opérations sans écrire en base.",
)
parser.add_argument(
'--update',
action='store_true',
default=False,
help=(
"Mettre à jour les RoadConcreteBlockSet existants "
"(identifiés via observation_source) au lieu de les ignorer."
),
)
def handle(self, *args, **options):
dry_run = options['dry_run']
do_update = options['update']
if dry_run:
self.stdout.write(self.style.WARNING("Mode dry-run : aucune écriture en base.\n"))
# ── Fetch data from observations ─────────────────────────────────────
data = _fetch_observation_data()
self.stdout.write(f"Observations avec blocs en béton trouvées : {len(data)}")
if not data:
self.stdout.write(self.style.WARNING("Aucune observation trouvée. Fin."))
return
# ── Existing RoadConcreteBlockSet keyed by observation_source_id ─────
existing = {
cbs.observation_source_id: cbs
for cbs in RoadConcreteBlockSet.objects.filter(
observation_source_id__in=list(data.keys())
)
}
self.stdout.write(f"RoadConcreteBlockSet déjà présents : {len(existing)}")
# ── Build objects ────────────────────────────────────────────────────
to_create: list[RoadConcreteBlockSet] = []
to_update_instances: list[RoadConcreteBlockSet] = []
update_fields: list[str] = []
skipped = created = updated = 0
for obs_id, row in data.items():
obs = row['obs']
block_types = row['block_types']
functions = row['functions']
block_count = row['block_count']
# Map functions to model choices; deduplicate while preserving order
cb_functions = []
for fn in functions:
mapped = FUNCTION_TO_CB.get(fn)
if mapped and mapped not in cb_functions:
cb_functions.append(mapped)
main_function = cb_functions[0] if cb_functions else None
secondary_functions = cb_functions[1:] if len(cb_functions) > 1 else None
geom_3812 = _make_geom_3812(obs)
geojson = _make_geojson(geom_3812)
lon, lat = _make_lon_lat(obs, geom_3812)
if obs_id in existing:
if not do_update:
skipped += 1
continue
# ── Update path ───────────────────────────────────────────
cbs = existing[obs_id]
cbs.address = obs.address
# cbs.main_function = main_function
# cbs.secondary_functions = secondary_functions or []
cbs.number_of_blocks = int(block_count) if block_count is not None else None
cbs.geom = geom_3812
cbs.geojson = geojson
cbs.lon = lon
cbs.lat = lat
to_update_instances.append(cbs)
if not update_fields:
update_fields = [
'address', 'main_function', 'secondary_functions',
'number_of_blocks', 'geom', 'geojson', 'lon', 'lat',
]
self.stdout.write(
f" [MÀJP] CBX id={cbs.id} ← OBS {obs.code} "
f"({len(functions)} fonction(s), {block_count} blocs)"
)
else:
# ── Create path ───────────────────────────────────────────
code = _obs_code_to_asset_code(obs)
cbs = RoadConcreteBlockSet(
code = code,
name_fr = obs.address or code,
status = 'active',
address = obs.address,
# main_function = main_function,
# secondary_functions = secondary_functions or [],
number_of_blocks = int(block_count) if block_count is not None else None,
geom = geom_3812,
geojson = geojson,
lon = lon,
lat = lat,
observation_source = obs,
)
to_create.append(cbs)
self.stdout.write(
f" [CRÉER] {code} ← OBS {obs.code} "
f"({len(functions)} fonction(s), {block_count} blocs)"
)
# ── Write to DB ──────────────────────────────────────────────────────
if not dry_run:
with transaction.atomic():
if to_create:
RoadConcreteBlockSet.objects.bulk_create(to_create, batch_size=200)
created = len(to_create)
if to_update_instances and update_fields:
RoadConcreteBlockSet.objects.bulk_update(
to_update_instances, update_fields, batch_size=200
)
updated = len(to_update_instances)
else:
created = len(to_create)
updated = len(to_update_instances)
# ── Attach photos ────────────────────────────────────────────────────
# Process photos for ALL observations that now have a linked CBS
# (newly created + pre-existing). Idempotency is handled inside.
all_obs_ids = list(data.keys())
photos_attached, photos_skipped = self._attach_photos(all_obs_ids, dry_run)
# ── Summary ──────────────────────────────────────────────────────────
suffix = " (dry-run)" if dry_run else ""
self.stdout.write("")
self.stdout.write(self.style.SUCCESS(
f"Terminé{suffix} : {created} CBS créé(s), {updated} mis à jour, {skipped} ignoré(s) — "
f"{photos_attached} photo(s) attachée(s), {photos_skipped} déjà présente(s)."
))
# -------------------------------------------------------------------------
def _attach_photos(self, obs_ids: list, dry_run: bool) -> tuple[int, int]:
"""
Crée des ManagedDocument (tag 'photo') et les attache aux
RoadConcreteBlockSet correspondants via DocumentAttachment.
Retourne (nb_attachées, nb_déjà_présentes).
"""
if not obs_ids:
return 0, 0
# Get-or-create the 'photo' DocumentTag
photo_tag, tag_created = DocumentTag.objects.get_or_create(
slug='photo',
defaults={'name_fr': 'photo', 'name_nl': 'foto', 'order': 0},
)
if tag_created:
self.stdout.write(self.style.SUCCESS(' DocumentTag "photo" créé.'))
cbs_ct = ContentType.objects.get_for_model(RoadConcreteBlockSet)
# CBS keyed by observation_source_id
cbs_by_obs = {
cbs.observation_source_id: cbs
for cbs in RoadConcreteBlockSet.objects.filter(
observation_source_id__in=obs_ids
)
}
# Photos grouped by observation_id
photos_by_obs: dict[int, list[ObservationPhoto]] = {obs_id: [] for obs_id in obs_ids}
for photo in ObservationPhoto.objects.filter(observation_id__in=obs_ids):
photos_by_obs[photo.observation_id].append(photo)
# Existing attachments for these CBS (to avoid per-photo DB queries)
cbs_pks = [cbs.pk for cbs in cbs_by_obs.values()]
existing_titles: set[tuple[int, str]] = set(
DocumentAttachment.objects
.filter(content_type=cbs_ct, object_id__in=cbs_pks)
.values_list('object_id', 'document__title')
)
attached = skipped = 0
for obs_id, photos in photos_by_obs.items():
cbs = cbs_by_obs.get(obs_id)
if cbs is None or not photos:
continue
for obs_photo in photos:
if not obs_photo.photo:
continue
filename = os.path.basename(obs_photo.photo.name)
title = os.path.splitext(filename)[0]
if (cbs.pk, title) in existing_titles:
skipped += 1
continue
if dry_run:
self.stdout.write(f" [DRY] Photo : {filename} → CBS {cbs.code}")
attached += 1
continue
try:
with obs_photo.photo.open('rb') as fh:
image_data = fh.read()
except Exception as exc:
self.stdout.write(
self.style.WARNING(f" Impossible de lire {filename} : {exc}")
)
continue
with transaction.atomic():
doc = ManagedDocument.objects.create(title=title)
doc.tags.add(photo_tag)
version = DocumentVersion(
document=doc,
status=DocumentVersion.STATUS_APPROVED,
)
version.file.save(filename, ContentFile(image_data), save=False)
version.save()
DocumentAttachment.objects.create(
document=doc,
content_type=cbs_ct,
object_id=cbs.pk,
)
existing_titles.add((cbs.pk, title)) # update local cache
attached += 1
self.stdout.write(f" Photo : {filename} → CBS {cbs.code}")
return attached, skipped

View file

@ -0,0 +1,418 @@
"""
Management command to import / update RoadDrain objects from
external/avaloirs_2026.csv.
CSV → RoadDrain field mapping
─────────────────────────────
geom → geom (PointField srid=3812, dim=3) + lon / lat / geojson
DR_ID → code (e.g. "DR-001")
DR_STREET → street (ForeignKey to RoadStreet, matched by name_fr)
DR_SOURCE → positionning_source (with DR_SURVEY_TYPE appended)
DR_SOILTYPE → soil_type
DR_LOCATION → drain_location_type
DR_VEGETATION → nearby_vegetation
DR_ROOTING → possible_rooting
DR_STATE → is_defect
DR_BAD_STATE_TYPE → defect_types (all tokens as list)
DR_STATE_NOTE → defect_note (appended)
DR_Z → altitude
DR_BEGIN_LIFE → installation_date
DR_MEASURE_POSITION / DR_POSITION / DR_BAD_POSITION_TYPE / DR_POSITION_NOTE
→ positionning_note (combined)
DR_MODEL → model (RoadDrainModel, looked-up or created by code)
DR_MODEL_NOTE / DR_NOTE → note (combined)
DR_TYPE → related_image_type
DR_OPERATOR → brand
"""
import csv
import json
import os
import re
from datetime import datetime
from django.contrib.gis.gdal import CoordTransform, SpatialReference
from django.contrib.gis.geos import Point
from django.core.management.base import BaseCommand
from django.db import transaction
from assets.models.roads import RoadDrain, RoadDrainModel, RoadStreet
# ── Path to CSV ───────────────────────────────────────────────────────────────
CSV_PATH = os.path.normpath(
os.path.join(
os.path.dirname(__file__), # …/assets/management/commands/
"..", "..", "..", # → repo root
"scripts", "data", "avaloirs_2026.csv",
)
)
BULK_BATCH_SIZE = 500
# ── Lookup / mapping tables ───────────────────────────────────────────────────
SOIL_TYPE_MAP = {
"CONCRETE": "concrete",
"ASPHALT": "asphalt",
"STONE": "stone",
"CONCRETE SLABS": "concrete_slabs",
"CONCRETE PAVING": "concrete_paving",
"STONE SLABS": "stone_slabs",
"STONE PAVING": "stone_paving",
"CONCRETE/GRASS": "concrete_grass",
"GRAVEL": "gravel",
"GRASS": "grass",
"EARTH": "earth",
}
LOCATION_TYPE_MAP = {
"WATERWAY": "waterway",
"SIDEWALK": "sidewalk",
"BERME": "berme",
"SIDEWALK-FACADE TO ROAD": "sidewalk_facade_to_road",
"SQUARE": "square",
"ALONG SIDEWALK": "along_sidewalk",
"MIDDLE ROAD": "middle_road",
}
DEFECT_TYPE_MAP = {
"OBSTRUCTED": "obstructed",
"SMELL CUT MISSING": "smell_cut_missing",
"GRATE BROKEN": "grate_broken",
"GRATE MISSING": "grate_missing",
"POOR WATER FLOW": "poor_water_flow",
"OTHER": "other",
}
# Fields updated on an existing drain (update path)
UPDATE_FIELDS = [
"street_id", "model_id", "soil_type", "drain_location_type",
"nearby_vegetation", "possible_rooting", "is_defect", "defect_types",
"defect_note", "positionning_source", "positionning_note",
"positionning_date", "altitude", "related_image_type",
"brand", "note", "installation_date",
"geom", "lon", "lat", "geojson",
]
# ── Helpers ───────────────────────────────────────────────────────────────────
def _parse_bool(val: str):
"""Return True/False/None from 'true'/'false'/'' strings."""
v = (val or "").strip().lower()
if v == "true":
return True
if v == "false":
return False
return None
def _parse_defect_tokens(raw: str) -> list[str]:
"""
Parse values like '{OBSTRUCTED}', '{GRATE BROKEN, OTHER}', '{}'.
Returns a list of upper-cased token strings.
"""
raw = raw.strip()
if not raw or raw == "{}":
return []
inner = re.sub(r"^\{|\}$", "", raw)
return [t.strip() for t in inner.split(",") if t.strip()]
def _extract_model_code(model_path: str) -> str:
"""'MODEL/AV/AV_TYPE-002.png' → 'AV_TYPE-002'"""
return os.path.splitext(os.path.basename(model_path))[0]
def _parse_geom(wkt: str):
"""
Parse 'POINT ZM (x y z m)' (already in EPSG:3812) into a 3-D Point.
Returns a GEOSGeometry Point(srid=3812) or None on failure.
"""
m = re.match(
r"POINT\s+ZM\s*\(\s*([\d.+-]+)\s+([\d.+-]+)\s+([\d.+-]+)\s+([\d.+-]+)\s*\)",
wkt,
re.IGNORECASE,
)
if not m:
return None
x, y, z = float(m.group(1)), float(m.group(2)), float(m.group(3))
return Point(x, y, z, srid=3812)
def _lon_lat_geojson(geom_3812):
"""Transform a 3812 Point to WGS84, return (lon, lat, geojson_str)."""
srs_3812 = SpatialReference(3812)
srs_4326 = SpatialReference(4326)
ct = CoordTransform(srs_3812, srs_4326)
g = geom_3812.clone()
g.transform(ct)
lon, lat = round(g.x, 8), round(g.y, 8)
geojson = json.dumps({"type": "Point", "coordinates": [lon, lat]})
return lon, lat, geojson
def _parse_date(raw: str):
"""Parse 'YYYY/MM/DD' → date, or None."""
raw = (raw or "").strip()
if not raw:
return None
try:
return datetime.strptime(raw, "%Y/%m/%d").date()
except ValueError:
return None
def _build_drain(row: dict, street_map: dict, drain_model_map: dict) -> dict:
"""
Convert one CSV row into a dict of RoadDrain field values.
Returns None if the row must be skipped.
"""
if (row.get("DR_TYPE") or "").strip().upper() != "DRAIN POINT":
return None
dr_id = (row.get("DR_ID") or "").strip()
if not dr_id:
return None
code = f"DR-{int(dr_id):05d}"
# ── Geometry ──────────────────────────────────────────────────────────────
wkt = (row.get("geom") or "").strip()
geom = _parse_geom(wkt) if wkt else None
if geom is None:
return None
lon, lat, geojson = _lon_lat_geojson(geom)
# ── Street ────────────────────────────────────────────────────────────────
# street_name = (row.get("DR_STREET") or "").strip()
# street = street_map.get(street_name) # may be None
street = None
# ── Drain model ───────────────────────────────────────────────────────────
model_path = (row.get("DR_MODEL") or "").strip()
drain_model = None
if model_path:
model_code = _extract_model_code(model_path)
drain_model = drain_model_map.get(model_code)
# ── Soil type ─────────────────────────────────────────────────────────────
soil_raw = (row.get("DR_SOILTYPE") or "").strip().upper()
soil_type = SOIL_TYPE_MAP.get(soil_raw)
# ── Location type ─────────────────────────────────────────────────────────
loc_raw = (row.get("DR_LOCATION") or "").strip().upper()
drain_location_type = LOCATION_TYPE_MAP.get(loc_raw)
# ── Boolean flags ─────────────────────────────────────────────────────────
nearby_vegetation = _parse_bool(row.get("DR_VEGETATION", ""))
possible_rooting = _parse_bool(row.get("DR_ROOTING", ""))
is_defect = _parse_bool(row.get("DR_STATE", ""))
# ── Defect type / note ────────────────────────────────────────────────────
defect_tokens = _parse_defect_tokens(row.get("DR_BAD_STATE_TYPE") or "")
defect_types = [DEFECT_TYPE_MAP[t] for t in defect_tokens if t in DEFECT_TYPE_MAP] or None
state_note = (row.get("DR_STATE_NOTE") or "").strip()
defect_note = state_note or None
# ── Positionning ──────────────────────────────────────────────────────────
survey_source = (row.get("DR_SOURCE") or "").strip()
survey_type = (row.get("DR_SURVEY_TYPE") or "").strip()
positionning_source = f"{survey_source} / {survey_type}" if survey_type else survey_source or None
pos_note_parts = []
measure_pos = (row.get("DR_MEASURE_POSITION") or "").strip()
if measure_pos:
pos_note_parts.append(measure_pos)
bad_position = _parse_bool(row.get("DR_POSITION", ""))
if bad_position:
bad_pos_tokens = _parse_defect_tokens(row.get("DR_BAD_POSITION_TYPE") or "")
if bad_pos_tokens:
pos_note_parts.append("Position: " + ", ".join(bad_pos_tokens))
pos_note_raw = (row.get("DR_POSITION_NOTE") or "").strip()
if pos_note_raw:
pos_note_parts.append(pos_note_raw)
positionning_note = "\n".join(pos_note_parts) or None
positionning_date = _parse_date(row.get("DR_BEGIN_LIFE") or "")
# ── Altitude ──────────────────────────────────────────────────────────────
z_raw = (row.get("DR_Z") or "").strip()
altitude = float(z_raw) if z_raw else None
# ── Note ─────────────────────────────────────────────────────────────────
note_parts = []
dr_note = (row.get("DR_NOTE") or "").strip()
model_note = (row.get("DR_MODEL_NOTE") or "").strip()
veg_note = (row.get("DR_VEGETATION_NOTE") or "").strip()
if dr_note:
note_parts.append(dr_note)
if model_note:
note_parts.append(f"Modèle : {model_note}")
if veg_note:
note_parts.append(f"Végétation : {veg_note}")
note = "\n".join(note_parts) or None
# ── Misc ──────────────────────────────────────────────────────────────────
related_image_type = (row.get("DR_TYPE") or "").strip() or None
brand = None
installation_date = None
return dict(
code=code,
street=street,
model=drain_model,
soil_type=soil_type,
drain_location_type=drain_location_type,
nearby_vegetation=nearby_vegetation,
possible_rooting=possible_rooting,
is_defect=is_defect,
defect_types=defect_types,
defect_note=defect_note,
positionning_source=positionning_source,
positionning_note=positionning_note,
positionning_date=positionning_date,
altitude=altitude,
related_image_type=related_image_type,
brand=brand,
note=note,
installation_date=installation_date,
geom=geom,
lon=lon,
lat=lat,
geojson=geojson,
)
# ── Management command ────────────────────────────────────────────────────────
class Command(BaseCommand):
help = "Import / update RoadDrain objects from external/avaloirs_2026.csv"
def add_arguments(self, parser):
parser.add_argument(
"--csv",
default=CSV_PATH,
help="Path to the CSV file (default: external/avaloirs_2026.csv)",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Parse and validate without writing to the database",
)
def handle(self, *args, **options):
csv_path = options["csv"]
dry_run = options["dry_run"]
if not os.path.isfile(csv_path):
self.stderr.write(self.style.ERROR(f"CSV file not found: {csv_path}"))
return
# ── Pre-load DB lookups ───────────────────────────────────────────────
street_map = {
s.name_fr: s
for s in RoadStreet.objects.all()
if s.name_fr
}
self.stdout.write(f" Streets loaded : {len(street_map)}")
# Get-or-create drain models keyed by code
drain_model_map: dict[str, RoadDrainModel] = {
m.code: m for m in RoadDrainModel.objects.all()
}
self.stdout.write(f" DrainModels loaded : {len(drain_model_map)}")
# Existing drains keyed by code
existing_drains: dict[str, RoadDrain] = {
d.code: d for d in RoadDrain.objects.all()
}
self.stdout.write(f" Existing drains : {len(existing_drains)}")
# ── Read CSV ──────────────────────────────────────────────────────────
with open(csv_path, newline="", encoding="utf-8-sig") as f:
rows = list(csv.DictReader(f))
self.stdout.write(f" CSV rows : {len(rows)}")
# ── Ensure drain models exist for all DR_MODEL values ─────────────────
if not dry_run:
needed_codes = set()
for row in rows:
mp = (row.get("DR_MODEL") or "").strip()
if mp:
needed_codes.add(_extract_model_code(mp))
new_models = []
for code in needed_codes:
if code not in drain_model_map:
dm = RoadDrainModel(code=code, name_fr=code)
new_models.append(dm)
if new_models:
RoadDrainModel.objects.bulk_create(new_models, batch_size=BULK_BATCH_SIZE)
# Reload
drain_model_map = {m.code: m for m in RoadDrainModel.objects.all()}
self.stdout.write(
f" DrainModels created: {len(new_models)} "
f"(total {len(drain_model_map)})"
)
# ── Parse rows ────────────────────────────────────────────────────────
to_create: list[RoadDrain] = []
to_update: list[RoadDrain] = []
skipped = 0
streets_not_found: set[str] = set()
for row in rows:
fields = _build_drain(row, street_map, drain_model_map)
if fields is None:
skipped += 1
continue
street_name = (row.get("DR_STREET") or "").strip()
if street_name and fields["street"] is None:
streets_not_found.add(street_name)
code = fields.pop("code")
existing = existing_drains.get(code)
if existing is None:
obj = RoadDrain(code=code, **fields)
to_create.append(obj)
else:
for attr, val in fields.items():
setattr(existing, attr, val)
to_update.append(existing)
# ── Report unmatched streets ──────────────────────────────────────────
for name in sorted(streets_not_found):
self.stdout.write(
self.style.WARNING(f" Street not found in DB: {name!r}")
)
self.stdout.write(
f" To create : {len(to_create)}, "
f"to update : {len(to_update)}, "
f"skipped : {skipped}"
)
if dry_run:
self.stdout.write(self.style.WARNING(" Dry-run: no changes written."))
return
# ── Write to DB ───────────────────────────────────────────────────────
with transaction.atomic():
if to_create:
RoadDrain.objects.bulk_create(
to_create, batch_size=BULK_BATCH_SIZE
)
if to_update:
RoadDrain.objects.bulk_update(
to_update, UPDATE_FIELDS, batch_size=BULK_BATCH_SIZE
)
self.stdout.write(
self.style.SUCCESS(
f" Done — created {len(to_create)}, updated {len(to_update)} drains."
)
)

View file

@ -0,0 +1,291 @@
import os
from django.core.management.base import BaseCommand
from django.conf import settings
from django.contrib.gis.gdal import DataSource
from django.contrib.gis.geos import GEOSGeometry
from django.utils.dateparse import parse_date
from django.db import transaction
from assets.models import NatureRWIAsset
# Dutch translations for type_ouvrage
TYPE_OUVRAGE_NL_MAP = {
# Points
'avaloir': 'afvoerput',
'bordure sequencee': 'rand met sequentie',
'bouche injection': 'injectieopening',
'clapet anti-retour': 'terugslagklep',
'empierrement sedimentation': 'verstening sedimentatie',
'kerb cell': 'kerbcel',
'puits infiltration': 'infiltratieput',
'surverse': 'overloop',
'vanne isolation': 'afsluitklep',
'vanne regulation': 'regelklep',
# Lines
'gargouille de trottoir': 'stoepgootsteen',
'filet eau': 'druppel water',
'avaloir de rail': 'spoorafvoer',
'bordure arasee': 'afgewerkte rand',
'caniveau infiltrant': 'infiltratiegoot',
'drain': 'afvoer',
'trop plein': 'overvol',
'reseau egout separatif': 'gescheiden rioolnetwerk',
# Polygons
'bassin sec/infiltration': 'droog bassin / infiltratiebekken',
'bassin en eau': 'waterbak',
'fosse': 'greppel',
'jardin de pluie': 'regentuin',
'noue': 'knoop',
'massif drainant': 'drainageblok',
'tranchee drainante': 'afwateringsgreppel',
'toiture stockante': 'stokkend dak',
'toiture verte': 'groendak',
'bassin orage': 'regenwaterreservoir',
'SAUL-vide': 'ultralichte holle structuur',
'SAUL-terre': 'ultralichte honingraatstructuur aarde',
'citerne': 'tankwagen',
'espace vert sur dalle': 'groene ruimte op betonnen plaat',
'bande filtrante': 'filterband',
'collecteur en beton poreux': 'poreuze betonnen collector',
}
def get_conn(feature, keys):
for k in keys:
if k in feature.fields:
val = feature.get(k)
if val is not None:
return str(val).strip()
return None
def get_field_if_exists(feature, name):
if name in feature.fields:
return feature.get(name)
return None
def get_rwi_asset_acronym(geometry_type, structure_type, code_name):
struct_type_lower = str(structure_type).strip().lower() if structure_type else None
if code_name:
return str(code_name).strip().upper()
mappings = {
'avaloir': 'AV',
'bordure sequencee': 'BS',
'bouche injection': 'BI',
'clapet anti-retour': 'CA',
'empierrement sedimentation': 'ES',
'kerb cell': 'KC',
'puits infiltration': 'PI',
'surverse': 'SU',
'vanne isolation': 'VI',
'vanne regulation': 'VR',
'gargouille de trottoir': 'GT',
'filet eau': 'FE',
'avaloir de rail': 'AR',
'bordure arasee': 'BA',
'caniveau infiltrant': 'CI',
'drain': 'D',
'trop plein': 'TP',
'reseau egout separatif': 'RES',
'bassin sec/infiltration': 'OED',
'bassin en eau': 'OED',
'fosse': 'EVC',
'jardin de pluie': 'EVC',
'noue': 'EVC',
'massif drainant': 'OED',
'tranchee drainante': 'OED',
'toiture stockante': 'OSI',
'toiture verte': 'OSI',
'bassin orage': 'OED',
'SAUL-vide': 'OED',
'SAUL-terre': 'TA',
'citerne': 'OED',
'espace vert sur dalle': 'EVC',
'bande filtrante': 'EVC',
'collecteur en beton poreux': 'OED',
'chambre de visite': 'CV',
}
if struct_type_lower in mappings:
return mappings[struct_type_lower]
if geometry_type:
return geometry_type.upper()
return 'UNK'
class Command(BaseCommand):
help = "Imports RWI (Rain Water Integration) assets from the GeoPackage database."
def add_arguments(self, parser):
parser.add_argument(
"gpkg_path",
nargs="?",
type=str,
help="Path to the GeoPackage file. Defaults to external_files/giep/inventaire_giep.gpkg if not provided.",
)
parser.add_argument(
"--dry-run",
action="store_true",
default=False,
help="Simulates the import process without writing changes to the database.",
)
def handle(self, *args, **options):
dry_run = options["dry_run"]
gpkg_path = options.get("gpkg_path")
if not gpkg_path:
gpkg_path = os.path.abspath(os.path.join(settings.BASE_DIR, "../external_files/giep/inventaire_giep.gpkg"))
if not os.path.exists(gpkg_path):
self.stderr.write(self.style.ERROR(f"Error: GeoPackage file not found at: {gpkg_path}"))
return
self.stdout.write(f"→ Opening GeoPackage file at: {gpkg_path}")
ds = DataSource(gpkg_path)
if dry_run:
self.stdout.write(self.style.WARNING("⚠ Dry-run mode active – no database changes will be saved."))
try:
with transaction.atomic():
layer_mappings = [
('point', 'point'),
('ligne', 'line'),
('polygone', 'polygon'),
]
total_created = 0
total_updated = 0
for layer_name, geometry_type in layer_mappings:
layer = ds[layer_name]
self.stdout.write(f"→ Importing layer: {layer_name} ({len(layer)} features)...")
created_count = 0
updated_count = 0
for feature in layer:
# Defensive geometry access
try:
if not feature.geom:
continue
ogr_geom = feature.geom.clone()
except Exception as ge:
self.stdout.write(self.style.WARNING(f" [Warning] Skipping feature {feature.fid} due to geometry error: {ge}"))
continue
# Force geometry to 2D
ogr_geom.set_3d(False)
# EPSG 31370 (Lambert 72) -> EPSG 3812 (Lambert 2008)
geom = GEOSGeometry(ogr_geom.wkb, srid=31370)
if geom.empty:
continue
geom.transform(3812)
# Coordinate transformation to EPSG 4326 (WGS 84) to extract lon/lat
geom_wgs84 = geom.clone()
geom_wgs84.transform(4326)
if geom_wgs84.empty:
continue
centroid = geom_wgs84.centroid
lon = centroid.x
lat = centroid.y
type_ouvrage = get_field_if_exists(feature, 'type_ouvra')
code_name = get_field_if_exists(feature, 'nom_code')
# Format standard RWI codes with acronym
acronym = get_rwi_asset_acronym(geometry_type, type_ouvrage, code_name)
code = f"RWI_{acronym}_{feature.fid:03d}"
name_fr = str(type_ouvrage).capitalize() if type_ouvrage else code
name_nl = TYPE_OUVRAGE_NL_MAP.get(type_ouvrage, "").capitalize() or name_fr
# Connection strings
sewer_conn = get_conn(feature, ['connexion_egout', 'connexion egout', 'Conexion egout'])
separate_conn = get_conn(feature, ['connexion_r.separatif', 'connexion r.separatif', 'connexion_r_separatif'])
hydro_conn = get_conn(feature, ['connexion_r.hydro', 'connexion r.hydro', 'connexion_r_hydro'])
# Filter mapping
has_filter = None
if 'filtre' in feature.fields:
filtre_val = feature.get('filtre')
if filtre_val is not None:
has_filter = True if str(filtre_val).lower().strip() in ('oui', '1', 'true') else False
# Commissioning dates parsing
comm_date_str = None
for k in ['date_mise_en_service', 'mise_serv', 'mise_servi']:
if k in feature.fields:
val = feature.get(k)
if val:
comm_date_str = str(val).strip()
break
commissioning_date = None
if comm_date_str:
commissioning_date = parse_date(comm_date_str)
# Dimensional metrics
surface_area = get_field_if_exists(feature, 'surf_m2')
length = get_field_if_exists(feature, 'length_m')
# Fetch existing or instantiate a new asset using external_id and geometry_type
asset = NatureRWIAsset.objects.filter(external_id=feature.fid, geometry_type=geometry_type).first()
is_new = asset is None
if is_new:
asset = NatureRWIAsset(code=code)
else:
# Update code to the new format if it changed
asset.code = code
# Populate attributes
asset.name_fr = name_fr
asset.name_nl = name_nl
asset.geom = geom
asset.lon = lon
asset.lat = lat
asset.geojson = geom.json
asset.geometry_type = geometry_type
asset.external_id = feature.fid
asset.code_name = get_field_if_exists(feature, 'nom_code')
asset.structure_type = type_ouvrage
asset.project_manager = get_field_if_exists(feature, 'chef_proj')
asset.construction_status = str(get_field_if_exists(feature, 'travaux')).strip() if get_field_if_exists(feature, 'travaux') else None
asset.commissioning_date = commissioning_date
asset.condition = get_field_if_exists(feature, 'etat')
asset.administrator = get_field_if_exists(feature, 'admin')
asset.monitoring = get_field_if_exists(feature, 'suivi')
asset.comment = get_field_if_exists(feature, 'comment') or get_field_if_exists(feature, 'commentaire')
asset.has_filter = has_filter
asset.sewer_connection = sewer_conn
asset.separate_network_connection = separate_conn
asset.hydro_network_connection = hydro_conn
asset.surface_area = surface_area
asset.length = length
asset.coating_category = get_field_if_exists(feature, 'cat_rev')
asset.coating_type = get_field_if_exists(feature, 'type_revetement')
asset.status = 'active'
asset.save()
if is_new:
created_count += 1
else:
updated_count += 1
self.stdout.write(self.style.SUCCESS(f"✓ Layer '{layer_name}': {created_count} created, {updated_count} updated."))
total_created += created_count
total_updated += updated_count
if dry_run:
# Roll back changes so database remains unmodified
transaction.set_rollback(True)
self.stdout.write(self.style.WARNING("✓ Dry-run completed. All changes rolled back successfully."))
else:
self.stdout.write(self.style.SUCCESS(
f"✓ Import completed! Total: {total_created} assets created, {total_updated} assets updated in the database."
))
except Exception as e:
self.stderr.write(self.style.ERROR(f"Error during import: {e}"))

View file

@ -0,0 +1,172 @@
"""
Management command to import photos for TrafficLightElectricalCabinet objects
from the MobiGIS CSV export.
For each cabinet row that has an image field, the file is fetched from:
https://data.mobility.brussels/<image_path>
e.g. media/maintain/inventory/cabin/TCN360_02_20210607_03_000410C.jpg
→ https://data.mobility.brussels/media/maintain/inventory/cabin/TCN360_02_...
A ManagedDocument + DocumentVersion are created and linked to the cabinet via a
DocumentAttachment. The DocumentTag "photo" is applied to every document.
Re-running the command is safe: existing attachments (same document title +
same cabinet) are skipped.
Usage
-----
python manage.py import_tl_cabinet_photos_from_mobigis
python manage.py import_tl_cabinet_photos_from_mobigis --limit 100
"""
import csv
import os
import urllib.request
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.db import transaction
from django.utils.text import slugify
from assets.models.trafficlights import TrafficLightElectricalCabinet
from documents.models import (
DocumentAttachment,
DocumentTag,
DocumentVersion,
ManagedDocument,
)
BASE_DIR = os.path.normpath(
os.path.join(
os.path.dirname(__file__),
"..", "..", "..", "scripts", "data", "tl_mobigis", "electrical_cabinet",
)
)
CSV_PATH = os.path.join(BASE_DIR, "electricalcabinet.csv")
PHOTO_BASE_URL = "https://data.mobility.brussels/"
DEFAULT_LIMIT = 50
class Command(BaseCommand):
help = (
"Fetch and attach photos for TrafficLightElectricalCabinet objects "
"from scripts/data/tl_mobigis/electrical_cabinet/electricalcabinet.csv"
)
def add_arguments(self, parser):
parser.add_argument(
"--limit",
type=int,
default=DEFAULT_LIMIT,
help=f"Maximum number of photos to download (default: {DEFAULT_LIMIT}).",
)
def handle(self, *args, **options):
limit = options["limit"]
# ── Get-or-create the "photo" DocumentTag ────────────────────────────
photo_tag, tag_created = DocumentTag.objects.get_or_create(
slug="photo",
defaults={"name_fr": "photo", "name_nl": "foto", "order": 0},
)
if tag_created:
self.stdout.write(self.style.SUCCESS(' DocumentTag "photo" created.'))
else:
self.stdout.write(f' DocumentTag "photo" found (id={photo_tag.pk}).')
# ── Pre-load cabinets keyed by code ───────────────────────────────────
cabinets_by_code: dict[str, TrafficLightElectricalCabinet] = {}
for cab in TrafficLightElectricalCabinet.objects.all():
# Keep first occurrence only; codes should be unique within DB
if cab.code not in cabinets_by_code:
cabinets_by_code[cab.code] = cab
cabinet_ct = ContentType.objects.get_for_model(TrafficLightElectricalCabinet)
self.stdout.write(f" Cabinets loaded: {len(cabinets_by_code)}")
# ── Read CSV, keep rows with image and a matching cabinet ─────────────
with open(CSV_PATH, newline="", encoding="utf-8-sig") as f:
rows = list(csv.DictReader(f, delimiter=";"))
candidates = []
for row in rows:
image_path = (row.get("image") or "").strip()
if not image_path:
continue
arma_key = (row.get("armoire_key") or "").strip()
arma_id = (row.get("armoire_id") or row.get("id") or "").strip()
code = arma_key if arma_key else f"CAB_{arma_id}"
cabinet = cabinets_by_code.get(code)
if cabinet is None:
continue
candidates.append((cabinet, image_path))
self.stdout.write(
f" CSV rows with image matching a DB cabinet: {len(candidates)}"
)
# ── Apply limit ───────────────────────────────────────────────────────
to_process = candidates[:limit]
self.stdout.write(f" Processing (limit={limit}): {len(to_process)}")
created = already_present = skipped = errors = 0
for cabinet, image_path in to_process:
filename = os.path.basename(image_path)
url = PHOTO_BASE_URL + image_path
title = os.path.splitext(filename)[0]
# ── Idempotency: skip if attachment already exists ────────────
already = DocumentAttachment.objects.filter(
content_type=cabinet_ct,
object_id=cabinet.pk,
document__title=title,
).exists()
if already:
already_present += 1
continue
# ── Download the image (outside transaction) ──────────────────
try:
with urllib.request.urlopen(url, timeout=15) as response:
image_data = response.read()
except Exception as exc:
self.stdout.write(
self.style.WARNING(f" Download failed {url}: {exc}")
)
errors += 1
continue
# ── Persist document + attachment (committed immediately) ─────
with transaction.atomic():
doc = ManagedDocument.objects.create(title=title)
doc.tags.add(photo_tag)
version = DocumentVersion(
document=doc,
status=DocumentVersion.STATUS_APPROVED,
)
version.file.save(filename, ContentFile(image_data), save=False)
version.save()
DocumentAttachment.objects.create(
document=doc,
content_type=cabinet_ct,
object_id=cabinet.pk,
)
created += 1
self.stdout.write(f" ✓ {cabinet.code} → {filename}")
self.stdout.write(
self.style.SUCCESS(
f"\nCabinet photo import complete:\n"
f" Downloaded & attached : {created}\n"
f" Already present : {already_present}\n"
f" Skipped (no cabinet) : {skipped}\n"
f" Errors (download) : {errors}"
)
)

View file

@ -0,0 +1,409 @@
"""
Management command to import TrafficLightElectricalCabinet objects
from the MobiGIS CSV export.
Source file : scripts/data/tl_mobigis/electrical_cabinet/electricalcabinet.csv
Lookup files: cabinettype.csv, calibre.csv, cleanliness.csv, compliance.csv,
content.csv, destination.csv, keytype.csv, obsolescence.csv
M2M files : electricalcabinet_calibre_differential_switch.csv
electricalcabinet_content.csv
electricalcabinet_exit_to.csv
Rules
-----
- Skip rows that have no tl_key_ep (link to TrafficLightIntersection).
- Geometry is in EPSG 31370 (Belgian Lambert 72); transform to EPSG 3812 for
the geom field, and derive lon/lat (WGS84) + geojson from the same point.
- code = armoire_key if non-empty, else "CAB_{armoire_id}".
- Existing cabinets (same intersection + same code) are updated, not duplicated.
- After cabinets, TrafficLightElectricalCabinetContent objects are synced from
electricalcabinet_content.csv (one record per content-type/cabinet pair).
- Multi-valued calibre differential-switch values are joined with "; ".
- Multi-valued exit-to destinations are joined with ", " (truncated to 50 chars).
Performance
-----------
All DB work is done in bulk_create + bulk_update batches to minimise round-trips.
"""
import csv
import json
import os
from collections import defaultdict
from datetime import datetime
from django.contrib.gis.gdal import CoordTransform, SpatialReference
from django.contrib.gis.geos import GEOSGeometry
from django.core.management.base import BaseCommand
from django.db import transaction
from assets.models.trafficlights import (
TrafficLightElectricalCabinet,
TrafficLightElectricalCabinetContent,
TrafficLightElectricalCabinetContentModel,
TrafficLightIntersection,
)
BASE_DIR = os.path.normpath(
os.path.join(
os.path.dirname(__file__), # …/assets/management/commands/
"..", "..", "..", "scripts", "data", "tl_mobigis", "electrical_cabinet",
)
)
BULK_BATCH_SIZE = 500
CABINET_UPDATE_FIELDS = [
"code", "name_fr", "name_nl",
"geom", "lon", "lat", "geojson",
"meter_inside", "meter_number", "ean_code", "power_supply",
"differential_breaker", "differential_switch", "publicity",
"note", "cleanliness", "key_type", "obsolescence", "visual_conformity",
"differential_breaker_caliber", "differential_caliber",
"differential_switch_caliber", "cabinet_type", "managed_by",
"departure_towards", "installation_date",
]
CONTENT_UPDATE_FIELDS = ["model_id", "name_fr", "name_nl"]
# cabinettype.code → CABINET_TYPE_CHOICES key
CABINET_TYPE_MAP = {
"Feux rouges": "traffic_lights",
"Luminaire": "other",
"STIB": "other",
"Mixte": "mixed",
"Autre": "other",
"Inconnu": "other",
}
# keytype.code → KEY_TYPE_CHOICES key
KEY_TYPE_MAP = {
"Yale 5": "yale5",
"Siemens": "siemens",
"58335": "58335",
"EU4": "eu4",
"Pas de clef": "no_key",
"BUR 16": "bur16",
"BKS": "bks",
}
# ── helpers ───────────────────────────────────────────────────────────────────
def _csv_path(filename):
return os.path.join(BASE_DIR, filename)
def _read_lookup(filename, key_col="id"):
"""Return {id_string: row_dict} for a semicolon-delimited CSV."""
result = {}
with open(_csv_path(filename), newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f, delimiter=";"):
result[(row.get(key_col) or "").strip()] = row
return result
def _bool(value):
"""'t' / 'true' / '1' → True, anything else → False."""
return (value or "").strip().lower() in ("t", "true", "1")
def _str(value):
"""Strip and return None if empty."""
v = (value or "").strip()
return v if v else None
def _parse_date(value):
v = (value or "").strip()
if not v:
return None
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y"):
try:
return datetime.strptime(v, fmt)
except ValueError:
continue
return None
def _lut_code(lut, id_val):
"""Return lut[id_val]['code'] stripped, or None."""
if id_val and id_val in lut:
return lut[id_val]["code"].strip() or None
return None
# ── command ───────────────────────────────────────────────────────────────────
class Command(BaseCommand):
help = (
"Import TrafficLightElectricalCabinet objects from "
"scripts/data/tl_mobigis/electrical_cabinet/electricalcabinet.csv"
)
def handle(self, *args, **options):
# ── Lookup tables (CSV only, no DB) ───────────────────────────────────
cabinettype_lut = _read_lookup("cabinettype.csv")
calibre_lut = _read_lookup("calibre.csv")
cleanliness_lut = _read_lookup("cleanliness.csv")
compliance_lut = _read_lookup("compliance.csv")
content_lut = _read_lookup("content.csv")
destination_lut = _read_lookup("destination.csv")
keytype_lut = _read_lookup("keytype.csv")
obsolescence_lut = _read_lookup("obsolescence.csv")
# ── Pre-load M2M CSVs into dicts keyed by electricalcabinet_id ────────
calibre_diff_switch: dict[str, list[str]] = defaultdict(list)
with open(_csv_path("electricalcabinet_calibre_differential_switch.csv"),
newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f, delimiter=";"):
cab_id = (row.get("electricalcabinet_id") or "").strip()
cal_id = (row.get("eccalibre_id") or "").strip()
if cab_id and cal_id in calibre_lut:
calibre_diff_switch[cab_id].append(calibre_lut[cal_id]["code"].strip())
content_m2m: dict[str, list[str]] = defaultdict(list)
with open(_csv_path("electricalcabinet_content.csv"),
newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f, delimiter=";"):
cab_id = (row.get("electricalcabinet_id") or "").strip()
content_id = (row.get("eccontent_id") or "").strip()
if cab_id and content_id in content_lut:
content_m2m[cab_id].append(content_id)
exit_to_m2m: dict[str, list[str]] = defaultdict(list)
with open(_csv_path("electricalcabinet_exit_to.csv"),
newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f, delimiter=";"):
cab_id = (row.get("electricalcabinet_id") or "").strip()
dest_id = (row.get("ecdestination_id") or "").strip()
if cab_id and dest_id in destination_lut:
exit_to_m2m[cab_id].append(destination_lut[dest_id]["code"].strip())
# ── DB lookups ────────────────────────────────────────────────────────
intersections = {i.code: i for i in TrafficLightIntersection.objects.all()}
self.stdout.write(f" Intersections loaded : {len(intersections)}")
existing_cabinets: dict[tuple, TrafficLightElectricalCabinet] = {
(c.intersection_id, c.code): c
for c in TrafficLightElectricalCabinet.objects.all()
}
self.stdout.write(f" Existing cabinets loaded : {len(existing_cabinets)}")
# ── Coordinate transforms (built once) ────────────────────────────────
srs_31370 = SpatialReference(31370)
srs_3812 = SpatialReference(3812)
srs_4326 = SpatialReference(4326)
ct_to_3812 = CoordTransform(srs_31370, srs_3812)
ct_to_4326 = CoordTransform(srs_31370, srs_4326)
# ── Parse main CSV ────────────────────────────────────────────────────
with open(_csv_path("electricalcabinet.csv"), newline="", encoding="utf-8-sig") as f:
rows = list(csv.DictReader(f, delimiter=";"))
to_create: list[TrafficLightElectricalCabinet] = []
to_update: list[TrafficLightElectricalCabinet] = []
# mob_id (CSV 'id') → cabinet instance for the content sync pass
mob_id_to_cabinet: dict[str, TrafficLightElectricalCabinet] = {}
skipped_no_key = skipped_no_intersection = skipped_no_geom = 0
for row in rows:
tl_key_ep = _str(row.get("tl_key_ep"))
if not tl_key_ep:
skipped_no_key += 1
continue
intersection = intersections.get(tl_key_ep)
if intersection is None:
skipped_no_intersection += 1
self.stdout.write(
self.style.WARNING(f" Intersection not found: {tl_key_ep!r}")
)
continue
# ── Geometry ──────────────────────────────────────────────────────
wkt = _str(row.get("wkt_geom"))
if not wkt:
skipped_no_geom += 1
continue
try:
geom_31370 = GEOSGeometry(wkt, srid=31370)
geom_3812 = geom_31370.clone()
geom_3812.transform(ct_to_3812)
geom_wgs84 = geom_31370.clone()
geom_wgs84.transform(ct_to_4326)
lon = geom_wgs84.x
lat = geom_wgs84.y
geojson = json.dumps(
{"type": "Point", "coordinates": [round(lon, 8), round(lat, 8)]}
)
except Exception as exc:
self.stdout.write(
self.style.WARNING(
f" Geometry error row id={row.get('id')}: {exc}"
)
)
skipped_no_geom += 1
continue
# ── Scalar fields ─────────────────────────────────────────────────
mob_id = (row.get("id") or "").strip()
arma_key = _str(row.get("armoire_key"))
code = arma_key if arma_key else f"CAB_{(row.get('armoire_id') or mob_id).strip()}"
# Cabinet type mapping
cab_type_id = _str(row.get("cabinet_type_id"))
cab_type_code = _lut_code(cabinettype_lut, cab_type_id)
cabinet_type = CABINET_TYPE_MAP.get(cab_type_code, "other") if cab_type_code else "traffic_lights"
# Key type mapping
key_type_id = _str(row.get("key_type_id"))
key_type_code = _lut_code(keytype_lut, key_type_id)
key_type = KEY_TYPE_MAP.get(key_type_code) if key_type_code else None
# Free-text lookups (stored as code label)
cleanliness = _lut_code(cleanliness_lut, _str(row.get("cleanliness_id")))
obsolescence = _lut_code(obsolescence_lut, _str(row.get("obsolescence_id"))) or 'unknown'
visual_conformity = _lut_code(compliance_lut, _str(row.get("visual_compliance_id")))
# Calibre fields
differential_breaker_caliber = _lut_code(calibre_lut, _str(row.get("calibre_cb_id")))
differential_caliber = _lut_code(calibre_lut, _str(row.get("calibre_differential_id")))
# M2M aggregated fields
diff_switch_caliber = "; ".join(calibre_diff_switch[mob_id]) or None
departure_towards = ", ".join(exit_to_m2m[mob_id])[:50] or None
managed_by = "BM" if _bool(row.get("bm_admin")) else None
vals = dict(
code = code,
name_fr = code,
name_nl = code,
geom = geom_3812,
lon = lon,
lat = lat,
geojson = geojson,
meter_inside = _bool(row.get("counter_present")),
meter_number = _str(row.get("nr_counter")),
ean_code = _str(row.get("ean_counter")),
power_supply = _str(row.get("power")),
differential_breaker = _bool(row.get("circuit_breaker")),
differential_switch = _bool(row.get("differential_switch")),
publicity = _bool(row.get("publicity")),
note = _str(row.get("remark")),
cleanliness = cleanliness,
key_type = key_type,
obsolescence = obsolescence,
visual_conformity = visual_conformity,
differential_breaker_caliber = differential_breaker_caliber,
differential_caliber = differential_caliber,
differential_switch_caliber = diff_switch_caliber,
cabinet_type = cabinet_type,
managed_by = managed_by,
departure_towards = departure_towards,
installation_date = _parse_date(row.get("install_date")),
)
key = (intersection.pk, code)
cabinet = existing_cabinets.get(key)
if cabinet is not None:
for attr, val in vals.items():
setattr(cabinet, attr, val)
to_update.append(cabinet)
else:
cabinet = TrafficLightElectricalCabinet(intersection=intersection, **vals)
to_create.append(cabinet)
mob_id_to_cabinet[mob_id] = cabinet
# ── Bulk write cabinets ───────────────────────────────────────────────
with transaction.atomic():
TrafficLightElectricalCabinet.objects.bulk_create(
to_create, batch_size=BULK_BATCH_SIZE
)
TrafficLightElectricalCabinet.objects.bulk_update(
to_update, fields=CABINET_UPDATE_FIELDS, batch_size=BULK_BATCH_SIZE
)
# After bulk_create the ORM fills in pk on each object in to_create,
# so mob_id_to_cabinet already has correct PKs for all cabinets.
# ── Sync content objects ──────────────────────────────────────────────
# Ensure a TrafficLightElectricalCabinetContentModel exists per content type.
content_id_to_model: dict[str, TrafficLightElectricalCabinetContentModel] = {}
for c_id, c_row in content_lut.items():
c_code = c_row.get("code", "").strip()
name_fr = c_row["descr_fr"].strip()
name_nl = (c_row.get("descr_nl") or "").strip() or name_fr
if not c_code:
continue
obj, _ = TrafficLightElectricalCabinetContentModel.objects.get_or_create(
code=c_code,
defaults={"name_fr": name_fr, "name_nl": name_nl},
)
content_id_to_model[c_id] = obj
existing_contents: dict[tuple, TrafficLightElectricalCabinetContent] = {
(c.cabinet_id, c.code): c
for c in TrafficLightElectricalCabinetContent.objects.all()
}
contents_to_create: list[TrafficLightElectricalCabinetContent] = []
contents_to_update: list[TrafficLightElectricalCabinetContent] = []
content_model_missing = 0
for mob_id, cabinet in mob_id_to_cabinet.items():
if not cabinet.pk:
continue # Safety guard (should not happen after bulk_create)
for eccontent_id in content_m2m[mob_id]:
content_model = content_id_to_model.get(eccontent_id)
if content_model is None:
content_model_missing += 1
continue
c_row = content_lut[eccontent_id]
code = f"CONT_{eccontent_id}"
name_fr = c_row["descr_fr"].strip()
name_nl = (c_row.get("descr_nl") or "").strip() or name_fr
key = (cabinet.pk, code)
existing = existing_contents.get(key)
if existing is not None:
existing.model_id = content_model.pk
existing.name_fr = name_fr
existing.name_nl = name_nl
contents_to_update.append(existing)
else:
contents_to_create.append(
TrafficLightElectricalCabinetContent(
cabinet = cabinet,
code = code,
name_fr = name_fr,
name_nl = name_nl,
model = content_model,
)
)
with transaction.atomic():
TrafficLightElectricalCabinetContent.objects.bulk_create(
contents_to_create, batch_size=BULK_BATCH_SIZE
)
TrafficLightElectricalCabinetContent.objects.bulk_update(
contents_to_update, fields=CONTENT_UPDATE_FIELDS, batch_size=BULK_BATCH_SIZE
)
# ── Summary ───────────────────────────────────────────────────────────
self.stdout.write(
self.style.SUCCESS(
f"\nTrafficLightElectricalCabinet import complete:\n"
f" Cabinets created : {len(to_create)}\n"
f" Cabinets updated : {len(to_update)}\n"
f" Skipped (no tl_key_ep) : {skipped_no_key}\n"
f" Skipped (intersection missing) : {skipped_no_intersection}\n"
f" Skipped (bad/missing geom) : {skipped_no_geom}\n"
f"\n Content objects created : {len(contents_to_create)}\n"
f" Content objects updated : {len(contents_to_update)}\n"
f" Content skipped (no model) : {content_model_missing}"
)
)

View file

@ -0,0 +1,303 @@
"""
Management command to import TrafficLightLantern and TrafficLightAccessory objects
from the MobiGIS CSV exports.
Source files : scripts/data/tl_mobigis/tllanterns.csv
scripts/data/tl_mobigis/tlaccessories.csv
Lookup files : tllanterntype.csv, tlaccessorytype.csv, tltrafficlights.csv
Mapping
-------
lantern/accessory.trafficlight_id
→ tltrafficlights.trafficlight_id
→ (tl_key, nr_trafficlight)
→ TrafficLightPole in DB
Idempotency : keyed on (pole, code) where code = MobiGIS row id.
Performance : bulk_create + bulk_update; CoordTransform built once (no geometry
in these CSVs, kept for consistency with other import commands).
"""
import csv
import os
from django.core.management.base import BaseCommand
from django.db import transaction
from assets.models.trafficlights import (
TrafficLightAccessory,
TrafficLightAccessoryModel,
TrafficLightLantern,
TrafficLightLanternModel,
TrafficLightPole,
)
BASE_DIR = os.path.normpath(
os.path.join(
os.path.dirname(__file__),
"..", "..", "..", "scripts", "data", "tl_mobigis",
)
)
BULK_BATCH_SIZE = 500
LANTERN_UPDATE_FIELDS = ["code", "model_id", "serial_number", "name_fr", "name_nl"]
ACCESSORY_UPDATE_FIELDS = ["code", "model_id", "serial_number", "name_fr", "name_nl",
"intersection_id"]
def _csv_path(filename):
return os.path.join(BASE_DIR, filename)
def _read_lookup(filename, key_col="id"):
"""Return {id_string: row_dict} for a semicolon-delimited CSV."""
result = {}
with open(_csv_path(filename), newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f, delimiter=";"):
result[(row.get(key_col) or "").strip()] = row
return result
def _serial(value):
"""Return None for the MobiGIS placeholder '0', else the trimmed value."""
v = (value or "").strip()
return None if v in ("0", "") else v
class Command(BaseCommand):
help = (
"Import TrafficLightLantern and TrafficLightAccessory from "
"scripts/data/tl_mobigis/tllanterns.csv and tlaccessories.csv"
)
def handle(self, *args, **options):
# ── Lookup tables (CSV only, no DB) ──────────────────────────────────
lantern_type_lut = _read_lookup("tllanterntype.csv") # id → {descr_fr, …}
accessory_type_lut = _read_lookup("tlaccessorytype.csv") # id → {descr_fr, …}
# lanterns/accessories reference poles by tltrafficlights.id (not trafficlight_id)
tl_lut = {}
with open(_csv_path("tltrafficlights.csv"), newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f, delimiter=";"):
row_id = (row.get("id") or "").strip()
tl_key = (row.get("tl_key") or "").strip()
code = (row.get("nr_trafficlight") or "").strip()
if row_id and tl_key:
tl_lut[row_id] = (tl_key, code)
# ── DB lookups (4 queries total) ──────────────────────────────────────
# Poles keyed by (intersection_code, pole.code) — after the poles import
# pole.code is already in the prefixed format "SUL21_A01".
poles = {
(p.intersection.code, p.code): p
for p in TrafficLightPole.objects.select_related("intersection").all()
}
self.stdout.write(f" Poles loaded : {len(poles)}")
lantern_models_by_name = {
m.name_fr: m for m in TrafficLightLanternModel.objects.all() if m.name_fr
}
accessory_models_by_name = {
m.name_fr: m for m in TrafficLightAccessoryModel.objects.all() if m.name_fr
}
self.stdout.write(
f" Lantern models loaded : {len(lantern_models_by_name)}\n"
f" Accessory models loaded : {len(accessory_models_by_name)}"
)
existing_lanterns = {
(l.pole_id, l.code): l
for l in TrafficLightLantern.objects.all()
}
existing_accessories = {
(a.pole_id, a.code): a
for a in TrafficLightAccessory.objects.filter(pole__isnull=False)
}
self.stdout.write(
f" Existing lanterns loaded : {len(existing_lanterns)}\n"
f" Existing accessories loaded: {len(existing_accessories)}"
)
# ── Read lanterns + accessories CSVs ─────────────────────────────────
with open(_csv_path("tllanterns.csv"), newline="", encoding="utf-8-sig") as f:
lan_rows = list(csv.DictReader(f, delimiter=";"))
with open(_csv_path("tlaccessories.csv"), newline="", encoding="utf-8-sig") as f:
acc_rows = list(csv.DictReader(f, delimiter=";"))
# ── Pre-pass: assign sequential codes per pole ────────────────────────
# Lanterns within a pole: SUL21_A01_LAN01, SUL21_A01_LAN02, …
# Accessories within a pole: SUL21_A01_ACC01, SUL21_A01_ACC02, …
# Ordering is by MobiGIS row id (integer sort for stability).
from collections import defaultdict
def _build_seq_codes(rows, id_col, suffix):
"""Return {mob_id: new_sequential_code} for a CSV row list."""
groups = defaultdict(list) # (tl_key, raw_nr) → [mob_id, …]
for row in rows:
tl_id = (row.get("trafficlight_id") or "").strip()
mob_id = (row.get(id_col) or "").strip()
mapping = tl_lut.get(tl_id)
if not mapping:
continue
tl_key, raw_nr = mapping
new_pole_code = f"{tl_key}_{raw_nr}"
if (tl_key, new_pole_code) not in poles:
continue
groups[(tl_key, raw_nr)].append(mob_id)
result = {}
for (tl_key, raw_nr), mob_ids in groups.items():
sorted_ids = sorted(mob_ids, key=lambda x: int(x) if x.isdigit() else x)
for seq, mid in enumerate(sorted_ids, 1):
result[mid] = f"{tl_key}_{raw_nr}_{suffix}{seq:02d}"
return result
lantern_new_codes = _build_seq_codes(lan_rows, "id", "LAN")
accessory_new_codes = _build_seq_codes(acc_rows, "id", "ACC")
# ── Parse lanterns CSV ────────────────────────────────────────────────
lan_create: list[TrafficLightLantern] = []
lan_update: list[TrafficLightLantern] = []
lan_skip_pole = lan_skip_model = 0
for row in lan_rows:
tl_id = (row.get("trafficlight_id") or "").strip()
mob_id = (row.get("id") or "").strip()
new_code = lantern_new_codes.get(mob_id)
if new_code is None:
lan_skip_pole += 1
continue
mapping = tl_lut.get(tl_id)
tl_key, raw_nr = mapping
new_pole_code = f"{tl_key}_{raw_nr}"
pole = poles.get((tl_key, new_pole_code))
if pole is None:
lan_skip_pole += 1
continue
type_id = (row.get("lantern_type_id") or "").strip()
type_row = lantern_type_lut.get(type_id)
lantern_model = (
lantern_models_by_name.get(type_row["descr_fr"].strip())
if type_row else None
)
if lantern_model is None:
lan_skip_model += 1
continue
serial = _serial(row.get("serial_number"))
name = lantern_model.name_fr or new_code
# Dual lookup: new code (re-run) or old mob_id (first migration)
obj = existing_lanterns.get((pole.pk, new_code)) \
or existing_lanterns.get((pole.pk, mob_id))
if obj is not None:
obj.code = new_code
obj.model_id = lantern_model.pk
obj.serial_number = serial
obj.name_fr = name
obj.name_nl = name
lan_update.append(obj)
else:
lan_create.append(
TrafficLightLantern(
pole = pole,
code = new_code,
name_fr = name,
name_nl = name,
model_id = lantern_model.pk,
serial_number = serial,
)
)
# ── Parse accessories CSV ─────────────────────────────────────────────
acc_create: list[TrafficLightAccessory] = []
acc_update: list[TrafficLightAccessory] = []
acc_skip_pole = acc_skip_model = 0
for row in acc_rows:
tl_id = (row.get("trafficlight_id") or "").strip()
mob_id = (row.get("id") or "").strip()
new_code = accessory_new_codes.get(mob_id)
if new_code is None:
acc_skip_pole += 1
continue
mapping = tl_lut.get(tl_id)
tl_key, raw_nr = mapping
new_pole_code = f"{tl_key}_{raw_nr}"
pole = poles.get((tl_key, new_pole_code))
if pole is None:
acc_skip_pole += 1
continue
type_id = (row.get("accessory_type_id") or "").strip()
type_row = accessory_type_lut.get(type_id)
accessory_model = (
accessory_models_by_name.get(type_row["descr_fr"].strip())
if type_row else None
)
if accessory_model is None:
acc_skip_model += 1
continue
serial = _serial(row.get("serial_number"))
name = accessory_model.name_fr or new_code
# Dual lookup: new code (re-run) or old mob_id (first migration)
obj = existing_accessories.get((pole.pk, new_code)) \
or existing_accessories.get((pole.pk, mob_id))
if obj is not None:
obj.code = new_code
obj.model_id = accessory_model.pk
obj.serial_number = serial
obj.name_fr = name
obj.name_nl = name
obj.intersection_id = pole.intersection_id
acc_update.append(obj)
else:
acc_create.append(
TrafficLightAccessory(
pole = pole,
intersection_id = pole.intersection_id,
code = new_code,
name_fr = name,
name_nl = name,
model_id = accessory_model.pk,
serial_number = serial,
)
)
# ── Bulk DB writes ────────────────────────────────────────────────────
with transaction.atomic():
TrafficLightLantern.objects.bulk_create(
lan_create, batch_size=BULK_BATCH_SIZE
)
TrafficLightLantern.objects.bulk_update(
lan_update, fields=LANTERN_UPDATE_FIELDS, batch_size=BULK_BATCH_SIZE
)
TrafficLightAccessory.objects.bulk_create(
acc_create, batch_size=BULK_BATCH_SIZE
)
TrafficLightAccessory.objects.bulk_update(
acc_update, fields=ACCESSORY_UPDATE_FIELDS, batch_size=BULK_BATCH_SIZE
)
# ── Summary ───────────────────────────────────────────────────────────
self.stdout.write(
self.style.SUCCESS(
f"\nLantern import complete:\n"
f" Created : {len(lan_create)}\n"
f" Updated : {len(lan_update)}\n"
f" Skipped (pole not found) : {lan_skip_pole}\n"
f" Skipped (model not found) : {lan_skip_model}\n"
f"\nAccessory import complete:\n"
f" Created : {len(acc_create)}\n"
f" Updated : {len(acc_update)}\n"
f" Skipped (pole not found) : {acc_skip_pole}\n"
f" Skipped (model not found) : {acc_skip_model}"
)
)

View file

@ -0,0 +1,144 @@
"""
Management command to import TrafficLight model types from MobiGIS CSV exports.
Handled files (located in scripts/data/tl_mobigis/):
- tlaccessorytype.csv → TrafficLightAccessoryModel
- tltrafficlighttype.csv → TrafficLightPoleModel
- tllanterntype.csv → TrafficLightLanternModel
CSV format: semicolon-delimited, UTF-8 BOM, columns: id;code;descr_fr;descr_nl;active
Only rows with active='t' are imported.
"""
import csv
import os
from django.core.management.base import BaseCommand
from django.db import transaction
from assets.models.trafficlights import (
TrafficLightAccessoryModel,
TrafficLightPoleModel,
TrafficLightLanternModel,
)
CSV_DIR = os.path.join(
os.path.dirname(__file__), # .../assets/management/commands/
"..", "..", "..", "scripts", "data", "tl_mobigis",
)
def _clean_code(value: str) -> str:
"""Return an empty string when the source value is the placeholder '0'."""
return "" if value.strip() == "0" else value.strip()
def _clean_nl(value: str) -> str | None:
"""Return None when the Dutch description is the placeholder '0'."""
v = value.strip()
return None if v == "0" else v
def _read_csv(filename: str):
path = os.path.normpath(os.path.join(CSV_DIR, filename))
rows = []
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f, delimiter=";")
for row in reader:
if row.get("active", "").strip() == "t":
rows.append(row)
return rows
class Command(BaseCommand):
help = (
"Import TrafficLightAccessoryModel, TrafficLightPoleModel and "
"TrafficLightLanternModel from the MobiGIS CSV exports."
)
@transaction.atomic
def handle(self, *args, **options):
self._import_accessory_models()
self._import_pole_models()
self._import_lantern_models()
# ------------------------------------------------------------------
# TrafficLightAccessoryModel ← tlaccessorytype.csv
# ------------------------------------------------------------------
def _import_accessory_models(self):
rows = _read_csv("tlaccessorytype.csv")
created = updated = 0
for row in rows:
name_fr = row["descr_fr"].strip()
if not name_fr:
continue
code = _clean_code(row.get("code", ""))
name_nl = _clean_nl(row.get("descr_nl", ""))
_, was_created = TrafficLightAccessoryModel.objects.update_or_create(
name_fr=name_fr,
defaults={"code": code, "name_nl": name_nl},
)
if was_created:
created += 1
else:
updated += 1
self.stdout.write(
self.style.SUCCESS(
f"TrafficLightAccessoryModel: {created} created, {updated} updated."
)
)
# ------------------------------------------------------------------
# TrafficLightPoleModel ← tltrafficlighttype.csv
# ------------------------------------------------------------------
def _import_pole_models(self):
rows = _read_csv("tltrafficlighttype.csv")
created = updated = 0
for row in rows:
name_fr = row["descr_fr"].strip()
if not name_fr:
continue
code = _clean_code(row.get("code", ""))
name_nl = _clean_nl(row.get("descr_nl", ""))
_, was_created = TrafficLightPoleModel.objects.update_or_create(
name_fr=name_fr,
defaults={"code": code, "name_nl": name_nl},
)
if was_created:
created += 1
else:
updated += 1
self.stdout.write(
self.style.SUCCESS(
f"TrafficLightPoleModel: {created} created, {updated} updated."
)
)
# ------------------------------------------------------------------
# TrafficLightLanternModel ← tllanterntype.csv
# The CSV does not contain voltage data; voltage defaults to 0.
# Update manually after import if needed.
# ------------------------------------------------------------------
def _import_lantern_models(self):
rows = _read_csv("tllanterntype.csv")
created = updated = 0
for row in rows:
name_fr = row["descr_fr"].strip()
if not name_fr:
continue
code = _clean_code(row.get("code", ""))
name_nl = _clean_nl(row.get("descr_nl", ""))
_, was_created = TrafficLightLanternModel.objects.update_or_create(
name_fr=name_fr,
defaults={"code": code, "name_nl": name_nl, "voltage": 0},
)
if was_created:
created += 1
else:
updated += 1
self.stdout.write(
self.style.SUCCESS(
f"TrafficLightLanternModel: {created} created, {updated} updated "
"(voltage defaulted to 0 — update manually if needed)."
)
)

View file

@ -0,0 +1,225 @@
"""
Management command to import SignPanel objects from the MobiGIS CSV export,
linked to the existing TrafficLightPole records.
Source files : scripts/data/tl_mobigis/tlpannels.csv
scripts/data/tl_mobigis/tlpanneltype.csv
Lookup file : scripts/data/tl_mobigis/tltrafficlights.csv
Mapping
-------
tlpannels.trafficlight_id
→ tltrafficlights.id (NOT trafficlight_id — same off-by-one trap)
→ (tl_key, nr_trafficlight)
→ TrafficLightPole in DB
→ SignPanel.trafficlight_pole
Panel type
----------
tlpannels.pannel_type_id
→ tlpanneltype.code e.g. "B5 Stop"
→ first token ("B5")
→ SignPanelType.objects.filter(code__iexact="B5")
Code format
-----------
{tl_key}_{nr}_PAN{seq:02d} e.g. SWB03_A04_PAN01, SWB03_A04_PAN02, …
Sequential per pole, ordered by MobiGIS row id (integer sort, stable).
Idempotency
-----------
Keyed on (trafficlight_pole_id, code). Running twice: 0 created, N updated.
Performance
-----------
bulk_create + bulk_update (batch size 500).
"""
import csv
import os
from collections import defaultdict
from django.core.management.base import BaseCommand
from django.db import transaction
from assets.models.sign import SignPanel, SignPanelType
from assets.models.trafficlights import TrafficLightPole
BASE_DIR = os.path.normpath(
os.path.join(
os.path.dirname(__file__),
"..", "..", "..", "scripts", "data", "tl_mobigis",
)
)
BULK_BATCH_SIZE = 500
UPDATE_FIELDS = [
"code", "name_fr", "name_nl",
"signpanel_type", "support_type",
"trafficlight_pole",
]
def _csv_path(filename):
return os.path.join(BASE_DIR, filename)
class Command(BaseCommand):
help = (
"Import SignPanel objects from "
"scripts/data/tl_mobigis/tlpannels.csv, "
"linked to TrafficLightPole via tltrafficlights.csv"
)
def handle(self, *args, **options):
# ── Lookup: tlpanneltype id → {code, descr_fr, descr_nl} ────────────
panneltype_lut = {}
with open(_csv_path("tlpanneltype.csv"), newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f, delimiter=";"):
panneltype_lut[(row.get("id") or "").strip()] = row
# ── Lookup: tltrafficlights id → (tl_key, nr_trafficlight) ──────────
# IMPORTANT: keyed by the "id" column, not "trafficlight_id"
tl_lut = {}
with open(_csv_path("tltrafficlights.csv"), newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f, delimiter=";"):
row_id = (row.get("id") or "").strip()
tl_key = (row.get("tl_key") or "").strip()
nr = (row.get("nr_trafficlight") or "").strip()
if row_id and tl_key:
tl_lut[row_id] = (tl_key, nr)
# ── DB: poles keyed by (intersection_code, pole_code) ────────────────
poles = {
(p.intersection.code, p.code): p
for p in TrafficLightPole.objects.select_related("intersection").all()
}
self.stdout.write(f" Poles loaded : {len(poles)}")
# ── DB: SignPanelType keyed by code (upper) ───────────────────────────
# Panel type code from CSV is like "B5 Stop"; we match on the first
# whitespace-separated token (e.g. "B5") against SignPanelType.code.
sign_panel_types = {
pt.code.strip().upper(): pt
for pt in SignPanelType.objects.all()
}
self.stdout.write(f" SignPanelType loaded : {len(sign_panel_types)}")
# ── DB: existing SignPanels keyed by (trafficlight_pole_id, code) ─────
existing_panels = {
(sp.trafficlight_pole_id, sp.code): sp
for sp in SignPanel.objects.filter(trafficlight_pole__isnull=False)
}
self.stdout.write(f" Existing panels loaded : {len(existing_panels)}")
# ── Read CSV ──────────────────────────────────────────────────────────
with open(_csv_path("tlpannels.csv"), newline="", encoding="utf-8-sig") as f:
pan_rows = list(csv.DictReader(f, delimiter=";"))
self.stdout.write(f" Panel rows in CSV : {len(pan_rows)}")
# ── Pre-pass: assign sequential codes per pole ────────────────────────
groups = defaultdict(list) # (tl_key, raw_nr) → [mob_id, …]
for row in pan_rows:
tl_ref = (row.get("trafficlight_id") or "").strip()
mob_id = (row.get("id") or "").strip()
mapping = tl_lut.get(tl_ref)
if not mapping:
continue
tl_key, raw_nr = mapping
if (tl_key, f"{tl_key}_{raw_nr}") not in poles:
continue
groups[(tl_key, raw_nr)].append(mob_id)
panel_new_codes = {} # mob_id → "SWB03_A04_PAN01"
for (tl_key, raw_nr), mob_ids in groups.items():
sorted_ids = sorted(mob_ids, key=lambda x: int(x) if x.isdigit() else x)
for seq, mid in enumerate(sorted_ids, 1):
panel_new_codes[mid] = f"{tl_key}_{raw_nr}_PAN{seq:02d}"
# ── Parse rows ────────────────────────────────────────────────────────
to_create: list[SignPanel] = []
to_update: list[SignPanel] = []
skip_pole = skip_type = 0
for row in pan_rows:
tl_ref = (row.get("trafficlight_id") or "").strip()
mob_id = (row.get("id") or "").strip()
new_code = panel_new_codes.get(mob_id)
if new_code is None:
skip_pole += 1
continue
mapping = tl_lut.get(tl_ref)
tl_key, raw_nr = mapping
pole = poles.get((tl_key, f"{tl_key}_{raw_nr}"))
if pole is None:
skip_pole += 1
continue
# Resolve panel type via prefix of tlpanneltype.code
type_row = panneltype_lut.get((row.get("pannel_type_id") or "").strip())
sign_panel_type = None
if type_row:
raw_code = (type_row.get("code") or "").strip()
name_fr = (type_row.get("descr_fr") or raw_code).strip()
name_nl = (type_row.get("descr_nl") or raw_code).strip()
prefix = raw_code.split()[0].upper() if raw_code else ""
sign_panel_type = sign_panel_types.get(prefix)
else:
name_fr = new_code
name_nl = new_code
if sign_panel_type is None:
skip_type += 1
# Still create the panel, type will be null
obj = existing_panels.get((pole.pk, new_code))
if obj is not None:
obj.code = new_code
obj.name_fr = name_fr
obj.name_nl = name_nl
obj.signpanel_type = sign_panel_type
obj.support_type = "trafficlightpole"
obj.trafficlight_pole = pole
to_update.append(obj)
else:
to_create.append(
SignPanel(
code = new_code,
name_fr = name_fr,
name_nl = name_nl,
signpanel_type = sign_panel_type,
support_type = "trafficlightpole",
trafficlight_pole = pole,
)
)
# ── Bulk DB operations ────────────────────────────────────────────────
with transaction.atomic():
created_count = 0
for i in range(0, len(to_create), BULK_BATCH_SIZE):
batch = SignPanel.objects.bulk_create(
to_create[i : i + BULK_BATCH_SIZE],
batch_size=BULK_BATCH_SIZE,
)
created_count += len(batch)
updated_count = 0
for i in range(0, len(to_update), BULK_BATCH_SIZE):
updated_count += SignPanel.objects.bulk_update(
to_update[i : i + BULK_BATCH_SIZE],
UPDATE_FIELDS,
batch_size=BULK_BATCH_SIZE,
)
self.stdout.write(
self.style.SUCCESS(
f"\nSignPanel import complete:\n"
f" Created : {created_count}\n"
f" Updated : {updated_count}\n"
f" Skipped (pole not found) : {skip_pole}\n"
f" Created without type (prefix no match): {skip_type}\n"
)
)

View file

@ -0,0 +1,207 @@
"""
Management command to import photos for the TrafficLightPoles
that have an image in the MobiGIS CSV export.
For each pole with an image field, the filename is extracted from the path and
the file is fetched from:
https://data.mobility.brussels/media/maintain/inventory/trafficlight/<filename>
A ManagedDocument + DocumentVersion are created and linked to the pole via a
DocumentAttachment. The DocumentTag "photo" is applied to every document.
Re-running the command is safe: existing attachments (same document title +
same pole) are skipped.
"""
import csv
import os
import urllib.request
from django.contrib.contenttypes.models import ContentType
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand
from django.db import models, transaction
from assets.models.trafficlights import TrafficLightIntersection, TrafficLightPole
from documents.models import (
DocumentAttachment,
DocumentTag,
DocumentVersion,
ManagedDocument,
)
BASE_DIR = os.path.normpath(
os.path.join(
os.path.dirname(__file__),
"..", "..", "..", "scripts", "data", "tl_mobigis",
)
)
CSV_PATH = os.path.join(BASE_DIR, "tltrafficlights.csv")
PHOTO_BASE_URL = "https://data.mobility.brussels/media/maintain/inventory/trafficlight/"
MAX_POLES = 10000
class Command(BaseCommand):
help = (
"Fetch and attach photos for the first 10000 TrafficLightPoles "
"that have an image in tltrafficlights.csv."
)
def handle(self, *args, **options):
# ── Get-or-create the "photo" DocumentTag ────────────────────────────
photo_tag, tag_created = DocumentTag.objects.get_or_create(
slug="photo",
defaults={"name_fr": "photo", "name_nl": "foto", "order": 0},
)
if tag_created:
self.stdout.write(self.style.SUCCESS(' DocumentTag "photo" created.'))
else:
self.stdout.write(f' DocumentTag "photo" found (id={photo_tag.pk}).')
# ── Pre-load poles keyed by (intersection_code, pole_code) ──────────
intersections = {i.code: i for i in TrafficLightIntersection.objects.all()}
poles_by_key = {
(p.intersection.code, p.code): p
for p in TrafficLightPole.objects.select_related("intersection").all()
}
pole_ct = ContentType.objects.get_for_model(TrafficLightPole)
# ── Deduplication pass ───────────────────────────────────────────────
# For each pole, group its DocumentAttachments by the filename stem
# stored in the related DocumentVersion.file field (stripped of path,
# extension, and any Django-added suffix like "_abc123").
# When >1 attachment share the same stem keep only the most recent one
# (highest pk) and delete the others together with their documents.
self.stdout.write(" Running deduplication pass…")
dedup_deleted = 0
all_pole_attachments = (
DocumentAttachment.objects
.filter(content_type=pole_ct)
.select_related("document")
.prefetch_related("document__versions")
)
# Group by (object_id, filename_stem)
seen: dict[tuple, int] = {} # (pole_pk, stem) → best attachment pk
duplicates: list[int] = [] # attachment pks to delete
for att in all_pole_attachments.order_by("pk"):
version = att.document.versions.order_by("pk").first()
if version is None:
continue
raw_name = os.path.basename(version.file.name) # foo_abc123.jpg
stem_full = os.path.splitext(raw_name)[0] # foo_abc123
# Strip Django's "_<random>" suffix (last "_<token>" if the token
# looks like an 8-char alphanumeric hash).
parts = stem_full.rsplit("_", 1)
stem = parts[0] if len(parts) == 2 and len(parts[1]) <= 10 and parts[1].isalnum() else stem_full
key = (att.object_id, stem)
if key in seen:
duplicates.append(att.pk) # keep the first (lowest pk), drop later ones
else:
seen[key] = att.pk
if duplicates:
# Delete in bulk: attachments, then orphaned documents
dup_attachments = DocumentAttachment.objects.filter(pk__in=duplicates)
doc_ids = list(dup_attachments.values_list("document_id", flat=True))
dup_attachments.delete()
ManagedDocument.objects.filter(pk__in=doc_ids).delete()
dedup_deleted = len(duplicates)
self.stdout.write(
self.style.WARNING(f" Deduplication: removed {dedup_deleted} duplicate attachment(s).")
)
else:
self.stdout.write(" Deduplication: no duplicates found.")
# ── Read CSV and collect rows with images ────────────────────────────
with open(CSV_PATH, newline="", encoding="utf-8-sig") as f:
rows = list(csv.DictReader(f, delimiter=";"))
candidates = [
r for r in rows
if (r.get("tl_key") or "").strip()
and (r.get("image") or "").strip()
][:MAX_POLES]
self.stdout.write(f" Candidates with photo: {len(candidates)}")
created = updated = skipped = errors = 0
for row in candidates:
tl_key = row["tl_key"].strip()
pole_code = (row.get("nr_trafficlight") or "").strip()
# Pole codes are stored as "<intersection_code>_<nr_trafficlight>"
full_pole_code = f"{tl_key}_{pole_code}" if pole_code else tl_key
image_path = row["image"].strip()
filename = os.path.basename(image_path)
url = PHOTO_BASE_URL + filename
# ── Find the pole ─────────────────────────────────────────────
pole = poles_by_key.get((tl_key, full_pole_code))
if pole is None:
self.stdout.write(
self.style.WARNING(
f" Pole not found: intersection={tl_key!r}, "
f"code={full_pole_code!r} — skipping."
)
)
skipped += 1
continue
# ── Idempotency: skip if attachment already exists ─────────────
# Match on document title (set by us) OR on the filename stem
# stored inside DocumentVersion.file (robust against Django's
# automatic "_<suffix>" renaming of duplicate file names).
title = os.path.splitext(filename)[0]
already = DocumentAttachment.objects.filter(
content_type=pole_ct,
object_id=pole.pk,
).filter(
models.Q(document__title=title)
| models.Q(document__versions__file__icontains=title)
).exists()
if already:
updated += 1
continue
# ── Download the image ────────────────────────────────────────
try:
with urllib.request.urlopen(url, timeout=15) as response:
image_data = response.read()
except Exception as exc:
self.stdout.write(
self.style.WARNING(f" Download failed {url}: {exc}")
)
errors += 1
continue
# ── Persist document + attachment (committed immediately) ─────
with transaction.atomic():
doc = ManagedDocument.objects.create(title=title)
doc.tags.add(photo_tag)
version = DocumentVersion(
document=doc,
status=DocumentVersion.STATUS_APPROVED,
)
version.file.save(filename, ContentFile(image_data), save=False)
version.save() # sets version_number, current_version, thumbnail
DocumentAttachment.objects.create(
document=doc,
content_type=pole_ct,
object_id=pole.pk,
)
created += 1
self.stdout.write(f" ✓ {tl_key}/{full_pole_code} → {filename}")
self.stdout.write(
self.style.SUCCESS(
f"\nPhoto import complete:\n"
f" Created : {created}\n"
f" Already present: {updated}\n"
f" Skipped (pole not found): {skipped}\n"
f" Errors (download) : {errors}"
)
)

View file

@ -0,0 +1,239 @@
"""
Management command to import TrafficLightPole objects from the MobiGIS CSV export.
Source file : scripts/data/tl_mobigis/tltrafficlights.csv
Lookup files: tltrafficlighttype.csv, tlcolour.csv, tlobsolescence.csv
Rules
-----
- Skip rows that have no tl_key (tl_key = intersection code).
- Geometry is in EPSG 31370 (Belgian Lambert 72); transform to EPSG 3812 for
the geom field, and derive lon/lat (WGS84) + geojson from the same point.
- Existing poles (same intersection + same code) are updated, not duplicated.
- name_fr and name_nl are set to the pole code.
- Photos are ignored for now.
Performance
-----------
All DB work is done in two bulk operations (bulk_create + bulk_update) instead
of one update_or_create per row, reducing ~12 000 individual SQL round-trips to
a handful of queries.
"""
import csv
import json
import os
from django.contrib.gis.gdal import CoordTransform, SpatialReference
from django.contrib.gis.geos import GEOSGeometry
from django.core.management.base import BaseCommand
from django.db import transaction
from assets.models.trafficlights import (
TrafficLightIntersection,
TrafficLightPole,
TrafficLightPoleModel,
)
BASE_DIR = os.path.normpath(
os.path.join(
os.path.dirname(__file__), # …/assets/management/commands/
"..", "..", "..", "scripts", "data", "tl_mobigis",
)
)
BULK_BATCH_SIZE = 500
# Fields written during both create and update
UPDATE_FIELDS = [
"code", "model_id", "geom", "lon", "lat", "geojson",
"arc_length", "extension", "colour", "obsolescence", "note",
"name_fr", "name_nl",
]
OBSOLESCENCE_MAP = {
"13": "good", # Bon état général
"14": "medium", # Moyen
"15": "bad", # Dégradé
"16": "critical", # Dangereux
}
def _csv_path(filename):
return os.path.join(BASE_DIR, filename)
def _read_lookup(filename, key_col="id"):
"""Return {id_string: row_dict} for a semicolon-delimited CSV."""
result = {}
with open(_csv_path(filename), newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f, delimiter=";"):
result[row[key_col].strip()] = row
return result
class Command(BaseCommand):
help = (
"Import TrafficLightPole objects from "
"scripts/data/tl_mobigis/tltrafficlights.csv"
)
def handle(self, *args, **options):
# ── Lookup tables (CSV, no DB) ───────────────────────────────────────
colour_lut = _read_lookup("tlcolour.csv")
type_lut = _read_lookup("tltrafficlighttype.csv")
# ── DB lookups (3 queries total) ─────────────────────────────────────
intersections = {i.code: i for i in TrafficLightIntersection.objects.all()}
self.stdout.write(f" Intersections loaded : {len(intersections)}")
all_models = list(TrafficLightPoleModel.objects.all())
pole_models_by_name = {m.name_fr: m for m in all_models if m.name_fr}
pole_models_by_code = {m.code: m for m in all_models if m.code}
self.stdout.write(f" PoleModels loaded : {len(all_models)}")
# Existing poles keyed by (intersection_id, code) → pole instance
existing_poles = {
(p.intersection_id, p.code): p
for p in TrafficLightPole.objects.all()
}
self.stdout.write(f" Existing poles loaded: {len(existing_poles)}")
# ── Coordinate transforms (built once, reused for every row) ─────────
srs_31370 = SpatialReference(31370)
srs_3812 = SpatialReference(3812)
srs_4326 = SpatialReference(4326)
ct_to_3812 = CoordTransform(srs_31370, srs_3812)
ct_to_4326 = CoordTransform(srs_31370, srs_4326)
# ── Parse CSV into two buckets ────────────────────────────────────────
to_create: list[TrafficLightPole] = []
to_update: list[TrafficLightPole] = []
skipped_no_key = skipped_no_intersection = skipped_no_geom = skipped_no_model = 0
with open(_csv_path("tltrafficlights.csv"), newline="", encoding="utf-8-sig") as f:
rows = list(csv.DictReader(f, delimiter=";"))
for row in rows:
tl_key = (row.get("tl_key") or "").strip()
if not tl_key:
skipped_no_key += 1
continue
intersection = intersections.get(tl_key)
if intersection is None:
skipped_no_intersection += 1
self.stdout.write(self.style.WARNING(f" Intersection not found: {tl_key!r}"))
continue
# ── Geometry ──────────────────────────────────────────────────────
wkt = (row.get("wkt_geom") or "").strip()
if not wkt:
skipped_no_geom += 1
continue
try:
geom_31370 = GEOSGeometry(wkt, srid=31370)
geom_3812 = geom_31370.clone()
geom_3812.transform(ct_to_3812)
geom_wgs84 = geom_31370.clone()
geom_wgs84.transform(ct_to_4326)
lon = geom_wgs84.x
lat = geom_wgs84.y
geojson = json.dumps(
{"type": "Point", "coordinates": [round(lon, 8), round(lat, 8)]}
)
except Exception as exc:
self.stdout.write(
self.style.WARNING(f" Geometry error row id={row.get('id')}: {exc}")
)
skipped_no_geom += 1
continue
# ── Pole model ────────────────────────────────────────────────────
type_id = (row.get("trafficlight_type_id") or "").strip()
pole_model = None
if type_id and type_id in type_lut:
name_fr = type_lut[type_id]["descr_fr"].strip()
pole_model = pole_models_by_name.get(name_fr) or pole_models_by_code.get(
type_lut[type_id]["code"].strip()
)
if pole_model is None:
pole_model = pole_models_by_code.get("A") # fallback → "Autre"
if pole_model is None:
skipped_no_model += 1
self.stdout.write(
self.style.WARNING(
f" No pole model for trafficlight_type_id={type_id!r} "
f"(row id={row.get('id')}); skipping."
)
)
continue
# ── Scalar fields ─────────────────────────────────────────────────
nr = (row.get("nr_trafficlight") or "").strip()
new_code = f"{tl_key}_{nr}" # e.g. SUL21_A01
extension = (row.get("extension") or "").strip().lower() == "t"
arc_raw = (row.get("length_arc") or "").strip()
arc_length = float(arc_raw) if arc_raw else None
colour_id = (row.get("colour_id") or "").strip()
colour = colour_lut[colour_id]["code"].strip() if colour_id in colour_lut else ""
obsolescence = OBSOLESCENCE_MAP.get((row.get("obsolescence_id") or "").strip(), "unknown")
note = (row.get("remark") or "").strip() or None
# ── Route to create or update bucket ──────────────────────────────
# Dual lookup: new prefixed code (re-run) OR old raw code (first migration)
key_new = (intersection.pk, new_code)
key_old = (intersection.pk, nr)
pole = existing_poles.get(key_new) or existing_poles.get(key_old)
if pole is not None:
pole.code = new_code
pole.model_id = pole_model.pk
pole.geom = geom_3812
pole.lon = lon
pole.lat = lat
pole.geojson = geojson
pole.arc_length = arc_length
pole.extension = extension
pole.colour = colour
pole.obsolescence = obsolescence
pole.note = note
pole.name_fr = nr
pole.name_nl = nr
to_update.append(pole)
else:
to_create.append(
TrafficLightPole(
intersection = intersection,
code = new_code,
name_fr = nr,
name_nl = nr,
model_id = pole_model.pk,
geom = geom_3812,
lon = lon,
lat = lat,
geojson = geojson,
arc_length = arc_length,
extension = extension,
colour = colour,
obsolescence = obsolescence,
note = note,
)
)
# ── Bulk DB writes (2 queries for the happy path) ─────────────────────
with transaction.atomic():
TrafficLightPole.objects.bulk_create(to_create, batch_size=BULK_BATCH_SIZE)
TrafficLightPole.objects.bulk_update(to_update, fields=UPDATE_FIELDS, batch_size=BULK_BATCH_SIZE)
# ── Summary ───────────────────────────────────────────────────────────
self.stdout.write(
self.style.SUCCESS(
f"\nTrafficLightPole import complete:\n"
f" Created : {len(to_create)}\n"
f" Updated : {len(to_update)}\n"
f" Skipped (no tl_key) : {skipped_no_key}\n"
f" Skipped (intersection missing): {skipped_no_intersection}\n"
f" Skipped (bad/missing geom) : {skipped_no_geom}\n"
f" Skipped (no pole model) : {skipped_no_model}"
)
)

View file

@ -0,0 +1,183 @@
"""
Management command to initialize AssetHistory for existing assets in the database.
Creates a 'create' history entry for each asset using:
- installation_date if available
- 01/01/2000 as fallback
Usage: python manage.py initialize_asset_history [--dry-run] [--batch-size=1000]
"""
from django.core.management.base import BaseCommand
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.db import transaction
from datetime import datetime
from assets.models import AssetHistory
class Command(BaseCommand):
help = 'Initialize AssetHistory for existing assets (creates __created__ entries)'
def add_arguments(self, parser):
parser.add_argument(
'--dry-run',
action='store_true',
help='Show what would be done without actually creating history entries'
)
parser.add_argument(
'--batch-size',
type=int,
default=1000,
help='Number of history entries to create in each batch (default: 1000)'
)
parser.add_argument(
'--app-label',
type=str,
default='assets',
help='App label to process (default: assets)'
)
def handle(self, *args, **options):
dry_run = options['dry_run']
batch_size = options['batch_size']
app_label = options['app_label']
self.stdout.write(self.style.SUCCESS('=' * 70))
self.stdout.write(self.style.SUCCESS('Asset History Initialization'))
self.stdout.write(self.style.SUCCESS('=' * 70))
if dry_run:
self.stdout.write(self.style.WARNING('\n[!] DRY RUN MODE - No changes will be made\n'))
# Default creation date for assets without installation_date
default_date = timezone.make_aware(datetime(2000, 1, 1, 0, 0, 0))
# Get all asset models from the specified app
asset_content_types = ContentType.objects.filter(app_label=app_label)
total_assets = 0
total_created = 0
total_skipped = 0
for content_type in asset_content_types:
model_class = content_type.model_class()
# Skip if model doesn't exist or isn't an asset-like model
if not model_class or not hasattr(model_class, 'code'):
continue
model_name = content_type.model
self.stdout.write(f'\nProcessing {content_type.app_label}.{model_name}...')
# Get all assets of this type
assets = model_class.objects.all()
asset_count = assets.count()
if asset_count == 0:
self.stdout.write(self.style.WARNING(f' No assets found'))
continue
self.stdout.write(f' Found {asset_count} assets')
# Get existing history entries for this model
existing_history = set(
AssetHistory.objects.filter(
content_type=content_type,
action_type='create'
).values_list('object_id', flat=True)
)
# Prepare batch of history entries
history_entries = []
processed = 0
created = 0
skipped = 0
for asset in assets.iterator(chunk_size=batch_size):
processed += 1
# Skip if already has creation history
if asset.id in existing_history:
skipped += 1
continue
# Determine creation date
if hasattr(asset, 'installation_date') and asset.installation_date:
# Combine date with time
dt = datetime.combine(asset.installation_date, datetime.min.time())
# Make timezone-aware if not already
if timezone.is_aware(dt):
creation_date = dt
else:
creation_date = timezone.make_aware(dt)
else:
creation_date = default_date
# Create creation history entry
history_entry = AssetHistory(
content_type=content_type,
object_id=asset.id,
timestamp=creation_date,
user=None, # System-generated
field_name='__created__',
old_value=None,
new_value=asset.code,
action_type='create'
)
history_entries.append(history_entry)
# Add initial status entry if asset has a status
if hasattr(asset, 'status') and asset.status:
status_entry = AssetHistory(
content_type=content_type,
object_id=asset.id,
timestamp=creation_date,
user=None, # System-generated
field_name='status',
old_value=None,
new_value=asset.status,
action_type='status_change'
)
history_entries.append(status_entry)
created += 1
# Batch insert when we reach batch_size
if len(history_entries) >= batch_size:
if not dry_run:
with transaction.atomic():
AssetHistory.objects.bulk_create(history_entries)
history_entries = []
self.stdout.write(f' ✓ Processed {processed}/{asset_count} assets...', ending='\r')
# Insert remaining entries
if history_entries and not dry_run:
with transaction.atomic():
AssetHistory.objects.bulk_create(history_entries)
# Summary for this model
self.stdout.write(f' ✓ Processed {processed}/{asset_count} assets')
if created > 0:
self.stdout.write(self.style.SUCCESS(f' [+] Created {created} history entries'))
if skipped > 0:
self.stdout.write(self.style.WARNING(f' [-] Skipped {skipped} (already have history)'))
total_assets += asset_count
total_created += created
total_skipped += skipped
# Final summary
self.stdout.write('\n' + '=' * 70)
self.stdout.write(self.style.SUCCESS('Summary:'))
self.stdout.write('=' * 70)
self.stdout.write(f'Total assets processed: {total_assets}')
self.stdout.write(self.style.SUCCESS(f'History entries created: {total_created}'))
self.stdout.write(self.style.WARNING(f'Assets skipped (existing history): {total_skipped}'))
if dry_run:
self.stdout.write('\n' + self.style.WARNING('[!] DRY RUN - No changes were made'))
self.stdout.write(self.style.WARNING(' Run without --dry-run to apply changes'))
else:
self.stdout.write('\n' + self.style.SUCCESS('[+] Asset history initialization complete!'))
self.stdout.write('')

View file

@ -0,0 +1,194 @@
from django.core.management.base import BaseCommand
from django.db import transaction
from django.contrib.contenttypes.models import ContentType
from assets.models import (
ITSGeoAsset,
ITSGeoAssetContract,
ITSAssetModel,
TrafficLightIntersection,
TrafficLightRadarModel,
TrafficLightRadar,
TrafficLightContract,
AssetCategory
)
from common.models import Thematic
from interventions.models import (
Intervention,
InterventionAsset,
InterventionLocation
)
class Command(BaseCommand):
help = "Transfer prevention radars from the ITS thematic to the Traffic Lights thematic."
def handle(self, *args, **options):
# 1. Retrieve all prevention radars in ITSGeoAsset
its_radars = ITSGeoAsset.objects.filter(code__startswith='RAD_PREV_')
total_radars = its_radars.count()
self.stdout.write(f"Found {total_radars} prevention radars starting with 'RAD_PREV_' in ITSGeoAsset.")
if total_radars == 0:
self.stdout.write(self.style.WARNING("No prevention radars to transfer. Exiting."))
return
# 2. Get necessary content types and thematics
its_geo_asset_ct = ContentType.objects.get_for_model(ITSGeoAsset)
traffic_light_radar_ct = ContentType.objects.get_for_model(TrafficLightRadar)
traffic_light_intersection_ct = ContentType.objects.get_for_model(TrafficLightIntersection)
try:
trafficlights_thematic = Thematic.objects.get(code='trafficlights')
except Thematic.DoesNotExist:
self.stdout.write(self.style.ERROR("Thematic 'trafficlights' not found. Exiting."))
return
try:
radar_category = AssetCategory.objects.get(code='TL_RADAR_PREVENTIF')
except AssetCategory.DoesNotExist:
try:
radar_category = AssetCategory.objects.get(id=20)
except AssetCategory.DoesNotExist:
radar_category = AssetCategory.objects.filter(name_fr='Radar Préventif').first()
if not radar_category:
self.stdout.write(self.style.ERROR("AssetCategory 'Radar Préventif' (code TL_RADAR_PREVENTIF) not found. Exiting."))
return
# We will keep track of models mapped, locations created/retrieved, radars created, interventions updated, contracts migrated
transferred_count = 0
locations_created = 0
models_mapped = 0
interventions_updated = 0
contracts_migrated = 0
# Run everything in a transaction block
with transaction.atomic():
for old_radar in its_radars:
# A. Map the location (TrafficLightIntersection)
intersection_code = old_radar.code.replace('RAD_PREV_', '').strip()
intersection, created = TrafficLightIntersection.objects.get_or_create(
code=intersection_code,
defaults={
'name_fr': f"Radar {intersection_code}",
'name_nl': f"Radar {intersection_code}",
'lon': old_radar.lon,
'lat': old_radar.lat,
'geom': old_radar.geom,
'geojson': old_radar.geojson,
'status': 'active',
'is_radar': True
}
)
if created:
locations_created += 1
elif not intersection.is_radar:
intersection.is_radar = True
intersection.save(update_fields=['is_radar'])
# B. Map the model (TrafficLightRadarModel)
its_model = old_radar.model
tl_model, created_model = TrafficLightRadarModel.objects.get_or_create(
code=its_model.code,
defaults={
'name_fr': its_model.name_fr,
'name_nl': its_model.name_nl,
'description': its_model.description,
'brand': its_model.brand,
'category': radar_category
}
)
if created_model:
models_mapped += 1
# C. Recreate/map the radar to TrafficLightRadar
new_radar = TrafficLightRadar.objects.create(
code=old_radar.code,
name_fr=old_radar.name_fr,
name_nl=old_radar.name_nl,
status=old_radar.status,
obsolescence=old_radar.obsolescence,
installation_date=old_radar.installation_date,
uninstallation_date=old_radar.uninstallation_date,
serial_number=old_radar.serial_number,
warranty_duration=old_radar.warranty_duration,
last_inspection_date=old_radar.last_inspection_date,
last_preventive_date=old_radar.last_preventive_date,
last_corrective_date=old_radar.last_corrective_date,
last_survey_date=old_radar.last_survey_date,
brand=old_radar.brand,
replaced_by=None,
marked_as_removed_date=old_radar.marked_as_removed_date,
marked_as_removed_by=old_radar.marked_as_removed_by,
note=old_radar.note,
validation_status=old_radar.validation_status,
updated_by=old_radar.updated_by,
lon=old_radar.lon,
lat=old_radar.lat,
geom=old_radar.geom,
geojson=old_radar.geojson,
model=tl_model,
intersection=intersection,
category=radar_category
)
transferred_count += 1
# D. Migrate associated Interventions
# Find all InterventionAssets referencing this old radar
int_assets = InterventionAsset.objects.filter(content_type=its_geo_asset_ct, object_id=old_radar.id)
for int_asset in int_assets:
# Update generic foreign key pointing to the new radar
int_asset.content_type = traffic_light_radar_ct
int_asset.object_id = new_radar.id
int_asset.save()
intervention = int_asset.intervention
# Update thematic of intervention
intervention.thematic = trafficlights_thematic
# Update asset category of intervention if it points to ITS Radars (ID 82)
if intervention.asset_category and intervention.asset_category.id == 82:
intervention.asset_category = radar_category
# Update location_code field if empty
if not intervention.location_code:
intervention.location_code = intersection.code
intervention.save()
# Create InterventionLocation pointing to the TrafficLightIntersection
InterventionLocation.objects.get_or_create(
intervention=intervention,
content_type=traffic_light_intersection_ct,
object_id=intersection.id
)
interventions_updated += 1
# E. Migrate associated Contracts
# For each ITSGeoAssetContract linked to this old radar
old_contracts = ITSGeoAssetContract.objects.filter(asset=old_radar)
for old_contract_link in old_contracts:
# Create corresponding TrafficLightContract for the intersection
TrafficLightContract.objects.get_or_create(
intersection=intersection,
contract=old_contract_link.contract,
defaults={
'status': old_contract_link.status,
'maintenance_type': 'other'
}
)
contracts_migrated += 1
# Clean up old contract link
old_contract_link.delete()
# F. Delete old ITSGeoAsset radar
old_radar.delete()
self.stdout.write(self.style.SUCCESS(
f"Successfully transferred {transferred_count}/{total_radars} prevention radars.\n"
f"Created {locations_created} TrafficLightIntersections.\n"
f"Mapped {models_mapped} TrafficLightRadarModels.\n"
f"Updated {interventions_updated} associated interventions/assets/locations.\n"
f"Migrated {contracts_migrated} contract links."
))

View file

@ -0,0 +1,184 @@
import json
import re
import ssl
import urllib.request
from django.contrib.gis.geos import Point
from django.contrib.gis.gdal import CoordTransform, SpatialReference
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from assets.models.its import ITSGeoAsset
def clean_code(code):
if not code:
return ''
c = code.upper().strip()
c = c.replace('RAD_PREV_', '').replace('RAD_', '').replace('CAM_', '').replace('CNT_', '').replace('PMV_', '')
c = c.replace('GATSO', '').replace('GAT', '').strip()
c = re.sub(r'\s+', '', c)
c = re.sub(r'_[0-9]+$', '', c)
c = re.sub(r'-[0-9]+$', '', c)
if c.startswith('Y_'):
c = c[2:]
if c and c[0] in ('S', 'R', 'C'):
c = c[1:]
return c
def get_keys_to_try(code):
cleaned = clean_code(code)
keys = [code, cleaned]
if cleaned and cleaned[0] in ('S', 'R', 'C'):
suffix = cleaned[1:]
keys.extend([suffix, 'S' + suffix, 'R' + suffix, 'C' + suffix])
return list(dict.fromkeys(keys)) # remove duplicates keeping order
def find_coords(code, coords_map):
# Try exact / core variations
for k in get_keys_to_try(code):
if k in coords_map:
return coords_map[k]
# Try trailing '0' normalization
cleaned = clean_code(code)
if cleaned.endswith('0'):
stripped = cleaned[:-1]
for k in get_keys_to_try(stripped):
if k in coords_map:
return coords_map[k]
# If WFS key in coords_map ends with '0'
for wfs_key, coords in coords_map.items():
if wfs_key.endswith('0'):
stripped_wfs = wfs_key[:-1]
# Check if our code core matches stripped WFS key
for k in get_keys_to_try(code):
if k == stripped_wfs:
return coords
return None
class Command(BaseCommand):
help = "Update speed cameras (ITS radars) objects where geometry is null using Brussels Mobility WFS GeoJSON speedcameras and traffic lights data."
def handle(self, *args, **options):
# 1. Fetch speedcameras WFS
url_speed = (
"https://data.mobility.brussels/geoserver/bm_security/wfs?"
"service=wfs&version=1.1.0&request=GetFeature&"
"typeName=bm_security:speedcameras&outputFormat=json&srsName=EPSG:4326"
)
# 2. Fetch traffic lights WFS
url_tl = (
"https://data.mobility.brussels/geoserver/bm_equipment/wfs?"
"service=wfs&version=1.1.0&request=GetFeature&"
"typeName=bm_equipment:traffic_lights&outputFormat=json&srsName=EPSG:4326"
)
context = ssl._create_unverified_context()
# Fetch speed cameras
self.stdout.write(f"Fetching speed cameras from WFS: {url_speed}")
try:
req_speed = urllib.request.Request(url_speed, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req_speed, context=context) as response:
data_speed = json.loads(response.read().decode('utf-8'))
features_speed = data_speed.get('features', [])
self.stdout.write(f"Successfully fetched {len(features_speed)} speed cameras.")
except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to fetch speed cameras data: {e}"))
return
# Fetch traffic lights
self.stdout.write(f"Fetching traffic lights from WFS: {url_tl}")
try:
req_tl = urllib.request.Request(url_tl, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req_tl, context=context) as response:
data_tl = json.loads(response.read().decode('utf-8'))
features_tl = data_tl.get('features', [])
self.stdout.write(f"Successfully fetched {len(features_tl)} traffic lights.")
except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to fetch traffic lights data: {e}"))
return
# 3. Map coordinates
coords_map = {}
# First map from speed cameras
for feature in features_speed:
props = feature.get('properties', {})
radar_id = props.get('radar_id') or ''
fr_key = props.get('fr_key') or ''
geom = feature.get('geometry')
if geom and geom.get('type') == 'Point':
coordinates = geom.get('coordinates')
if len(coordinates) == 2:
for val in [radar_id, fr_key]:
cleaned = clean_code(val)
if cleaned:
coords_map[cleaned] = coordinates
# Second, map from traffic lights if not already mapped
for feature in features_tl:
props = feature.get('properties', {})
key = props.get('key') or ''
geom = feature.get('geometry')
if geom and geom.get('type') == 'Point':
coordinates = geom.get('coordinates')
if len(coordinates) == 2:
cleaned = clean_code(key)
if cleaned and cleaned not in coords_map:
coords_map[cleaned] = coordinates
self.stdout.write(f"Mapped coordinates for {len(coords_map)} unique identifiers.")
# 4. Setup coordinate transformation to EPSG:3812 (Belgian Lambert 72)
srs_3812 = SpatialReference(3812)
srs_4326 = SpatialReference(4326)
ct_to_3812 = CoordTransform(srs_4326, srs_3812)
# 5. Find all ITSGeoAsset (Radars) where geom is null
null_radars = ITSGeoAsset.objects.filter(
Q(category__name_fr='Radars') | Q(code__startswith='RAD_'),
geom__isnull=True
)
self.stdout.write(f"Found {null_radars.count()} radars with null geometry in the database.")
updated_radars_count = 0
# We perform updates in a transaction block
with transaction.atomic():
for radar in null_radars:
code = radar.code.strip()
coords = find_coords(code, coords_map)
if coords:
lon, lat = coords
try:
# Create EPSG:4326 point and transform to EPSG:3812
geom_wgs84 = Point(lon, lat, srid=4326)
geom_3812 = geom_wgs84.clone()
geom_3812.transform(ct_to_3812)
geojson_str = json.dumps(
{"type": "Point", "coordinates": [round(lon, 8), round(lat, 8)]}
)
radar.geom = geom_3812
radar.lon = lon
radar.lat = lat
radar.geojson = geojson_str
radar.save()
updated_radars_count += 1
except Exception as e:
self.stdout.write(
self.style.WARNING(f"Error transforming coordinates for radar {code}: {e}")
)
self.stdout.write(
self.style.SUCCESS(f"Updated {updated_radars_count} radars from WFS speedcameras/traffic-lights data.")
)

View file

@ -0,0 +1,233 @@
import json
import ssl
import urllib.request
from django.contrib.gis.geos import Point
from django.contrib.gis.gdal import CoordTransform, SpatialReference
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Q
from django.utils import timezone
from assets.models.trafficlights import (
TrafficLightIntersection,
TrafficLightPole,
TrafficLightController,
TrafficLightControllerHardware,
TrafficLightDetector,
TrafficLightNetworkHardware,
TrafficLightVoltageHardware,
TrafficLightElectricalCabinet,
TrafficLightElectricalCabinetContent,
TrafficLightAccessory,
)
def clean_code(code):
c = code.upper().strip()
if c.startswith('Y_'):
c = c[2:]
return c
def get_keys_to_try(code):
cleaned = clean_code(code)
keys = [code, cleaned]
if cleaned and cleaned[0] in ('S', 'R', 'C'):
suffix = cleaned[1:]
keys.extend([suffix, 'S' + suffix, 'R' + suffix, 'C' + suffix])
return list(dict.fromkeys(keys)) # remove duplicates keeping order
def find_coords(code, coords_map):
# Try exact / core variations
for k in get_keys_to_try(code):
if k in coords_map:
return coords_map[k]
# Try trailing '0' normalization
# If code ends with '0'
cleaned = clean_code(code)
if cleaned.endswith('0'):
stripped = cleaned[:-1]
for k in get_keys_to_try(stripped):
if k in coords_map:
return coords_map[k]
# If WFS key in coords_map ends with '0'
for wfs_key, coords in coords_map.items():
if wfs_key.endswith('0'):
stripped_wfs = wfs_key[:-1]
# Check if our code core matches stripped WFS key
for k in get_keys_to_try(code):
if k == stripped_wfs:
return coords
return None
class Command(BaseCommand):
help = "Update TrafficLightIntersection and equipment objects where geometry is null using Brussels Mobility WFS GeoJSON data."
def handle(self, *args, **options):
# 1. Fetch WFS GeoJSON
url = (
"https://data.mobility.brussels/geoserver/bm_equipment/wfs?"
"service=wfs&version=1.1.0&request=GetFeature&"
"typeName=bm_equipment:traffic_lights&outputFormat=json&srsName=EPSG:4326"
)
self.stdout.write(f"Fetching GeoJSON from: {url}")
try:
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
context = ssl._create_unverified_context()
with urllib.request.urlopen(req, context=context) as response:
data = json.loads(response.read().decode('utf-8'))
except Exception as e:
self.stdout.write(self.style.ERROR(f"Failed to fetch GeoJSON data: {e}"))
return
features = data.get('features', [])
self.stdout.write(f"Successfully fetched {len(features)} features.")
# 2. Map coordinates by intersection code (key)
# Coordinates in GeoJSON are [lon, lat] (EPSG:4326)
coords_map = {}
for feature in features:
props = feature.get('properties', {})
key = (props.get('key') or '').strip().upper()
geom = feature.get('geometry')
if key and geom and geom.get('type') == 'Point':
coordinates = geom.get('coordinates')
if len(coordinates) == 2:
coords_map[key] = coordinates
self.stdout.write(f"Mapped coordinates for {len(coords_map)} intersections.")
# 3. Setup coordinate transformation to EPSG:3812 (Belgian Lambert 72)
srs_3812 = SpatialReference(3812)
srs_4326 = SpatialReference(4326)
ct_to_3812 = CoordTransform(srs_4326, srs_3812)
# 4. Find all TrafficLightIntersection where geom is null
null_intersections = TrafficLightIntersection.objects.filter(geom__isnull=True)
self.stdout.write(f"Found {null_intersections.count()} intersections with null geometry in the database.")
updated_intersections_count = 0
now = timezone.now()
# We perform updates in a transaction block
with transaction.atomic():
for intersection in null_intersections:
code = intersection.code.strip()
coords = find_coords(code, coords_map)
if coords:
lon, lat = coords
try:
# Create EPSG:4326 point and transform to EPSG:3812
geom_wgs84 = Point(lon, lat, srid=4326)
geom_3812 = geom_wgs84.clone()
geom_3812.transform(ct_to_3812)
geojson_str = json.dumps(
{"type": "Point", "coordinates": [round(lon, 8), round(lat, 8)]}
)
intersection.geom = geom_3812
intersection.lon = lon
intersection.lat = lat
intersection.geojson = geojson_str
# updated_at will be set automatically by save() auto_now=True
intersection.save()
updated_intersections_count += 1
except Exception as e:
self.stdout.write(
self.style.WARNING(f"Error transforming coordinates for {code}: {e}")
)
self.stdout.write(
self.style.SUCCESS(f"Updated {updated_intersections_count} intersections from WFS data.")
)
# 5. Update child equipment where geometry is null
self.stdout.write("Updating equipment with null geometry based on their parent intersections...")
# Get all intersections that now have a valid geometry
valid_intersections = TrafficLightIntersection.objects.filter(geom__isnull=False)
equipment_stats = {
"poles": 0,
"controllers": 0,
"hardwares": 0,
"detectors": 0,
"network_hardwares": 0,
"voltage_hardwares": 0,
"cabinets": 0,
"cabinet_contents": 0,
"accessories": 0,
}
with transaction.atomic():
for intersection in valid_intersections:
# Cache geometry fields
geom = intersection.geom
lon = intersection.lon
lat = intersection.lat
geojson = intersection.geojson
# Update TrafficLightPole
poles = TrafficLightPole.objects.filter(intersection=intersection, geom__isnull=True)
if poles.exists():
p_count = poles.update(geom=geom, lon=lon, lat=lat, geojson=geojson, updated_at=now)
equipment_stats["poles"] += p_count
# Update TrafficLightController
controllers = TrafficLightController.objects.filter(intersection=intersection, geom__isnull=True)
if controllers.exists():
c_count = controllers.update(geom=geom, lon=lon, lat=lat, geojson=geojson, updated_at=now)
equipment_stats["controllers"] += c_count
# Update TrafficLightControllerHardware
hardwares = TrafficLightControllerHardware.objects.filter(controller__intersection=intersection, geom__isnull=True)
if hardwares.exists():
h_count = hardwares.update(geom=geom, lon=lon, lat=lat, geojson=geojson, updated_at=now)
equipment_stats["hardwares"] += h_count
# Update TrafficLightDetector
detectors = TrafficLightDetector.objects.filter(intersection=intersection, geom__isnull=True)
if detectors.exists():
d_count = detectors.update(geom=geom, lon=lon, lat=lat, geojson=geojson, updated_at=now)
equipment_stats["detectors"] += d_count
# Update TrafficLightNetworkHardware
network_hardwares = TrafficLightNetworkHardware.objects.filter(intersection=intersection, geom__isnull=True)
if network_hardwares.exists():
nh_count = network_hardwares.update(geom=geom, lon=lon, lat=lat, geojson=geojson, updated_at=now)
equipment_stats["network_hardwares"] += nh_count
# Update TrafficLightVoltageHardware
voltage_hardwares = TrafficLightVoltageHardware.objects.filter(intersection=intersection, geom__isnull=True)
if voltage_hardwares.exists():
vh_count = voltage_hardwares.update(geom=geom, lon=lon, lat=lat, geojson=geojson, updated_at=now)
equipment_stats["voltage_hardwares"] += vh_count
# Update TrafficLightElectricalCabinet
cabinets = TrafficLightElectricalCabinet.objects.filter(intersection=intersection, geom__isnull=True)
if cabinets.exists():
cab_count = cabinets.update(geom=geom, lon=lon, lat=lat, geojson=geojson, updated_at=now)
equipment_stats["cabinets"] += cab_count
# Update TrafficLightElectricalCabinetContent
cabinet_contents = TrafficLightElectricalCabinetContent.objects.filter(cabinet__intersection=intersection, geom__isnull=True)
if cabinet_contents.exists():
cc_count = cabinet_contents.update(geom=geom, lon=lon, lat=lat, geojson=geojson, updated_at=now)
equipment_stats["cabinet_contents"] += cc_count
# Update TrafficLightAccessory
accessories = TrafficLightAccessory.objects.filter(
Q(intersection=intersection) | Q(pole__intersection=intersection),
geom__isnull=True
)
if accessories.exists():
acc_count = accessories.update(geom=geom, lon=lon, lat=lat, geojson=geojson, updated_at=now)
equipment_stats["accessories"] += acc_count
self.stdout.write(self.style.SUCCESS("Equipment update complete. Summary of updated equipment:"))
for eq_type, count in equipment_stats.items():
self.stdout.write(f" - {eq_type}: {count}")

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,44 @@
# Generated by Django 5.2.8 on 2026-05-21 13:51
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('assets', '0001_initial'),
('contracts', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='artworkfountain',
name='maintenance_contract',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='contracts.contract'),
),
migrations.AddField(
model_name='artworkfountain',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='artworkfountain',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.artworkfountain', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='artworkfountain',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='artworkfountaindocument',
name='fountain',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.artworkfountain', verbose_name='Associated Fountain'),
),
]

View file

@ -0,0 +1,762 @@
# Generated by Django 5.2.8 on 2026-05-21 13:51
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('assets', '0002_initial'),
('common', '0002_initial'),
('contenttypes', '0002_remove_content_type_name'),
('contracts', '0001_initial'),
('documents', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='artworkfountaindocument',
name='managed_document',
field=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'),
),
migrations.AddField(
model_name='artworkfountaindocument',
name='uploaded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par'),
),
migrations.AddField(
model_name='artworkmonument',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='artworkmonument',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.artworkmonument', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='artworkmonument',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='artworkmonumentdocument',
name='fountain',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.artworkmonument', verbose_name='Associated Monument'),
),
migrations.AddField(
model_name='artworkmonumentdocument',
name='managed_document',
field=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'),
),
migrations.AddField(
model_name='artworkmonumentdocument',
name='uploaded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par'),
),
migrations.AddField(
model_name='artworkstreet',
name='municipality',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='artworks_streets', to='common.municipality'),
),
migrations.AddField(
model_name='artworkstreet',
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.AddField(
model_name='artworkmonument',
name='street',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='monuments', to='assets.artworkstreet'),
),
migrations.AddField(
model_name='artworkfountain',
name='street',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='fountains', to='assets.artworkstreet'),
),
migrations.AddField(
model_name='assetcategory',
name='parent',
field=models.ForeignKey(blank=True, help_text="Laisser vide pour une catégorie de premier niveau. Une sous-catégorie ne peut pas avoir d'enfants.", null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='assets.assetcategory', verbose_name='Catégorie parente'),
),
migrations.AddField(
model_name='assetcategory',
name='thematic',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='asset_categories', to='common.thematic'),
),
migrations.AddField(
model_name='artworkmonument',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='artworkfountain',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='assethistory',
name='content_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype', verbose_name="Type d'asset"),
),
migrations.AddField(
model_name='assethistory',
name='user',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='asset_changes', to=settings.AUTH_USER_MODEL, verbose_name='Utilisateur'),
),
migrations.AddField(
model_name='cleanglasscontainer',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='cleanglasscontainer',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='cleanglasscontainer',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.cleanglasscontainer', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='cleanglasscontainer',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='cleanglasscontainermodel',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='cleanglasscontainer',
name='model',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.cleanglasscontainermodel', verbose_name='Model'),
),
migrations.AddField(
model_name='cleanlitterbin',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='cleanlitterbin',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='cleanlitterbin',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.cleanlitterbin', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='cleanlitterbin',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='cleanlitterbinmodel',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='cleanlitterbin',
name='model',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.cleanlitterbinmodel', verbose_name='Model'),
),
migrations.AddField(
model_name='cleanlitterbinobservation',
name='litter_bin',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='assets.cleanlitterbin', verbose_name='Litter bin'),
),
migrations.AddField(
model_name='cleanlitterbinphoto',
name='litter_bin',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='assets.cleanlitterbin', verbose_name='Litter bin'),
),
migrations.AddField(
model_name='cleanlocation',
name='municipality',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='clean_locations', to='common.municipality'),
),
migrations.AddField(
model_name='cleanlocation',
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.AddField(
model_name='cleanlitterbin',
name='location',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='litter_bins', to='assets.cleanlocation', verbose_name='Location'),
),
migrations.AddField(
model_name='cleanglasscontainer',
name='location',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='glass_containers', to='assets.cleanlocation', verbose_name='Location'),
),
migrations.AddField(
model_name='cleanlocationcontract',
name='contract',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='clean_location_contracts', to='contracts.contract'),
),
migrations.AddField(
model_name='cleanlocationcontract',
name='location',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contracts', to='assets.cleanlocation'),
),
migrations.AddField(
model_name='complementarypoliceregulation',
name='managed_document',
field=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'),
),
migrations.AddField(
model_name='complementarypoliceregulation',
name='uploaded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_complementary_police_regulations_assets', to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par'),
),
migrations.AddField(
model_name='controlcenterassetdocument',
name='managed_document',
field=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'),
),
migrations.AddField(
model_name='controlcenterassetdocument',
name='uploaded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par'),
),
migrations.AddField(
model_name='controlcenterassetmodel',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='controlcentercontract',
name='contract',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='controlcenters_contracts', to='contracts.contract'),
),
migrations.AddField(
model_name='controlcentercontract',
name='control_center',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='controlcenters_contracts', to='assets.controlcenter'),
),
migrations.AddField(
model_name='controlcenterdocument',
name='control_center',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.controlcenter', verbose_name='Associated Location'),
),
migrations.AddField(
model_name='controlcenterdocument',
name='managed_document',
field=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'),
),
migrations.AddField(
model_name='controlcenterdocument',
name='uploaded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par'),
),
migrations.AddField(
model_name='controlcentergeoasset',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='controlcentergeoasset',
name='control_center',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assets', to='assets.controlcenter'),
),
migrations.AddField(
model_name='controlcentergeoasset',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='controlcentergeoasset',
name='model',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='controlcenters_assets', to='assets.controlcenterassetmodel'),
),
migrations.AddField(
model_name='controlcentergeoasset',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.controlcentergeoasset', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='controlcentergeoasset',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='controlcenterassetdocument',
name='control_center_asset',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.controlcentergeoasset', verbose_name='Associated Location'),
),
migrations.AddField(
model_name='controlcentergeoassetcontract',
name='contract',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='controlcenters_assets_contracts', to='contracts.contract'),
),
migrations.AddField(
model_name='controlcentergeoassetcontract',
name='control_center_asset',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='controlcenters_assets_contracts', to='assets.controlcentergeoasset'),
),
migrations.AddField(
model_name='controlcenterlocation',
name='control_center',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='all_locations', to='assets.controlcenter'),
),
migrations.AddField(
model_name='controlcenterlocation',
name='parent_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_locations', to='assets.controlcenterlocation'),
),
migrations.AddField(
model_name='controlcenterlocation',
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.AddField(
model_name='controlcentergeoasset',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assets', to='assets.controlcenterlocation'),
),
migrations.AddField(
model_name='controlcenter',
name='main_location',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='main_control_center', to='assets.controlcenterlocation'),
),
migrations.AddField(
model_name='greensurfacescomment',
name='created_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='green_surfaces_comments', to=settings.AUTH_USER_MODEL, verbose_name='Créé par'),
),
migrations.AddField(
model_name='greensurfacescommentphoto',
name='comment',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='photos', to='assets.greensurfacescomment', verbose_name='Commentaire'),
),
migrations.AddField(
model_name='itsassetmodel',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='itsgeoasset',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='itsgeoasset',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='itsgeoasset',
name='model',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='assets.itsassetmodel'),
),
migrations.AddField(
model_name='itsgeoasset',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.itsgeoasset', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='itsgeoasset',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='itsgeoassetcontract',
name='asset',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='its_assets_contracts', to='assets.itsgeoasset'),
),
migrations.AddField(
model_name='itsgeoassetcontract',
name='contract',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='its_assets_contracts', to='contracts.contract'),
),
migrations.AddField(
model_name='itslocation',
name='parent_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_locations', to='assets.itslocation'),
),
migrations.AddField(
model_name='itslocation',
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.AddField(
model_name='itsgeoasset',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assets', to='assets.itslocation'),
),
migrations.AddField(
model_name='itslocationcontract',
name='contract',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='its_locations_contracts', to='contracts.contract'),
),
migrations.AddField(
model_name='itslocationcontract',
name='location',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='its_locations_contracts', to='assets.itslocation'),
),
migrations.AddField(
model_name='itslocationdocument',
name='location',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.itslocation', verbose_name='Associated ITS Location'),
),
migrations.AddField(
model_name='itslocationdocument',
name='managed_document',
field=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'),
),
migrations.AddField(
model_name='itslocationdocument',
name='uploaded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par'),
),
migrations.AddField(
model_name='natureassetmodel',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='naturebench',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='naturebench',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='naturebench',
name='model',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nature_benches', to='assets.natureassetmodel'),
),
migrations.AddField(
model_name='naturebench',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.naturebench', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='naturebench',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='naturegreensurface',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='naturegreensurface',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='naturegreensurface',
name='model',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nature_green_surfaces', to='assets.natureassetmodel'),
),
migrations.AddField(
model_name='naturegreensurface',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.naturegreensurface', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='naturegreensurface',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='naturegreensurfacedocument',
name='green_surface',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.naturegreensurface', verbose_name='Associated Surface'),
),
migrations.AddField(
model_name='naturegreensurfacedocument',
name='managed_document',
field=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'),
),
migrations.AddField(
model_name='naturegreensurfacedocument',
name='uploaded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par'),
),
migrations.AddField(
model_name='naturehollowgreenspace',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='naturehollowgreenspace',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='naturehollowgreenspace',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.naturehollowgreenspace', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='naturehollowgreenspace',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='naturelocation',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='naturelocation',
name='municipality',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='common.municipality'),
),
migrations.AddField(
model_name='naturelocation',
name='parent_location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_locations', to='assets.naturelocation'),
),
migrations.AddField(
model_name='naturelocation',
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.AddField(
model_name='naturegreensurface',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nature_green_surfaces', to='assets.naturelocation'),
),
migrations.AddField(
model_name='naturebench',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nature_benches', to='assets.naturelocation'),
),
migrations.AddField(
model_name='naturerwiz',
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.AddField(
model_name='naturehollowgreenspace',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hollow_green_spaces', to='assets.naturerwiz'),
),
migrations.AddField(
model_name='natureshrub',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='natureshrub',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nature_shrubs', to='assets.naturelocation'),
),
migrations.AddField(
model_name='natureshrub',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='natureshrub',
name='model',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nature_shrubs', to='assets.natureassetmodel'),
),
migrations.AddField(
model_name='natureshrub',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.natureshrub', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='natureshrub',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='naturetree',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='naturetree',
name='location',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nature_trees', to='assets.naturelocation'),
),
migrations.AddField(
model_name='naturetree',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='naturetree',
name='model',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nature_trees', to='assets.natureassetmodel'),
),
migrations.AddField(
model_name='naturetree',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.naturetree', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='naturetree',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='publiclightingelectricalcabinet',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='publiclightingelectricalcabinet',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='publiclightingelectricalcabinet',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.publiclightingelectricalcabinet', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='publiclightingelectricalcabinet',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='publiclightingelectricalcabinetmodel',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='publiclightingelectricalcabinet',
name='model',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='assets.publiclightingelectricalcabinetmodel'),
),
migrations.AddField(
model_name='publiclightinglightfixture',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='publiclightinglightfixture',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='publiclightinglightfixture',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.publiclightinglightfixture', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='publiclightinglightfixture',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='publiclightinglightfixturemodel',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='publiclightinglightfixture',
name='model',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='assets.publiclightinglightfixturemodel'),
),
migrations.AddField(
model_name='publiclightingpole',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='publiclightingpole',
name='marked_as_removed_by',
field=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'),
),
migrations.AddField(
model_name='publiclightingpole',
name='replaced_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='replaces', to='assets.publiclightingpole', verbose_name='Remplacé par'),
),
migrations.AddField(
model_name='publiclightingpole',
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_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Mis à jour par'),
),
migrations.AddField(
model_name='publiclightinglightfixture',
name='pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='light_fixtures', to='assets.publiclightingpole'),
),
migrations.AddField(
model_name='itsgeoasset',
name='lighting_pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='its_assets', to='assets.publiclightingpole', verbose_name='Lighting Pole'),
),
migrations.AddField(
model_name='publiclightingpolemodel',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='publiclightingpole',
name='model',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='assets.publiclightingpolemodel'),
),
migrations.AddField(
model_name='publiclightingstreet',
name='municipality',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='publiclighting_streets', to='common.municipality'),
),
migrations.AddField(
model_name='publiclightingstreet',
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.AddField(
model_name='publiclightingpole',
name='street',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='poles', to='assets.publiclightingstreet'),
),
migrations.AddField(
model_name='publiclightinglightfixture',
name='street',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='light_fixtures', to='assets.publiclightingstreet'),
),
migrations.AddField(
model_name='publiclightingelectricalcabinet',
name='street',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='electrical_cabinets', to='assets.publiclightingstreet'),
),
migrations.AddField(
model_name='publiclightingstreetdocument',
name='managed_document',
field=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'),
),
migrations.AddField(
model_name='publiclightingstreetdocument',
name='street',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.publiclightingstreet', verbose_name='Associated Street'),
),
migrations.AddField(
model_name='publiclightingstreetdocument',
name='uploaded_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Ajouté par'),
),
migrations.AddField(
model_name='roadconcreteblockset',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory'),
),
migrations.AddField(
model_name='roadconcreteblockset',
name='marked_as_removed_by',
field=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'),
),
]

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,188 @@
# Generated by Django 5.2.8 on 2026-05-23 05:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0004_initial'),
]
operations = [
migrations.AlterField(
model_name='artworkfountain',
name='status',
field=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),
),
migrations.AlterField(
model_name='artworkmonument',
name='status',
field=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),
),
migrations.AlterField(
model_name='cleanglasscontainer',
name='status',
field=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),
),
migrations.AlterField(
model_name='cleanlitterbin',
name='status',
field=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),
),
migrations.AlterField(
model_name='controlcentergeoasset',
name='status',
field=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),
),
migrations.AlterField(
model_name='itsgeoasset',
name='status',
field=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),
),
migrations.AlterField(
model_name='naturebench',
name='status',
field=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),
),
migrations.AlterField(
model_name='naturegreensurface',
name='status',
field=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),
),
migrations.AlterField(
model_name='naturehollowgreenspace',
name='status',
field=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),
),
migrations.AlterField(
model_name='natureshrub',
name='status',
field=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),
),
migrations.AlterField(
model_name='naturetree',
name='status',
field=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),
),
migrations.AlterField(
model_name='publiclightingelectricalcabinet',
name='status',
field=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),
),
migrations.AlterField(
model_name='publiclightinglightfixture',
name='status',
field=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),
),
migrations.AlterField(
model_name='publiclightingpole',
name='status',
field=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),
),
migrations.AlterField(
model_name='roadconcreteblockset',
name='status',
field=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),
),
migrations.AlterField(
model_name='roaddrain',
name='status',
field=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),
),
migrations.AlterField(
model_name='roadfurniture',
name='status',
field=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),
),
migrations.AlterField(
model_name='roadsidewalk',
name='status',
field=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),
),
migrations.AlterField(
model_name='roadway',
name='status',
field=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),
),
migrations.AlterField(
model_name='signpanel',
name='status',
field=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),
),
migrations.AlterField(
model_name='signparkingspot',
name='status',
field=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),
),
migrations.AlterField(
model_name='signpole',
name='status',
field=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),
),
migrations.AlterField(
model_name='structuregeoasset',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightaccessory',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightcable',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightcontroller',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightcontrollerhardware',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightdetector',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightelectricalcabinet',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightelectricalcabinetcontent',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightlantern',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightnetworkhardware',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightpole',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightprogramming',
name='status',
field=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),
),
migrations.AlterField(
model_name='trafficlightvoltagehardware',
name='status',
field=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),
),
]

View file

@ -0,0 +1,92 @@
# Generated by Django 5.2.8 on 2026-05-27 09:44
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_alter_artworkfountain_status_and_more'),
('documents', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='NatureGIEPAsset',
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.GeometryField(blank=True, null=True, srid=3812)),
('external_id', models.IntegerField(blank=True, null=True, verbose_name='External ID')),
('code_name', models.CharField(blank=True, choices=[('AV', 'Stormwater inlet (Avaloir)'), ('BS', 'Sequenced border (Bordure séquencée)'), ('BI', "Injection port (Bouche d'injection)"), ('CA', 'Non-return valve (Clapet anti-retour)'), ('ES', 'Sedimentation (Empierrement sédimentation)'), ('KC', 'Kerb cell'), ('PI', "Infiltration well (Puits d'infiltration)"), ('SU', 'Overflow (Surverse)'), ('VI', "Isolation valve (Vanne d'isolation)"), ('VR', 'Control valve (Vanne de régulation)'), ('GT', 'Sidewalk gargoyle (Gargouille de trottoir)'), ('FE', "Trickle of water (Filet d'eau)"), ('AR', 'Rail drain (Avaloir de rail)'), ('BA', 'Leveled edge (Bordure arasée)'), ('CI', 'Infiltration channel (Caniveau infiltrant)'), ('D', 'Drain'), ('TP', 'Overflowing (Trop plein)'), ('RES', 'Separate sewer system (Réseau égout séparatif)'), ('EVC', 'Hollow green space (Espace vert creux)'), ('OED', 'Decentralized underground structure (Ouvrage enterré décentralisé)'), ('OSI', 'Work on an impermeable surface (Ouvrage sur surface imperméable)'), ('TA', 'Alternative technique (Technique alternative)'), ('OA', 'Supplementary work (Ouvrage annexe)')], max_length=50, null=True, verbose_name='Code name')),
('structure_type', models.CharField(blank=True, choices=[('avaloir', 'Stormwater inlet (Avaloir)'), ('bordure sequencee', 'Sequenced border (Bordure séquencée)'), ('bouche injection', "Injection port (Bouche d'injection)"), ('clapet anti-retour', 'Non-return valve (Clapet anti-retour)'), ('empierrement sedimentation', 'Sedimentation (Empierrement sédimentation)'), ('kerb cell', 'Kerb cell'), ('puits infiltration', "Infiltration well (Puits d'infiltration)"), ('surverse', 'Overflow (Surverse)'), ('vanne isolation', "Isolation valve (Vanne d'isolation)"), ('vanne regulation', 'Control valve (Vanne de régulation)'), ('gargouille de trottoir', 'Sidewalk gargoyle (Gargouille de trottoir)'), ('filet eau', "Trickle of water (Filet d'eau)"), ('avaloir de rail', 'Rail drain (Avaloir de rail)'), ('bordure arasee', 'Leveled edge (Bordure arasée)'), ('caniveau infiltrant', 'Infiltration channel (Caniveau infiltrant)'), ('drain', 'Drain'), ('trop plein', 'Overflowing (Trop plein)'), ('reseau egout separatif', 'Separate sewer system (Réseau égout séparatif)'), ('bassin sec/infiltration', 'Dry/infiltration basin (Bassin sec/infiltration)'), ('bassin en eau', 'Wet basin (Bassin en eau)'), ('fosse', 'Ditch (Fossé)'), ('jardin de pluie', 'Rain garden (Jardin de pluie)'), ('noue', 'Swale (Noue)'), ('massif drainant', 'Draining structure (Massif drainant)'), ('tranchee drainante', 'Draining trench (Tranchée drainante)'), ('toiture stockante', 'Retention roof (Toiture stockante)'), ('toiture verte', 'Green roof (Toiture verte)'), ('bassin orage', "Stormwater basin (Bassin d'orage)"), ('SAUL-vide', 'SAUL-empty (SAUL-vide)'), ('SAUL-terre', 'SAUL-earth (SAUL-terre)'), ('citerne', 'Cistern (Citerne)'), ('espace vert sur dalle', 'Green space on slab (Espace vert sur dalle)'), ('bande filtrante', 'Filter strip (Bande filtrante)'), ('collecteur en beton poreux', 'Porous concrete collector (Collecteur en béton poreux)')], max_length=100, null=True, verbose_name='Structure type')),
('project_manager', models.CharField(blank=True, choices=[('DPV', 'DPV'), ('DITP', 'DITP'), ('DEN', 'DEN'), ('DPO', 'DPO'), ('Beliris', 'Beliris'), ('Commune', 'Commune')], max_length=50, null=True, verbose_name='Project manager')),
('location_precision', models.CharField(blank=True, choices=[('precise', 'Precise (Précise)'), ('supposee', 'Assumed (Supposée)')], max_length=50, null=True, verbose_name='Location precision')),
('construction_status', models.CharField(blank=True, choices=[('etude', 'Design (Étude)'), ('travaux', 'Construction (Travaux)'), ('reception provisoire', 'Provisional acceptance (Réception provisoire)'), ('reception definitive', 'Final acceptance (Réception définitive)')], max_length=100, null=True, verbose_name='Construction status')),
('commissioning_date', models.DateField(blank=True, null=True, verbose_name='Commissioning date')),
('condition', models.CharField(blank=True, choices=[('bon', 'Good (Bon)'), ('examen a programmer', 'Inspection to schedule (Examen à programmer)'), ('intervention curative necessaire', 'Curative action needed (Intervention curative nécessaire)'), ('refection totale necessaire', 'Total rehabilitation needed (Réfection totale nécessaire)'), ('travaux entretien a realiser', 'Maintenance works to perform (Travaux entretien à réaliser)'), ('travaux maintenance', 'Maintenance works (Travaux maintenance)'), ('travaux maintenance a programmer', 'Maintenance works to schedule (Travaux maintenance à programmer)')], max_length=100, null=True, verbose_name='Condition')),
('administrator', models.CharField(blank=True, max_length=500, null=True, verbose_name='Administrator')),
('monitoring', models.CharField(blank=True, max_length=500, null=True, verbose_name='Monitoring')),
('comment', models.TextField(blank=True, null=True, verbose_name='Comment')),
('has_filter', models.BooleanField(blank=True, null=True, verbose_name='Has filter')),
('sewer_connection', models.CharField(blank=True, choices=[('connecte', 'Connected (Connecté)'), ('non connecte', 'Not connected (Non connecté)')], max_length=50, null=True, verbose_name='Sewer connection')),
('separate_network_connection', models.CharField(blank=True, choices=[('connecte', 'Connected (Connecté)'), ('non connecte', 'Not connected (Non connecté)')], max_length=50, null=True, verbose_name='Separate network connection')),
('hydro_network_connection', models.CharField(blank=True, choices=[('connecte', 'Connected (Connecté)'), ('non connecte', 'Not connected (Non connecté)')], max_length=50, null=True, verbose_name='Hydro network connection')),
('surface_area', models.FloatField(blank=True, null=True, verbose_name='Surface area (m²)')),
('length', models.FloatField(blank=True, null=True, verbose_name='Length (m)')),
('coating_category', models.CharField(blank=True, choices=[('Vegetal', 'Vegetal (Végétal)'), ('Structure enherbee', 'Grassed structure (Structure enherbée)'), ('Materiau non agrege', 'Unbound material (Matériau non agrégé)'), ('Structure a remplissage mineral', 'Mineral-filled structure (Structure à remplissage minéral)'), ('Revetement sureleve', 'Raised coating (Revêtement surélevé)'), ('Modulaire poreux', 'Porous modular (Modulaire poreux)'), ('Continu poreux', 'Porous continuous (Continu poreux)'), ('Impermeable', 'Impermeable (Imperméable)')], max_length=100, null=True, verbose_name='Coating category')),
('coating_type', models.CharField(blank=True, choices=[('Pleine terre', 'Open soil (Pleine terre)'), ('Melange terre-pierre', 'Soil-stone mixture (Mélange terre-pierre)'), ('Paves a joints larges enherbes', 'Grassed wide-joint pavers (Pavés à joints larges enherbés)'), ('Dalles gazon en beton', 'Concrete grass grids (Dalles gazon en béton)'), ('Dalles alveolaires vegetalisees', 'Vegetated cellular tiles (Dalles alvéolaires végétalisées)'), ('Mulch vegetal', 'Woodchips/mulch (Mulch végétal)'), ('Graviers calcaires', 'Limestone gravel (Graviers calcaires)'), ('Graviers non calcaires', 'Non-limestone gravel (Graviers non calcaires)'), ('Terre battue', 'Dirt ground (Terre battue)'), ('Paves a joints larges engravillonnes', 'Gravel wide-joint pavers (Pavés à joints larges engravillonnés)'), ('Dalles alveolaires en gravier', 'Gravel cellular tiles (Dalles alvéolaires en gravier)'), ('Dalles en PEHD remplies de pave', 'HDPE tiles filled with pavers (Dalles en PEHD remplies de pavé)'), ('Grilles', 'Grates (Grilles)'), ('Platelages bois', 'Wooden decking (Platelages bois)'), ('Pave en beton poreux', 'Porous concrete pavers (Pavé en béton poreux)'), ('Dalle en beton poreux', 'Porous concrete slab (Dalle en béton poreux)'), ('Enrobes bitumineux poreux', 'Porous asphalt (Enrobés bitumineux poreux)'), ('Betons poreux', 'Porous concrete (Bétons poreux)'), ('Graviers lies a la resine', 'Resin-bound gravel (Graviers liés à la résine)'), ('Graviers concasses lies aux polymeres', 'Polymer-bound crushed gravel (Graviers concassés liés aux polymères)'), ('Revetements EPDM et ESBR', 'EPDM and SBR rubber coatings (Revêtements EPDM et ESBR)'), ('Revetements en beton de ciment non permeables', 'Non-porous cement concrete coatings (Revêtements en béton de ciment non perméables)'), ('Revetements bitumineux non permeables', 'Non-porous asphalt coatings (Revêtements bitumineux non perméables)'), ('Dallages non permeables', 'Non-porous tiling (Dallages non perméables)'), ('Pavages non permeables', 'Non-porous paving (Pavages non perméables)'), ('Autres revetements non permeables', 'Other non-porous coatings (Autres revêtements non perméables)')], max_length=100, null=True, verbose_name='Coating type')),
('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.naturegiepasset', 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': 'GIEP Asset',
'verbose_name_plural': 'GIEP Assets',
},
),
migrations.CreateModel(
name='NatureGIEPAssetDocument',
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/nature/giep/', verbose_name='File')),
('thumbnail', models.ImageField(blank=True, max_length=255, null=True, storage=common.storages.PrivateMediaStorage(), upload_to='assets_documents/nature/giep/')),
('giep_asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.naturegiepasset', verbose_name='GIEP Asset')),
('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': 'GIEP Asset Document',
'verbose_name_plural': 'GIEP Asset Documents',
},
),
]

View file

@ -0,0 +1,111 @@
# Generated by Django 5.2.8 on 2026-05-27 09:50
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_naturegiepasset_naturegiepassetdocument'),
('documents', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RemoveField(
model_name='naturegiepassetdocument',
name='giep_asset',
),
migrations.RemoveField(
model_name='naturegiepassetdocument',
name='managed_document',
),
migrations.RemoveField(
model_name='naturegiepassetdocument',
name='uploaded_by',
),
migrations.CreateModel(
name='NatureRWIAsset',
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.GeometryField(blank=True, null=True, srid=3812)),
('external_id', models.IntegerField(blank=True, null=True, verbose_name='External ID')),
('code_name', models.CharField(blank=True, choices=[('AV', 'Stormwater inlet (Avaloir)'), ('BS', 'Sequenced border (Bordure séquencée)'), ('BI', "Injection port (Bouche d'injection)"), ('CA', 'Non-return valve (Clapet anti-retour)'), ('ES', 'Sedimentation (Empierrement sédimentation)'), ('KC', 'Kerb cell'), ('PI', "Infiltration well (Puits d'infiltration)"), ('SU', 'Overflow (Surverse)'), ('VI', "Isolation valve (Vanne d'isolation)"), ('VR', 'Control valve (Vanne de régulation)'), ('GT', 'Sidewalk gargoyle (Gargouille de trottoir)'), ('FE', "Trickle of water (Filet d'eau)"), ('AR', 'Rail drain (Avaloir de rail)'), ('BA', 'Leveled edge (Bordure arasée)'), ('CI', 'Infiltration channel (Caniveau infiltrant)'), ('D', 'Drain'), ('TP', 'Overflowing (Trop plein)'), ('RES', 'Separate sewer system (Réseau égout séparatif)'), ('EVC', 'Hollow green space (Espace vert creux)'), ('OED', 'Decentralized underground structure (Ouvrage enterré décentralisé)'), ('OSI', 'Work on an impermeable surface (Ouvrage sur surface imperméable)'), ('TA', 'Alternative technique (Technique alternative)'), ('OA', 'Supplementary work (Ouvrage annexe)')], max_length=50, null=True, verbose_name='Code name')),
('structure_type', models.CharField(blank=True, choices=[('avaloir', 'Stormwater inlet (Avaloir)'), ('bordure sequencee', 'Sequenced border (Bordure séquencée)'), ('bouche injection', "Injection port (Bouche d'injection)"), ('clapet anti-retour', 'Non-return valve (Clapet anti-retour)'), ('empierrement sedimentation', 'Sedimentation (Empierrement sédimentation)'), ('kerb cell', 'Kerb cell'), ('puits infiltration', "Infiltration well (Puits d'infiltration)"), ('surverse', 'Overflow (Surverse)'), ('vanne isolation', "Isolation valve (Vanne d'isolation)"), ('vanne regulation', 'Control valve (Vanne de régulation)'), ('gargouille de trottoir', 'Sidewalk gargoyle (Gargouille de trottoir)'), ('filet eau', "Trickle of water (Filet d'eau)"), ('avaloir de rail', 'Rail drain (Avaloir de rail)'), ('bordure arasee', 'Leveled edge (Bordure arasée)'), ('caniveau infiltrant', 'Infiltration channel (Caniveau infiltrant)'), ('drain', 'Drain'), ('trop plein', 'Overflowing (Trop plein)'), ('reseau egout separatif', 'Separate sewer system (Réseau égout séparatif)'), ('bassin sec/infiltration', 'Dry/infiltration basin (Bassin sec/infiltration)'), ('bassin en eau', 'Wet basin (Bassin en eau)'), ('fosse', 'Ditch (Fossé)'), ('jardin de pluie', 'Rain garden (Jardin de pluie)'), ('noue', 'Swale (Noue)'), ('massif drainant', 'Draining structure (Massif drainant)'), ('tranchee drainante', 'Draining trench (Tranchée drainante)'), ('toiture stockante', 'Retention roof (Toiture stockante)'), ('toiture verte', 'Green roof (Toiture verte)'), ('bassin orage', "Stormwater basin (Bassin d'orage)"), ('SAUL-vide', 'SAUL-empty (SAUL-vide)'), ('SAUL-terre', 'SAUL-earth (SAUL-terre)'), ('citerne', 'Cistern (Citerne)'), ('espace vert sur dalle', 'Green space on slab (Espace vert sur dalle)'), ('bande filtrante', 'Filter strip (Bande filtrante)'), ('collecteur en beton poreux', 'Porous concrete collector (Collecteur en béton poreux)')], max_length=100, null=True, verbose_name='Structure type')),
('geometry_type', models.CharField(blank=True, choices=[('point', 'Point'), ('line', 'Line'), ('polygon', 'Polygon')], max_length=50, null=True, verbose_name='Geometry type')),
('project_manager', models.CharField(blank=True, choices=[('DPV', 'DPV'), ('DITP', 'DITP'), ('DEN', 'DEN'), ('DPO', 'DPO'), ('Beliris', 'Beliris'), ('Commune', 'Commune')], max_length=50, null=True, verbose_name='Project manager')),
('location_precision', models.CharField(blank=True, choices=[('precise', 'Precise (Précise)'), ('supposee', 'Assumed (Supposée)')], max_length=50, null=True, verbose_name='Location precision')),
('construction_status', models.CharField(blank=True, choices=[('etude', 'Design (Étude)'), ('travaux', 'Construction (Travaux)'), ('reception provisoire', 'Provisional acceptance (Réception provisoire)'), ('reception definitive', 'Final acceptance (Réception définitive)')], max_length=100, null=True, verbose_name='Construction status')),
('commissioning_date', models.DateField(blank=True, null=True, verbose_name='Commissioning date')),
('condition', models.CharField(blank=True, choices=[('bon', 'Good (Bon)'), ('examen a programmer', 'Inspection to schedule (Examen à programmer)'), ('intervention curative necessaire', 'Curative action needed (Intervention curative nécessaire)'), ('refection totale necessaire', 'Total rehabilitation needed (Réfection totale nécessaire)'), ('travaux entretien a realiser', 'Maintenance works to perform (Travaux entretien à réaliser)'), ('travaux maintenance', 'Maintenance works (Travaux maintenance)'), ('travaux maintenance a programmer', 'Maintenance works to schedule (Travaux maintenance à programmer)')], max_length=100, null=True, verbose_name='Condition')),
('administrator', models.CharField(blank=True, max_length=500, null=True, verbose_name='Administrator')),
('monitoring', models.CharField(blank=True, max_length=500, null=True, verbose_name='Monitoring')),
('comment', models.TextField(blank=True, null=True, verbose_name='Comment')),
('has_filter', models.BooleanField(blank=True, null=True, verbose_name='Has filter')),
('sewer_connection', models.CharField(blank=True, choices=[('connecte', 'Connected (Connecté)'), ('non connecte', 'Not connected (Non connecté)')], max_length=50, null=True, verbose_name='Sewer connection')),
('separate_network_connection', models.CharField(blank=True, choices=[('connecte', 'Connected (Connecté)'), ('non connecte', 'Not connected (Non connecté)')], max_length=50, null=True, verbose_name='Separate network connection')),
('hydro_network_connection', models.CharField(blank=True, choices=[('connecte', 'Connected (Connecté)'), ('non connecte', 'Not connected (Non connecté)')], max_length=50, null=True, verbose_name='Hydro network connection')),
('surface_area', models.FloatField(blank=True, null=True, verbose_name='Surface area (m²)')),
('length', models.FloatField(blank=True, null=True, verbose_name='Length (m)')),
('coating_category', models.CharField(blank=True, choices=[('Vegetal', 'Vegetal (Végétal)'), ('Structure enherbee', 'Grassed structure (Structure enherbée)'), ('Materiau non agrege', 'Unbound material (Matériau non agrégé)'), ('Structure a remplissage mineral', 'Mineral-filled structure (Structure à remplissage minéral)'), ('Revetement sureleve', 'Raised coating (Revêtement surélevé)'), ('Modulaire poreux', 'Porous modular (Modulaire poreux)'), ('Continu poreux', 'Porous continuous (Continu poreux)'), ('Impermeable', 'Impermeable (Imperméable)')], max_length=100, null=True, verbose_name='Coating category')),
('coating_type', models.CharField(blank=True, choices=[('Pleine terre', 'Open soil (Pleine terre)'), ('Melange terre-pierre', 'Soil-stone mixture (Mélange terre-pierre)'), ('Paves a joints larges enherbes', 'Grassed wide-joint pavers (Pavés à joints larges enherbés)'), ('Dalles gazon en beton', 'Concrete grass grids (Dalles gazon en béton)'), ('Dalles alveolaires vegetalisees', 'Vegetated cellular tiles (Dalles alvéolaires végétalisées)'), ('Mulch vegetal', 'Woodchips/mulch (Mulch végétal)'), ('Graviers calcaires', 'Limestone gravel (Graviers calcaires)'), ('Graviers non calcaires', 'Non-limestone gravel (Graviers non calcaires)'), ('Terre battue', 'Dirt ground (Terre battue)'), ('Paves a joints larges engravillonnes', 'Gravel wide-joint pavers (Pavés à joints larges engravillonnés)'), ('Dalles alveolaires en gravier', 'Gravel cellular tiles (Dalles alvéolaires en gravier)'), ('Dalles en PEHD remplies de pave', 'HDPE tiles filled with pavers (Dalles en PEHD remplies de pavé)'), ('Grilles', 'Grates (Grilles)'), ('Platelages bois', 'Wooden decking (Platelages bois)'), ('Pave en beton poreux', 'Porous concrete pavers (Pavé en béton poreux)'), ('Dalle en beton poreux', 'Porous concrete slab (Dalle en béton poreux)'), ('Enrobes bitumineux poreux', 'Porous asphalt (Enrobés bitumineux poreux)'), ('Betons poreux', 'Porous concrete (Bétons poreux)'), ('Graviers lies a la resine', 'Resin-bound gravel (Graviers liés à la résine)'), ('Graviers concasses lies aux polymeres', 'Polymer-bound crushed gravel (Graviers concassés liés aux polymères)'), ('Revetements EPDM et ESBR', 'EPDM and SBR rubber coatings (Revêtements EPDM et ESBR)'), ('Revetements en beton de ciment non permeables', 'Non-porous cement concrete coatings (Revêtements en béton de ciment non perméables)'), ('Revetements bitumineux non permeables', 'Non-porous asphalt coatings (Revêtements bitumineux non perméables)'), ('Dallages non permeables', 'Non-porous tiling (Dallages non perméables)'), ('Pavages non permeables', 'Non-porous paving (Pavages non perméables)'), ('Autres revetements non permeables', 'Other non-porous coatings (Autres revêtements non perméables)')], max_length=100, null=True, verbose_name='Coating type')),
('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.naturerwiasset', 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': 'RWI Asset',
'verbose_name_plural': 'RWI Assets',
},
),
migrations.CreateModel(
name='NatureRWIAssetDocument',
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/nature/rwi/', verbose_name='File')),
('thumbnail', models.ImageField(blank=True, max_length=255, null=True, storage=common.storages.PrivateMediaStorage(), upload_to='assets_documents/nature/rwi/')),
('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')),
('rwi_asset', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='assets.naturerwiasset', verbose_name='RWI Asset')),
('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': 'RWI Asset Document',
'verbose_name_plural': 'RWI Asset Documents',
},
),
migrations.DeleteModel(
name='NatureGIEPAsset',
),
migrations.DeleteModel(
name='NatureGIEPAssetDocument',
),
]

View file

@ -0,0 +1,16 @@
# Generated by Django 5.2.8 on 2026-05-27 12:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('assets', '0007_remove_naturegiepassetdocument_giep_asset_and_more'),
]
operations = [
migrations.DeleteModel(
name='NatureHollowGreenSpace',
),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 5.2.8 on 2026-05-30 13:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0008_delete_naturehollowgreenspace'),
]
operations = [
migrations.AddField(
model_name='signpaneltype',
name='default_diameter',
field=models.FloatField(blank=True, null=True, verbose_name='Diamètre par défaut (cm)'),
),
migrations.AddField(
model_name='signpaneltype',
name='default_height',
field=models.FloatField(blank=True, null=True, verbose_name='Hauteur par défaut (cm)'),
),
migrations.AddField(
model_name='signpaneltype',
name='default_width',
field=models.FloatField(blank=True, null=True, verbose_name='Largeur par défaut (cm)'),
),
]

View file

@ -0,0 +1,71 @@
# Generated by Django 5.2.8 on 2026-06-22 08:31
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', '0009_signpaneltype_default_diameter_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='TrafficLightRadarModel',
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)),
('description', models.TextField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
],
options={
'verbose_name': 'Traffic Light Radar Model',
'verbose_name_plural': 'Traffic Light Radar Models',
},
),
migrations.CreateModel(
name='TrafficLightRadar',
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)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('intersection', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='radars', to='assets.trafficlightintersection')),
('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.trafficlightradar', 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')),
('model', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='assets.trafficlightradarmodel')),
],
options={
'verbose_name': 'Radar',
'verbose_name_plural': 'Radars',
},
),
]

View file

@ -0,0 +1,44 @@
from django.db import migrations
def create_radar_preventif_category(apps, schema_editor):
Thematic = apps.get_model('common', 'Thematic')
AssetCategory = apps.get_model('assets', 'AssetCategory')
# Get trafficlights thematic
try:
tl_thematic = Thematic.objects.get(code='trafficlights')
except Thematic.DoesNotExist:
tl_thematic = Thematic.objects.create(
code='trafficlights',
name_fr='Feux de signalisation',
name_nl='Verkeerslichten'
)
# Get or create radar_preventif category
category, created = AssetCategory.objects.get_or_create(
code='TL_RADAR_PREVENTIF',
defaults={
'name_fr': 'Radar Préventif',
'name_nl': 'Preventieve radar',
'thematic': tl_thematic
}
)
if not created and category.thematic != tl_thematic:
category.thematic = tl_thematic
category.save()
def reverse_radar_preventif_category(apps, schema_editor):
AssetCategory = apps.get_model('assets', 'AssetCategory')
AssetCategory.objects.filter(code='TL_RADAR_PREVENTIF').delete()
class Migration(migrations.Migration):
dependencies = [
('assets', '0010_trafficlightradarmodel_trafficlightradar'),
('common', '0001_initial'),
]
operations = [
migrations.RunPython(create_radar_preventif_category, reverse_radar_preventif_category),
]

View file

@ -0,0 +1,28 @@
# Generated by Django 5.2.8 on 2026-06-22 12:43
from django.db import migrations, models
def populate_is_radar(apps, schema_editor):
TrafficLightIntersection = apps.get_model('assets', 'TrafficLightIntersection')
TrafficLightRadar = apps.get_model('assets', 'TrafficLightRadar')
# Intersections linked to at least one radar are radar intersections
radar_intersection_ids = TrafficLightRadar.objects.filter(intersection__isnull=False).values_list('intersection_id', flat=True).distinct()
TrafficLightIntersection.objects.filter(id__in=radar_intersection_ids).update(is_radar=True)
class Migration(migrations.Migration):
dependencies = [
('assets', '0011_create_radar_preventif_category'),
]
operations = [
migrations.AddField(
model_name='trafficlightintersection',
name='is_radar',
field=models.BooleanField(default=False, verbose_name='Est un radar ?'),
),
migrations.RunPython(populate_is_radar, migrations.RunPython.noop),
]

View file

@ -0,0 +1,169 @@
from django.db import migrations
def update_rwi_codes(apps, schema_editor):
NatureRWIAsset = apps.get_model('assets', 'NatureRWIAsset')
# We try-catch the GuidedOperationData import because it's in another app,
# and we want to ensure robust execution.
try:
GuidedOperationData = apps.get_model('interventions', 'GuidedOperationData')
except LookupError:
GuidedOperationData = None
# Mappings from structure type to acronym
mappings = {
'avaloir': 'AV',
'bordure sequencee': 'BS',
'bouche injection': 'BI',
'clapet anti-retour': 'CA',
'empierrement sedimentation': 'ES',
'kerb cell': 'KC',
'puits infiltration': 'PI',
'surverse': 'SU',
'vanne isolation': 'VI',
'vanne regulation': 'VR',
'gargouille de trottoir': 'GT',
'filet eau': 'FE',
'avaloir de rail': 'AR',
'bordure arasee': 'BA',
'caniveau infiltrant': 'CI',
'drain': 'D',
'trop plein': 'TP',
'reseau egout separatif': 'RES',
'bassin sec/infiltration': 'OED',
'bassin en eau': 'OED',
'fosse': 'EVC',
'jardin de pluie': 'EVC',
'noue': 'EVC',
'massif drainant': 'OED',
'tranchee drainante': 'OED',
'toiture stockante': 'OSI',
'toiture verte': 'OSI',
'bassin orage': 'OED',
'SAUL-vide': 'OED',
'SAUL-terre': 'TA',
'citerne': 'OED',
'espace vert sur dalle': 'EVC',
'bande filtrante': 'EVC',
'collecteur en beton poreux': 'OED',
'chambre de visite': 'CV',
}
code_updates = {}
for asset in NatureRWIAsset.objects.all():
old_code = asset.code
# Calculate acronym
struct_type_lower = asset.structure_type.strip().lower() if asset.structure_type else None
acronym = None
if asset.code_name:
acronym = asset.code_name.strip().upper()
elif struct_type_lower in mappings:
acronym = mappings[struct_type_lower]
elif asset.geometry_type:
acronym = asset.geometry_type.upper()
else:
acronym = 'UNK'
fid = asset.external_id or asset.id
new_code = f"RWI_{acronym}_{fid:03d}"
if old_code != new_code:
asset.code = new_code
if asset.name_fr == old_code:
asset.name_fr = new_code
if asset.name_nl == old_code:
asset.name_nl = new_code
asset.save(update_fields=['code', 'name_fr', 'name_nl'])
code_updates[old_code] = new_code
# Update GuidedOperationData JSON references
if GuidedOperationData and code_updates:
for gd in GuidedOperationData.objects.all():
modified = False
data = gd.data
if not isinstance(data, dict):
continue
def replace_codes(obj):
nonlocal modified
if isinstance(obj, dict):
for k, v in list(obj.items()):
if k == 'rwi_asset_code' and v in code_updates:
obj[k] = code_updates[v]
modified = True
else:
replace_codes(v)
elif isinstance(obj, list):
for item in obj:
replace_codes(item)
replace_codes(data)
if modified:
gd.data = data
gd.save(update_fields=['data'])
def reverse_rwi_codes(apps, schema_editor):
NatureRWIAsset = apps.get_model('assets', 'NatureRWIAsset')
try:
GuidedOperationData = apps.get_model('interventions', 'GuidedOperationData')
except LookupError:
GuidedOperationData = None
code_updates_reverse = {}
for asset in NatureRWIAsset.objects.all():
old_code = asset.code
# Calculate old format: RWI_{geometry_type.upper()}_{external_id:03d}
geom_type_upper = asset.geometry_type.upper() if asset.geometry_type else 'POLYGON'
fid = asset.external_id or asset.id
original_code = f"RWI_{geom_type_upper}_{fid:03d}"
if old_code != original_code:
asset.code = original_code
if asset.name_fr == old_code:
asset.name_fr = original_code
if asset.name_nl == old_code:
asset.name_nl = original_code
asset.save(update_fields=['code', 'name_fr', 'name_nl'])
code_updates_reverse[old_code] = original_code
# Revert GuidedOperationData JSON references
if GuidedOperationData and code_updates_reverse:
for gd in GuidedOperationData.objects.all():
modified = False
data = gd.data
if not isinstance(data, dict):
continue
def replace_codes(obj):
nonlocal modified
if isinstance(obj, dict):
for k, v in list(obj.items()):
if k == 'rwi_asset_code' and v in code_updates_reverse:
obj[k] = code_updates_reverse[v]
modified = True
else:
replace_codes(v)
elif isinstance(obj, list):
for item in obj:
replace_codes(item)
replace_codes(data)
if modified:
gd.data = data
gd.save(update_fields=['data'])
class Migration(migrations.Migration):
dependencies = [
('assets', '0012_trafficlightintersection_is_radar'),
]
operations = [
migrations.RunPython(update_rwi_codes, reverse_rwi_codes),
]

View file

@ -0,0 +1,17 @@
# Generated by Django 5.2.8 on 2026-06-25 12:13
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('assets', '0013_update_rwi_assets_codification'),
]
operations = [
migrations.RemoveField(
model_name='naturerwiasset',
name='location_precision',
),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 5.2.8 on 2026-06-26 12:23
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0014_remove_nature_rwi_asset_location_precision'),
]
operations = [
migrations.AddField(
model_name='trafficlightradar',
name='pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='radars', to='assets.publiclightingpole'),
),
]

View file

@ -0,0 +1,91 @@
# Generated by Django 5.2.8 on 2026-06-26 12:41
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', '0015_trafficlightradar_pole'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='trafficlightradar',
name='publiclighting_pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='radars', to='assets.publiclightingpole'),
),
migrations.AddField(
model_name='trafficlightradar',
name='trafficlights_pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='radars', to='assets.trafficlightpole'),
),
migrations.CreateModel(
name='TrafficLightRadarPole',
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)),
('colour', models.CharField(blank=True, max_length=200, null=True, verbose_name='Couleur')),
('ral', models.CharField(blank=True, max_length=200, null=True, verbose_name='RAL')),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
('intersection', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='radar_poles', to='assets.trafficlightintersection')),
('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.trafficlightradarpole', 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': 'Radar Pole',
'verbose_name_plural': 'Radar Poles',
},
),
migrations.AlterField(
model_name='trafficlightradar',
name='pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='radars', to='assets.trafficlightradarpole'),
),
migrations.CreateModel(
name='TrafficLightRadarPoleModel',
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)),
('description', models.TextField(blank=True, null=True)),
('brand', models.CharField(blank=True, max_length=500, null=True)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.assetcategory')),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='trafficlightradarpole',
name='model',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='assets.trafficlightradarpolemodel'),
),
]

View file

@ -0,0 +1,44 @@
from django.db import migrations
def create_radar_pole_category(apps, schema_editor):
Thematic = apps.get_model('common', 'Thematic')
AssetCategory = apps.get_model('assets', 'AssetCategory')
# Get trafficlights thematic
try:
tl_thematic = Thematic.objects.get(code='trafficlights')
except Thematic.DoesNotExist:
tl_thematic = Thematic.objects.create(
code='trafficlights',
name_fr='Feux de signalisation',
name_nl='Verkeerslichten'
)
# Get or create radar_pole category
category, created = AssetCategory.objects.get_or_create(
code='TL_RADAR_POLE',
defaults={
'name_fr': 'Poteau radar',
'name_nl': 'Radarpaal',
'thematic': tl_thematic
}
)
if not created and category.thematic != tl_thematic:
category.thematic = tl_thematic
category.save()
def reverse_radar_pole_category(apps, schema_editor):
AssetCategory = apps.get_model('assets', 'AssetCategory')
AssetCategory.objects.filter(code='TL_RADAR_POLE').delete()
class Migration(migrations.Migration):
dependencies = [
('assets', '0016_trafficlightradar_publiclighting_pole_and_more'),
('common', '0001_initial'),
]
operations = [
migrations.RunPython(create_radar_pole_category, reverse_radar_pole_category),
]

View file

@ -0,0 +1,27 @@
# Generated by Django 5.2.8 on 2026-06-26 16:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0017_create_radar_pole_category'),
('contenttypes', '0002_remove_content_type_name'),
]
operations = [
migrations.AlterModelOptions(
name='publiclightingpole',
options={'verbose_name': 'Public lighting pole', 'verbose_name_plural': 'Public lighting poles'},
),
migrations.AlterModelOptions(
name='trafficlightpole',
options={'verbose_name': 'Traffic light pole', 'verbose_name_plural': 'Traffic light poles'},
),
migrations.AddField(
model_name='assetcategory',
name='allowed_models',
field=models.ManyToManyField(blank=True, help_text="Les types de modèles d'assets (Django) autorisés pour cette catégorie.", related_name='asset_categories', to='contenttypes.contenttype', verbose_name='Modèles Django autorisés'),
),
]

View file

@ -0,0 +1,70 @@
from django.db import migrations
def populate_allowed_models(apps, schema_editor):
AssetCategory = apps.get_model('assets', 'AssetCategory')
ContentType = apps.get_model('contenttypes', 'ContentType')
# Mapping of category codes to Django model name strings (lowercase)
mapping = {
# Traffic lights
'TL_POTEAU': ['trafficlightpole'],
'TL_CABLE': ['trafficlightcable'],
'TL_LANTERNE': ['trafficlightlantern'],
'TL_REGELAAR': ['trafficlightcontroller'],
'TL_ARMOIRE': ['trafficlightelectricalcabinet'],
'TL_DETEC_BP': ['trafficlightdetector'],
'TL_DETEC_RADAR': ['trafficlightdetector'],
'TL_DETEC_VIDEO': ['trafficlightdetector'],
'TL_DETEC_OTHER': ['trafficlightdetector'],
'TL_BORNIER': ['trafficlightaccessory'],
'TL_DIVERS': ['trafficlightaccessory'],
'TL_RADAR_PREVENTIF': ['trafficlightradar'],
'TL_RADAR_POLE': ['trafficlightradarpole'],
# Public lighting
'LI_poles': ['publiclightingpole'],
'LI_lightfixtures': ['publiclightinglightfixture'],
'LI_electrical_cabinets': ['publiclightingelectricalcabinet'],
# Cleanliness
'CL_BASKET': ['cleanlitterbin'],
'CL_BUBBLE': ['cleanglasscontainer'],
# Nature
'NAT_GREEN_AREAS': ['naturegreensurface'],
'NAT_TREES': ['naturetree'],
'NAT_RWI': ['naturerwiasset'],
'NAT_FURNITURES': ['naturebench', 'natureshrub'],
# Artworks
'ART_MONUMENT': ['artworkmonument'],
'ART_FONTAIN': ['artworkfountain'],
}
for category_code, model_names in mapping.items():
try:
category = AssetCategory.objects.get(code=category_code)
for model_name in model_names:
try:
ct = ContentType.objects.get(app_label='assets', model=model_name)
category.allowed_models.add(ct)
except ContentType.DoesNotExist:
print(f"ContentType assets.{model_name} not found.")
except AssetCategory.DoesNotExist:
pass
def clear_allowed_models(apps, schema_editor):
AssetCategory = apps.get_model('assets', 'AssetCategory')
for category in AssetCategory.objects.all():
category.allowed_models.clear()
class Migration(migrations.Migration):
dependencies = [
('assets', '0018_alter_publiclightingpole_options_and_more'),
]
operations = [
migrations.RunPython(populate_allowed_models, clear_allowed_models),
]

View file

@ -0,0 +1,19 @@
# Generated by Django 5.2.8 on 2026-06-27 07:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0019_populate_assetcategory_allowed_models'),
]
operations = [
migrations.AlterField(
model_name='trafficlightradarpole',
name='model',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='assets.trafficlightradarpolemodel'),
),
]

View file

@ -0,0 +1,39 @@
# Generated by Django 5.2.8 on 2026-07-03 09:16
from django.db import migrations
def set_default_validation_status(apps, schema_editor):
model_names = [
'trafficlightpole', 'trafficlightcable', 'trafficlightlantern', 'trafficlightcontroller',
'trafficlightcontrollerhardware', 'trafficlightdetector', 'trafficlightnetworkhardware',
'trafficlightvoltagehardware', 'trafficlightelectricalcabinet', 'trafficlightelectricalcabinetcontent',
'trafficlightaccessory', 'trafficlightprogramming', 'trafficlightradarpole', 'trafficlightradar',
'publiclightingpole', 'publiclightinglightfixture', 'publiclightingelectricalcabinet', 'artworkmonument',
'artworkfountain', 'naturegreensurface', 'naturetree', 'natureshrub', 'naturebench',
'naturerwiasset', 'structuregeoasset', 'controlcentergeoasset', 'signpole', 'signpanel',
'signparkingspot', 'roadway', 'roadsidewalk', 'roadfurniture', 'roadconcreteblockset',
'roaddrain', 'itsgeoasset', 'cleanlitterbin', 'cleanglasscontainer'
]
for model_name in model_names:
try:
Model = apps.get_model('assets', model_name)
# Update all active assets with no validation status to 'validated'
Model.objects.filter(status='active', validation_status__isnull=True).update(validation_status='validated')
Model.objects.filter(status='active', validation_status='').update(validation_status='validated')
except LookupError:
pass
def reverse_default_validation_status(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('assets', '0020_alter_trafficlightradarpole_model'),
]
operations = [
migrations.RunPython(set_default_validation_status, reverse_default_validation_status),
]

View file

@ -0,0 +1,48 @@
# Generated by hand
from django.db import migrations
def sync_trafficlights_documents(apps, schema_editor):
from assets.models import TrafficLightIntersection as ActiveIntersection, TrafficLightIntersectionDocument as ActiveIntersectionDoc
from assets.views.trafficlights import ensure_intersection_document_folder, generate_dxf_overlay_managed_document
from documents.models import ensure_document_for_legacy
for intersection in ActiveIntersection.objects.all():
try:
ensure_intersection_document_folder(intersection)
except Exception as e:
print(f"Error ensuring document folder for intersection {intersection.id}: {e}")
for doc in ActiveIntersectionDoc.objects.all():
try:
import os
if not doc.file or not doc.file.name:
continue
# If the file does not exist on disk, we skip it gracefully
if not os.path.exists(doc.file.path):
print(f"Migration: File not found on disk, skipping sync for {doc.file.name}")
continue
# Sync it to the ManagedDocument system
ensure_document_for_legacy(doc)
# If it's a DXF, generate the overlay
if doc.file.name.lower().endswith('.dxf') and doc.managed_document:
generate_dxf_overlay_managed_document(doc.managed_document, user=doc.uploaded_by)
except Exception as e:
print(f"Error syncing document during migration for {doc.file.name if doc.file else 'None'}: {e}")
def reverse_sync_trafficlights_documents(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('assets', '0021_set_default_validation_status_for_active'),
('documents', '0003_manageddocument_georeference_coords_and_more'),
]
operations = [
migrations.RunPython(sync_trafficlights_documents, reverse_sync_trafficlights_documents),
]

View file

@ -0,0 +1,39 @@
# Generated by Django 5.2.8 on 2026-07-10 09:13
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('assets', '0022_sync_trafficlights_documents'),
]
operations = [
migrations.AddField(
model_name='trafficlightradar',
name='power_publiclighting_pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='powered_radars', to='assets.publiclightingpole', verbose_name='Public Lighting Pole (Power)'),
),
migrations.AddField(
model_name='trafficlightradar',
name='power_trafficlights_pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='powered_radars', to='assets.trafficlightpole', verbose_name='Traffic Light Pole (Power)'),
),
migrations.AlterField(
model_name='trafficlightradar',
name='pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='radars', to='assets.trafficlightradarpole', verbose_name='Radar Pole (Support)'),
),
migrations.AlterField(
model_name='trafficlightradar',
name='publiclighting_pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='radars', to='assets.publiclightingpole', verbose_name='Public Lighting Pole (Support)'),
),
migrations.AlterField(
model_name='trafficlightradar',
name='trafficlights_pole',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='radars', to='assets.trafficlightpole', verbose_name='Traffic Light Pole (Support)'),
),
]

View file

View file

@ -0,0 +1,12 @@
from .core import AbstractLocation, AbstractAsset, AbstractGeoAsset, AbstractAssetModel, AssetCategory, ASSET_STATUS_CHOICES
from .history import AssetHistory
from .trafficlights import TrafficLightIntersection, TrafficLightIntersectionDocument, TrafficLightPoleModel, TrafficLightPole, TrafficLightCableModel, TrafficLightCable, TrafficLightLanternModel, TrafficLightLantern, TrafficLightControllerModel, TrafficLightController, TrafficLightDetectorModel, TrafficLightDetector, TrafficLightContract, TrafficLightElectricalCabinetModel, TrafficLightElectricalCabinet, TrafficLightNetworkHardware, TrafficLightVoltageHardware, TrafficLightControllerHardware, TrafficLightProgramming, TrafficLightRadarModel, TrafficLightRadar, TrafficLightRadarPoleModel, TrafficLightRadarPole, TrafficLightControllerLogFile, TrafficLightElectricalCabinetContentModel, TrafficLightElectricalCabinetContent, TrafficLightAccessoryModel, TrafficLightAccessory
from .publiclighting import PublicLightingStreet, PublicLightingStreetDocument, PublicLightingPoleModel, PublicLightingPole, PublicLightingLightFixtureModel, PublicLightingLightFixture, PublicLightingElectricalCabinetModel, PublicLightingElectricalCabinet
from .artworks import ArtworkMonument, ArtworkFountain, ArtworkFountainDocument, ArtworkMonumentDocument, ArtworkStreet
from .nature import NatureLocation, NatureAssetModel, NatureGreenSurface, NatureGreenSurfaceDocument, NatureTree, NatureShrub, NatureBench, GreenSurfacesComment, GreenSurfacesCommentPhoto, NatureRWIZ, NatureRWIAsset, NatureRWIAssetDocument
from .structures import Structure, StructureLocation, StructureGeoAsset, StructureAssetModel, StructureDocument, StructureContract
from .controlcenters import ControlCenter, ControlCenterLocation, ControlCenterGeoAsset, ControlCenterAssetModel, ControlCenterDocument, ControlCenterContract, ControlCenterGeoAssetContract, ControlCenterAssetDocument
from .sign import SignStreet, SignStreetDocument, SignPole, SignPoleModel, SignPanel, SignPanelModel, SignPanelType, ComplementaryPoliceRegulation, SignParkingSpot
from .roads import RoadStreet, RoadStreetDocument, Roadway, RoadwayModel, RoadSidewalk, RoadSidewalkModel, RoadFurniture, RoadFurnitureModel, RoadwayFunction, RoadConcreteBlockSet, RoadDrainModel, RoadDrain
from .its import ITSLocation, ITSLocationDocument, ITSAssetModel, ITSGeoAsset, ITSGeoAssetContract, ITSLocationContract
from .clean import CleanLocation, CleanLitterBinModel, CleanLitterBin, CleanLitterBinPhoto, CleanLitterBinObservation, CleanGlassContainerModel, CleanGlassContainer, CleanLocationContract

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