Initial commit of clean Core
This commit is contained in:
commit
8c83ff764c
1924 changed files with 559868 additions and 0 deletions
43
.dockerignore
Normal file
43
.dockerignore
Normal 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
105
.github/workflows/deploy.yml
vendored
Normal 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
42
.gitignore
vendored
Normal 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
111
Dockerfile
Normal 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
859
doc/DOCUMENTATION.html
Normal 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>
|
||||
135
docker-compose.panoramax.yml
Normal file
135
docker-compose.panoramax.yml
Normal 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
102
docker-compose.prod.yml
Normal 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
73
docker-compose.yml
Normal 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
11
docker-entrypoint.sh
Normal 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
77
panoramax.env.example
Normal 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
3
requirements/ai.txt
Normal 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
44
requirements/base.txt
Normal 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
82
streetup/.env.example
Normal 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=
|
||||
|
||||
0
streetup/analytics/__init__.py
Normal file
0
streetup/analytics/__init__.py
Normal file
185
streetup/analytics/admin.py
Normal file
185
streetup/analytics/admin.py
Normal 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
|
||||
7
streetup/analytics/apps.py
Normal file
7
streetup/analytics/apps.py
Normal 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'
|
||||
0
streetup/analytics/management/__init__.py
Normal file
0
streetup/analytics/management/__init__.py
Normal file
0
streetup/analytics/management/commands/__init__.py
Normal file
0
streetup/analytics/management/commands/__init__.py
Normal file
204
streetup/analytics/management/commands/cleanup_analytics.py
Normal file
204
streetup/analytics/management/commands/cleanup_analytics.py
Normal 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,
|
||||
}
|
||||
196
streetup/analytics/middleware.py
Normal file
196
streetup/analytics/middleware.py
Normal 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
|
||||
84
streetup/analytics/migrations/0001_initial.py
Normal file
84
streetup/analytics/migrations/0001_initial.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
streetup/analytics/migrations/__init__.py
Normal file
0
streetup/analytics/migrations/__init__.py
Normal file
260
streetup/analytics/models.py
Normal file
260
streetup/analytics/models.py
Normal 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"
|
||||
291
streetup/analytics/templates/analytics/dashboard.html
Normal file
291
streetup/analytics/templates/analytics/dashboard.html
Normal 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 %}
|
||||
48
streetup/analytics/templates/analytics/page_views_list.html
Normal file
48
streetup/analytics/templates/analytics/page_views_list.html
Normal 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 %}
|
||||
56
streetup/analytics/templates/analytics/sessions_list.html
Normal file
56
streetup/analytics/templates/analytics/sessions_list.html
Normal 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
286
streetup/analytics/tests.py
Normal 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)
|
||||
14
streetup/analytics/urls.py
Normal file
14
streetup/analytics/urls.py
Normal 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
224
streetup/analytics/views.py
Normal 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
0
streetup/api/__init__.py
Normal file
92
streetup/api/admin.py
Normal file
92
streetup/api/admin.py
Normal 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
6
streetup/api/apps.py
Normal 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
124
streetup/api/auth.py
Normal 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é")
|
||||
33
streetup/api/migrations/0001_initial.py
Normal file
33
streetup/api/migrations/0001_initial.py
Normal 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',),
|
||||
},
|
||||
),
|
||||
]
|
||||
0
streetup/api/migrations/__init__.py
Normal file
0
streetup/api/migrations/__init__.py
Normal file
61
streetup/api/models.py
Normal file
61
streetup/api/models.py
Normal 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
3
streetup/api/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
30
streetup/api/urls.py
Normal file
30
streetup/api/urls.py
Normal 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
534
streetup/api/views.py
Normal 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("\"", """)}"' 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("\"", """)}"' 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
|
||||
0
streetup/assets/__init__.py
Normal file
0
streetup/assets/__init__.py
Normal file
653
streetup/assets/admin.py
Normal file
653
streetup/assets/admin.py
Normal 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
49
streetup/assets/apps.py
Normal 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
273
streetup/assets/forms.py
Normal 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',
|
||||
},
|
||||
}
|
||||
BIN
streetup/assets/locale/en/LC_MESSAGES/django.mo
Normal file
BIN
streetup/assets/locale/en/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
5826
streetup/assets/locale/en/LC_MESSAGES/django.po
Normal file
5826
streetup/assets/locale/en/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
BIN
streetup/assets/locale/en/LC_MESSAGES/djangojs.mo
Normal file
BIN
streetup/assets/locale/en/LC_MESSAGES/djangojs.mo
Normal file
Binary file not shown.
579
streetup/assets/locale/en/LC_MESSAGES/djangojs.po
Normal file
579
streetup/assets/locale/en/LC_MESSAGES/djangojs.po
Normal 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"
|
||||
BIN
streetup/assets/locale/fr/LC_MESSAGES/django.mo
Normal file
BIN
streetup/assets/locale/fr/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
5754
streetup/assets/locale/fr/LC_MESSAGES/django.po
Normal file
5754
streetup/assets/locale/fr/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
BIN
streetup/assets/locale/fr/LC_MESSAGES/djangojs.mo
Normal file
BIN
streetup/assets/locale/fr/LC_MESSAGES/djangojs.mo
Normal file
Binary file not shown.
593
streetup/assets/locale/fr/LC_MESSAGES/djangojs.po
Normal file
593
streetup/assets/locale/fr/LC_MESSAGES/djangojs.po
Normal 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 --"
|
||||
BIN
streetup/assets/locale/nl/LC_MESSAGES/django.mo
Normal file
BIN
streetup/assets/locale/nl/LC_MESSAGES/django.mo
Normal file
Binary file not shown.
5725
streetup/assets/locale/nl/LC_MESSAGES/django.po
Normal file
5725
streetup/assets/locale/nl/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
BIN
streetup/assets/locale/nl/LC_MESSAGES/djangojs.mo
Normal file
BIN
streetup/assets/locale/nl/LC_MESSAGES/djangojs.mo
Normal file
Binary file not shown.
595
streetup/assets/locale/nl/LC_MESSAGES/djangojs.po
Normal file
595
streetup/assets/locale/nl/LC_MESSAGES/djangojs.po
Normal 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 --"
|
||||
0
streetup/assets/management/__init__.py
Normal file
0
streetup/assets/management/__init__.py
Normal file
0
streetup/assets/management/commands/__init__.py
Normal file
0
streetup/assets/management/commands/__init__.py
Normal file
199
streetup/assets/management/commands/assets_at_date.py
Normal file
199
streetup/assets/management/commands/assets_at_date.py
Normal 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"))
|
||||
BIN
streetup/assets/management/commands/assets_import_template.xlsx
Normal file
BIN
streetup/assets/management/commands/assets_import_template.xlsx
Normal file
Binary file not shown.
|
|
@ -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()}"))
|
||||
909
streetup/assets/management/commands/import_assets_from_excel.py
Normal file
909
streetup/assets/management/commands/import_assets_from_excel.py
Normal 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
|
||||
|
|
@ -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
|
||||
418
streetup/assets/management/commands/import_drains_from_csv.py
Normal file
418
streetup/assets/management/commands/import_drains_from_csv.py
Normal 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."
|
||||
)
|
||||
)
|
||||
291
streetup/assets/management/commands/import_rwi.py
Normal file
291
streetup/assets/management/commands/import_rwi.py
Normal 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}"))
|
||||
|
|
@ -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}"
|
||||
)
|
||||
)
|
||||
|
|
@ -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}"
|
||||
)
|
||||
)
|
||||
|
|
@ -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}"
|
||||
)
|
||||
)
|
||||
|
|
@ -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)."
|
||||
)
|
||||
)
|
||||
|
|
@ -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"
|
||||
)
|
||||
)
|
||||
|
|
@ -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}"
|
||||
)
|
||||
)
|
||||
|
|
@ -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}"
|
||||
)
|
||||
)
|
||||
183
streetup/assets/management/commands/initialize_asset_history.py
Normal file
183
streetup/assets/management/commands/initialize_asset_history.py
Normal 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('')
|
||||
|
|
@ -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."
|
||||
))
|
||||
184
streetup/assets/management/commands/update_null_radars_geom.py
Normal file
184
streetup/assets/management/commands/update_null_radars_geom.py
Normal 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.")
|
||||
)
|
||||
|
|
@ -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}")
|
||||
2311
streetup/assets/migrations/0001_initial.py
Normal file
2311
streetup/assets/migrations/0001_initial.py
Normal file
File diff suppressed because it is too large
Load diff
44
streetup/assets/migrations/0002_initial.py
Normal file
44
streetup/assets/migrations/0002_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
762
streetup/assets/migrations/0003_initial.py
Normal file
762
streetup/assets/migrations/0003_initial.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
1010
streetup/assets/migrations/0004_initial.py
Normal file
1010
streetup/assets/migrations/0004_initial.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
|
|
@ -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)'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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',
|
||||
),
|
||||
]
|
||||
19
streetup/assets/migrations/0015_trafficlightradar_pole.py
Normal file
19
streetup/assets/migrations/0015_trafficlightradar_pole.py
Normal 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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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),
|
||||
]
|
||||
|
|
@ -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)'),
|
||||
),
|
||||
]
|
||||
0
streetup/assets/migrations/__init__.py
Normal file
0
streetup/assets/migrations/__init__.py
Normal file
12
streetup/assets/models/__init__.py
Normal file
12
streetup/assets/models/__init__.py
Normal 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
Loading…
Reference in a new issue