final 1.2

This commit is contained in:
2026-06-06 23:19:55 +02:00
parent a8c841bdbe
commit bc57d0dcd0
17 changed files with 142 additions and 537 deletions
+68
View File
@@ -0,0 +1,68 @@
APP_NAME=Glastree
APP_ENV=local
APP_KEY=base64:aWz7908H9c7s+it9uMTwb6pyUrpddyMclcuN9Kzv7Ao=
APP_DEBUG=true
APP_URL=
APP_PROTOCOL=https
FORCE_HTTPS=true
TRUSTED_PROXIES=*
APP_LOCALE=it
APP_FALLBACK_LOCALE=it
APP_FAKER_LOCALE=it_IT
APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database
# PHP_CLI_SERVER_WORKERS=4
BCRYPT_ROUNDS=12
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=localhost
DB_PORT=3306
DB_DATABASE=glastree
DB_USERNAME=glastree
DB_PASSWORD=glastree
SESSION_DRIVER=file
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
QUEUE_CONNECTION=database
CACHE_STORE=database
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
REDIS_CLIENT=phpredis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
-1
View File
@@ -1,2 +1 @@
glastree-image.tar
.env
+48 -78
View File
@@ -1,91 +1,61 @@
# 🧠 MEMORY.md — Stato Progetto
## Goal
- Implementare 3 feature in Glastree Laravel 13:
1. **Page-length selector** su pagine lista (Individui, Eventi, Email)
2. **CSV import per gruppi** (stesso pattern import individui)
3. **ICS import per eventi** (via sabre/vobject)
- Risolvere login non funzionante dopo fresh install (causa: tinker senza escaping + `2>/dev/null`)
- Sostituire tinker per creazione admin con PDO prepared statement + SQL file per schema
## Constraints & Preferences
- Laravel 13, PHP 8.4, AdminLTE 4, MySQL/MariaDB
- Laravel 13, PHP 8.4, AdminLTE 4, MySQL/MariaDB (install.sql solo MySQL)
- Zero placeholder, zero TODO, codice funzionante
- Seguire pattern esistenti (individui import, controller conventions, view layouts)
- sabre/vobject già installato in composer.json (verificato funzionante in vendor/)
- UI in Italiano come da applicazione esistente
- Compatibilità cross-DB MySQL ↔ SQLite (fallback migration per SQLite)
## Verifica Finale — Tutto OK ✅
## Progress
### Done
- **Creazione `database/install.sql`** — schema MySQL completo + seed data (~1059 righe):
- Tutte le 45 tabelle nello stato finale (consolidate da 58 migration)
- Seed: 119 diocesi, 54 comuni, 8 tipologie documenti, 8 tipologie eventi, 8 ruoli, 23 permessi Spatie, 2 ruoli Spatie, 3 role preset, 5 email folders, 1 riga app_settings
- **NON contiene l'admin** (creato via PDO da install.php)
- `SET FOREIGN_KEY_CHECKS=0` per ordine tabelle, riattivato alla fine
- **Riscritto `install.php`** — eliminata dipendenza da tinker per la creazione admin:
- Aggiunta funzione `createAdminUser()`: PDO prepared statement + `password_hash()` + Spatie role assignment
- Fresh Install Apache: importa `install.sql` via `mysql` CLI, poi crea admin via PDO
- Fresh Install Docker: copia `install.sql` nel container, importa via mysql CLI, crea admin via script PHP temporaneo con `var_export()` (safe)
- Restore: crea admin via PDO se non esiste
- Fallback SQLite: mantiene `php artisan migrate --seed` + tinker ma con `var_export()` per escaping
- Step 5 rinominato da "MIGRATION E SEED" a "SCHEMA DATABASE E DATI BASE"
- Step 6 usa PDO invece di tinker (MySQL)
- **Unified MySQL setup**: singolo tentativo PDO con `dbname` nel DSN — se fallisce (1044/1045/1049), passa automaticamente in root-mode: chiede password root, crea database + utente + GRANT ALL su `localhost` e `%`
- **Sostituito loop 30-retry** con connessione singola PDO (timeout 5s)
- **Fix parse error**: `\$adminName``$adminName` in `var_export()` nel ramo SQLite
- **Fix composer**: rimosso `sudo -u www-data` da composer (causa git dubious ownership + permission denied), aggiunto `COMPOSER_ROOT_VERSION=dev-main`; `vendor` aggiunto al chown finale
| Controllo | Risultato |
|-----------|-----------|
| Syntax PHP tutti i file modificati | ✅ Nessun errore |
| Bilanciamento `{}` JS in tutte le views | ✅ Tutte bilanciate |
| Rotta `gruppi.import` GET | ✅ Registrata |
| Rotta `gruppi.import.store` POST | ✅ Registrata |
| Rotta `gruppi.template` GET | ✅ Registrata |
| Rotta `eventi.import` GET | ✅ Registrata |
| Rotta `eventi.import.store` POST | ✅ Registrata |
| View `gruppi/import.blade.php` | ✅ Creata |
| View `eventi/import.blade.php` | ✅ Creata |
| sabre/vobject loadabile | ✅ `Sabre\VObject\Reader` disponibile |
### In Progress
- *(nessuno)*
## Modifiche Effettuate
### 1. Page-length selector (3 pagine)
**File modificati:**
- `app/Http/Controllers/IndividuoController.php:20``paginate(20)``paginate($perPage)` con clamp `[10,20,25,50,100]`, default 20
- `app/Http/Controllers/EventoController.php:48-49` — stessa logica, default 20
- `app/Http/Controllers/EmailController.php:61-62` — stessa logica, default 20
- `resources/views/individui/index.blade.php` — dropdown `<select name="perPage">` in card-footer con hidden fields per preservare altri query params
- `resources/views/eventi/index.blade.php` — stesso dropdown in card-footer
- `resources/views/email/index.blade.php` — stesso dropdown in card-footer
### 2. CSV Import Gruppi (nuovo)
**File modificati:**
- `app/Http/Controllers/GruppoController.php` — aggiunti 3 metodi:
- `import()`: GET, mostra form upload (authorizeWrite)
- `importStore()`: POST, validate file CSV, parsing fault-tolerant (skip righe vuote, log errori), crea Gruppo::create() con `nome,descrizione,parent_id,diocesi_id,indirizzo_incontro,cap_incontro,città_incontro,sigla_provincia_incontro`
- `downloadTemplate()`: GET, restituisce CSV template con header + esempio
- `resources/views/gruppi/import.blade.php` — form upload + template download + colonna info campi
- `resources/views/gruppi/index.blade.php` — bottone "Importa" in card-tools
- `routes/web.php``gruppi/import` (GET/POST), `gruppi/template` (GET), tutte prima di `Route::resource('gruppi')`
### 3. ICS Import Eventi (nuovo)
**File modificati:**
- `app/Models/Evento.php:34` — aggiunto `'uid_esterno'` a `$fillable` (colonna esisteva in DB ma mancava dal model — potenziale bug risolto)
- `app/Http/Controllers/EventoController.php` — aggiunti 2 metodi:
- `importIcs()`: GET, mostra form upload (authorizeWrite)
- `importIcsStore()`: POST, validate file ICS, usa `Sabre\VObject\Reader::read()` per parsare, estrae VEVENT, mapping:
- `SUMMARY``nome_evento`
- `DESCRIPTION``descrizione`
- `DTSTART``data_specifica` + `ora_inizio`
- `DTEND``durata_minuti` (differenza in minuti)
- `LOCATION``luogo_indirizzo`
- `UID``uid_esterno` (per dedup)
- RRULE: import come `tipo_recorrenza: 'singolo'` con prima occorrenza (no espansione)
- Dedup: skip se `uid_esterno` già presente
- `resources/views/eventi/import.blade.php` — form upload + colonna info campi
- `resources/views/eventi/index.blade.php` — bottone "Importa ICS" in card-tools
- `routes/web.php``eventi/import` (GET/POST), prima di `Route::resource('eventi')`
## Bug Fix Trovato
- **`Evento::$fillable` mancava `uid_esterno`** — la colonna esisteva in DB (aggiunta da migration `2026_06_02_000002_add_uid_esterno_to_eventi_table.php`) ma non era in `$fillable`, quindi mass-assignment la ignorava silenziosamente. Risolto.
### Blocked
- *(nessuno)*
## Key Decisions
- **perPage clamping**: soli valori `[10,20,25,50,100]`, fallback a default 20 su valore non valido
- **CSV gruppi segue pattern individui**: stesso approccio fault-tolerant (skip righe incomplete, log errori, riepilogo finale)
- **ICS senza espansione RRULE**: eventi ricorrenti importati come evento singolo con data della prima occorrenza
- **Dedup via uid_esterno**: lookup prima della creazione, skip se già presente
- **PDO > tinker**: `createAdminUser()` usa PDO prepared statement con `password_hash()`, niente interpolazione PHP in stringhe shell → zero escaping issues
- **`install.sql` > migration sequenziali**: unico file SQL per fresh install, più veloce e debuggabile (si può lanciare `mysql < install.sql` a mano)
- **`var_export()` per tinker fallback**: nei rari casi SQLite, si usa `var_export()` che produce stringhe PHP valide con escaping automatico
- **`2>/dev/null` rimosso** dal MySQL main path (non serve più, non c'è tinker)
- **Docker MySQL credenziali**: hardcoded `mysql:glastree:secret:glastree` (dal docker-compose.mysql.yml)
- **Composer senza sudo**: `COMPOSER_ROOT_VERSION=dev-main` per evitare git dubious ownership; composer eseguito come utente corrente (root), non più come www-data — dopo, `chown -R www-data vendor` per permessi lettura webserver
- **Unified MySQL setup**: PDO connect con `dbname` nel DSN → se fallisce 1044/1045/1049, root-mode: crea DB + utente + GRANT ALL su `localhost` e `%` + FLUSH
## Next Steps
- *(nessuno)*
## Critical Context
- `install.sql` è solo per MySQL. SQLite usa `php artisan migrate --seed` + `DatabaseSeeder`
- `DatabaseSeeder` crea ancora admin `admin@glastree.local / password` ma per MySQL viene cancellato dal PDO script
- `createAdminUser()` setta `is_admin=1`, `status='active'`, `permissions` completo, `role_preset_id=1`, e Spatie `model_has_roles` con role_id=1
- `password_hash($pass, PASSWORD_BCRYPT)` produce hash `$2y$...` compatibile con `Hash::check()` di Laravel
- Composer ora eseguito come utente corrente (root) con `COMPOSER_ROOT_VERSION=dev-main` per evitare git dubious ownership e permission denied su `vendor/`
- `vendor/` aggiunto al `chown` finale in entrambi i modi (Apache e Restore) per garantire leggibilità da www-data
- `ensureMysqlUserAndDb()` sanitizza input (rimuove `' " \0 \`) prima di passarli a SQL exec
## Relevant Files
- `app/Http/Controllers/IndividuoController.php` — perPage in index()
- `app/Http/Controllers/EventoController.php` — perPage in index() + importIcs()/importIcsStore()
- `app/Http/Controllers/GruppoController.php` — import()/importStore()/downloadTemplate()
- `app/Http/Controllers/EmailController.php` — perPage in index()
- `app/Models/Evento.php` — uid_esterno in $fillable
- `resources/views/individui/index.blade.php` — perPage dropdown
- `resources/views/eventi/index.blade.php` — perPage dropdown + ICS import button
- `resources/views/eventi/import.blade.php` — ICS import form (NEW)
- `resources/views/gruppi/index.blade.php` — CSV import button
- `resources/views/gruppi/import.blade.php` — CSV import form (NEW)
- `resources/views/email/index.blade.php` — perPage dropdown
- `routes/web.php` — nuove route gruppi/eventi import
- `database/install.sql`: NEW — schema MySQL completo + seed data (1059 righe)
- `install.php`: MODIFIED — `createAdminUser()` + `ensureMysqlUserAndDb()` + composer fix + auto-create MySQL user
+1 -2
View File
@@ -59,8 +59,7 @@ class EmailController extends Controller
$query->orderBy($sortField === 'sent_at' ? 'received_at' : $sortField, $sortDir);
$perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$messages = $query->paginate($perPage)->withQueryString();
$messages = $query->paginate(20)->withQueryString();
$folders = EmailFolder::orderBy('sort_order')->get();
$mailingLists = MailingList::where('attiva', true)->get();
$gruppi = Gruppo::orderBy('nome')->get();
+1 -101
View File
@@ -8,8 +8,6 @@ use App\Models\Individuo;
use App\Models\TipologiaEvento;
use App\Services\IcsExportService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\HttpFoundation\StreamedResponse;
class EventoController extends Controller
@@ -45,8 +43,7 @@ class EventoController extends Controller
$query->orderByDesc('created_at');
}
$perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$eventi = $query->paginate($perPage);
$eventi = $query->paginate(20);
$gruppi = Gruppo::orderBy('nome')->get();
return view('eventi.index', compact('eventi', 'gruppi'));
@@ -480,101 +477,4 @@ class EventoController extends Controller
return $colors['primary'];
}
public function importIcs()
{
$this->authorizeWrite('eventi');
return view('eventi.import');
}
public function importIcsStore(Request $request)
{
$this->authorizeWrite('eventi');
$request->validate([
'file' => 'required|file|mimes:ics,text/calendar',
]);
$file = $request->file('file');
$content = file_get_contents($file->path());
try {
$vcalendar = \Sabre\VObject\Reader::read($content);
} catch (\Exception $e) {
Log::warning('Errore lettura file ICS: ' . $e->getMessage());
return back()->with('error', 'Il file ICS non è valido: ' . $e->getMessage());
}
$imported = 0;
$skipped = 0;
$errors = [];
foreach ($vcalendar->VEVENT as $vevent) {
try {
$uid = (string) ($vevent->UID ?? '');
if (!empty($uid)) {
$existing = Evento::where('uid_esterno', $uid)->first();
if ($existing) {
$skipped++;
continue;
}
}
$nomeEvento = (string) ($vevent->SUMMARY ?? '');
if (empty($nomeEvento)) {
$skipped++;
continue;
}
$dtStart = $vevent->DTSTART ?? null;
$dtEnd = $vevent->DTEND ?? null;
$dataSpecifica = null;
$oraInizio = null;
$durataMinuti = null;
if ($dtStart) {
$dtStartDateTime = new \DateTime((string) $dtStart);
$dataSpecifica = $dtStartDateTime->format('Y-m-d');
$oraInizio = $dtStartDateTime->format('H:i');
if ($dtEnd) {
$dtEndDateTime = new \DateTime((string) $dtEnd);
$durataMinuti = (int) ($dtEndDateTime->getTimestamp() - $dtStartDateTime->getTimestamp()) / 60;
if ($durataMinuti < 1) {
$durataMinuti = null;
}
}
}
$descrizione = (string) ($vevent->DESCRIPTION ?? '');
$luogo = (string) ($vevent->LOCATION ?? '');
Evento::create([
'nome_evento' => $nomeEvento,
'descrizione' => !empty($descrizione) ? $descrizione : null,
'tipo_recorrenza' => 'singolo',
'data_specifica' => $dataSpecifica,
'ora_inizio' => $oraInizio,
'durata_minuti' => $durataMinuti,
'luogo_indirizzo' => !empty($luogo) ? $luogo : null,
'uid_esterno' => !empty($uid) ? $uid : null,
]);
$imported++;
} catch (\Exception $e) {
Log::warning('Errore import evento ICS: ' . $e->getMessage());
$errors[] = 'Errore: ' . $e->getMessage();
}
}
$message = 'Importati ' . $imported . ' eventi.';
if ($skipped > 0) {
$message .= ' Saltati ' . $skipped . ' eventi (già presenti o senza nome).';
}
if (count($errors) > 0) {
return back()->with('error', $message . ' Errori: ' . implode('; ', $errors));
}
return redirect('/eventi')->with('success', $message);
}
}
-98
View File
@@ -8,7 +8,6 @@ use App\Models\Gruppo;
use App\Models\Individuo;
use App\Models\Diocesi;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class GruppoController extends Controller
{
@@ -301,101 +300,4 @@ class GruppoController extends Controller
return redirect()->route('gruppi.index')->with('success', 'Vista eliminata.');
}
public function import()
{
$this->authorizeWrite('gruppi');
return view('gruppi.import');
}
public function importStore(Request $request)
{
$this->authorizeWrite('gruppi');
$request->validate([
'file' => 'required|file|mimes:csv,txt',
]);
$file = $request->file('file');
$handle = fopen($file->path(), 'r');
$header = fgetcsv($handle, 1000, ',');
if ($header === false) {
fclose($handle);
return back()->with('error', 'Il file CSV non è valido.');
}
$header = array_map('trim', $header);
$imported = 0;
$skipped = 0;
$errors = [];
$rowNumber = 1;
while (($row = fgetcsv($handle, 1000, ',')) !== false) {
$rowNumber++;
if (count($row) < count($header)) {
$skipped++;
continue;
}
$data = array_combine($header, $row);
if ($data === false) {
$skipped++;
continue;
}
$data = array_map('trim', $data);
$nome = $data['nome'] ?? '';
if (empty($nome)) {
$skipped++;
continue;
}
try {
Gruppo::create([
'nome' => $nome,
'descrizione' => $data['descrizione'] ?? null,
'parent_id' => !empty($data['parent_id']) ? (int) $data['parent_id'] : null,
'diocesi_id' => !empty($data['diocesi_id']) ? (int) $data['diocesi_id'] : null,
'indirizzo_incontro' => $data['indirizzo_incontro'] ?? null,
'cap_incontro' => $data['cap_incontro'] ?? null,
'città_incontro' => $data['città_incontro'] ?? null,
'sigla_provincia_incontro' => $data['sigla_provincia_incontro'] ?? null,
]);
$imported++;
} catch (\Exception $e) {
Log::warning('Errore import gruppo riga ' . $rowNumber . ': ' . $e->getMessage());
$errors[] = 'Errore riga ' . $rowNumber . ': ' . $e->getMessage();
}
}
fclose($handle);
$message = 'Importati ' . $imported . ' gruppi.';
if ($skipped > 0) {
$message .= ' Saltate ' . $skipped . ' righe (vuote o incomplete).';
}
if (count($errors) > 0) {
return back()->with('error', $message . ' Errori: ' . implode('; ', $errors));
}
return redirect()->route('gruppi.index')->with('success', $message);
}
public function downloadTemplate()
{
$this->authorizeRead('gruppi');
$headers = ['nome', 'descrizione', 'parent_id', 'diocesi_id', 'indirizzo_incontro', 'cap_incontro', 'città_incontro', 'sigla_provincia_incontro'];
$example = ['Gruppo Giovani', 'Giovani dai 18 ai 30 anni', '', '', 'Via Roma 10', '00100', 'Roma', 'RM'];
$csv = implode(',', $headers) . "\n" . implode(',', $example);
return response($csv, 200, [
'Content-Type' => 'text/csv',
'Content-Disposition' => 'attachment; filename="template_gruppi.csv"',
]);
}
}
+1 -2
View File
@@ -17,8 +17,7 @@ class IndividuoController extends Controller
public function index(Request $request)
{
$this->authorizeRead('individui');
$perPage = in_array((int) $request->perPage, [10, 20, 25, 50, 100]) ? (int) $request->perPage : 20;
$individui = Individuo::with('contatti')->orderBy('cognome')->orderBy('nome')->paginate($perPage);
$individui = Individuo::with('contatti')->orderBy('cognome')->orderBy('nome')->paginate(20);
$vista = null;
-1
View File
@@ -30,7 +30,6 @@ class Evento extends Model
'is_incontro_gruppo',
'luogo_indirizzo',
'luogo_url_maps',
'uid_esterno',
];
protected $casts = [
+4 -5
View File
@@ -197,7 +197,6 @@ CREATE TABLE `gruppo_individuo` (
`gruppo_id` bigint unsigned NOT NULL,
`individuo_id` bigint unsigned NOT NULL,
`ruolo_ids` json DEFAULT NULL,
`ruolo_nel_gruppo` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
`data_adesione` date DEFAULT NULL,
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
@@ -488,7 +487,7 @@ CREATE TABLE `documenti` (
`file_path` varchar(255) NOT NULL,
`tipo` varchar(255) DEFAULT NULL,
`tipologia` enum('avatar','galleria','documento','statuto','altro','programma','locandina','email_attachment') DEFAULT 'documento',
`visibilita` enum('pubblico','gruppo','individuo','associazione','federazione','evento') DEFAULT 'gruppo',
`visibilita` enum('pubblico','gruppo','individuo','associazione','federazione') DEFAULT NULL,
`visibilita_target_id` bigint unsigned DEFAULT NULL,
`visibilita_target_type` varchar(255) DEFAULT NULL,
`mime_type` varchar(255) DEFAULT NULL,
@@ -1036,13 +1035,13 @@ SELECT 2, `id` FROM `permissions` WHERE `name` IN (
-- Role presets
INSERT INTO `role_presets` (`name`, `description`, `permissions`, `created_at`, `updated_at`) VALUES
('admin', 'Amministratore con accesso completo a tutti i moduli',
'{\"individui\":2,\"gruppi\":2,\"eventi\":2,\"documenti\":2,\"mailing\":2,\"viste\":2,\"report\":2,\"settings\":2}',
'{\"individui\":2,\"gruppi\":2,\"eventi\":2,\"documenti\":2,\"mailing\":2,\"viste\":2}',
NOW(), NOW()),
('operatore', 'Operatore con accesso completo a individui/gruppi/eventi, sola lettura per documenti/mailing',
'{\"individui\":2,\"gruppi\":2,\"eventi\":2,\"documenti\":1,\"mailing\":1,\"viste\":1,\"report\":1,\"settings\":1}',
'{\"individui\":2,\"gruppi\":2,\"eventi\":2,\"documenti\":1,\"mailing\":1,\"viste\":1}',
NOW(), NOW()),
('lettura', 'Sola lettura su tutti i moduli',
'{\"individui\":1,\"gruppi\":1,\"eventi\":1,\"documenti\":1,\"mailing\":1,\"viste\":1,\"report\":1,\"settings\":1}',
'{\"individui\":1,\"gruppi\":1,\"eventi\":1,\"documenti\":1,\"mailing\":1,\"viste\":1}',
NOW(), NOW());
-- Email folders (system)
+13 -50
View File
@@ -131,17 +131,11 @@ function createAdminUser(
$userId = $pdo->lastInsertId();
// Assegna ruolo Spatie 'admin' (lookup dinamico per nome)
$stmt = $pdo->prepare("SELECT id FROM roles WHERE name = 'admin' LIMIT 1");
$stmt->execute();
$adminRoleId = (int) $stmt->fetchColumn();
if ($adminRoleId === 0) {
throw new \Exception('Ruolo Spatie admin non trovato');
}
// Assegna ruolo Spatie 'admin' (role_id = 1)
$stmt = $pdo->prepare(
"INSERT INTO model_has_roles (role_id, model_type, model_id) VALUES (?, ?, ?)"
"INSERT INTO model_has_roles (role_id, model_type, model_id) VALUES (1, ?, ?)"
);
$stmt->execute([$adminRoleId, 'App\\Models\\User', $userId]);
$stmt->execute(['App\\Models\\User', $userId]);
return true;
} catch (\PDOException $e) {
@@ -185,10 +179,6 @@ $appName = askRequired('Nome del sito/app (es. Glastree MyChurch)');
$adminName = askRequired('Nome del primo utente amministratore');
$adminEmail = askRequired('Email dell\'amministratore');
$adminPass = askRequired('Password per l\'amministratore');
$adminPass2 = askRequired('Conferma password amministratore');
if ($adminPass !== $adminPass2) {
fail('Le password non corrispondono');
}
$appUrl = askRequired('URL pubblico del sito (es. https://glastree.esempio.it)');
// ── DB params (Apache / Restore) ─────────────────────
@@ -274,9 +264,7 @@ if ($mode === 'Fresh Install su Apache (LAMP tradizionale)') {
}
// APP_KEY
if (!run("{$sudo}php artisan key:generate --force", 'Generazione APP_KEY...')) {
fail('Generazione APP_KEY fallita');
}
runCapture("{$sudo}php artisan key:generate --force");
ok('APP_KEY generata');
// Config
@@ -385,9 +373,7 @@ if ($mode === 'Fresh Install su Apache (LAMP tradizionale)') {
println(color(' 3/7 — INSTALLAZIONE DIPENDENZE', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if (!run("COMPOSER_ROOT_VERSION=dev-main composer install --no-dev --optimize-autoloader", 'Installazione dipendenze Composer...')) {
fail('Installazione dipendenze Composer fallita');
}
run("COMPOSER_ROOT_VERSION=dev-main composer install --no-dev --optimize-autoloader", 'Installazione dipendenze Composer...');
ok('Dipendenze PHP installate');
// ── Step 4: Package discovery ────────────────────
@@ -411,7 +397,7 @@ if ($mode === 'Fresh Install su Apache (LAMP tradizionale)') {
}
if ($dbType === 'mysql') {
$importCmd = "MYSQL_PWD=" . escapeshellarg($dbPass) . " mysql -h {$dbHost} -P {$dbPort} -u {$dbUser} {$dbName} < {$sqlFile}";
$importCmd = "mysql -h {$dbHost} -P {$dbPort} -u {$dbUser} -p{$dbPass} {$dbName} < {$sqlFile}";
if (!run($importCmd, 'Importazione struttura database e dati base...')) {
fail("Importazione SQL fallita. Verifica il file database/install.sql e le credenziali.");
}
@@ -516,12 +502,6 @@ if ($mode === 'Fresh Install via Docker') {
println(color('══════════════════════════════════════════', 'yellow'));
checkCmd('docker');
$dockerComposeCmd = 'docker compose';
if (trim(shell_exec('command -v docker-compose 2>/dev/null') ?: '') !== '') {
$dockerComposeCmd = 'docker-compose';
} elseif (trim(shell_exec('command -v docker 2>/dev/null && docker compose version 2>/dev/null') ?: '') === '') {
fail('Docker Compose (v1 o v2) non trovato.');
}
println();
println(color('══════════════════════════════════════════', 'yellow'));
@@ -538,13 +518,10 @@ if ($mode === 'Fresh Install via Docker') {
println(color(' INSTALLAZIONE DOCKER', 'yellow'));
println(color('══════════════════════════════════════════', 'yellow'));
if (!run("{$dockerComposeCmd} -f {$composeFile} build", 'Build immagine Docker...')) {
if (!run("docker compose -f {$composeFile} build", 'Build immagine Docker...')) {
fail('Build fallita');
}
// Genera APP_KEY per il container Docker
$appKey = 'base64:' . base64_encode(random_bytes(32));
// Override compose con variabili d'ambiente
$override = [
'services:',
@@ -554,12 +531,11 @@ if ($mode === 'Fresh Install via Docker') {
" - APP_URL={$appUrl}",
' - APP_ENV=production',
' - APP_DEBUG=false',
" - APP_KEY={$appKey}",
];
file_put_contents('docker-compose.override.yml', implode(PHP_EOL, $override) . PHP_EOL);
if (!run(
"{$dockerComposeCmd} -f {$composeFile} -f docker-compose.override.yml up -d",
"docker compose -f {$composeFile} -f docker-compose.override.yml up -d",
'Avvio container...'
)) {
@unlink('docker-compose.override.yml');
@@ -568,7 +544,7 @@ if ($mode === 'Fresh Install via Docker') {
// Attendi container pronto
info('Attendo che il container sia pronto...');
$containerId = runCapture("{$dockerComposeCmd} -f {$composeFile} ps -q glastree 2>/dev/null");
$containerId = runCapture("docker compose -f {$composeFile} ps -q glastree 2>/dev/null");
$adminCreated = false;
if ($containerId !== '') {
@@ -584,11 +560,6 @@ if ($mode === 'Fresh Install via Docker') {
sleep(2);
}
if ($status !== 'running') {
@unlink('docker-compose.override.yml');
fail("Container non pronto dopo 60 secondi (stato: {$status}). Controlla i log con: {$dockerComposeCmd} -f {$composeFile} logs");
}
// Import schema SQL nel container
$sqlFile = $scriptDir . '/database/install.sql';
if (file_exists($sqlFile) && $dockerDb === 'mysql') {
@@ -634,11 +605,7 @@ if ($mode === 'Fresh Install via Docker') {
\$pdo->prepare("DELETE FROM users WHERE email = ?")->execute([{$emailExp}]);
\$pdo->prepare("INSERT INTO users (name, email, password, is_admin, status, permissions, role_preset_id, created_at, updated_at) VALUES (?, ?, ?, 1, 'active', ?, 1, NOW(), NOW())")->execute([{$nameExp}, {$emailExp}, {$hashExp}, '{$perm}']);
\$uid = \$pdo->lastInsertId();
\$stmt = \$pdo->prepare("SELECT id FROM roles WHERE name = 'admin' LIMIT 1");
\$stmt->execute();
\$adminRoleId = (int) \$stmt->fetchColumn();
if (\$adminRoleId === 0) { echo 'ERR:admin_role_not_found'; exit(1); }
\$pdo->prepare("INSERT INTO model_has_roles (role_id, model_type, model_id) VALUES (?, ?, ?)")->execute([\$adminRoleId, 'App\\\\Models\\\\User', \$uid]);
\$pdo->prepare("INSERT INTO model_has_roles (role_id, model_type, model_id) VALUES (1, ?, ?)")->execute(['App\\\\Models\\\\User', \$uid]);
echo "OK:" . \$uid;
PHP;
file_put_contents($adminScriptPath, $adminPhp);
@@ -768,9 +735,7 @@ if ($mode === 'Restore da Backup (Apache)') {
file_put_contents('.env', $dotenv);
// Generate APP_KEY
if (!run("{$sudo}php artisan key:generate --force", 'Generazione APP_KEY...')) {
fail('Generazione APP_KEY fallita');
}
runCapture("{$sudo}php artisan key:generate --force");
ok('APP_KEY rigenerata');
// Import database
@@ -778,7 +743,7 @@ if ($mode === 'Restore da Backup (Apache)') {
$sqlFile = "{$tmpDir}/database.sql";
if (file_exists($sqlFile)) {
info("Importazione database MySQL: {$sqlFile}");
$cmd = "MYSQL_PWD=" . escapeshellarg($dbPass) . " mysql -h {$dbHost} -P {$dbPort} -u {$dbUser} {$dbName} < {$sqlFile}";
$cmd = "mysql -h {$dbHost} -P {$dbPort} -u {$dbUser} -p{$dbPass} {$dbName} < {$sqlFile}";
if (run($cmd, null)) {
ok('Database importato');
} else {
@@ -800,9 +765,7 @@ if ($mode === 'Restore da Backup (Apache)') {
}
// Composer
if (!run("COMPOSER_ROOT_VERSION=dev-main composer install --no-dev --optimize-autoloader", 'Installazione dipendenze Composer...')) {
fail('Installazione dipendenze Composer fallita');
}
run("COMPOSER_ROOT_VERSION=dev-main composer install --no-dev --optimize-autoloader", 'Installazione dipendenze Composer...');
runCapture("{$sudo}php artisan package:discover --ansi 2>/dev/null");
// Storage link + cache
+2 -23
View File
@@ -131,29 +131,8 @@ $currentFolder = $folders->firstWhere('type', $folder);
</table>
</div>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div>
<form method="GET" class="form-inline" id="perPageForm">
<label class="mr-2 small">Righe per pagina:</label>
<select name="perPage" class="form-control form-control-sm" onchange="this.form.submit()" style="width: auto;">
@foreach([10, 20, 25, 50, 100] as $p)
<option value="{{ $p }}" {{ (int) request('perPage', 20) === $p ? 'selected' : '' }}>{{ $p }}</option>
@endforeach
</select>
@foreach(request()->except(['perPage', 'page']) as $key => $value)
@if(is_array($value))
@foreach($value as $v)
<input type="hidden" name="{{ $key }}[]" value="{{ $v }}">
@endforeach
@else
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
</form>
</div>
<div>
{{ $messages->links('vendor.pagination.simple-bootstrap-4') }}
</div>
<div class="card-footer">
{{ $messages->withQueryString()->links('vendor.pagination.simple-bootstrap-4') }}
</div>
</div>
@endsection
-55
View File
@@ -1,55 +0,0 @@
@extends('layouts.adminlte')
@section('title', 'Importa Eventi')
@section('page_title', 'Importa Eventi da ICS')
@section('content')
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-upload mr-2"></i>Carica file ICS</h3>
</div>
<div class="card-body">
<form action="{{ route('eventi.import.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="form-group">
<label for="file">Seleziona file ICS *</label>
<input type="file" name="file" id="file" class="form-control" accept=".ics" required>
<small class="form-text text-muted">
Il file deve essere in formato ICS (iCalendar).
</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload mr-1"></i> Importa
</button>
<a href="/eventi" class="btn btn-secondary ml-2">
Annulla
</a>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-info-circle mr-2"></i>Informazioni</h3>
</div>
<div class="card-body">
<h6>Campi importati:</h6>
<ul>
<li><code>SUMMARY</code> Nome evento (obbligatorio)</li>
<li><code>DESCRIPTION</code> Descrizione</li>
<li><code>DTSTART</code> Data/Ora inizio</li>
<li><code>DTEND</code> Durata</li>
<li><code>LOCATION</code> Luogo</li>
<li><code>UID</code> ID esterno (per evitare duplicati)</li>
</ul>
<p class="text-muted small mt-2">
<i class="fas fa-info-circle mr-1"></i>
Gli eventi con UID già presente nel sistema verranno saltati automaticamente.
</p>
</div>
</div>
</div>
</div>
@endsection
+2 -26
View File
@@ -34,9 +34,6 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
</div>
@endif
@if($canWriteEventi)
<a href="/eventi/import" class="btn btn-sm btn-warning mr-2">
<i class="fas fa-file-import mr-1"></i> Importa ICS
</a>
<a href="/eventi/create" class="btn btn-xs btn-success">
<i class="fas fa-plus mr-1"></i> Nuovo Evento
</a>
@@ -250,29 +247,8 @@ $canDeleteEventi = Auth::user()->canDelete('eventi');
</tbody>
</table>
</div>
<div class="card-footer d-flex justify-content-between align-items-center">
<div>
<form method="GET" class="form-inline" id="perPageForm">
<label class="mr-2 small">Righe per pagina:</label>
<select name="perPage" class="form-control form-control-sm" onchange="this.form.submit()" style="width: auto;">
@foreach([10, 20, 25, 50, 100] as $p)
<option value="{{ $p }}" {{ (int) request('perPage', 20) === $p ? 'selected' : '' }}>{{ $p }}</option>
@endforeach
</select>
@foreach(request()->except(['perPage', 'page']) as $key => $value)
@if(is_array($value))
@foreach($value as $v)
<input type="hidden" name="{{ $key }}[]" value="{{ $v }}">
@endforeach
@else
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
</form>
</div>
<div>
{{ $eventi->links() }}
</div>
<div class="card-footer">
{{ $eventi->withQueryString()->links() }}
</div>
</div>
-64
View File
@@ -1,64 +0,0 @@
@extends('layouts.adminlte')
@section('title', 'Importa Gruppi')
@section('page_title', 'Importa Gruppi da CSV')
@section('content')
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-upload mr-2"></i>Carica file CSV</h3>
</div>
<div class="card-body">
<form action="{{ route('gruppi.import.store') }}" method="POST" enctype="multipart/form-data">
@csrf
<div class="form-group">
<label for="file">Seleziona file CSV *</label>
<input type="file" name="file" id="file" class="form-control" accept=".csv,.txt" required>
<small class="form-text text-muted">
Il file deve essere in formato CSV con separatore virgola.
</small>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-upload mr-1"></i> Importa
</button>
<a href="{{ route('gruppi.index') }}" class="btn btn-secondary ml-2">
Annulla
</a>
</form>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header bg-info">
<h3 class="card-title text-white"><i class="fas fa-download mr-2"></i>Template</h3>
</div>
<div class="card-body">
<p>Scarica il template CSV per l'importazione:</p>
<a href="{{ route('gruppi.template') }}" class="btn btn-success btn-block">
<i class="fas fa-file-csv mr-1"></i> Scarica Template
</a>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h3 class="card-title"><i class="fas fa-info-circle mr-2"></i>Formato CSV</h3>
</div>
<div class="card-body">
<h6>Campi:</h6>
<ul>
<li><code>nome</code> (obbligatorio)</li>
<li><code>descrizione</code></li>
<li><code>parent_id</code> (ID del gruppo padre)</li>
<li><code>diocesi_id</code> (ID della diocesi)</li>
<li><code>indirizzo_incontro</code></li>
<li><code>cap_incontro</code></li>
<li><code>città_incontro</code></li>
<li><code>sigla_provincia_incontro</code> (2 lettere)</li>
</ul>
</div>
</div>
</div>
</div>
@endsection
-3
View File
@@ -55,9 +55,6 @@ $visibleColumnsJson = json_encode($visibleColumns);
<i class="fas fa-save mr-1"></i> Salva Vista
</button>
@if($canWriteGruppi)
<a href="{{ route('gruppi.import') }}" class="btn btn-warning btn-sm mr-1">
<i class="fas fa-file-import mr-1"></i> Importa
</a>
<a href="{{ route('gruppi.create') }}" class="btn btn-success btn-sm">
<i class="fas fa-plus"></i> Nuovo Gruppo
</a>
+1 -22
View File
@@ -220,30 +220,9 @@ $canDeleteIndividui = Auth::user()->canDelete('individui');
@endif
</div>
@if($individui->count() > 0)
<div class="card-footer d-flex justify-content-between align-items-center">
<div>
<form method="GET" class="form-inline" id="perPageForm">
<label class="mr-2 small">Righe per pagina:</label>
<select name="perPage" class="form-control form-control-sm" onchange="this.form.submit()" style="width: auto;">
@foreach([10, 20, 25, 50, 100] as $p)
<option value="{{ $p }}" {{ (int) request('perPage', 20) === $p ? 'selected' : '' }}>{{ $p }}</option>
@endforeach
</select>
@foreach(request()->except(['perPage', 'page']) as $key => $value)
@if(is_array($value))
@foreach($value as $v)
<input type="hidden" name="{{ $key }}[]" value="{{ $v }}">
@endforeach
@else
<input type="hidden" name="{{ $key }}" value="{{ $value }}">
@endif
@endforeach
</form>
</div>
<div>
<div class="card-footer">
{{ $individui->links() }}
</div>
</div>
@endif
</div>
-5
View File
@@ -87,9 +87,6 @@ Route::patch('gruppi/{gruppo}/membri/{individuo}', [GruppoMembroController::clas
Route::delete('gruppi/{gruppo}/membri/{individuo}', [GruppoMembroController::class, 'destroy'])->middleware('auth');
// Gruppi specific routes (MUST be before resource route)
Route::get('gruppi/import', [GruppoController::class, 'import'])->middleware('auth')->name('gruppi.import');
Route::post('gruppi/import', [GruppoController::class, 'importStore'])->middleware('auth')->name('gruppi.import.store');
Route::get('gruppi/template', [GruppoController::class, 'downloadTemplate'])->middleware('auth')->name('gruppi.template');
Route::get('gruppi/all', [GruppoController::class, 'allGruppi'])->middleware('auth');
Route::get('gruppi/{gruppo}/individui-email', [GruppoController::class, 'individuiEmail'])->middleware('auth');
@@ -100,8 +97,6 @@ Route::delete('gruppi/{gruppo}/avatar', [\App\Http\Controllers\GruppoAvatarContr
Route::resource('gruppi', GruppoController::class)->middleware('auth');
Route::get('eventi/calendar', [EventoController::class, 'calendar'])->name('eventi.calendar')->middleware('auth');
Route::get('eventi/calendar/events', [EventoController::class, 'calendarEvents'])->name('eventi.calendar.events')->middleware('auth');
Route::get('eventi/import', [EventoController::class, 'importIcs'])->middleware('auth')->name('eventi.import');
Route::post('eventi/import', [EventoController::class, 'importIcsStore'])->middleware('auth')->name('eventi.import.store');
Route::get('eventi/{evento}/export-ics', [EventoController::class, 'exportIcs'])->name('eventi.export-ics')->middleware('auth');
Route::post('eventi/export-ics', [EventoController::class, 'exportSelectedIcs'])->name('eventi.export-selected-ics')->middleware('auth');
Route::post('eventi/mass-elimina', [EventoController::class, 'massElimina'])->middleware('auth');