diff --git a/LOG_SYSTEM.md b/LOG_SYSTEM.md new file mode 100644 index 0000000..a0e60b2 --- /dev/null +++ b/LOG_SYSTEM.md @@ -0,0 +1,168 @@ +# Sistema di Logging - Territory Manager + +## Panoramica + +Il sistema di logging è stato implementato per tracciare tutte le azioni eseguite dagli utenti all'interno dell'applicazione Territory Manager. + +## Funzionalità Implementate + +### 1. Database +- **Tabella `activity_logs`**: Memorizza tutti i log delle attività + - `user_id`: ID dell'utente che ha eseguito l'azione + - `username`: Nome utente per riferimento rapido + - `action_type`: Tipo di azione (login, logout, create, update, delete, assign, return, export) + - `action_description`: Descrizione dettagliata dell'azione + - `entity_type`: Tipo di entità coinvolta (territory, assignment, user, config, auth) + - `entity_id`: ID dell'entità coinvolta + - `ip_address`: Indirizzo IP dell'utente + - `user_agent`: User agent del browser + - `created_at`: Timestamp dell'azione + +### 2. Funzioni di Logging (functions.php) + +#### `logActivity($action_type, $action_description, $entity_type, $entity_id)` +Registra un'attività nel log. Cattura automaticamente: +- Utente corrente dalla sessione +- Indirizzo IP +- User Agent del browser + +#### `getActivityLogs($filters, $page, $per_page)` +Recupera i log con filtri e paginazione. Supporta filtri per: +- Utente +- Tipo di azione +- Tipo di entità +- Intervallo di date +- Ricerca testuale + +### 3. Pagina Visualizzazione Log (logs.php) +**Accesso**: Solo amministratori + +Funzionalità: +- Visualizzazione log con tabella paginata +- Filtri multipli: + - Ricerca testuale + - Utente + - Tipo di azione + - Tipo di entità + - Data da/a +- Paginazione (50 log per pagina) +- Badge colorati per tipo di azione +- Link per esportazione PDF + +### 4. Esportazione PDF (export_logs_pdf.php) +**Accesso**: Solo amministratori + +Funzionalità: +- Esporta tutti i log filtrati in formato PDF +- Include gli stessi filtri della pagina di visualizzazione +- Genera report con: + - Elenco completo dei log + - Riepilogo per tipo di azione + - Riepilogo per utente + - Timestamp di generazione + - Informazioni sui filtri applicati + +### 5. Azioni Tracciate + +#### Autenticazione +- `login`: Accesso al sistema +- `logout`: Uscita dal sistema + +#### Territori +- `create`: Creazione nuovo territorio +- `update`: Modifica territorio esistente +- `delete`: Eliminazione territorio + +#### Assegnazioni +- `assign`: Assegnazione territorio a un proclamatore +- `return`: Riconsegna territorio + +#### Utenti +- `create`: Creazione nuovo utente +- `update`: Modifica password +- `delete`: Eliminazione utente + +#### Configurazioni +- `update`: Modifica configurazioni di sistema + +#### Log +- `export`: Esportazione log in PDF + +## Integrazione + +Il logging è stato integrato in tutte le operazioni principali: +- `login.php`: Log di accesso +- `logout.php`: Log di uscita +- `territories.php`: Log di creazione, modifica ed eliminazione territori +- `assignments.php`: Log di assegnazione e riconsegna +- `settings.php`: Log di modifiche configurazioni e gestione utenti +- `export_logs_pdf.php`: Log di esportazione + +## Menu di Navigazione + +Il link "Log" è stato aggiunto nel menu principale, visibile solo agli amministratori. + +## Utilizzo + +### Visualizzare i Log +1. Accedere come amministratore +2. Cliccare su "Log" nel menu +3. Utilizzare i filtri per trovare log specifici + +### Esportare i Log +1. Dalla pagina Log, applicare eventuali filtri +2. Cliccare su "Esporta PDF" +3. Si aprirà una nuova finestra con il report +4. Utilizzare il pulsante "Stampa / Salva come PDF" del browser + +## Note Tecniche + +- Il sistema cattura automaticamente IP e User Agent per tracciabilità +- I log sono legati agli utenti tramite foreign key (cascade on delete) +- La paginazione limita il carico sul database +- L'export PDF è ottimizzato per la stampa (orientamento landscape) +- I filtri sono persistenti durante la navigazione + +## Sicurezza + +- Solo gli amministratori possono accedere ai log +- I log non possono essere modificati o eliminati dall'interfaccia +- Tutte le operazioni sensibili vengono registrate +- Gli IP vengono tracciati per audit trail + +## Manutenzione + +Per pulire vecchi log (da eseguire manualmente nel database): +```sql +-- Elimina log più vecchi di 1 anno +DELETE FROM activity_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR); +``` + +## Database Migration + +Per applicare le modifiche al database esistente: +```sql +-- Eseguire lo script SQL aggiornato +SOURCE database.sql; +``` + +O solo la parte relativa ai log: +```sql +CREATE TABLE IF NOT EXISTS activity_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + username VARCHAR(50) NOT NULL, + action_type VARCHAR(50) NOT NULL, + action_description TEXT NOT NULL, + entity_type VARCHAR(50), + entity_id INT, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user (user_id), + INDEX idx_action_type (action_type), + INDEX idx_created_at (created_at), + INDEX idx_entity (entity_type, entity_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +``` diff --git a/assignments.php b/assignments.php index 60b3156..32f8459 100644 --- a/assignments.php +++ b/assignments.php @@ -52,6 +52,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($result) { $assignment_id = $db->getConnection()->lastInsertId(); + $territory = $db->fetchOne("SELECT numero, zona FROM territories WHERE id = ?", [$territory_id]); + logActivity('assign', "Assegnato territorio {$territory['numero']} - {$territory['zona']} a $assigned_to", 'assignment', $assignment_id); setFlashMessage('Territorio assegnato con successo', 'success'); header("Location: assignments.php?action=view&id=$assignment_id"); } else { @@ -65,12 +67,22 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $territory_id = (int)$_POST['territory_id']; $returned_date = $_POST['returned_date']; + // Recupera info assegnazione + $assignment = $db->fetchOne( + "SELECT a.id, a.assigned_to, t.numero, t.zona + FROM assignments a + JOIN territories t ON a.territory_id = t.id + WHERE a.territory_id = ? AND a.returned_date IS NULL", + [$territory_id] + ); + $result = $db->query( "UPDATE assignments SET returned_date = ? WHERE territory_id = ? AND returned_date IS NULL", [$returned_date, $territory_id] ); - if ($result) { + if ($result && $assignment) { + logActivity('return', "Riconsegnato territorio {$assignment['numero']} - {$assignment['zona']} da {$assignment['assigned_to']}", 'assignment', $assignment['id']); setFlashMessage('Territorio riconsegnato con successo', 'success'); } else { setFlashMessage('Errore durante la riconsegna', 'error'); diff --git a/database.sql b/database.sql index 22dd8a7..d859f12 100644 --- a/database.sql +++ b/database.sql @@ -100,3 +100,22 @@ WHERE t.id NOT IN ( ) GROUP BY t.id, t.numero, t.zona, t.tipologia, t.image_path ORDER BY last_returned_date ASC NULLS FIRST; + +-- Tabella log attività +CREATE TABLE IF NOT EXISTS activity_logs ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + username VARCHAR(50) NOT NULL, + action_type VARCHAR(50) NOT NULL, + action_description TEXT NOT NULL, + entity_type VARCHAR(50), + entity_id INT, + ip_address VARCHAR(45), + user_agent TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user (user_id), + INDEX idx_action_type (action_type), + INDEX idx_created_at (created_at), + INDEX idx_entity (entity_type, entity_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/export_logs_pdf.php b/export_logs_pdf.php new file mode 100644 index 0000000..a3107fb --- /dev/null +++ b/export_logs_pdf.php @@ -0,0 +1,284 @@ += ?"; + $params[] = $filters['date_from']; +} + +if (!empty($filters['date_to'])) { + $where[] = "DATE(created_at) <= ?"; + $params[] = $filters['date_to']; +} + +if (!empty($filters['search'])) { + $where[] = "(action_description LIKE ? OR username LIKE ?)"; + $search_term = '%' . $filters['search'] . '%'; + $params[] = $search_term; + $params[] = $search_term; +} + +$where_clause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + +$logs = $db->fetchAll( + "SELECT * FROM activity_logs + $where_clause + ORDER BY created_at DESC", + $params +); + +// Classe semplice per generare PDF (HTML per stampa) +class LogPDF { + private $content = ''; + + public function addTitle($title) { + $this->content .= "

$title

"; + } + + public function addSubtitle($subtitle) { + $this->content .= "

$subtitle

"; + } + + public function addText($text) { + $this->content .= "

$text

"; + } + + public function addTable($headers, $rows) { + $this->content .= ""; + $this->content .= ""; + foreach ($headers as $header) { + $this->content .= ""; + } + $this->content .= ""; + + $odd = true; + foreach ($rows as $row) { + $bgColor = $odd ? '#ffffff' : '#f8f9fa'; + $this->content .= ""; + foreach ($row as $cell) { + $this->content .= ""; + } + $this->content .= ""; + $odd = !$odd; + } + + $this->content .= "
$header
$cell
"; + } + + public function output($filename) { + header('Content-Type: text/html; charset=utf-8'); + header('Content-Disposition: inline; filename="' . $filename . '.html"'); + + echo " + + + + + $filename + + +"; + echo "
"; + echo ""; + echo ""; + echo "
"; + echo $this->content; + echo "
"; + echo "

Generato il " . date('d/m/Y H:i') . " da " . htmlspecialchars($_SESSION['username']) . " - " . APP_NAME . "

"; + echo "
"; + echo ""; + } +} + +$pdf = new LogPDF(); + +// Titolo +$pdf->addTitle('Log Attività Sistema'); + +// Informazioni sul report +$info_text = "Report generato il " . date('d/m/Y H:i'); +if (!empty($filters)) { + $info_text .= " - Filtri applicati: "; + $filter_parts = []; + + if (!empty($filters['user_id'])) { + $user = $db->fetchOne("SELECT username FROM users WHERE id = ?", [$filters['user_id']]); + $filter_parts[] = "Utente: " . htmlspecialchars($user['username']); + } + if (!empty($filters['action_type'])) { + $filter_parts[] = "Azione: " . htmlspecialchars($filters['action_type']); + } + if (!empty($filters['entity_type'])) { + $filter_parts[] = "Entità: " . htmlspecialchars($filters['entity_type']); + } + if (!empty($filters['date_from'])) { + $filter_parts[] = "Dal: " . formatDate($filters['date_from']); + } + if (!empty($filters['date_to'])) { + $filter_parts[] = "Al: " . formatDate($filters['date_to']); + } + if (!empty($filters['search'])) { + $filter_parts[] = "Ricerca: " . htmlspecialchars($filters['search']); + } + + $info_text .= implode(', ', $filter_parts); +} +$pdf->addText($info_text); +$pdf->addText("Totale log: " . count($logs)); + +// Prepara i dati per la tabella +$headers = ['Data/Ora', 'Utente', 'Azione', 'Descrizione', 'Entità', 'IP']; +$rows = []; + +foreach ($logs as $log) { + $action_badge = strtoupper(htmlspecialchars($log['action_type'])); + + $entity_info = '-'; + if ($log['entity_type']) { + $entity_info = htmlspecialchars($log['entity_type']); + if ($log['entity_id']) { + $entity_info .= ' #' . $log['entity_id']; + } + } + + $rows[] = [ + formatDateTime($log['created_at']), + htmlspecialchars($log['username']), + $action_badge, + htmlspecialchars($log['action_description']), + $entity_info, + htmlspecialchars($log['ip_address']) + ]; +} + +$pdf->addTable($headers, $rows); + +// Riepilogo per tipo di azione +$action_summary = $db->fetchAll( + "SELECT action_type, COUNT(*) as count + FROM activity_logs + $where_clause + GROUP BY action_type + ORDER BY count DESC", + $params +); + +if (!empty($action_summary)) { + $pdf->addSubtitle('Riepilogo per Tipo di Azione'); + $summary_headers = ['Tipo Azione', 'Numero Occorrenze']; + $summary_rows = []; + foreach ($action_summary as $summary) { + $summary_rows[] = [ + strtoupper(htmlspecialchars($summary['action_type'])), + number_format($summary['count']) + ]; + } + $pdf->addTable($summary_headers, $summary_rows); +} + +// Riepilogo per utente +$user_summary = $db->fetchAll( + "SELECT username, COUNT(*) as count + FROM activity_logs + $where_clause + GROUP BY username + ORDER BY count DESC", + $params +); + +if (!empty($user_summary)) { + $pdf->addSubtitle('Riepilogo per Utente'); + $user_headers = ['Utente', 'Numero Azioni']; + $user_rows = []; + foreach ($user_summary as $summary) { + $user_rows[] = [ + htmlspecialchars($summary['username']), + number_format($summary['count']) + ]; + } + $pdf->addTable($user_headers, $user_rows); +} + +// Output del PDF +$filename = 'log_attivita_' . date('Y-m-d_H-i-s'); +$pdf->output($filename); + +// Log dell'esportazione +logActivity('export', 'Esportazione log attività in PDF', 'logs', null); diff --git a/functions.php b/functions.php index 0ae9f2a..5130476 100644 --- a/functions.php +++ b/functions.php @@ -110,3 +110,110 @@ function formatDateTime($datetime) { if (empty($datetime)) return '-'; return date('d/m/Y H:i', strtotime($datetime)); } + +/** + * Registra un'attività nel log + * + * @param string $action_type Tipo di azione (es: 'login', 'create', 'update', 'delete', 'assign', 'return') + * @param string $action_description Descrizione dell'azione + * @param string $entity_type Tipo di entità (es: 'territory', 'assignment', 'user', 'config') + * @param int $entity_id ID dell'entità coinvolta + */ +function logActivity($action_type, $action_description, $entity_type = null, $entity_id = null) { + if (!isLoggedIn()) { + return false; + } + + $db = getDB(); + + // Ottieni informazioni sull'utente e sulla richiesta + $user_id = $_SESSION['user_id']; + $username = $_SESSION['username']; + $ip_address = $_SERVER['REMOTE_ADDR'] ?? null; + $user_agent = $_SERVER['HTTP_USER_AGENT'] ?? null; + + try { + $db->execute( + "INSERT INTO activity_logs + (user_id, username, action_type, action_description, entity_type, entity_id, ip_address, user_agent) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [$user_id, $username, $action_type, $action_description, $entity_type, $entity_id, $ip_address, $user_agent] + ); + return true; + } catch (Exception $e) { + error_log("Errore nel log dell'attività: " . $e->getMessage()); + return false; + } +} + +/** + * Ottieni i log delle attività con filtri e paginazione + * + * @param array $filters Array di filtri (user_id, action_type, entity_type, date_from, date_to) + * @param int $page Numero pagina + * @param int $per_page Elementi per pagina + * @return array Array con 'logs' e 'total' + */ +function getActivityLogs($filters = [], $page = 1, $per_page = 50) { + $db = getDB(); + + $where = []; + $params = []; + + if (!empty($filters['user_id'])) { + $where[] = "user_id = ?"; + $params[] = $filters['user_id']; + } + + if (!empty($filters['action_type'])) { + $where[] = "action_type = ?"; + $params[] = $filters['action_type']; + } + + if (!empty($filters['entity_type'])) { + $where[] = "entity_type = ?"; + $params[] = $filters['entity_type']; + } + + if (!empty($filters['date_from'])) { + $where[] = "DATE(created_at) >= ?"; + $params[] = $filters['date_from']; + } + + if (!empty($filters['date_to'])) { + $where[] = "DATE(created_at) <= ?"; + $params[] = $filters['date_to']; + } + + if (!empty($filters['search'])) { + $where[] = "(action_description LIKE ? OR username LIKE ?)"; + $search_term = '%' . $filters['search'] . '%'; + $params[] = $search_term; + $params[] = $search_term; + } + + $where_clause = !empty($where) ? 'WHERE ' . implode(' AND ', $where) : ''; + + // Conta totale + $total = $db->fetchOne( + "SELECT COUNT(*) as count FROM activity_logs $where_clause", + $params + )['count']; + + // Ottieni log con paginazione + $offset = ($page - 1) * $per_page; + $logs = $db->fetchAll( + "SELECT * FROM activity_logs + $where_clause + ORDER BY created_at DESC + LIMIT ? OFFSET ?", + array_merge($params, [$per_page, $offset]) + ); + + return [ + 'logs' => $logs, + 'total' => $total, + 'pages' => ceil($total / $per_page), + 'current_page' => $page + ]; +} diff --git a/header.php b/header.php index 530e56a..42efe52 100644 --- a/header.php +++ b/header.php @@ -26,6 +26,7 @@ $current_page = basename($_SERVER['PHP_SELF'], '.php');
  • Assegnazioni
  • Statistiche
  • +
  • Log
  • Impostazioni
  • diff --git a/login.php b/login.php index 802f4c1..7ed4084 100644 --- a/login.php +++ b/login.php @@ -23,6 +23,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $error = 'Inserire username e password'; } else { if (login($username, $password)) { + logActivity('login', 'Accesso effettuato al sistema', 'auth', null); header('Location: index.php'); exit; } else { diff --git a/logout.php b/logout.php index 334360a..c0cd6ee 100644 --- a/logout.php +++ b/logout.php @@ -7,6 +7,7 @@ require_once 'config.php'; require_once 'functions.php'; +logActivity('logout', 'Uscita dal sistema', 'auth', null); logout(); header('Location: login.php'); exit; diff --git a/logs.php b/logs.php new file mode 100644 index 0000000..dd740d2 --- /dev/null +++ b/logs.php @@ -0,0 +1,301 @@ +fetchAll("SELECT id, username FROM users ORDER BY username"); + +// Ottieni tipi di azione unici +$action_types = $db->fetchAll("SELECT DISTINCT action_type FROM activity_logs ORDER BY action_type"); + +// Ottieni tipi di entità unici +$entity_types = $db->fetchAll("SELECT DISTINCT entity_type FROM activity_logs WHERE entity_type IS NOT NULL ORDER BY entity_type"); + +include 'header.php'; +?> + + + + +
    +
    +

    Filtri

    +
    +
    +
    +
    +
    + + +
    + +
    + + +
    + +
    + + +
    + +
    + + +
    +
    + +
    +
    + + +
    + +
    + + +
    + +
    + + Reset +
    +
    +
    +
    +
    + + +
    +
    +

    Risultati ( log)

    +
    +
    + +

    Nessun log trovato con i filtri selezionati.

    + +
    + + + + + + + + + + + + + + + + + + + + + + + +
    Data/OraUtenteAzioneDescrizioneEntitàIP
    + + + + + + + + + + + # + + + + - + + + +
    +
    + + + 1): ?> + + + +
    +
    + + + + diff --git a/settings.php b/settings.php index 52482ac..d5c7d0d 100644 --- a/settings.php +++ b/settings.php @@ -28,6 +28,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $db->updateConfig('warning_days_priority', $warning_days_priority); $db->updateConfig('warning_days_return', $warning_days_return); + logActivity('update', 'Aggiornate configurazioni di sistema', 'config', null); setFlashMessage('Configurazioni salvate con successo', 'success'); header('Location: settings.php'); exit; @@ -50,6 +51,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } else { $hashed = password_hash($new_password, PASSWORD_DEFAULT); $db->query("UPDATE users SET password = ? WHERE id = ?", [$hashed, $user['id']]); + logActivity('update', 'Modificata la propria password', 'user', $user['id']); setFlashMessage('Password modificata con successo', 'success'); } @@ -73,6 +75,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ); if ($result) { + $new_user_id = $db->getConnection()->lastInsertId(); + $role = $is_admin ? 'amministratore' : 'utente'; + logActivity('create', "Creato nuovo utente '$username' con ruolo $role", 'user', $new_user_id); setFlashMessage('Utente aggiunto con successo', 'success'); } else { setFlashMessage('Errore: username già esistente', 'error'); @@ -90,7 +95,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { if ($user_id == $_SESSION['user_id']) { setFlashMessage('Non puoi eliminare il tuo account', 'error'); } else { + $user_to_delete = $db->fetchOne("SELECT username FROM users WHERE id = ?", [$user_id]); $db->query("DELETE FROM users WHERE id = ?", [$user_id]); + if ($user_to_delete) { + logActivity('delete', "Eliminato utente '{$user_to_delete['username']}'", 'user', $user_id); + } setFlashMessage('Utente eliminato con successo', 'success'); } diff --git a/territories.php b/territories.php index 389318c..70d83d1 100644 --- a/territories.php +++ b/territories.php @@ -52,6 +52,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ); if ($result) { + $territory_id = $db->getConnection()->lastInsertId(); + logActivity('create', "Creato territorio $numero - $zona", 'territory', $territory_id); setFlashMessage('Territorio aggiunto con successo', 'success'); } else { setFlashMessage('Errore durante l\'aggiunta del territorio', 'error'); @@ -99,6 +101,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { ); if ($result) { + logActivity('update', "Modificato territorio $numero - $zona", 'territory', $id); setFlashMessage('Territorio modificato con successo', 'success'); } else { setFlashMessage('Errore durante la modifica del territorio', 'error'); @@ -112,7 +115,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $id = (int)$_POST['id']; // Recupera l'immagine per eliminarla - $territory = $db->fetchOne("SELECT image_path FROM territories WHERE id = ?", [$id]); + $territory = $db->fetchOne("SELECT numero, zona, image_path FROM territories WHERE id = ?", [$id]); if ($territory && $territory['image_path']) { $file_path = BASE_PATH . $territory['image_path']; if (file_exists($file_path)) { @@ -123,6 +126,9 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { $result = $db->query("DELETE FROM territories WHERE id = ?", [$id]); if ($result) { + if ($territory) { + logActivity('delete', "Eliminato territorio {$territory['numero']} - {$territory['zona']}", 'territory', $id); + } setFlashMessage('Territorio eliminato con successo', 'success'); } else { setFlashMessage('Errore durante l\'eliminazione del territorio', 'error');