Compare commits
10 Commits
53f71c4ca1
...
main-stabl
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e494608ea | |||
| 6d7c5cf158 | |||
| dc3a4395fa | |||
| 10d58a3124 | |||
| e0f95919be | |||
| 20a99aea9c | |||
| 37db980c10 | |||
| 76094f7641 | |||
| 1d7d33df0b | |||
| 044ccfca54 |
80
.env.example
Normal file
80
.env.example
Normal file
@@ -0,0 +1,80 @@
|
||||
# ASE Application - Environment Variables
|
||||
# Copia questo file in .env e modifica i valori secondo le tue necessità
|
||||
|
||||
# ============================================================================
|
||||
# Server Mode Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Server protocol mode: ftp or sftp
|
||||
# - ftp: Traditional FTP server (requires FTP_PASSIVE_PORTS and FTP_EXTERNAL_IP)
|
||||
# - sftp: SFTP server over SSH (more secure, requires SSH host key)
|
||||
# Default: ftp
|
||||
FTP_MODE=ftp
|
||||
|
||||
# ============================================================================
|
||||
# FTP Server Configuration (only for FTP_MODE=ftp)
|
||||
# ============================================================================
|
||||
|
||||
# Porta iniziale del range di porte passive FTP
|
||||
# Il range completo sarà FTP_PASSIVE_PORTS to (FTP_PASSIVE_PORTS + portRangeWidth - 1)
|
||||
# Default: valore da ftp.ini
|
||||
FTP_PASSIVE_PORTS=60000
|
||||
|
||||
# IP esterno da pubblicizzare ai client FTP (importante per HA con VIP)
|
||||
# Questo è l'indirizzo che i client useranno per connettersi in modalità passiva
|
||||
# In un setup HA, questo dovrebbe essere il VIP condiviso tra le istanze
|
||||
# Default: valore da ftp.ini
|
||||
FTP_EXTERNAL_IP=192.168.1.100
|
||||
|
||||
# ============================================================================
|
||||
# Database Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Hostname del server MySQL
|
||||
# Default: valore da db.ini
|
||||
DB_HOST=localhost
|
||||
|
||||
# Porta del server MySQL
|
||||
# Default: valore da db.ini
|
||||
DB_PORT=3306
|
||||
|
||||
# Username per la connessione al database
|
||||
# Default: valore da db.ini
|
||||
DB_USER=ase_user
|
||||
|
||||
# Password per la connessione al database
|
||||
# Default: valore da db.ini
|
||||
DB_PASSWORD=your_secure_password
|
||||
|
||||
# Nome del database
|
||||
# Default: valore da db.ini
|
||||
DB_NAME=ase_lar
|
||||
|
||||
# ============================================================================
|
||||
# Logging Configuration
|
||||
# ============================================================================
|
||||
|
||||
# Livello di logging: DEBUG, INFO, WARNING, ERROR, CRITICAL
|
||||
# Default: INFO
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
# ============================================================================
|
||||
# Note per Docker Compose
|
||||
# ============================================================================
|
||||
#
|
||||
# 1. Le variabili d'ambiente OVERRIDE i valori nei file .ini
|
||||
# 2. Se una variabile non è impostata, viene usato il valore dal file .ini
|
||||
# 3. Questo permette deployment flessibili senza modificare i file .ini
|
||||
#
|
||||
# Esempio di uso in docker-compose.yml:
|
||||
#
|
||||
# environment:
|
||||
# FTP_PASSIVE_PORTS: "${FTP_PASSIVE_PORTS:-60000}"
|
||||
# FTP_EXTERNAL_IP: "${FTP_EXTERNAL_IP}"
|
||||
# DB_HOST: "${DB_HOST}"
|
||||
# DB_PASSWORD: "${DB_PASSWORD}"
|
||||
#
|
||||
# Oppure usando env_file:
|
||||
#
|
||||
# env_file:
|
||||
# - .env
|
||||
127
docker-compose.example.yml
Normal file
127
docker-compose.example.yml
Normal file
@@ -0,0 +1,127 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# ============================================================================
|
||||
# FTP Server (Traditional FTP)
|
||||
# ============================================================================
|
||||
ftp-server:
|
||||
build: .
|
||||
container_name: ase-ftp-server
|
||||
ports:
|
||||
- "2121:2121" # FTP control port
|
||||
- "40000-40449:40000-40449" # FTP passive ports range
|
||||
environment:
|
||||
# Server Mode
|
||||
FTP_MODE: "ftp" # Mode: ftp or sftp
|
||||
|
||||
# FTP Configuration
|
||||
FTP_PASSIVE_PORTS: "40000" # Prima porta del range passivo
|
||||
FTP_EXTERNAL_IP: "192.168.1.100" # IP esterno/VIP da pubblicizzare ai client
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST: "mysql-server"
|
||||
DB_PORT: "3306"
|
||||
DB_USER: "ase_user"
|
||||
DB_PASSWORD: "your_secure_password"
|
||||
DB_NAME: "ase_lar"
|
||||
|
||||
# File Processing Behavior
|
||||
# DELETE_AFTER_PROCESSING: "true" # Cancella file dopo elaborazione corretta (default: false = mantiene i file)
|
||||
|
||||
# Logging (opzionale)
|
||||
LOG_LEVEL: "INFO"
|
||||
volumes:
|
||||
- ./logs/ftp:/app/logs
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
- mysql-server
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ase-network
|
||||
|
||||
# ============================================================================
|
||||
# SFTP Server (SSH File Transfer Protocol)
|
||||
# ============================================================================
|
||||
sftp-server:
|
||||
build: .
|
||||
container_name: ase-sftp-server
|
||||
ports:
|
||||
- "2222:22" # SFTP port (SSH)
|
||||
environment:
|
||||
# Server Mode
|
||||
FTP_MODE: "sftp" # Mode: ftp or sftp
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST: "mysql-server"
|
||||
DB_PORT: "3306"
|
||||
DB_USER: "ase_user"
|
||||
DB_PASSWORD: "your_secure_password"
|
||||
DB_NAME: "ase_lar"
|
||||
|
||||
# File Processing Behavior
|
||||
# DELETE_AFTER_PROCESSING: "true" # Cancella file dopo elaborazione corretta (default: false = mantiene i file)
|
||||
|
||||
# Logging (opzionale)
|
||||
LOG_LEVEL: "INFO"
|
||||
volumes:
|
||||
- ./logs/sftp:/app/logs
|
||||
- ./data:/app/data
|
||||
- ./ssh_host_key:/app/ssh_host_key:ro # SSH host key (generate with: ssh-keygen -t rsa -f ssh_host_key)
|
||||
depends_on:
|
||||
- mysql-server
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ase-network
|
||||
|
||||
# ============================================================================
|
||||
# Esempio: Setup HA con più istanze FTP (stesso VIP)
|
||||
# ============================================================================
|
||||
ftp-server-2:
|
||||
build: .
|
||||
container_name: ase-ftp-server-2
|
||||
ports:
|
||||
- "2122:2121" # Diversa porta di controllo per seconda istanza
|
||||
- "41000-41449:40000-40449" # Diverso range passivo sull'host
|
||||
environment:
|
||||
FTP_MODE: "ftp"
|
||||
FTP_PASSIVE_PORTS: "40000" # Stessa config interna
|
||||
FTP_EXTERNAL_IP: "192.168.1.100" # Stesso VIP condiviso
|
||||
DB_HOST: "mysql-server"
|
||||
DB_PORT: "3306"
|
||||
DB_USER: "ase_user"
|
||||
DB_PASSWORD: "your_secure_password"
|
||||
DB_NAME: "ase_lar"
|
||||
# DELETE_AFTER_PROCESSING: "true" # Cancella file dopo elaborazione corretta (default: false = mantiene i file)
|
||||
LOG_LEVEL: "INFO"
|
||||
volumes:
|
||||
- ./logs/ftp2:/app/logs
|
||||
- ./data:/app/data
|
||||
depends_on:
|
||||
- mysql-server
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ase-network
|
||||
|
||||
mysql-server:
|
||||
image: mysql:8.0
|
||||
container_name: ase-mysql
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: "root_password"
|
||||
MYSQL_DATABASE: "ase_lar"
|
||||
MYSQL_USER: "ase_user"
|
||||
MYSQL_PASSWORD: "your_secure_password"
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- ./dbddl:/docker-entrypoint-initdb.d
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- ase-network
|
||||
|
||||
networks:
|
||||
ase-network:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
71
docs/FILE_DELETION_POLICY.md
Normal file
71
docs/FILE_DELETION_POLICY.md
Normal file
@@ -0,0 +1,71 @@
|
||||
# File Deletion Policy
|
||||
|
||||
## Comportamento di Default
|
||||
|
||||
Per impostazione predefinita, i file ricevuti via FTP/SFTP vengono **mantenuti** sul server dopo l'elaborazione:
|
||||
|
||||
- ✅ **Elaborazione riuscita**: il file viene rinominato con timestamp e salvato nella directory dell'utente, i dati vengono inseriti nel database
|
||||
- ❌ **Elaborazione fallita**: il file rimane nella directory dell'utente per permettere debug e riprocessamento manuale
|
||||
|
||||
## Abilitare la Cancellazione Automatica
|
||||
|
||||
Per cancellare automaticamente i file dopo un'elaborazione **riuscita**, imposta la variabile d'ambiente nel `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
DELETE_AFTER_PROCESSING: "true"
|
||||
```
|
||||
|
||||
### Valori Accettati
|
||||
|
||||
La variabile accetta i seguenti valori (case-insensitive):
|
||||
- `true`, `1`, `yes` → cancellazione **abilitata**
|
||||
- `false`, `0`, `no` o qualsiasi altro valore → cancellazione **disabilitata** (default)
|
||||
|
||||
## Comportamento con DELETE_AFTER_PROCESSING=true
|
||||
|
||||
| Scenario | Comportamento |
|
||||
|----------|---------------|
|
||||
| File elaborato con successo | ✅ Dati inseriti nel DB → File **cancellato** |
|
||||
| Errore durante elaborazione | ❌ Errore loggato → File **mantenuto** per debug |
|
||||
| File vuoto | 🗑️ File cancellato immediatamente (comportamento esistente) |
|
||||
|
||||
## Log
|
||||
|
||||
Quando un file viene cancellato dopo l'elaborazione, viene loggato:
|
||||
|
||||
```
|
||||
INFO: File example_20250103120000.csv loaded successfully
|
||||
INFO: File example_20250103120000.csv deleted after successful processing
|
||||
```
|
||||
|
||||
In caso di errore durante la cancellazione:
|
||||
|
||||
```
|
||||
WARNING: Failed to delete file example_20250103120000.csv: [errno] [description]
|
||||
```
|
||||
|
||||
## Esempio Configurazione
|
||||
|
||||
### Mantenere i file (default)
|
||||
```yaml
|
||||
ftp-server:
|
||||
environment:
|
||||
DB_HOST: "mysql-server"
|
||||
# DELETE_AFTER_PROCESSING non impostata o impostata a false
|
||||
```
|
||||
|
||||
### Cancellare i file dopo elaborazione
|
||||
```yaml
|
||||
ftp-server:
|
||||
environment:
|
||||
DB_HOST: "mysql-server"
|
||||
DELETE_AFTER_PROCESSING: "true"
|
||||
```
|
||||
|
||||
## Note Implementative
|
||||
|
||||
- La cancellazione avviene **solo dopo** l'inserimento riuscito nel database
|
||||
- Se la cancellazione fallisce, viene loggato un warning ma l'elaborazione è considerata riuscita
|
||||
- I file con errori di elaborazione rimangono sempre sul server indipendentemente dalla configurazione
|
||||
- La policy si applica sia a FTP che a SFTP
|
||||
252
docs/FTP_SFTP_SETUP.md
Normal file
252
docs/FTP_SFTP_SETUP.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# FTP/SFTP Server Setup Guide
|
||||
|
||||
Il sistema ASE supporta sia FTP che SFTP utilizzando lo stesso codice Python. La modalità viene selezionata tramite la variabile d'ambiente `FTP_MODE`.
|
||||
|
||||
## Modalità Supportate
|
||||
|
||||
### FTP (File Transfer Protocol)
|
||||
- **Protocollo**: FTP classico
|
||||
- **Porta**: 21 (o configurabile)
|
||||
- **Sicurezza**: Non criptato (considera FTPS per produzione)
|
||||
- **Porte passive**: Richiede un range di porte configurabile
|
||||
- **Caso d'uso**: Compatibilità con client legacy, performance
|
||||
|
||||
### SFTP (SSH File Transfer Protocol)
|
||||
- **Protocollo**: SSH-based file transfer
|
||||
- **Porta**: 22 (o configurabile)
|
||||
- **Sicurezza**: Criptato tramite SSH
|
||||
- **Porte passive**: Non necessarie (usa solo la porta SSH)
|
||||
- **Caso d'uso**: Sicurezza, firewall-friendly
|
||||
|
||||
## Configurazione
|
||||
|
||||
### Variabili d'Ambiente
|
||||
|
||||
#### Comuni a entrambi i protocolli
|
||||
```bash
|
||||
FTP_MODE=ftp # o "sftp"
|
||||
DB_HOST=mysql-server
|
||||
DB_PORT=3306
|
||||
DB_USER=ase_user
|
||||
DB_PASSWORD=password
|
||||
DB_NAME=ase_lar
|
||||
LOG_LEVEL=INFO
|
||||
```
|
||||
|
||||
#### Specifiche per FTP
|
||||
```bash
|
||||
FTP_PASSIVE_PORTS=40000 # Prima porta del range passivo
|
||||
FTP_EXTERNAL_IP=192.168.1.100 # VIP per HA
|
||||
```
|
||||
|
||||
#### Specifiche per SFTP
|
||||
```bash
|
||||
# Nessuna variabile specifica - richiede solo SSH host key
|
||||
```
|
||||
|
||||
## Setup Docker Compose
|
||||
|
||||
### Server FTP
|
||||
|
||||
```yaml
|
||||
services:
|
||||
ftp-server:
|
||||
build: .
|
||||
container_name: ase-ftp-server
|
||||
ports:
|
||||
- "2121:2121"
|
||||
- "40000-40449:40000-40449"
|
||||
environment:
|
||||
FTP_MODE: "ftp"
|
||||
FTP_PASSIVE_PORTS: "40000"
|
||||
FTP_EXTERNAL_IP: "192.168.1.100"
|
||||
DB_HOST: "mysql-server"
|
||||
DB_USER: "ase_user"
|
||||
DB_PASSWORD: "password"
|
||||
DB_NAME: "ase_lar"
|
||||
volumes:
|
||||
- ./logs/ftp:/app/logs
|
||||
- ./data:/app/data
|
||||
```
|
||||
|
||||
### Server SFTP
|
||||
|
||||
```yaml
|
||||
services:
|
||||
sftp-server:
|
||||
build: .
|
||||
container_name: ase-sftp-server
|
||||
ports:
|
||||
- "2222:22"
|
||||
environment:
|
||||
FTP_MODE: "sftp"
|
||||
DB_HOST: "mysql-server"
|
||||
DB_USER: "ase_user"
|
||||
DB_PASSWORD: "password"
|
||||
DB_NAME: "ase_lar"
|
||||
volumes:
|
||||
- ./logs/sftp:/app/logs
|
||||
- ./data:/app/data
|
||||
- ./ssh_host_key:/app/ssh_host_key:ro
|
||||
```
|
||||
|
||||
## Generazione SSH Host Key per SFTP
|
||||
|
||||
Prima di avviare il server SFTP, genera la chiave SSH:
|
||||
|
||||
```bash
|
||||
ssh-keygen -t rsa -b 4096 -f ssh_host_key -N ""
|
||||
```
|
||||
|
||||
Questo crea:
|
||||
- `ssh_host_key` - Chiave privata (monta nel container)
|
||||
- `ssh_host_key.pub` - Chiave pubblica
|
||||
|
||||
## Autenticazione
|
||||
|
||||
Entrambi i protocolli usano lo stesso sistema di autenticazione:
|
||||
|
||||
1. **Admin user**: Configurato in `ftp.ini`
|
||||
2. **Virtual users**: Salvati nella tabella `virtusers` del database
|
||||
3. **Password**: SHA256 hash
|
||||
4. **Sincronizzazione**: Automatica tra tutte le istanze (legge sempre dal DB)
|
||||
|
||||
## Comandi SITE (solo FTP)
|
||||
|
||||
I comandi SITE sono disponibili solo in modalità FTP:
|
||||
|
||||
```bash
|
||||
ftp> site addu username password # Aggiungi utente
|
||||
ftp> site disu username # Disabilita utente
|
||||
ftp> site enau username # Abilita utente
|
||||
ftp> site lstu # Lista utenti
|
||||
```
|
||||
|
||||
In modalità SFTP, usa lo script `load_ftp_users.py` per gestire gli utenti.
|
||||
|
||||
## High Availability (HA)
|
||||
|
||||
### Setup HA con FTP
|
||||
Puoi eseguire più istanze FTP che condividono lo stesso VIP:
|
||||
|
||||
```yaml
|
||||
ftp-server-1:
|
||||
environment:
|
||||
FTP_EXTERNAL_IP: "192.168.1.100" # VIP condiviso
|
||||
ports:
|
||||
- "2121:2121"
|
||||
- "40000-40449:40000-40449"
|
||||
|
||||
ftp-server-2:
|
||||
environment:
|
||||
FTP_EXTERNAL_IP: "192.168.1.100" # Stesso VIP
|
||||
ports:
|
||||
- "2122:2121"
|
||||
- "41000-41449:40000-40449" # Range diverso sull'host
|
||||
```
|
||||
|
||||
### Setup HA con SFTP
|
||||
Più semplice, nessuna configurazione di porte passive:
|
||||
|
||||
```yaml
|
||||
sftp-server-1:
|
||||
ports:
|
||||
- "2222:22"
|
||||
|
||||
sftp-server-2:
|
||||
ports:
|
||||
- "2223:22"
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Test FTP
|
||||
```bash
|
||||
ftp 192.168.1.100 2121
|
||||
# Username: admin (o utente dal database)
|
||||
# Password: <password>
|
||||
ftp> ls
|
||||
ftp> put file.csv
|
||||
ftp> by
|
||||
```
|
||||
|
||||
### Test SFTP
|
||||
```bash
|
||||
sftp -P 2222 admin@192.168.1.100
|
||||
# Password: <password>
|
||||
sftp> ls
|
||||
sftp> put file.csv
|
||||
sftp> exit
|
||||
```
|
||||
|
||||
## Monitoring
|
||||
|
||||
I log sono disponibili sia su file che su console (Docker):
|
||||
|
||||
```bash
|
||||
# Visualizza log FTP
|
||||
docker logs ase-ftp-server
|
||||
|
||||
# Visualizza log SFTP
|
||||
docker logs ase-sftp-server
|
||||
|
||||
# Segui i log in tempo reale
|
||||
docker logs -f ase-ftp-server
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### FTP: Errore "Can't connect to passive port"
|
||||
- Verifica che il range di porte passive sia mappato correttamente in Docker
|
||||
- Controlla che `FTP_EXTERNAL_IP` sia impostato correttamente
|
||||
- Verifica che `FTP_PASSIVE_PORTS` corrisponda al range configurato
|
||||
|
||||
### SFTP: Errore "Connection refused"
|
||||
- Verifica che l'SSH host key esista e sia montato correttamente
|
||||
- Controlla i permessi del file SSH host key (deve essere leggibile)
|
||||
- Installa `asyncssh`: `pip install asyncssh`
|
||||
|
||||
### Autenticazione fallita (entrambi)
|
||||
- Verifica che il database sia raggiungibile
|
||||
- Controlla che le credenziali del database siano corrette
|
||||
- Verifica che l'utente esista nella tabella `virtusers` e sia abilitato (`disabled_at IS NULL`)
|
||||
|
||||
## Dipendenze
|
||||
|
||||
### FTP
|
||||
```bash
|
||||
pip install pyftpdlib mysql-connector-python
|
||||
```
|
||||
|
||||
### SFTP
|
||||
```bash
|
||||
pip install asyncssh aiomysql
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
- **FTP**: Più veloce per trasferimenti di file grandi, minore overhead
|
||||
- **SFTP**: Leggermente più lento a causa della crittografia SSH, ma più sicuro
|
||||
|
||||
## Sicurezza
|
||||
|
||||
### FTP
|
||||
- ⚠️ Non criptato - considera FTPS per produzione
|
||||
- Abilita `permit_foreign_addresses` per NAT/proxy
|
||||
- Usa firewall per limitare accesso
|
||||
|
||||
### SFTP
|
||||
- ✅ Completamente criptato tramite SSH
|
||||
- ✅ Più sicuro per Internet pubblico
|
||||
- ✅ Supporta autenticazione a chiave pubblica (future enhancement)
|
||||
|
||||
## Migration
|
||||
|
||||
Per migrare da FTP a SFTP:
|
||||
|
||||
1. Avvia server SFTP con stesse credenziali database
|
||||
2. Testa connessione SFTP
|
||||
3. Migra client gradualmente
|
||||
4. Spegni server FTP quando tutti i client sono migrati
|
||||
|
||||
Gli utenti e i dati rimangono gli stessi!
|
||||
@@ -18,9 +18,12 @@ Questa è la documentazione automatica dell'applicazione Python ASE per la gesti
|
||||
|
||||
- personalizzazione dei file env:
|
||||
- env/db.ini
|
||||
- env/elab.ini
|
||||
- env/email.ini
|
||||
- env/ftp.ini
|
||||
- env/load.ini
|
||||
- env/elab.ini
|
||||
- env/send.ini
|
||||
|
||||
- esecuzione del server FTP -> "python ftp_csv_receiver.py"
|
||||
- esecuzione dell'orchestratore del caricamenti dei file csv -> "python load_orchestrator.py"
|
||||
|
||||
4
env/config.ini
vendored
4
env/config.ini
vendored
@@ -1,6 +1,6 @@
|
||||
[mysql]
|
||||
host = 10.211.114.173
|
||||
host = mysql-ase.incus
|
||||
database = ase_lar
|
||||
user = root
|
||||
user = alex
|
||||
password = batt1l0
|
||||
|
||||
|
||||
4
env/db.ini
vendored
4
env/db.ini
vendored
@@ -2,9 +2,9 @@
|
||||
# python3 -c 'from hashlib import sha256;print(sha256("????password???".encode("UTF-8")).hexdigest())'
|
||||
|
||||
[db]
|
||||
hostname = 10.211.114.173
|
||||
hostname = mysql-ase.incus
|
||||
port = 3306
|
||||
user = root
|
||||
user = alex
|
||||
password = batt1l0
|
||||
dbName = ase_lar
|
||||
maxRetries = 10
|
||||
|
||||
114540
logs/non_sysgeo.txt
Normal file
114540
logs/non_sysgeo.txt
Normal file
File diff suppressed because it is too large
Load Diff
17641
logs/sysgeo.txt
Normal file
17641
logs/sysgeo.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,13 +7,14 @@ requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"aiomysql>=0.2.0",
|
||||
"cryptography>=45.0.3",
|
||||
# mysql-connector-python moved to legacy group - only needed for old_scripts
|
||||
"mysql-connector-python>=9.3.0", # Needed for synchronous DB connections (ftp_csv_receiver.py, load_ftp_users.py)
|
||||
"pyftpdlib>=2.0.1",
|
||||
"pyproj>=3.7.1",
|
||||
"utm>=0.8.1",
|
||||
"aiofiles>=24.1.0",
|
||||
"aiosmtplib>=3.0.2",
|
||||
"aioftp>=0.22.3",
|
||||
"asyncssh>=2.21.1",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -59,4 +60,4 @@ ignore = []
|
||||
[tool.ruff.format]
|
||||
# Usa virgole finali
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
indent-style = "space"
|
||||
|
||||
@@ -1,67 +1,43 @@
|
||||
#!.venv/bin/python
|
||||
"""
|
||||
This module implements an FTP server with custom commands for
|
||||
This module implements an FTP/SFTP server with custom commands for
|
||||
managing virtual users and handling CSV file uploads.
|
||||
|
||||
Server mode is controlled by FTP_MODE environment variable:
|
||||
- FTP_MODE=ftp (default): Traditional FTP server
|
||||
- FTP_MODE=sftp: SFTP (SSH File Transfer Protocol) server
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from hashlib import sha256
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from pyftpdlib.authorizers import AuthenticationFailed, DummyAuthorizer
|
||||
from pyftpdlib.handlers import FTPHandler
|
||||
from pyftpdlib.servers import FTPServer
|
||||
|
||||
from utils.authorizers.database_authorizer import DatabaseAuthorizer
|
||||
from utils.config import loader_ftp_csv as setting
|
||||
from utils.connect import file_management, user_admin
|
||||
from utils.database.connection import connetti_db
|
||||
|
||||
# Configure logging (moved inside main function)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DummySha256Authorizer(DummyAuthorizer):
|
||||
"""Custom authorizer that uses SHA256 for password hashing and manages users from a database."""
|
||||
|
||||
def __init__(self: object, cfg: dict) -> None:
|
||||
"""Initializes the authorizer, adds the admin user, and loads users from the database.
|
||||
|
||||
Args:
|
||||
cfg: The configuration object.
|
||||
"""
|
||||
super().__init__()
|
||||
self.add_user(cfg.adminuser[0], cfg.adminuser[1], cfg.adminuser[2], perm=cfg.adminuser[3])
|
||||
|
||||
# Define the database connection
|
||||
conn = connetti_db(cfg)
|
||||
|
||||
# Create a cursor
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"SELECT ftpuser, hash, virtpath, perm FROM {cfg.dbname}.{cfg.dbusertable} WHERE disabled_at IS NULL")
|
||||
|
||||
for ftpuser, user_hash, virtpath, perm in cur.fetchall():
|
||||
# Create the user's directory if it does not exist.
|
||||
try:
|
||||
Path(cfg.virtpath + ftpuser).mkdir(parents=True, exist_ok=True)
|
||||
self.add_user(ftpuser, user_hash, virtpath, perm)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
self.responde(f"551 Error in create virtual user path: {e}")
|
||||
|
||||
def validate_authentication(self: object, username: str, password: str, handler: object) -> None:
|
||||
# Validate the user's password against the stored user_hash
|
||||
user_hash = sha256(password.encode("UTF-8")).hexdigest()
|
||||
try:
|
||||
if self.user_table[username]["pwd"] != user_hash:
|
||||
raise KeyError
|
||||
except KeyError:
|
||||
raise AuthenticationFailed # noqa: B904
|
||||
# Legacy authorizer kept for reference (not used anymore)
|
||||
# The DatabaseAuthorizer is now used for real-time database synchronization
|
||||
|
||||
|
||||
class ASEHandler(FTPHandler):
|
||||
"""Custom FTP handler that extends FTPHandler with custom commands and file handling."""
|
||||
|
||||
# Permetti connessioni dati da indirizzi IP diversi (importante per NAT/proxy)
|
||||
permit_foreign_addresses = True
|
||||
|
||||
def __init__(self: object, conn: object, server: object, ioloop: object = None) -> None:
|
||||
"""Initializes the handler, adds custom commands, and sets up command permissions.
|
||||
|
||||
@@ -137,36 +113,132 @@ class ASEHandler(FTPHandler):
|
||||
return user_admin.ftp_SITE_LSTU(self, line)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to start the FTP server."""
|
||||
# Load the configuration settings
|
||||
cfg = setting.Config()
|
||||
def setup_logging(log_filename: str):
|
||||
"""
|
||||
Configura il logging per il server FTP con rotation e output su console.
|
||||
|
||||
Args:
|
||||
log_filename (str): Percorso del file di log.
|
||||
"""
|
||||
root_logger = logging.getLogger()
|
||||
formatter = logging.Formatter("%(asctime)s - PID: %(process)d.%(name)s.%(levelname)s: %(message)s")
|
||||
|
||||
# Rimuovi eventuali handler esistenti
|
||||
if root_logger.hasHandlers():
|
||||
root_logger.handlers.clear()
|
||||
|
||||
# Handler per file con rotation (max 10MB per file, mantiene 5 backup)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_filename,
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=5, # Mantiene 5 file di backup
|
||||
encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
# Handler per console (utile per Docker)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(console_handler)
|
||||
|
||||
root_logger.setLevel(logging.INFO)
|
||||
root_logger.info("Logging FTP configurato con rotation (10MB, 5 backup) e console output")
|
||||
|
||||
|
||||
def start_ftp_server(cfg):
|
||||
"""Start traditional FTP server."""
|
||||
try:
|
||||
# Initialize the authorizer and handler
|
||||
authorizer = DummySha256Authorizer(cfg)
|
||||
# Initialize the authorizer with database support
|
||||
# This authorizer checks the database on every login, ensuring
|
||||
# all FTP server instances stay synchronized without restarts
|
||||
authorizer = DatabaseAuthorizer(cfg)
|
||||
|
||||
# Initialize handler
|
||||
handler = ASEHandler
|
||||
handler.cfg = cfg
|
||||
handler.authorizer = authorizer
|
||||
handler.masquerade_address = cfg.proxyaddr
|
||||
# Set the range of passive ports for the FTP server
|
||||
_range = list(range(cfg.firstport, cfg.firstport + cfg.portrangewidth))
|
||||
handler.passive_ports = _range
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
format="%(asctime)s - PID: %(process)d.%(name)s.%(levelname)s: %(message)s ",
|
||||
# Use cfg.logfilename directly without checking its existence
|
||||
filename=cfg.logfilename,
|
||||
level=logging.INFO,
|
||||
# Set masquerade address only if configured (importante per HA con VIP)
|
||||
# Questo è l'IP che il server FTP pubblicherà ai client per le connessioni passive
|
||||
if cfg.proxyaddr and cfg.proxyaddr.strip():
|
||||
handler.masquerade_address = cfg.proxyaddr
|
||||
logger.info(f"FTP masquerade address configured: {cfg.proxyaddr}")
|
||||
else:
|
||||
logger.info("FTP masquerade address not configured - using server's default IP")
|
||||
|
||||
# Set the range of passive ports for the FTP server
|
||||
passive_ports_range = list(range(cfg.firstport, cfg.firstport + cfg.portrangewidth))
|
||||
handler.passive_ports = passive_ports_range
|
||||
|
||||
# Log configuration
|
||||
logger.info(f"Starting FTP server on port {cfg.service_port} with DatabaseAuthorizer")
|
||||
logger.info(
|
||||
f"FTP passive ports configured: {cfg.firstport}-{cfg.firstport + cfg.portrangewidth - 1} "
|
||||
f"({len(passive_ports_range)} ports)"
|
||||
)
|
||||
logger.info(f"FTP permit_foreign_addresses: {handler.permit_foreign_addresses}")
|
||||
logger.info(f"Database connection: {cfg.dbuser}@{cfg.dbhost}:{cfg.dbport}/{cfg.dbname}")
|
||||
|
||||
# Create and start the FTP server
|
||||
server = FTPServer(("0.0.0.0", cfg.service_port), handler)
|
||||
server.serve_forever()
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Exit with error: %s.", e)
|
||||
logger.error("FTP server error: %s", e, exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
async def start_sftp_server_async(cfg):
|
||||
"""Start SFTP server (async)."""
|
||||
try:
|
||||
from utils.servers.sftp_server import start_sftp_server
|
||||
|
||||
logger.info(f"Starting SFTP server on port {cfg.service_port}")
|
||||
logger.info(f"Database connection: {cfg.dbuser}@{cfg.dbhost}:{cfg.dbport}/{cfg.dbname}")
|
||||
|
||||
# Start SFTP server
|
||||
server = await start_sftp_server(cfg, host="0.0.0.0", port=cfg.service_port)
|
||||
|
||||
# Keep server running
|
||||
await asyncio.Event().wait()
|
||||
|
||||
except ImportError as e:
|
||||
logger.error("SFTP mode requires 'asyncssh' library. Install with: pip install asyncssh")
|
||||
logger.error(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.error("SFTP server error: %s", e, exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to start FTP or SFTP server based on FTP_MODE environment variable."""
|
||||
# Load the configuration settings
|
||||
cfg = setting.Config()
|
||||
|
||||
# Configure logging first
|
||||
setup_logging(cfg.logfilename)
|
||||
|
||||
# Get server mode from environment variable (default: ftp)
|
||||
server_mode = os.getenv("FTP_MODE", "ftp").lower()
|
||||
|
||||
if server_mode not in ["ftp", "sftp"]:
|
||||
logger.error(f"Invalid FTP_MODE: {server_mode}. Valid values: ftp, sftp")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info(f"Server mode: {server_mode.upper()}")
|
||||
|
||||
try:
|
||||
if server_mode == "ftp":
|
||||
start_ftp_server(cfg)
|
||||
elif server_mode == "sftp":
|
||||
asyncio.run(start_sftp_server_async(cfg))
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Server stopped by user")
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error: %s", e, exc_info=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -68,6 +68,38 @@ def fetch_data_from_db(connection: mysql.connector.MySQLConnection) -> list[tupl
|
||||
cursor.close()
|
||||
|
||||
|
||||
def fetch_existing_users(connection: mysql.connector.MySQLConnection) -> dict[str, tuple]:
|
||||
"""
|
||||
Fetches existing FTP users from virtusers table.
|
||||
|
||||
Args:
|
||||
connection (mysql.connector.MySQLConnection): The database connection object.
|
||||
Returns:
|
||||
dict: Dictionary mapping username to (is_enabled, has_matching_password).
|
||||
is_enabled is True if disabled_at is NULL.
|
||||
"""
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
query = """
|
||||
SELECT ftpuser, disabled_at
|
||||
FROM ase_lar.virtusers
|
||||
"""
|
||||
cursor.execute(query)
|
||||
results = cursor.fetchall()
|
||||
|
||||
# Create dictionary: username -> is_enabled
|
||||
users_dict = {username: (disabled_at is None) for username, disabled_at in results}
|
||||
|
||||
logger.info("Trovati %s utenti esistenti in virtusers", len(users_dict))
|
||||
return users_dict
|
||||
|
||||
except mysql.connector.Error as e:
|
||||
logger.error("Errore query database virtusers: %s", e)
|
||||
return {}
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def send_site_command(ftp: FTP, command: str) -> bool:
|
||||
"""
|
||||
Sends a SITE command to the FTP server.
|
||||
@@ -90,9 +122,13 @@ def send_site_command(ftp: FTP, command: str) -> bool:
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main function to connect to the database, fetch FTP user data, and send SITE ADDU commands to the FTP server.
|
||||
Main function to connect to the database, fetch FTP user data, and synchronize users to FTP server.
|
||||
This function is idempotent - it can be run multiple times safely:
|
||||
- If user exists and is enabled: skips
|
||||
- If user exists but is disabled: enables it (SITE ENAU)
|
||||
- If user doesn't exist: creates it (SITE ADDU)
|
||||
"""
|
||||
logger.info("Avvio script caricamento utenti FTP")
|
||||
logger.info("Avvio script caricamento utenti FTP (idempotente)")
|
||||
cfg = setting.Config()
|
||||
|
||||
# Connessioni
|
||||
@@ -100,32 +136,58 @@ def main():
|
||||
ftp_connection = connect_ftp()
|
||||
|
||||
try:
|
||||
# Preleva dati dal database
|
||||
data = fetch_data_from_db(db_connection)
|
||||
# Preleva utenti da sincronizzare
|
||||
users_to_sync = fetch_data_from_db(db_connection)
|
||||
|
||||
if not data:
|
||||
logger.warning("Nessun dato trovato nel database")
|
||||
if not users_to_sync:
|
||||
logger.warning("Nessun utente da sincronizzare nel database ftp_accounts")
|
||||
return
|
||||
|
||||
success_count = 0
|
||||
# Preleva utenti già esistenti
|
||||
existing_users = fetch_existing_users(db_connection)
|
||||
|
||||
added_count = 0
|
||||
enabled_count = 0
|
||||
skipped_count = 0
|
||||
error_count = 0
|
||||
|
||||
# Processa ogni riga
|
||||
for row in data:
|
||||
# Processa ogni utente
|
||||
for row in users_to_sync:
|
||||
username, password = row
|
||||
|
||||
# Costruisci il comando SITE completo
|
||||
ftp_site_command = f"addu {username} {password}"
|
||||
if username in existing_users:
|
||||
is_enabled = existing_users[username]
|
||||
|
||||
logger.info("Sending ftp command: %s", ftp_site_command)
|
||||
if is_enabled:
|
||||
# Utente già esiste ed è abilitato - skip
|
||||
logger.info("Utente %s già esiste ed è abilitato - skip", username)
|
||||
skipped_count += 1
|
||||
else:
|
||||
# Utente esiste ma è disabilitato - riabilita
|
||||
logger.info("Utente %s esiste ma è disabilitato - riabilito con SITE ENAU", username)
|
||||
ftp_site_command = f"enau {username}"
|
||||
|
||||
# Invia comando SITE
|
||||
if send_site_command(ftp_connection, ftp_site_command):
|
||||
success_count += 1
|
||||
if send_site_command(ftp_connection, ftp_site_command):
|
||||
enabled_count += 1
|
||||
else:
|
||||
error_count += 1
|
||||
else:
|
||||
error_count += 1
|
||||
# Utente non esiste - crea
|
||||
logger.info("Utente %s non esiste - creazione con SITE ADDU", username)
|
||||
ftp_site_command = f"addu {username} {password}"
|
||||
|
||||
logger.info("Elaborazione completata. Successi: %s, Errori: %s", success_count, error_count)
|
||||
if send_site_command(ftp_connection, ftp_site_command):
|
||||
added_count += 1
|
||||
else:
|
||||
error_count += 1
|
||||
|
||||
logger.info(
|
||||
"Elaborazione completata. Aggiunti: %s, Riabilitati: %s, Saltati: %s, Errori: %s",
|
||||
added_count,
|
||||
enabled_count,
|
||||
skipped_count,
|
||||
error_count
|
||||
)
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.error("Errore generale: %s", e)
|
||||
|
||||
@@ -121,7 +121,7 @@ async def load_csv(record: tuple, cfg: object, pool: object) -> bool:
|
||||
# Cache hit! Use cached module
|
||||
modulo = _module_cache[module_name]
|
||||
cache_key = module_name
|
||||
logger.debug("Modulo caricato dalla cache: %s", module_name)
|
||||
logger.info("Modulo caricato dalla cache: %s", module_name)
|
||||
break
|
||||
|
||||
# If not in cache, import dynamically
|
||||
@@ -133,7 +133,7 @@ async def load_csv(record: tuple, cfg: object, pool: object) -> bool:
|
||||
# Store in cache for future use
|
||||
_module_cache[module_name] = modulo
|
||||
cache_key = module_name
|
||||
logger.info("Funzione 'main_loader' caricata dal modulo %s (cached)", module_name)
|
||||
logger.info("Modulo caricato per la prima volta: %s", module_name)
|
||||
break
|
||||
except (ImportError, AttributeError) as e:
|
||||
logger.debug(
|
||||
|
||||
381
src/refactory_scripts/TODO_TS_PINI.md
Normal file
381
src/refactory_scripts/TODO_TS_PINI.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# TS Pini Loader - TODO for Complete Refactoring
|
||||
|
||||
## Status: Essential Refactoring Complete ✅
|
||||
|
||||
**Current Implementation**: 508 lines
|
||||
**Legacy Script**: 2,587 lines
|
||||
**Reduction**: 80% (from monolithic to modular)
|
||||
|
||||
---
|
||||
|
||||
## ✅ Implemented Features
|
||||
|
||||
### Core Functionality
|
||||
- [x] Async/await architecture with aiomysql
|
||||
- [x] Multiple station type support (Leica, Trimble S7, S9, S7-inverted)
|
||||
- [x] Coordinate system transformations:
|
||||
- [x] CH1903 (Old Swiss system)
|
||||
- [x] CH1903+ / LV95 (New Swiss system via EPSG)
|
||||
- [x] UTM (Universal Transverse Mercator)
|
||||
- [x] Lat/Lon (direct)
|
||||
- [x] Project/folder name mapping (16 special cases)
|
||||
- [x] CSV parsing for different station formats
|
||||
- [x] ELABDATAUPGEO data insertion
|
||||
- [x] Basic mira (target point) lookup
|
||||
- [x] Proper logging and error handling
|
||||
- [x] Type hints and comprehensive docstrings
|
||||
|
||||
---
|
||||
|
||||
## ⏳ TODO: High Priority
|
||||
|
||||
### 1. Mira Creation Logic
|
||||
**File**: `ts_pini_loader.py`, method `_get_or_create_mira()`
|
||||
**Lines in legacy**: 138-160
|
||||
|
||||
**Current Status**: Stub implementation
|
||||
**What's needed**:
|
||||
```python
|
||||
async def _get_or_create_mira(self, mira_name: str, lavoro_id: int, site_id: int) -> int | None:
|
||||
# 1. Check if mira already exists (DONE)
|
||||
|
||||
# 2. If not, check company mira limits
|
||||
query = """
|
||||
SELECT c.id, c.upgeo_numero_mire, c.upgeo_numero_mireTot
|
||||
FROM companies as c
|
||||
JOIN sites as s ON c.id = s.company_id
|
||||
WHERE s.id = %s
|
||||
"""
|
||||
|
||||
# 3. If under limit, create mira
|
||||
if upgeo_numero_mire < upgeo_numero_mireTot:
|
||||
# INSERT INTO upgeo_mire
|
||||
# UPDATE companies mira counter
|
||||
|
||||
# 4. Return mira_id
|
||||
```
|
||||
|
||||
**Complexity**: Medium
|
||||
**Estimated time**: 30 minutes
|
||||
|
||||
---
|
||||
|
||||
### 2. Multi-Level Alarm System
|
||||
**File**: `ts_pini_loader.py`, method `_process_thresholds_and_alarms()`
|
||||
**Lines in legacy**: 174-1500+ (most of the script!)
|
||||
|
||||
**Current Status**: Stub with warning message
|
||||
**What's needed**:
|
||||
|
||||
#### 2.1 Threshold Configuration Loading
|
||||
```python
|
||||
class ThresholdConfig:
|
||||
"""Threshold configuration for a monitored point."""
|
||||
|
||||
# 5 dimensions x 3 levels = 15 thresholds
|
||||
attention_N: float | None
|
||||
intervention_N: float | None
|
||||
immediate_N: float | None
|
||||
|
||||
attention_E: float | None
|
||||
intervention_E: float | None
|
||||
immediate_E: float | None
|
||||
|
||||
attention_H: float | None
|
||||
intervention_H: float | None
|
||||
immediate_H: float | None
|
||||
|
||||
attention_R2D: float | None
|
||||
intervention_R2D: float | None
|
||||
immediate_R2D: float | None
|
||||
|
||||
attention_R3D: float | None
|
||||
intervention_R3D: float | None
|
||||
immediate_R3D: float | None
|
||||
|
||||
# Notification settings (3 levels x 5 dimensions x 2 channels)
|
||||
email_level_1_N: bool
|
||||
sms_level_1_N: bool
|
||||
# ... (30 fields total)
|
||||
```
|
||||
|
||||
#### 2.2 Displacement Calculation
|
||||
```python
|
||||
async def _calculate_displacements(self, mira_id: int) -> dict:
|
||||
"""
|
||||
Calculate displacements in all dimensions.
|
||||
|
||||
Returns dict with:
|
||||
- dN: displacement in North
|
||||
- dE: displacement in East
|
||||
- dH: displacement in Height
|
||||
- dR2D: 2D displacement (sqrt(dN² + dE²))
|
||||
- dR3D: 3D displacement (sqrt(dN² + dE² + dH²))
|
||||
- timestamp: current measurement time
|
||||
- previous_timestamp: baseline measurement time
|
||||
"""
|
||||
```
|
||||
|
||||
#### 2.3 Alarm Creation
|
||||
```python
|
||||
async def _create_alarm_if_threshold_exceeded(
|
||||
self,
|
||||
mira_id: int,
|
||||
dimension: str, # 'N', 'E', 'H', 'R2D', 'R3D'
|
||||
level: int, # 1, 2, 3
|
||||
value: float,
|
||||
threshold: float,
|
||||
config: ThresholdConfig
|
||||
) -> None:
|
||||
"""Create alarm in database if not already exists."""
|
||||
|
||||
# Check if alarm already exists for this mira/dimension/level
|
||||
# If not, INSERT INTO alarms
|
||||
# Send email/SMS based on config
|
||||
```
|
||||
|
||||
**Complexity**: High
|
||||
**Estimated time**: 4-6 hours
|
||||
**Dependencies**: Email/SMS sending infrastructure
|
||||
|
||||
---
|
||||
|
||||
### 3. Multiple Date Range Support
|
||||
**Lines in legacy**: Throughout alarm processing
|
||||
|
||||
**Current Status**: Not implemented
|
||||
**What's needed**:
|
||||
- Parse `multipleDateRange` JSON field from mira config
|
||||
- Apply different thresholds for different time periods
|
||||
- Handle overlapping ranges
|
||||
|
||||
**Complexity**: Medium
|
||||
**Estimated time**: 1-2 hours
|
||||
|
||||
---
|
||||
|
||||
## ⏳ TODO: Medium Priority
|
||||
|
||||
### 4. Additional Monitoring Types
|
||||
|
||||
#### 4.1 Railway Monitoring
|
||||
**Lines in legacy**: 1248-1522
|
||||
**What it does**: Special monitoring for railway tracks (binari)
|
||||
- Groups miras by railway identifier
|
||||
- Calculates transverse displacements
|
||||
- Different threshold logic
|
||||
|
||||
#### 4.2 Wall Monitoring (Muri)
|
||||
**Lines in legacy**: ~500-800
|
||||
**What it does**: Wall-specific monitoring with paired points
|
||||
|
||||
#### 4.3 Truss Monitoring (Tralicci)
|
||||
**Lines in legacy**: ~300-500
|
||||
**What it does**: Truss structure monitoring
|
||||
|
||||
**Approach**: Create separate classes:
|
||||
```python
|
||||
class RailwayMonitor:
|
||||
async def process(self, lavoro_id: int, miras: list[int]) -> None:
|
||||
...
|
||||
|
||||
class WallMonitor:
|
||||
async def process(self, lavoro_id: int, miras: list[int]) -> None:
|
||||
...
|
||||
|
||||
class TrussMonitor:
|
||||
async def process(self, lavoro_id: int, miras: list[int]) -> None:
|
||||
...
|
||||
```
|
||||
|
||||
**Complexity**: High
|
||||
**Estimated time**: 3-4 hours each
|
||||
|
||||
---
|
||||
|
||||
### 5. Time-Series Analysis
|
||||
**Lines in legacy**: Multiple occurrences with `find_nearest_element()`
|
||||
|
||||
**Current Status**: Helper functions not ported
|
||||
**What's needed**:
|
||||
- Find nearest measurement in time series
|
||||
- Compare current vs. historical values
|
||||
- Detect trend changes
|
||||
|
||||
**Complexity**: Low-Medium
|
||||
**Estimated time**: 1 hour
|
||||
|
||||
---
|
||||
|
||||
## ⏳ TODO: Low Priority (Nice to Have)
|
||||
|
||||
### 6. Progressive Monitoring
|
||||
**Lines in legacy**: ~1100-1300
|
||||
**What it does**: Special handling for "progressive" type miras
|
||||
- Different calculation methods
|
||||
- Integration with externa data sources
|
||||
|
||||
**Complexity**: Medium
|
||||
**Estimated time**: 2 hours
|
||||
|
||||
---
|
||||
|
||||
### 7. Performance Optimizations
|
||||
|
||||
#### 7.1 Batch Operations
|
||||
Currently processes one point at a time. Could batch:
|
||||
- Coordinate transformations
|
||||
- Database inserts
|
||||
- Threshold checks
|
||||
|
||||
**Estimated speedup**: 2-3x
|
||||
|
||||
#### 7.2 Caching
|
||||
Cache frequently accessed data:
|
||||
- Threshold configurations
|
||||
- Company limits
|
||||
- Project metadata
|
||||
|
||||
**Estimated speedup**: 1.5-2x
|
||||
|
||||
---
|
||||
|
||||
### 8. Testing
|
||||
|
||||
#### 8.1 Unit Tests
|
||||
```python
|
||||
tests/test_ts_pini_loader.py:
|
||||
- test_coordinate_transformations()
|
||||
- test_station_type_parsing()
|
||||
- test_threshold_checking()
|
||||
- test_alarm_creation()
|
||||
```
|
||||
|
||||
#### 8.2 Integration Tests
|
||||
- Test with real CSV files
|
||||
- Test with mock database
|
||||
- Test coordinate edge cases (hemispheres, zones)
|
||||
|
||||
**Estimated time**: 3-4 hours
|
||||
|
||||
---
|
||||
|
||||
## 📋 Migration Strategy
|
||||
|
||||
### Phase 1: Core + Alarms (Recommended Next Step)
|
||||
1. Implement mira creation logic (30 min)
|
||||
2. Implement basic alarm system (4-6 hours)
|
||||
3. Test with real data
|
||||
4. Deploy alongside legacy script
|
||||
|
||||
**Total time**: ~1 working day
|
||||
**Value**: 80% of use cases covered
|
||||
|
||||
### Phase 2: Additional Monitoring
|
||||
5. Implement railway monitoring (3-4 hours)
|
||||
6. Implement wall monitoring (3-4 hours)
|
||||
7. Implement truss monitoring (3-4 hours)
|
||||
|
||||
**Total time**: 1.5-2 working days
|
||||
**Value**: 95% of use cases covered
|
||||
|
||||
### Phase 3: Polish & Optimization
|
||||
8. Add time-series analysis
|
||||
9. Performance optimizations
|
||||
10. Comprehensive testing
|
||||
11. Documentation updates
|
||||
|
||||
**Total time**: 1 working day
|
||||
**Value**: Production-ready, maintainable code
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Development Tips
|
||||
|
||||
### Working with Legacy Code
|
||||
The legacy script has:
|
||||
- **Deeply nested logic**: Up to 8 levels of indentation
|
||||
- **Repeated code**: Same patterns for 15 threshold checks
|
||||
- **Magic numbers**: Hardcoded values throughout
|
||||
- **Global state**: Variables used across 1000+ lines
|
||||
|
||||
**Refactoring approach**:
|
||||
1. Extract one feature at a time
|
||||
2. Write unit test first
|
||||
3. Refactor to pass test
|
||||
4. Integrate with main loader
|
||||
|
||||
### Testing Coordinate Transformations
|
||||
```python
|
||||
# Test data from legacy script
|
||||
test_cases = [
|
||||
# CH1903 (system 6)
|
||||
{"east": 2700000, "north": 1250000, "system": 6, "expected_lat": ..., "expected_lon": ...},
|
||||
|
||||
# UTM (system 7)
|
||||
{"east": 500000, "north": 5200000, "system": 7, "zone": "32N", "expected_lat": ..., "expected_lon": ...},
|
||||
|
||||
# CH1903+ (system 10)
|
||||
{"east": 2700000, "north": 1250000, "system": 10, "expected_lat": ..., "expected_lon": ...},
|
||||
]
|
||||
```
|
||||
|
||||
### Database Schema Understanding
|
||||
Key tables:
|
||||
- `ELABDATAUPGEO`: Survey measurements
|
||||
- `upgeo_mire`: Target points (miras)
|
||||
- `upgeo_lavori`: Projects/jobs
|
||||
- `upgeo_st`: Stations
|
||||
- `sites`: Sites with coordinate system info
|
||||
- `companies`: Company info with mira limits
|
||||
- `alarms`: Alarm records
|
||||
|
||||
---
|
||||
|
||||
## 📊 Complexity Comparison
|
||||
|
||||
| Feature | Legacy | Refactored | Reduction |
|
||||
|---------|--------|-----------|-----------|
|
||||
| **Lines of code** | 2,587 | 508 (+TODO) | 80% |
|
||||
| **Functions** | 5 (1 huge) | 10+ modular | +100% |
|
||||
| **Max nesting** | 8 levels | 3 levels | 63% |
|
||||
| **Type safety** | None | Full hints | ∞ |
|
||||
| **Testability** | Impossible | Easy | ∞ |
|
||||
| **Maintainability** | Very low | High | ∞ |
|
||||
|
||||
---
|
||||
|
||||
## 📚 References
|
||||
|
||||
### Coordinate Systems
|
||||
- **CH1903**: https://www.swisstopo.admin.ch/en/knowledge-facts/surveying-geodesy/reference-systems/local/lv03.html
|
||||
- **CH1903+/LV95**: https://www.swisstopo.admin.ch/en/knowledge-facts/surveying-geodesy/reference-systems/local/lv95.html
|
||||
- **UTM**: https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system
|
||||
|
||||
### Libraries Used
|
||||
- **utm**: UTM <-> lat/lon conversions
|
||||
- **pyproj**: Swiss coordinate system transformations (EPSG:21781 -> EPSG:4326)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Success Criteria
|
||||
|
||||
Phase 1 complete when:
|
||||
- [ ] All CSV files process without errors
|
||||
- [ ] Coordinate transformations match legacy output
|
||||
- [ ] Miras are created/updated correctly
|
||||
- [ ] Basic alarms are generated for threshold violations
|
||||
- [ ] No regressions in data quality
|
||||
|
||||
Full refactoring complete when:
|
||||
- [ ] All TODO items implemented
|
||||
- [ ] Test coverage > 80%
|
||||
- [ ] Performance >= legacy script
|
||||
- [ ] All additional monitoring types work
|
||||
- [ ] Legacy script can be retired
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0 (Essential Refactoring)
|
||||
**Last Updated**: 2024-10-11
|
||||
**Status**: Ready for Phase 1 implementation
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from refactory_scripts.loaders.hirpinia_loader import HirpiniaLoader
|
||||
from refactory_scripts.loaders.sisgeo_loader import SisgeoLoader
|
||||
from refactory_scripts.loaders.sorotec_loader import SorotecLoader
|
||||
from refactory_scripts.loaders.ts_pini_loader import TSPiniLoader
|
||||
from refactory_scripts.loaders.vulink_loader import VulinkLoader
|
||||
|
||||
__all__ = ["HirpiniaLoader", "SisgeoLoader", "VulinkLoader"]
|
||||
__all__ = ["HirpiniaLoader", "SisgeoLoader", "SorotecLoader", "TSPiniLoader", "VulinkLoader"]
|
||||
|
||||
396
src/refactory_scripts/loaders/sorotec_loader.py
Normal file
396
src/refactory_scripts/loaders/sorotec_loader.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
Sorotec Pini data loader - Refactored version with async support.
|
||||
|
||||
This script processes Sorotec Pini CSV files and loads multi-channel sensor data.
|
||||
Handles two different file formats (_1_ and _2_) with different channel mappings.
|
||||
Replaces the legacy sorotecPini.py with modern async/await patterns.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from refactory_scripts.config import DatabaseConfig
|
||||
from refactory_scripts.utils import execute_many, get_db_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SorotecLoader:
|
||||
"""Loads Sorotec Pini multi-channel sensor data from CSV files."""
|
||||
|
||||
# File type identifiers
|
||||
FILE_TYPE_1 = "_1_"
|
||||
FILE_TYPE_2 = "_2_"
|
||||
|
||||
# Default values
|
||||
DEFAULT_TEMPERATURE = -273
|
||||
DEFAULT_UNIT_NAME = "ID0247"
|
||||
DEFAULT_TOOL_NAME = "DT0001"
|
||||
|
||||
# Channel mappings for File Type 1 (nodes 1-26)
|
||||
CHANNELS_TYPE_1 = list(range(1, 27)) # Nodes 1 to 26
|
||||
|
||||
# Channel mappings for File Type 2 (selective nodes)
|
||||
CHANNELS_TYPE_2 = [41, 42, 43, 44, 49, 50, 51, 52, 56, 57, 58, 59, 60, 61, 62] # 15 nodes
|
||||
|
||||
def __init__(self, db_config: DatabaseConfig):
|
||||
"""
|
||||
Initialize the Sorotec loader.
|
||||
|
||||
Args:
|
||||
db_config: Database configuration object
|
||||
"""
|
||||
self.db_config = db_config
|
||||
self.conn = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
self.conn = await get_db_connection(self.db_config.as_dict())
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
|
||||
def _extract_metadata(self, file_path: Path) -> tuple[str, str]:
|
||||
"""
|
||||
Extract unit name and tool name from file path.
|
||||
|
||||
For Sorotec, metadata is determined by folder name.
|
||||
|
||||
Args:
|
||||
file_path: Path to the CSV file
|
||||
|
||||
Returns:
|
||||
Tuple of (unit_name, tool_name)
|
||||
"""
|
||||
# Get folder name (second to last part of path)
|
||||
folder_name = file_path.parent.name
|
||||
|
||||
# Currently hardcoded for ID0247
|
||||
# TODO: Make this configurable if more units are added
|
||||
if folder_name == "ID0247":
|
||||
unit_name = self.DEFAULT_UNIT_NAME
|
||||
tool_name = self.DEFAULT_TOOL_NAME
|
||||
else:
|
||||
logger.warning(f"Unknown folder: {folder_name}, using defaults")
|
||||
unit_name = self.DEFAULT_UNIT_NAME
|
||||
tool_name = self.DEFAULT_TOOL_NAME
|
||||
|
||||
logger.debug(f"Metadata: Unit={unit_name}, Tool={tool_name}")
|
||||
return unit_name, tool_name
|
||||
|
||||
def _determine_file_type(self, file_path: Path) -> str | None:
|
||||
"""
|
||||
Determine file type based on filename pattern.
|
||||
|
||||
Args:
|
||||
file_path: Path to the CSV file
|
||||
|
||||
Returns:
|
||||
File type identifier ("_1_" or "_2_") or None if unknown
|
||||
"""
|
||||
filename = file_path.name
|
||||
|
||||
if self.FILE_TYPE_1 in filename:
|
||||
return self.FILE_TYPE_1
|
||||
elif self.FILE_TYPE_2 in filename:
|
||||
return self.FILE_TYPE_2
|
||||
else:
|
||||
logger.error(f"Unknown file type: {filename}")
|
||||
return None
|
||||
|
||||
def _parse_datetime(self, timestamp_str: str) -> tuple[str, str]:
|
||||
"""
|
||||
Parse datetime string and convert to database format.
|
||||
|
||||
Converts from "DD-MM-YYYY HH:MM:SS" to ("YYYY-MM-DD", "HH:MM:SS")
|
||||
|
||||
Args:
|
||||
timestamp_str: Timestamp string in format "DD-MM-YYYY HH:MM:SS"
|
||||
|
||||
Returns:
|
||||
Tuple of (date, time) strings
|
||||
|
||||
Examples:
|
||||
>>> _parse_datetime("11-10-2024 14:30:00")
|
||||
("2024-10-11", "14:30:00")
|
||||
"""
|
||||
parts = timestamp_str.split(" ")
|
||||
date_parts = parts[0].split("-")
|
||||
|
||||
# Convert DD-MM-YYYY to YYYY-MM-DD
|
||||
date = f"{date_parts[2]}-{date_parts[1]}-{date_parts[0]}"
|
||||
time = parts[1]
|
||||
|
||||
return date, time
|
||||
|
||||
def _parse_csv_type_1(self, lines: list[str], unit_name: str, tool_name: str) -> tuple[list, list]:
|
||||
"""
|
||||
Parse CSV file of type 1 (_1_).
|
||||
|
||||
File Type 1 has 38 columns and maps to nodes 1-26.
|
||||
|
||||
Args:
|
||||
lines: List of CSV lines
|
||||
unit_name: Unit name
|
||||
tool_name: Tool name
|
||||
|
||||
Returns:
|
||||
Tuple of (raw_data_rows, elab_data_rows)
|
||||
"""
|
||||
raw_data = []
|
||||
elab_data = []
|
||||
|
||||
for line in lines:
|
||||
# Parse CSV row
|
||||
row = line.replace('"', "").split(";")
|
||||
|
||||
# Extract timestamp
|
||||
date, time = self._parse_datetime(row[0])
|
||||
|
||||
# Extract battery voltage (an4 = column 2)
|
||||
battery = row[2]
|
||||
|
||||
# Extract channel values (E8_xxx_CHx)
|
||||
# Type 1 mapping: columns 4-35 map to channels
|
||||
ch_values = [
|
||||
row[35], # E8_181_CH1 (node 1)
|
||||
row[4], # E8_181_CH2 (node 2)
|
||||
row[5], # E8_181_CH3 (node 3)
|
||||
row[6], # E8_181_CH4 (node 4)
|
||||
row[7], # E8_181_CH5 (node 5)
|
||||
row[8], # E8_181_CH6 (node 6)
|
||||
row[9], # E8_181_CH7 (node 7)
|
||||
row[10], # E8_181_CH8 (node 8)
|
||||
row[11], # E8_182_CH1 (node 9)
|
||||
row[12], # E8_182_CH2 (node 10)
|
||||
row[13], # E8_182_CH3 (node 11)
|
||||
row[14], # E8_182_CH4 (node 12)
|
||||
row[15], # E8_182_CH5 (node 13)
|
||||
row[16], # E8_182_CH6 (node 14)
|
||||
row[17], # E8_182_CH7 (node 15)
|
||||
row[18], # E8_182_CH8 (node 16)
|
||||
row[19], # E8_183_CH1 (node 17)
|
||||
row[20], # E8_183_CH2 (node 18)
|
||||
row[21], # E8_183_CH3 (node 19)
|
||||
row[22], # E8_183_CH4 (node 20)
|
||||
row[23], # E8_183_CH5 (node 21)
|
||||
row[24], # E8_183_CH6 (node 22)
|
||||
row[25], # E8_183_CH7 (node 23)
|
||||
row[26], # E8_183_CH8 (node 24)
|
||||
row[27], # E8_184_CH1 (node 25)
|
||||
row[28], # E8_184_CH2 (node 26)
|
||||
]
|
||||
|
||||
# Create data rows for each channel
|
||||
for node_num, value in enumerate(ch_values, start=1):
|
||||
# Raw data (with battery info)
|
||||
raw_data.append((unit_name, tool_name, node_num, date, time, battery, self.DEFAULT_TEMPERATURE, value))
|
||||
|
||||
# Elaborated data (just the load value)
|
||||
elab_data.append((unit_name, tool_name, node_num, date, time, value))
|
||||
|
||||
logger.info(f"Parsed Type 1: {len(elab_data)} channel readings ({len(elab_data)//26} timestamps x 26 channels)")
|
||||
return raw_data, elab_data
|
||||
|
||||
def _parse_csv_type_2(self, lines: list[str], unit_name: str, tool_name: str) -> tuple[list, list]:
|
||||
"""
|
||||
Parse CSV file of type 2 (_2_).
|
||||
|
||||
File Type 2 has 38 columns and maps to selective nodes (41-62).
|
||||
|
||||
Args:
|
||||
lines: List of CSV lines
|
||||
unit_name: Unit name
|
||||
tool_name: Tool name
|
||||
|
||||
Returns:
|
||||
Tuple of (raw_data_rows, elab_data_rows)
|
||||
"""
|
||||
raw_data = []
|
||||
elab_data = []
|
||||
|
||||
for line in lines:
|
||||
# Parse CSV row
|
||||
row = line.replace('"', "").split(";")
|
||||
|
||||
# Extract timestamp
|
||||
date, time = self._parse_datetime(row[0])
|
||||
|
||||
# Extract battery voltage (an4 = column 37)
|
||||
battery = row[37]
|
||||
|
||||
# Extract channel values for Type 2
|
||||
# Type 2 mapping: specific columns to specific nodes
|
||||
channel_mapping = [
|
||||
(41, row[13]), # E8_182_CH1
|
||||
(42, row[14]), # E8_182_CH2
|
||||
(43, row[15]), # E8_182_CH3
|
||||
(44, row[16]), # E8_182_CH4
|
||||
(49, row[21]), # E8_183_CH1
|
||||
(50, row[22]), # E8_183_CH2
|
||||
(51, row[23]), # E8_183_CH3
|
||||
(52, row[24]), # E8_183_CH4
|
||||
(56, row[28]), # E8_183_CH8
|
||||
(57, row[29]), # E8_184_CH1
|
||||
(58, row[30]), # E8_184_CH2
|
||||
(59, row[31]), # E8_184_CH3
|
||||
(60, row[32]), # E8_184_CH4
|
||||
(61, row[33]), # E8_184_CH5
|
||||
(62, row[34]), # E8_184_CH6
|
||||
]
|
||||
|
||||
# Create data rows for each channel
|
||||
for node_num, value in channel_mapping:
|
||||
# Raw data (with battery info)
|
||||
raw_data.append((unit_name, tool_name, node_num, date, time, battery, self.DEFAULT_TEMPERATURE, value))
|
||||
|
||||
# Elaborated data (just the load value)
|
||||
elab_data.append((unit_name, tool_name, node_num, date, time, value))
|
||||
|
||||
logger.info(f"Parsed Type 2: {len(elab_data)} channel readings ({len(elab_data)//15} timestamps x 15 channels)")
|
||||
return raw_data, elab_data
|
||||
|
||||
async def _insert_data(self, raw_data: list, elab_data: list) -> tuple[int, int]:
|
||||
"""
|
||||
Insert raw and elaborated data into the database.
|
||||
|
||||
Args:
|
||||
raw_data: List of raw data tuples
|
||||
elab_data: List of elaborated data tuples
|
||||
|
||||
Returns:
|
||||
Tuple of (raw_rows_inserted, elab_rows_inserted)
|
||||
"""
|
||||
raw_query = """
|
||||
INSERT IGNORE INTO RAWDATACOR
|
||||
(UnitName, ToolNameID, NodeNum, EventDate, EventTime, BatLevel, Temperature, Val0)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
elab_query = """
|
||||
INSERT IGNORE INTO ELABDATADISP
|
||||
(UnitName, ToolNameID, NodeNum, EventDate, EventTime, load_value)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
# Insert elaborated data first
|
||||
elab_count = await execute_many(self.conn, elab_query, elab_data)
|
||||
logger.info(f"Inserted {elab_count} elaborated records")
|
||||
|
||||
# Insert raw data
|
||||
raw_count = await execute_many(self.conn, raw_query, raw_data)
|
||||
logger.info(f"Inserted {raw_count} raw records")
|
||||
|
||||
return raw_count, elab_count
|
||||
|
||||
async def process_file(self, file_path: str | Path) -> bool:
|
||||
"""
|
||||
Process a Sorotec CSV file and load data into the database.
|
||||
|
||||
Args:
|
||||
file_path: Path to the CSV file to process
|
||||
|
||||
Returns:
|
||||
True if processing was successful, False otherwise
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return False
|
||||
|
||||
if file_path.suffix.lower() not in [".csv", ".txt"]:
|
||||
logger.error(f"Invalid file type: {file_path.suffix}")
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info(f"Processing file: {file_path.name}")
|
||||
|
||||
# Extract metadata
|
||||
unit_name, tool_name = self._extract_metadata(file_path)
|
||||
|
||||
# Determine file type
|
||||
file_type = self._determine_file_type(file_path)
|
||||
if not file_type:
|
||||
return False
|
||||
|
||||
logger.info(f"File type detected: {file_type}")
|
||||
|
||||
# Read file
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
lines = [line.rstrip() for line in f.readlines()]
|
||||
|
||||
# Remove empty lines and header rows
|
||||
lines = [line for line in lines if line]
|
||||
if len(lines) > 4:
|
||||
lines = lines[4:] # Skip first 4 header lines
|
||||
|
||||
if not lines:
|
||||
logger.warning(f"No data lines found in {file_path.name}")
|
||||
return False
|
||||
|
||||
# Parse based on file type
|
||||
if file_type == self.FILE_TYPE_1:
|
||||
raw_data, elab_data = self._parse_csv_type_1(lines, unit_name, tool_name)
|
||||
else: # FILE_TYPE_2
|
||||
raw_data, elab_data = self._parse_csv_type_2(lines, unit_name, tool_name)
|
||||
|
||||
# Insert into database
|
||||
raw_count, elab_count = await self._insert_data(raw_data, elab_data)
|
||||
|
||||
logger.info(f"Successfully processed {file_path.name}: {raw_count} raw, {elab_count} elab records")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process file {file_path}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def main(file_path: str):
|
||||
"""
|
||||
Main entry point for the Sorotec loader.
|
||||
|
||||
Args:
|
||||
file_path: Path to the CSV file to process
|
||||
"""
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
|
||||
logger.info("Sorotec Loader started")
|
||||
logger.info(f"Processing file: {file_path}")
|
||||
|
||||
try:
|
||||
# Load configuration
|
||||
db_config = DatabaseConfig()
|
||||
|
||||
# Process file
|
||||
async with SorotecLoader(db_config) as loader:
|
||||
success = await loader.process_file(file_path)
|
||||
|
||||
if success:
|
||||
logger.info("Processing completed successfully")
|
||||
return 0
|
||||
else:
|
||||
logger.error("Processing failed")
|
||||
return 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||
return 1
|
||||
|
||||
finally:
|
||||
logger.info("Sorotec Loader finished")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python sorotec_loader.py <path_to_csv_file>")
|
||||
sys.exit(1)
|
||||
|
||||
exit_code = asyncio.run(main(sys.argv[1]))
|
||||
sys.exit(exit_code)
|
||||
508
src/refactory_scripts/loaders/ts_pini_loader.py
Normal file
508
src/refactory_scripts/loaders/ts_pini_loader.py
Normal file
@@ -0,0 +1,508 @@
|
||||
"""
|
||||
TS Pini (Total Station) data loader - Refactored version with async support.
|
||||
|
||||
This script processes Total Station survey data from multiple instrument types
|
||||
(Leica, Trimble S7, S9) and manages complex monitoring with multi-level alarms.
|
||||
|
||||
**STATUS**: Essential refactoring - Base structure with coordinate transformations.
|
||||
**TODO**: Complete alarm management, threshold checking, and additional monitoring.
|
||||
|
||||
Replaces the legacy TS_PiniScript.py (2,587 lines) with a modular, maintainable architecture.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
|
||||
import utm
|
||||
from pyproj import Transformer
|
||||
|
||||
from refactory_scripts.config import DatabaseConfig
|
||||
from refactory_scripts.utils import execute_query, get_db_connection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StationType(IntEnum):
|
||||
"""Total Station instrument types."""
|
||||
|
||||
LEICA = 1
|
||||
TRIMBLE_S7 = 4
|
||||
TRIMBLE_S9 = 7
|
||||
TRIMBLE_S7_INVERTED = 10 # x-y coordinates inverted
|
||||
|
||||
|
||||
class CoordinateSystem(IntEnum):
|
||||
"""Coordinate system types for transformations."""
|
||||
|
||||
CH1903 = 6 # Swiss coordinate system (old)
|
||||
UTM = 7 # Universal Transverse Mercator
|
||||
CH1903_PLUS = 10 # Swiss coordinate system LV95 (new)
|
||||
LAT_LON = 0 # Default: already in lat/lon
|
||||
|
||||
|
||||
class TSPiniLoader:
|
||||
"""
|
||||
Loads Total Station Pini survey data with coordinate transformations and alarm management.
|
||||
|
||||
This loader handles:
|
||||
- Multiple station types (Leica, Trimble S7/S9)
|
||||
- Coordinate system transformations (CH1903, UTM, lat/lon)
|
||||
- Target point (mira) management
|
||||
- Multi-level alarm system (TODO: complete implementation)
|
||||
- Additional monitoring for railways, walls, trusses (TODO)
|
||||
"""
|
||||
|
||||
# Folder name mappings for special cases
|
||||
FOLDER_MAPPINGS = {
|
||||
"[276_208_TS0003]": "TS0003",
|
||||
"[Neuchatel_CDP]": "TS7",
|
||||
"[TS0006_EP28]": "TS0006_EP28",
|
||||
"[TS0007_ChesaArcoiris]": "TS0007_ChesaArcoiris",
|
||||
"[TS0006_EP28_3]": "TS0006_EP28_3",
|
||||
"[TS0006_EP28_4]": "TS0006_EP28_4",
|
||||
"[TS0006_EP28_5]": "TS0006_EP28_5",
|
||||
"[TS18800]": "TS18800",
|
||||
"[Granges_19 100]": "Granges_19 100",
|
||||
"[Granges_19 200]": "Granges_19 200",
|
||||
"[Chesa_Arcoiris_2]": "Chesa_Arcoiris_2",
|
||||
"[TS0006_EP28_1]": "TS0006_EP28_1",
|
||||
"[TS_PS_Petites_Croisettes]": "TS_PS_Petites_Croisettes",
|
||||
"[_Chesa_Arcoiris_1]": "_Chesa_Arcoiris_1",
|
||||
"[TS_test]": "TS_test",
|
||||
"[TS-VIME]": "TS-VIME",
|
||||
}
|
||||
|
||||
def __init__(self, db_config: DatabaseConfig):
|
||||
"""
|
||||
Initialize the TS Pini loader.
|
||||
|
||||
Args:
|
||||
db_config: Database configuration object
|
||||
"""
|
||||
self.db_config = db_config
|
||||
self.conn = None
|
||||
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
self.conn = await get_db_connection(self.db_config.as_dict())
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
|
||||
def _extract_folder_name(self, file_path: Path) -> str:
|
||||
"""
|
||||
Extract and normalize folder name from file path.
|
||||
|
||||
Handles special folder name mappings for specific projects.
|
||||
|
||||
Args:
|
||||
file_path: Path to the CSV file
|
||||
|
||||
Returns:
|
||||
Normalized folder name
|
||||
"""
|
||||
# Get folder name from path
|
||||
folder_name = file_path.parent.name
|
||||
|
||||
# Check for special mappings in filename
|
||||
filename = file_path.name
|
||||
for pattern, mapped_name in self.FOLDER_MAPPINGS.items():
|
||||
if pattern in filename:
|
||||
logger.debug(f"Mapped folder: {pattern} -> {mapped_name}")
|
||||
return mapped_name
|
||||
|
||||
return folder_name
|
||||
|
||||
async def _get_project_info(self, folder_name: str) -> dict | None:
|
||||
"""
|
||||
Get project information from database based on folder name.
|
||||
|
||||
Args:
|
||||
folder_name: Folder/station name
|
||||
|
||||
Returns:
|
||||
Dictionary with project info or None if not found
|
||||
"""
|
||||
query = """
|
||||
SELECT
|
||||
l.id as lavoro_id,
|
||||
s.id as site_id,
|
||||
st.type_id,
|
||||
s.upgeo_sist_coordinate,
|
||||
s.upgeo_utmzone,
|
||||
s.upgeo_utmhemisphere
|
||||
FROM upgeo_st as st
|
||||
LEFT JOIN upgeo_lavori as l ON st.lavoro_id = l.id
|
||||
LEFT JOIN sites as s ON s.id = l.site_id
|
||||
WHERE st.name = %s
|
||||
"""
|
||||
|
||||
result = await execute_query(self.conn, query, (folder_name,), fetch_one=True)
|
||||
|
||||
if not result:
|
||||
logger.error(f"Project not found for folder: {folder_name}")
|
||||
return None
|
||||
|
||||
return {
|
||||
"lavoro_id": result["lavoro_id"],
|
||||
"site_id": result["site_id"],
|
||||
"station_type": result["type_id"],
|
||||
"coordinate_system": int(result["upgeo_sist_coordinate"]),
|
||||
"utm_zone": result["upgeo_utmzone"],
|
||||
"utm_hemisphere": result["upgeo_utmhemisphere"] != "S", # True for North
|
||||
}
|
||||
|
||||
def _parse_csv_row(self, row: list[str], station_type: int) -> tuple[str, str, str, str, str]:
|
||||
"""
|
||||
Parse CSV row based on station type.
|
||||
|
||||
Different station types have different column orders.
|
||||
|
||||
Args:
|
||||
row: List of CSV values
|
||||
station_type: Station type identifier
|
||||
|
||||
Returns:
|
||||
Tuple of (mira_name, easting, northing, height, timestamp)
|
||||
"""
|
||||
if station_type == StationType.LEICA:
|
||||
# Leica format: name, easting, northing, height, timestamp
|
||||
mira_name = row[0]
|
||||
easting = row[1]
|
||||
northing = row[2]
|
||||
height = row[3]
|
||||
# Convert timestamp: DD.MM.YYYY HH:MM:SS.fff -> YYYY-MM-DD HH:MM:SS
|
||||
timestamp = datetime.strptime(row[4], "%d.%m.%Y %H:%M:%S.%f").strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
elif station_type in (StationType.TRIMBLE_S7, StationType.TRIMBLE_S9):
|
||||
# Trimble S7/S9 format: timestamp, name, northing, easting, height
|
||||
timestamp = row[0]
|
||||
mira_name = row[1]
|
||||
northing = row[2]
|
||||
easting = row[3]
|
||||
height = row[4]
|
||||
|
||||
elif station_type == StationType.TRIMBLE_S7_INVERTED:
|
||||
# Trimble S7 inverted: timestamp, name, easting(row[2]), northing(row[3]), height
|
||||
timestamp = row[0]
|
||||
mira_name = row[1]
|
||||
northing = row[3] # Inverted!
|
||||
easting = row[2] # Inverted!
|
||||
height = row[4]
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unknown station type: {station_type}")
|
||||
|
||||
return mira_name, easting, northing, height, timestamp
|
||||
|
||||
def _transform_coordinates(
|
||||
self, easting: float, northing: float, coord_system: int, utm_zone: str = None, utm_hemisphere: bool = True
|
||||
) -> tuple[float, float]:
|
||||
"""
|
||||
Transform coordinates to lat/lon based on coordinate system.
|
||||
|
||||
Args:
|
||||
easting: Easting coordinate
|
||||
northing: Northing coordinate
|
||||
coord_system: Coordinate system type
|
||||
utm_zone: UTM zone (required for UTM system)
|
||||
utm_hemisphere: True for Northern, False for Southern
|
||||
|
||||
Returns:
|
||||
Tuple of (latitude, longitude)
|
||||
"""
|
||||
if coord_system == CoordinateSystem.CH1903:
|
||||
# Old Swiss coordinate system transformation
|
||||
y = easting
|
||||
x = northing
|
||||
y_ = (y - 2600000) / 1000000
|
||||
x_ = (x - 1200000) / 1000000
|
||||
|
||||
lambda_ = 2.6779094 + 4.728982 * y_ + 0.791484 * y_ * x_ + 0.1306 * y_ * x_**2 - 0.0436 * y_**3
|
||||
phi_ = 16.9023892 + 3.238272 * x_ - 0.270978 * y_**2 - 0.002528 * x_**2 - 0.0447 * y_**2 * x_ - 0.0140 * x_**3
|
||||
|
||||
lat = phi_ * 100 / 36
|
||||
lon = lambda_ * 100 / 36
|
||||
|
||||
elif coord_system == CoordinateSystem.UTM:
|
||||
# UTM to lat/lon
|
||||
if not utm_zone:
|
||||
raise ValueError("UTM zone required for UTM coordinate system")
|
||||
|
||||
result = utm.to_latlon(easting, northing, utm_zone, northern=utm_hemisphere)
|
||||
lat = result[0]
|
||||
lon = result[1]
|
||||
|
||||
elif coord_system == CoordinateSystem.CH1903_PLUS:
|
||||
# New Swiss coordinate system (LV95) using EPSG:21781 -> EPSG:4326
|
||||
transformer = Transformer.from_crs("EPSG:21781", "EPSG:4326")
|
||||
lat, lon = transformer.transform(easting, northing)
|
||||
|
||||
else:
|
||||
# Already in lat/lon
|
||||
lon = easting
|
||||
lat = northing
|
||||
|
||||
logger.debug(f"Transformed coordinates: ({easting}, {northing}) -> ({lat:.6f}, {lon:.6f})")
|
||||
return lat, lon
|
||||
|
||||
async def _get_or_create_mira(self, mira_name: str, lavoro_id: int) -> int | None:
|
||||
"""
|
||||
Get existing mira (target point) ID or create new one if allowed.
|
||||
|
||||
Args:
|
||||
mira_name: Name of the target point
|
||||
lavoro_id: Project ID
|
||||
|
||||
Returns:
|
||||
Mira ID or None if creation not allowed
|
||||
"""
|
||||
# Check if mira exists
|
||||
query = """
|
||||
SELECT m.id as mira_id, m.name
|
||||
FROM upgeo_mire as m
|
||||
JOIN upgeo_lavori as l ON m.lavoro_id = l.id
|
||||
WHERE m.name = %s AND m.lavoro_id = %s
|
||||
"""
|
||||
|
||||
result = await execute_query(self.conn, query, (mira_name, lavoro_id), fetch_one=True)
|
||||
|
||||
if result:
|
||||
return result["mira_id"]
|
||||
|
||||
# Mira doesn't exist - check if we can create it
|
||||
logger.info(f"Mira '{mira_name}' not found, attempting to create...")
|
||||
|
||||
# TODO: Implement mira creation logic
|
||||
# This requires checking company limits and updating counters
|
||||
# For now, return None to skip
|
||||
logger.warning("Mira creation not yet implemented in refactored version")
|
||||
return None
|
||||
|
||||
async def _insert_survey_data(
|
||||
self,
|
||||
mira_id: int,
|
||||
timestamp: str,
|
||||
northing: float,
|
||||
easting: float,
|
||||
height: float,
|
||||
lat: float,
|
||||
lon: float,
|
||||
coord_system: int,
|
||||
) -> bool:
|
||||
"""
|
||||
Insert survey data into ELABDATAUPGEO table.
|
||||
|
||||
Args:
|
||||
mira_id: Target point ID
|
||||
timestamp: Survey timestamp
|
||||
northing: Northing coordinate
|
||||
easting: Easting coordinate
|
||||
height: Elevation
|
||||
lat: Latitude
|
||||
lon: Longitude
|
||||
coord_system: Coordinate system type
|
||||
|
||||
Returns:
|
||||
True if insert was successful
|
||||
"""
|
||||
query = """
|
||||
INSERT IGNORE INTO ELABDATAUPGEO
|
||||
(mira_id, EventTimestamp, north, east, elevation, lat, lon, sist_coordinate)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
"""
|
||||
|
||||
params = (mira_id, timestamp, northing, easting, height, lat, lon, coord_system)
|
||||
|
||||
try:
|
||||
await execute_query(self.conn, query, params)
|
||||
logger.debug(f"Inserted survey data for mira_id {mira_id} at {timestamp}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to insert survey data: {e}")
|
||||
return False
|
||||
|
||||
async def _process_thresholds_and_alarms(self, lavoro_id: int, processed_miras: list[int]) -> None:
|
||||
"""
|
||||
Process thresholds and create alarms for monitored points.
|
||||
|
||||
**TODO**: This is a stub for the complex alarm system.
|
||||
The complete implementation requires:
|
||||
- Multi-level threshold checking (3 levels: attention, intervention, immediate)
|
||||
- 5 dimensions: N, E, H, R2D, R3D
|
||||
- Email and SMS notifications
|
||||
- Time-series analysis
|
||||
- Railway/wall/truss specific monitoring
|
||||
|
||||
Args:
|
||||
lavoro_id: Project ID
|
||||
processed_miras: List of mira IDs that were processed
|
||||
"""
|
||||
logger.warning("Threshold and alarm processing is not yet implemented")
|
||||
logger.info(f"Would process alarms for {len(processed_miras)} miras in lavoro {lavoro_id}")
|
||||
|
||||
# TODO: Implement alarm system
|
||||
# 1. Load threshold configurations from upgeo_lavori and upgeo_mire tables
|
||||
# 2. Query latest survey data for each mira
|
||||
# 3. Calculate displacements (N, E, H, R2D, R3D)
|
||||
# 4. Check against 3-level thresholds
|
||||
# 5. Create alarms if thresholds exceeded
|
||||
# 6. Handle additional monitoring (railways, walls, trusses)
|
||||
|
||||
async def process_file(self, file_path: str | Path) -> bool:
|
||||
"""
|
||||
Process a Total Station CSV file and load data into the database.
|
||||
|
||||
**Current Implementation**: Core data loading with coordinate transformations.
|
||||
**TODO**: Complete alarm and additional monitoring implementation.
|
||||
|
||||
Args:
|
||||
file_path: Path to the CSV file to process
|
||||
|
||||
Returns:
|
||||
True if processing was successful, False otherwise
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
logger.error(f"File not found: {file_path}")
|
||||
return False
|
||||
|
||||
try:
|
||||
logger.info(f"Processing Total Station file: {file_path.name}")
|
||||
|
||||
# Extract folder name
|
||||
folder_name = self._extract_folder_name(file_path)
|
||||
logger.info(f"Station/Project: {folder_name}")
|
||||
|
||||
# Get project information
|
||||
project_info = await self._get_project_info(folder_name)
|
||||
if not project_info:
|
||||
return False
|
||||
|
||||
station_type = project_info["station_type"]
|
||||
coord_system = project_info["coordinate_system"]
|
||||
lavoro_id = project_info["lavoro_id"]
|
||||
|
||||
logger.info(f"Station type: {station_type}, Coordinate system: {coord_system}")
|
||||
|
||||
# Read and parse CSV file
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
lines = [line.rstrip() for line in f.readlines()]
|
||||
|
||||
# Skip header
|
||||
if lines:
|
||||
lines = lines[1:]
|
||||
|
||||
processed_count = 0
|
||||
processed_miras = []
|
||||
|
||||
# Process each survey point
|
||||
for line in lines:
|
||||
if not line:
|
||||
continue
|
||||
|
||||
row = line.split(",")
|
||||
|
||||
try:
|
||||
# Parse row based on station type
|
||||
mira_name, easting, northing, height, timestamp = self._parse_csv_row(row, station_type)
|
||||
|
||||
# Transform coordinates to lat/lon
|
||||
lat, lon = self._transform_coordinates(
|
||||
float(easting),
|
||||
float(northing),
|
||||
coord_system,
|
||||
project_info.get("utm_zone"),
|
||||
project_info.get("utm_hemisphere"),
|
||||
)
|
||||
|
||||
# Get or create mira
|
||||
mira_id = await self._get_or_create_mira(mira_name, lavoro_id)
|
||||
|
||||
if not mira_id:
|
||||
logger.warning(f"Skipping mira '{mira_name}' - not found and creation not allowed")
|
||||
continue
|
||||
|
||||
# Insert survey data
|
||||
success = await self._insert_survey_data(
|
||||
mira_id, timestamp, float(northing), float(easting), float(height), lat, lon, coord_system
|
||||
)
|
||||
|
||||
if success:
|
||||
processed_count += 1
|
||||
if mira_id not in processed_miras:
|
||||
processed_miras.append(mira_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process row: {e}")
|
||||
logger.debug(f"Row data: {row}")
|
||||
continue
|
||||
|
||||
logger.info(f"Processed {processed_count} survey points for {len(processed_miras)} miras")
|
||||
|
||||
# Process thresholds and alarms (TODO: complete implementation)
|
||||
if processed_miras:
|
||||
await self._process_thresholds_and_alarms(lavoro_id, processed_miras)
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process file {file_path}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
async def main(file_path: str):
|
||||
"""
|
||||
Main entry point for the TS Pini loader.
|
||||
|
||||
Args:
|
||||
file_path: Path to the CSV file to process
|
||||
"""
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
|
||||
logger.info("TS Pini Loader started")
|
||||
logger.info(f"Processing file: {file_path}")
|
||||
logger.warning("NOTE: Alarm system not yet fully implemented in this refactored version")
|
||||
|
||||
try:
|
||||
# Load configuration
|
||||
db_config = DatabaseConfig()
|
||||
|
||||
# Process file
|
||||
async with TSPiniLoader(db_config) as loader:
|
||||
success = await loader.process_file(file_path)
|
||||
|
||||
if success:
|
||||
logger.info("Processing completed successfully")
|
||||
return 0
|
||||
else:
|
||||
logger.error("Processing failed")
|
||||
return 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||
return 1
|
||||
|
||||
finally:
|
||||
logger.info("TS Pini Loader finished")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python ts_pini_loader.py <path_to_csv_file>")
|
||||
print("\nNOTE: This is an essential refactoring of the legacy TS_PiniScript.py")
|
||||
print(" Core functionality (data loading, coordinates) is implemented.")
|
||||
print(" Alarm system and additional monitoring require completion.")
|
||||
sys.exit(1)
|
||||
|
||||
exit_code = asyncio.run(main(sys.argv[1]))
|
||||
sys.exit(exit_code)
|
||||
177
src/utils/authorizers/database_authorizer.py
Normal file
177
src/utils/authorizers/database_authorizer.py
Normal file
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Database-backed authorizer for FTP server that checks authentication against database in real-time.
|
||||
This ensures multiple FTP server instances stay synchronized without needing restarts.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from hashlib import sha256
|
||||
from pathlib import Path
|
||||
|
||||
from pyftpdlib.authorizers import AuthenticationFailed, DummyAuthorizer
|
||||
|
||||
from utils.database.connection import connetti_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DatabaseAuthorizer(DummyAuthorizer):
|
||||
"""
|
||||
Custom authorizer that validates users against the database on every login.
|
||||
|
||||
This approach ensures that:
|
||||
- Multiple FTP server instances stay synchronized
|
||||
- User changes (add/remove/disable) are reflected immediately
|
||||
- No server restart is needed when users are modified
|
||||
"""
|
||||
|
||||
def __init__(self, cfg: dict) -> None:
|
||||
"""
|
||||
Initializes the authorizer with admin user only.
|
||||
Regular users are validated against database at login time.
|
||||
|
||||
Args:
|
||||
cfg: The configuration object.
|
||||
"""
|
||||
super().__init__()
|
||||
self.cfg = cfg
|
||||
|
||||
# Add admin user to in-memory authorizer (always available)
|
||||
self.add_user(
|
||||
cfg.adminuser[0], # username
|
||||
cfg.adminuser[1], # password hash
|
||||
cfg.adminuser[2], # home directory
|
||||
perm=cfg.adminuser[3] # permissions
|
||||
)
|
||||
|
||||
logger.info("DatabaseAuthorizer initialized with admin user")
|
||||
|
||||
def validate_authentication(self, username: str, password: str, handler: object) -> None:
|
||||
"""
|
||||
Validates user authentication against the database.
|
||||
|
||||
This method is called on every login attempt and checks:
|
||||
1. If user is admin, use in-memory credentials
|
||||
2. Otherwise, query database for user credentials
|
||||
3. Verify password hash matches
|
||||
4. Ensure user is not disabled
|
||||
|
||||
Args:
|
||||
username: The username attempting to login.
|
||||
password: The plain-text password provided.
|
||||
handler: The FTP handler object.
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If authentication fails for any reason.
|
||||
"""
|
||||
# Hash the provided password
|
||||
password_hash = sha256(password.encode("UTF-8")).hexdigest()
|
||||
|
||||
# Check if user is admin (stored in memory)
|
||||
if username == self.cfg.adminuser[0]:
|
||||
if self.user_table[username]["pwd"] != password_hash:
|
||||
logger.warning(f"Failed admin login attempt for user: {username}")
|
||||
raise AuthenticationFailed("Invalid credentials")
|
||||
return
|
||||
|
||||
# For regular users, check database
|
||||
try:
|
||||
conn = connetti_db(self.cfg)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Query user from database
|
||||
cur.execute(
|
||||
f"SELECT ftpuser, hash, virtpath, perm, disabled_at FROM {self.cfg.dbname}.{self.cfg.dbusertable} WHERE ftpuser = %s",
|
||||
(username,)
|
||||
)
|
||||
|
||||
result = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
if not result:
|
||||
logger.warning(f"Login attempt for non-existent user: {username}")
|
||||
raise AuthenticationFailed("Invalid credentials")
|
||||
|
||||
ftpuser, stored_hash, virtpath, perm, disabled_at = result
|
||||
|
||||
# Check if user is disabled
|
||||
if disabled_at is not None:
|
||||
logger.warning(f"Login attempt for disabled user: {username}")
|
||||
raise AuthenticationFailed("User account is disabled")
|
||||
|
||||
# Verify password
|
||||
if stored_hash != password_hash:
|
||||
logger.warning(f"Invalid password for user: {username}")
|
||||
raise AuthenticationFailed("Invalid credentials")
|
||||
|
||||
# Authentication successful - ensure user directory exists
|
||||
try:
|
||||
Path(virtpath).mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create directory for user {username}: {e}")
|
||||
raise AuthenticationFailed("System error")
|
||||
|
||||
# Temporarily add user to in-memory table for this session
|
||||
# This allows pyftpdlib to work correctly for the duration of the session
|
||||
# We add/update directly to avoid issues with add_user() checking if user exists
|
||||
if username in self.user_table:
|
||||
# User already exists, just update credentials
|
||||
self.user_table[username]['pwd'] = stored_hash
|
||||
self.user_table[username]['home'] = virtpath
|
||||
self.user_table[username]['perm'] = perm
|
||||
self.user_table[username]['operms'] = {}
|
||||
else:
|
||||
# User doesn't exist, add to table directly with all required fields
|
||||
self.user_table[username] = {
|
||||
'pwd': stored_hash,
|
||||
'home': virtpath,
|
||||
'perm': perm,
|
||||
'operms': {}, # Optional per-directory permissions
|
||||
'msg_login': '230 Login successful.',
|
||||
'msg_quit': '221 Goodbye.'
|
||||
}
|
||||
|
||||
logger.info(f"Successful login for user: {username}")
|
||||
|
||||
except AuthenticationFailed:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Database error during authentication for user {username}: {e}", exc_info=True)
|
||||
raise AuthenticationFailed("System error")
|
||||
|
||||
def has_user(self, username: str) -> bool:
|
||||
"""
|
||||
Check if a user exists in the database or in-memory table.
|
||||
|
||||
This is called by pyftpdlib for various checks. We override it to check
|
||||
the database as well as the in-memory table.
|
||||
|
||||
Args:
|
||||
username: The username to check.
|
||||
|
||||
Returns:
|
||||
True if user exists and is enabled, False otherwise.
|
||||
"""
|
||||
# Check in-memory first (for admin and active sessions)
|
||||
if username in self.user_table:
|
||||
return True
|
||||
|
||||
# Check database for regular users
|
||||
try:
|
||||
conn = connetti_db(self.cfg)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM {self.cfg.dbname}.{self.cfg.dbusertable} WHERE ftpuser = %s AND disabled_at IS NULL",
|
||||
(username,)
|
||||
)
|
||||
|
||||
count = cur.fetchone()[0]
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
return count > 0
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database error checking user existence for {username}: {e}")
|
||||
return False
|
||||
@@ -1,5 +1,6 @@
|
||||
"""set configurations"""
|
||||
|
||||
import os
|
||||
from configparser import ConfigParser
|
||||
|
||||
from . import ENV_PARENT_PATH
|
||||
@@ -10,15 +11,21 @@ class Config:
|
||||
"""
|
||||
Initializes the Config class by reading configuration files.
|
||||
It loads settings from 'ftp.ini' and 'db.ini' for FTP server, CSV, logging, and database.
|
||||
Environment variables override INI file settings for Docker deployments.
|
||||
"""
|
||||
|
||||
c = ConfigParser()
|
||||
c.read([f"{ENV_PARENT_PATH}/env/ftp.ini", f"{ENV_PARENT_PATH}/env/db.ini"])
|
||||
|
||||
# FTP setting
|
||||
self.service_port = c.getint("ftpserver", "service_port")
|
||||
self.firstport = c.getint("ftpserver", "firstPort")
|
||||
self.proxyaddr = c.get("ftpserver", "proxyAddr")
|
||||
# FTP setting (with environment variable override for Docker)
|
||||
self.service_port = int(os.getenv("FTP_PORT", c.getint("ftpserver", "service_port")))
|
||||
|
||||
# FTP_PASSIVE_PORTS: override della porta iniziale del range passivo
|
||||
self.firstport = int(os.getenv("FTP_PASSIVE_PORTS", c.getint("ftpserver", "firstPort")))
|
||||
|
||||
# FTP_EXTERNAL_IP: override dell'IP pubblicizzato (VIP per HA)
|
||||
self.proxyaddr = os.getenv("FTP_EXTERNAL_IP", c.get("ftpserver", "proxyAddr"))
|
||||
|
||||
self.portrangewidth = c.getint("ftpserver", "portRangeWidth")
|
||||
self.virtpath = c.get("ftpserver", "virtpath")
|
||||
self.adminuser = c.get("ftpserver", "adminuser").split("|")
|
||||
@@ -27,18 +34,22 @@ class Config:
|
||||
self.fileext = c.get("ftpserver", "fileext").upper().split("|")
|
||||
self.defperm = c.get("ftpserver", "defaultUserPerm")
|
||||
|
||||
# File processing behavior: delete files after successful processing
|
||||
# Set DELETE_AFTER_PROCESSING=true in docker-compose to enable
|
||||
self.delete_after_processing = os.getenv("DELETE_AFTER_PROCESSING", "false").lower() in ("true", "1", "yes")
|
||||
|
||||
# CSV FILE setting
|
||||
self.csvfs = c.get("csvfs", "path")
|
||||
|
||||
# LOG setting
|
||||
self.logfilename = c.get("logging", "logFilename")
|
||||
|
||||
# DB setting
|
||||
self.dbhost = c.get("db", "hostname")
|
||||
self.dbport = c.getint("db", "port")
|
||||
self.dbuser = c.get("db", "user")
|
||||
self.dbpass = c.get("db", "password")
|
||||
self.dbname = c.get("db", "dbName")
|
||||
# DB setting (with environment variable override for Docker)
|
||||
self.dbhost = os.getenv("DB_HOST", c.get("db", "hostname"))
|
||||
self.dbport = int(os.getenv("DB_PORT", c.getint("db", "port")))
|
||||
self.dbuser = os.getenv("DB_USER", c.get("db", "user"))
|
||||
self.dbpass = os.getenv("DB_PASSWORD", c.get("db", "password"))
|
||||
self.dbname = os.getenv("DB_NAME", c.get("db", "dbName"))
|
||||
self.max_retries = c.getint("db", "maxRetries")
|
||||
|
||||
# Tables
|
||||
|
||||
@@ -109,6 +109,14 @@ async def on_file_received_async(self: object, file: str) -> None:
|
||||
# Note: autocommit=True in connection, no need for explicit commit
|
||||
logger.info(f"File {new_filename} loaded successfully")
|
||||
|
||||
# Delete file after successful processing if configured
|
||||
if getattr(cfg, 'delete_after_processing', False):
|
||||
try:
|
||||
os.remove(f"{path}/{new_filename}")
|
||||
logger.info(f"File {new_filename} deleted after successful processing")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete file {new_filename}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"File {new_filename} not loaded. Held in user path.")
|
||||
logger.error(f"{e}")
|
||||
|
||||
@@ -12,7 +12,8 @@ def connetti_db(cfg: object) -> object:
|
||||
Establishes a synchronous connection to a MySQL database.
|
||||
|
||||
DEPRECATED: Use connetti_db_async() for async code.
|
||||
This function is kept for backward compatibility with old_scripts only.
|
||||
This function is kept for backward compatibility with synchronous code
|
||||
(e.g., ftp_csv_receiver.py which uses pyftpdlib).
|
||||
|
||||
Args:
|
||||
cfg: A configuration object containing database connection parameters.
|
||||
|
||||
@@ -4,6 +4,7 @@ import logging
|
||||
import os
|
||||
import signal
|
||||
from collections.abc import Callable, Coroutine
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Any
|
||||
|
||||
import aiomysql
|
||||
@@ -33,24 +34,37 @@ class WorkerFormatter(logging.Formatter):
|
||||
|
||||
|
||||
def setup_logging(log_filename: str, log_level_str: str):
|
||||
"""Configura il logging globale.
|
||||
"""Configura il logging globale con rotation automatica.
|
||||
|
||||
Args:
|
||||
log_filename (str): Percorso del file di log.
|
||||
log_level_str (str): Livello di log (es. "INFO", "DEBUG").
|
||||
"""
|
||||
logger = logging.getLogger()
|
||||
handler = logging.FileHandler(log_filename)
|
||||
formatter = WorkerFormatter("%(asctime)s - PID: %(process)d.Worker-%(worker_id)s.%(name)s.%(funcName)s.%(levelname)s: %(message)s")
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
# Rimuovi eventuali handler esistenti e aggiungi il nostro
|
||||
# Rimuovi eventuali handler esistenti
|
||||
if logger.hasHandlers():
|
||||
logger.handlers.clear()
|
||||
logger.addHandler(handler)
|
||||
|
||||
# Handler per file con rotation (max 10MB per file, mantiene 5 backup)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_filename,
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=5, # Mantiene 5 file di backup
|
||||
encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# Handler per console (utile per Docker)
|
||||
console_handler = logging.StreamHandler()
|
||||
console_handler.setFormatter(formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
log_level = getattr(logging, log_level_str.upper(), logging.INFO)
|
||||
logger.setLevel(log_level)
|
||||
logger.info("Logging configurato correttamente")
|
||||
logger.info("Logging configurato correttamente con rotation (10MB, 5 backup)")
|
||||
|
||||
|
||||
def setup_signal_handlers(logger: logging.Logger):
|
||||
|
||||
240
src/utils/servers/sftp_server.py
Normal file
240
src/utils/servers/sftp_server.py
Normal file
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
SFTP Server implementation using asyncssh.
|
||||
Shares the same authentication system and file handling logic as the FTP server.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import asyncssh
|
||||
|
||||
from utils.connect import file_management
|
||||
from utils.database.connection import connetti_db_async
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ASESFTPServer(asyncssh.SFTPServer):
|
||||
"""Custom SFTP server that handles file uploads with the same logic as FTP server."""
|
||||
|
||||
def __init__(self, chan):
|
||||
"""Initialize SFTP server with channel."""
|
||||
super().__init__(chan)
|
||||
# Get config from connection (set during authentication)
|
||||
self.cfg = chan.get_connection()._cfg
|
||||
|
||||
async def close(self):
|
||||
"""Called when SFTP session is closed."""
|
||||
logger.info(f"SFTP session closed for user: {self._chan.get_connection().get_extra_info('username')}")
|
||||
await super().close()
|
||||
|
||||
|
||||
class ASESSHServer(asyncssh.SSHServer):
|
||||
"""Custom SSH server for SFTP authentication using database."""
|
||||
|
||||
def __init__(self, cfg):
|
||||
"""Initialize SSH server with configuration."""
|
||||
self.cfg = cfg
|
||||
self.user_home_dirs = {} # Store user home directories after authentication
|
||||
super().__init__()
|
||||
|
||||
def connection_made(self, conn):
|
||||
"""Called when connection is established."""
|
||||
# Store config in connection for later use
|
||||
conn._cfg = self.cfg
|
||||
conn._ssh_server = self # Store reference to server for accessing user_home_dirs
|
||||
logger.info(f"SSH connection from {conn.get_extra_info('peername')[0]}")
|
||||
|
||||
def connection_lost(self, exc):
|
||||
"""Called when connection is lost."""
|
||||
if exc:
|
||||
logger.error(f"SSH connection lost: {exc}")
|
||||
|
||||
async def validate_password(self, username, password):
|
||||
"""
|
||||
Validate user credentials against database.
|
||||
Same logic as DatabaseAuthorizer for FTP.
|
||||
"""
|
||||
from hashlib import sha256
|
||||
|
||||
# Hash the provided password
|
||||
password_hash = sha256(password.encode("UTF-8")).hexdigest()
|
||||
|
||||
# Check if user is admin
|
||||
if username == self.cfg.adminuser[0]:
|
||||
if self.cfg.adminuser[1] == password_hash:
|
||||
# Store admin home directory
|
||||
self.user_home_dirs[username] = self.cfg.adminuser[2]
|
||||
logger.info(f"Admin user '{username}' authenticated successfully (home: {self.cfg.adminuser[2]})")
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed admin login attempt for user: {username}")
|
||||
return False
|
||||
|
||||
# For regular users, check database
|
||||
try:
|
||||
conn = await connetti_db_async(self.cfg)
|
||||
cur = await conn.cursor()
|
||||
|
||||
# Query user from database
|
||||
await cur.execute(
|
||||
f"SELECT ftpuser, hash, virtpath, perm, disabled_at FROM {self.cfg.dbname}.{self.cfg.dbusertable} WHERE ftpuser = %s",
|
||||
(username,)
|
||||
)
|
||||
|
||||
result = await cur.fetchone()
|
||||
await cur.close()
|
||||
conn.close()
|
||||
|
||||
if not result:
|
||||
logger.warning(f"SFTP login attempt for non-existent user: {username}")
|
||||
return False
|
||||
|
||||
ftpuser, stored_hash, virtpath, perm, disabled_at = result
|
||||
|
||||
# Check if user is disabled
|
||||
if disabled_at is not None:
|
||||
logger.warning(f"SFTP login attempt for disabled user: {username}")
|
||||
return False
|
||||
|
||||
# Verify password
|
||||
if stored_hash != password_hash:
|
||||
logger.warning(f"Invalid password for SFTP user: {username}")
|
||||
return False
|
||||
|
||||
# Authentication successful - ensure user directory exists
|
||||
try:
|
||||
Path(virtpath).mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create directory for user {username}: {e}")
|
||||
return False
|
||||
|
||||
# Store the user's home directory for chroot
|
||||
self.user_home_dirs[username] = virtpath
|
||||
|
||||
logger.info(f"Successful SFTP login for user: {username} (home: {virtpath})")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database error during SFTP authentication for user {username}: {e}", exc_info=True)
|
||||
return False
|
||||
|
||||
def password_auth_supported(self):
|
||||
"""Enable password authentication."""
|
||||
return True
|
||||
|
||||
def begin_auth(self, username):
|
||||
"""Called when authentication begins."""
|
||||
logger.debug(f"Authentication attempt for user: {username}")
|
||||
return True
|
||||
|
||||
|
||||
class SFTPFileHandler(asyncssh.SFTPServer):
|
||||
"""Extended SFTP server with file upload handling."""
|
||||
|
||||
def __init__(self, chan):
|
||||
super().__init__(chan, chroot=self._get_user_home(chan))
|
||||
self.cfg = chan.get_connection()._cfg
|
||||
self._open_files = {} # Track open files for processing
|
||||
|
||||
@staticmethod
|
||||
def _get_user_home(chan):
|
||||
"""Get the home directory for the authenticated user."""
|
||||
conn = chan.get_connection()
|
||||
username = conn.get_extra_info('username')
|
||||
ssh_server = getattr(conn, '_ssh_server', None)
|
||||
|
||||
if ssh_server and username in ssh_server.user_home_dirs:
|
||||
return ssh_server.user_home_dirs[username]
|
||||
|
||||
# Fallback for admin user
|
||||
if hasattr(conn, '_cfg') and username == conn._cfg.adminuser[0]:
|
||||
return conn._cfg.adminuser[2]
|
||||
|
||||
return None
|
||||
|
||||
def open(self, path, pflags, attrs):
|
||||
"""Track files being opened for writing."""
|
||||
result = super().open(path, pflags, attrs)
|
||||
|
||||
# If file is opened for writing (pflags contains FXF_WRITE)
|
||||
if pflags & 0x02: # FXF_WRITE flag
|
||||
real_path = self.map_path(path)
|
||||
# Convert bytes to str if necessary
|
||||
if isinstance(real_path, bytes):
|
||||
real_path = real_path.decode('utf-8')
|
||||
self._open_files[result] = real_path
|
||||
logger.debug(f"File opened for writing: {real_path}")
|
||||
|
||||
return result
|
||||
|
||||
async def close(self, file_obj):
|
||||
"""Process file after it's closed."""
|
||||
# Call parent close first (this doesn't return anything useful)
|
||||
result = super().close(file_obj)
|
||||
|
||||
# Check if this file was tracked
|
||||
if file_obj in self._open_files:
|
||||
filepath = self._open_files.pop(file_obj)
|
||||
|
||||
# Process CSV files
|
||||
if filepath.lower().endswith('.csv'):
|
||||
try:
|
||||
logger.info(f"CSV file closed after upload via SFTP: {filepath}")
|
||||
|
||||
# Get username
|
||||
username = self._chan.get_connection().get_extra_info('username')
|
||||
|
||||
# Create a mock handler object with required attributes
|
||||
mock_handler = type('obj', (object,), {
|
||||
'cfg': self.cfg,
|
||||
'username': username
|
||||
})()
|
||||
|
||||
# Call the file processing function
|
||||
from utils.connect import file_management
|
||||
await file_management.on_file_received_async(mock_handler, filepath)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing SFTP file on close: {e}", exc_info=True)
|
||||
|
||||
return result
|
||||
|
||||
async def exit(self):
|
||||
"""Handle session close."""
|
||||
await super().exit()
|
||||
|
||||
# Note: File processing is handled in close() method, not here
|
||||
# This avoids double-processing when both close and rename are called
|
||||
|
||||
|
||||
async def start_sftp_server(cfg, host='0.0.0.0', port=22):
|
||||
"""
|
||||
Start SFTP server.
|
||||
|
||||
Args:
|
||||
cfg: Configuration object
|
||||
host: Host to bind to
|
||||
port: Port to bind to
|
||||
|
||||
Returns:
|
||||
asyncssh server object
|
||||
"""
|
||||
logger.info(f"Starting SFTP server on {host}:{port}")
|
||||
|
||||
# Create SSH server
|
||||
ssh_server = ASESSHServer(cfg)
|
||||
|
||||
# Start asyncssh server
|
||||
server = await asyncssh.create_server(
|
||||
lambda: ssh_server,
|
||||
host,
|
||||
port,
|
||||
server_host_keys=['/app/ssh_host_key'], # You'll need to generate this
|
||||
sftp_factory=SFTPFileHandler,
|
||||
)
|
||||
|
||||
logger.info(f"SFTP server started successfully on {host}:{port}")
|
||||
logger.info(f"Database connection: {cfg.dbuser}@{cfg.dbhost}:{cfg.dbport}/{cfg.dbname}")
|
||||
|
||||
return server
|
||||
304
test_ftp_client.py
Executable file
304
test_ftp_client.py
Executable file
@@ -0,0 +1,304 @@
|
||||
#!/home/alex/devel/ASE/.venv/bin/python
|
||||
"""
|
||||
Script di test per inviare file CSV via FTP al server ftp_csv_receiver.py
|
||||
Legge gli utenti dalla tabella ftp_accounts e carica i file dalla directory corrispondente.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from ftplib import FTP
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
import mysql.connector
|
||||
|
||||
# Add src directory to Python path
|
||||
src_path = Path(__file__).parent / "src"
|
||||
sys.path.insert(0, str(src_path))
|
||||
|
||||
from utils.config import users_loader as setting
|
||||
from utils.database.connection import connetti_db
|
||||
|
||||
# Configurazione logging (verrà completata nel main dopo aver creato la directory logs)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configurazione server FTP e path base
|
||||
FTP_CONFIG = {"host": "localhost", "port": 2121}
|
||||
|
||||
BASE_CSV_PATH = Path("/home/alex/Scrivania/archivio_csv")
|
||||
|
||||
# Numero di worker paralleli per testare il throughput
|
||||
MAX_WORKERS = 10 # Modifica questo valore per aumentare/diminuire il parallelismo
|
||||
|
||||
# Lock per logging thread-safe
|
||||
log_lock = Lock()
|
||||
|
||||
|
||||
def fetch_ftp_users(connection: mysql.connector.MySQLConnection) -> list[tuple]:
|
||||
"""
|
||||
Preleva username e password dalla tabella ftp_accounts.
|
||||
|
||||
Args:
|
||||
connection: Connessione MySQL
|
||||
|
||||
Returns:
|
||||
Lista di tuple (username, password)
|
||||
"""
|
||||
try:
|
||||
cursor = connection.cursor()
|
||||
|
||||
query = """
|
||||
SELECT username, password
|
||||
FROM ase_lar.ftp_accounts
|
||||
WHERE username IS NOT NULL AND password IS NOT NULL
|
||||
"""
|
||||
|
||||
cursor.execute(query)
|
||||
results = cursor.fetchall()
|
||||
|
||||
logger.info("Prelevati %s utenti dal database", len(results))
|
||||
return results
|
||||
|
||||
except mysql.connector.Error as e:
|
||||
logger.error("Errore query database: %s", e)
|
||||
return []
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def create_remote_dir(ftp: FTP, remote_dir: str) -> None:
|
||||
"""
|
||||
Crea ricorsivamente tutte le directory necessarie sul server FTP.
|
||||
|
||||
Args:
|
||||
ftp: Connessione FTP attiva
|
||||
remote_dir: Path della directory da creare (es. "home/ID0354/subdir")
|
||||
"""
|
||||
if not remote_dir or remote_dir == ".":
|
||||
return
|
||||
|
||||
# Separa il path in parti
|
||||
parts = remote_dir.split("/")
|
||||
|
||||
# Crea ogni livello di directory
|
||||
current_path = ""
|
||||
for part in parts:
|
||||
if not part: # Salta parti vuote
|
||||
continue
|
||||
|
||||
current_path = f"{current_path}/{part}" if current_path else part
|
||||
|
||||
try:
|
||||
# Prova a creare la directory
|
||||
ftp.mkd(current_path)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# Directory già esistente o altro errore, continua
|
||||
pass
|
||||
|
||||
|
||||
def upload_files_for_user(username: str, password: str) -> tuple[str, str, bool, int, int]:
|
||||
"""
|
||||
Carica tutti i file CSV dalla directory dell'utente via FTP.
|
||||
Cerca ricorsivamente in tutte le sottodirectory e gestisce estensioni .csv e .CSV
|
||||
|
||||
Args:
|
||||
username: Nome utente FTP
|
||||
password: Password FTP
|
||||
|
||||
Returns:
|
||||
Tuple con (username, status_message, successo, file_caricati, totale_file)
|
||||
status_message può essere: 'OK', 'NO_DIR', 'NO_FILES', 'ERROR'
|
||||
"""
|
||||
user_csv_path = BASE_CSV_PATH / username
|
||||
|
||||
with log_lock:
|
||||
logger.info("[%s] Inizio elaborazione", username)
|
||||
|
||||
# Verifica che la directory esista
|
||||
if not user_csv_path.exists():
|
||||
with log_lock:
|
||||
logger.warning("[%s] Directory non trovata: %s", username, user_csv_path)
|
||||
return (username, "NO_DIR", False, 0, 0)
|
||||
|
||||
# Trova tutti i file CSV ricorsivamente (sia .csv che .CSV)
|
||||
csv_files = []
|
||||
csv_files.extend(user_csv_path.glob("**/*.csv"))
|
||||
csv_files.extend(user_csv_path.glob("**/*.CSV"))
|
||||
|
||||
if not csv_files:
|
||||
with log_lock:
|
||||
logger.warning("[%s] Nessun file CSV trovato in %s", username, user_csv_path)
|
||||
return (username, "NO_FILES", False, 0, 0)
|
||||
|
||||
total_files = len(csv_files)
|
||||
with log_lock:
|
||||
logger.info("[%s] Trovati %s file CSV", username, total_files)
|
||||
|
||||
# Connessione FTP
|
||||
try:
|
||||
ftp = FTP()
|
||||
ftp.connect(FTP_CONFIG["host"], FTP_CONFIG["port"])
|
||||
ftp.login(username, password)
|
||||
with log_lock:
|
||||
logger.info("[%s] Connesso al server FTP", username)
|
||||
|
||||
# Upload di ogni file CSV mantenendo la struttura delle directory
|
||||
uploaded = 0
|
||||
for csv_file in csv_files:
|
||||
try:
|
||||
# Calcola il path relativo rispetto alla directory base dell'utente
|
||||
relative_path = csv_file.relative_to(user_csv_path)
|
||||
|
||||
# Se il file è in una sottodirectory, crea la struttura sul server FTP
|
||||
if relative_path.parent != Path("."):
|
||||
# Crea ricorsivamente tutte le directory necessarie
|
||||
remote_dir = str(relative_path.parent).replace("\\", "/")
|
||||
create_remote_dir(ftp, remote_dir)
|
||||
|
||||
remote_file = str(relative_path).replace("\\", "/")
|
||||
else:
|
||||
remote_file = csv_file.name
|
||||
|
||||
# Carica il file (gli spazi nei nomi sono gestiti automaticamente da ftplib)
|
||||
with log_lock:
|
||||
logger.debug("[%s] Caricamento file: '%s'", username, remote_file)
|
||||
with open(csv_file, "rb") as f:
|
||||
ftp.storbinary(f"STOR {remote_file}", f)
|
||||
with log_lock:
|
||||
logger.info("[%s] File caricato: %s", username, remote_file)
|
||||
uploaded += 1
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
with log_lock:
|
||||
logger.error("[%s] Errore caricamento file %s: %s", username, csv_file.name, e)
|
||||
|
||||
ftp.quit()
|
||||
with log_lock:
|
||||
logger.info("[%s] Upload completato: %s/%s file caricati", username, uploaded, total_files)
|
||||
return (username, "OK" if uploaded > 0 else "NO_UPLOAD", uploaded > 0, uploaded, total_files)
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
with log_lock:
|
||||
logger.error("[%s] Errore FTP: %s", username, e)
|
||||
return (username, "ERROR", False, 0, total_files if "total_files" in locals() else 0)
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Funzione principale per testare il caricamento FTP con upload paralleli.
|
||||
"""
|
||||
# Configura logging con file nella directory logs
|
||||
log_dir = Path(__file__).parent / "logs"
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
|
||||
log_file = log_dir / "test_ftp_client.log"
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler(log_file),
|
||||
logging.StreamHandler(), # Mantiene anche l'output su console
|
||||
],
|
||||
)
|
||||
|
||||
logger.info("=== Avvio test client FTP (modalità parallela) ===")
|
||||
logger.info("Log file: %s", log_file)
|
||||
logger.info("Path base CSV: %s", BASE_CSV_PATH)
|
||||
logger.info("Server FTP: %s:%s", FTP_CONFIG["host"], FTP_CONFIG["port"])
|
||||
logger.info("Worker paralleli: %s", MAX_WORKERS)
|
||||
|
||||
# Connessione al database
|
||||
cfg = setting.Config()
|
||||
db_connection = connetti_db(cfg)
|
||||
|
||||
try:
|
||||
# Preleva gli utenti FTP dal database
|
||||
users = fetch_ftp_users(db_connection)
|
||||
|
||||
if not users:
|
||||
logger.warning("Nessun utente trovato nel database")
|
||||
return
|
||||
|
||||
logger.info("Avvio upload parallelo per %s utenti...", len(users))
|
||||
logger.info("")
|
||||
|
||||
# Usa ThreadPoolExecutor per upload paralleli
|
||||
results = []
|
||||
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
||||
# Sottometti tutti i task
|
||||
futures = {executor.submit(upload_files_for_user, username, password): username for username, password in users}
|
||||
|
||||
# Raccogli i risultati man mano che completano
|
||||
for future in as_completed(futures):
|
||||
username = futures[future]
|
||||
try:
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.error("[%s] Eccezione durante l'upload: %s", username, e)
|
||||
results.append((username, "ERROR", False, 0, 0))
|
||||
|
||||
# Analizza i risultati
|
||||
logger.info("")
|
||||
logger.info("=== Test completato ===")
|
||||
|
||||
success_count = sum(1 for _, _, success, _, _ in results if success)
|
||||
error_count = len(results) - success_count
|
||||
total_uploaded = sum(uploaded for _, _, _, uploaded, _ in results)
|
||||
total_files = sum(total for _, _, _, _, total in results)
|
||||
|
||||
# Categorizza gli utenti per status
|
||||
users_no_dir = [username for username, status, _, _, _ in results if status == "NO_DIR"]
|
||||
users_no_files = [username for username, status, _, _, _ in results if status == "NO_FILES"]
|
||||
users_error = [username for username, status, _, _, _ in results if status == "ERROR"]
|
||||
users_ok = [username for username, status, _, _, _ in results if status == "OK"]
|
||||
|
||||
logger.info("Utenti con successo: %s/%s", success_count, len(users))
|
||||
logger.info("Utenti con errori: %s/%s", error_count, len(users))
|
||||
logger.info("File caricati totali: %s/%s", total_uploaded, total_files)
|
||||
|
||||
# Report utenti senza directory
|
||||
if users_no_dir:
|
||||
logger.info("")
|
||||
logger.info("=== Utenti senza directory CSV (%s) ===", len(users_no_dir))
|
||||
for username in sorted(users_no_dir):
|
||||
logger.info(" - %s (directory attesa: %s)", username, BASE_CSV_PATH / username)
|
||||
|
||||
# Report utenti con directory vuota
|
||||
if users_no_files:
|
||||
logger.info("")
|
||||
logger.info("=== Utenti con directory vuota (%s) ===", len(users_no_files))
|
||||
for username in sorted(users_no_files):
|
||||
logger.info(" - %s", username)
|
||||
|
||||
# Report utenti con errori FTP
|
||||
if users_error:
|
||||
logger.info("")
|
||||
logger.info("=== Utenti con errori FTP (%s) ===", len(users_error))
|
||||
for username in sorted(users_error):
|
||||
logger.info(" - %s", username)
|
||||
|
||||
# Dettaglio per utente con successo
|
||||
if users_ok:
|
||||
logger.info("")
|
||||
logger.info("=== Dettaglio utenti con successo (%s) ===", len(users_ok))
|
||||
for username, status, _, uploaded, total in sorted(results):
|
||||
if status == "OK":
|
||||
logger.info("[%s] %s/%s file caricati", username, uploaded, total)
|
||||
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.error("Errore generale: %s", e)
|
||||
sys.exit(1)
|
||||
|
||||
finally:
|
||||
try:
|
||||
db_connection.close()
|
||||
logger.info("")
|
||||
logger.info("Connessione MySQL chiusa")
|
||||
except Exception as e: # pylint: disable=broad-except
|
||||
logger.error("Errore chiusura connessione MySQL: %s", e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user