Revisione blocchi 2 e 3: file protetti, ripristino atomico, import sicuri
Blocco 2 - Esiti delle azioni Livewire come avvisi (trait Avvisa) invece di flash che comparivano alla pagina dopo. - PDF, miniature e versioni dei territori non più serviti da nginx su /storage: TerritorioFileController con login, permessi e cache privata. - Ripristino tutto o niente: verifica dell'archivio, una transazione con import, piantine, blocco account e scambio dei file annullabile. - Backup formato 2 con piantine, storico PDF e area KML. - Posto unico preso in un passo (Cache::lock) e rispettato anche da import XML e PDF. - Import PDF da cartella: abbinamento corretto (1-A, 12 B, ambigui), caricamento a lotti, file non PDF ignorati, PDF sostituito nello storico. - L'import XML elimina i file dei territori che non usa più; comando territori:file-orfani per quelli accumulati prima. Blocco 3 - Password attuale richiesta al cambio volontario; AuthenticateSession chiude le sessioni aperte altrove. - Niente nomi dei proclamatori in chiaro nel registro attività. - Export XML senza remember_token; chi riceve l'hash dal file deve cambiare password. - Campagne: ultimo giorno incluso, percentuale sui territori distinti. - Coda: retry_after 960 s, stop_grace_period 16m, Redis volatile-lru, impostazioni rilette a ogni job. - Azzeramento che cancella anche piantine, area KML e anteprime. - Pulsanti profilo e menu che non si nascondevano; icona del sito. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
141
HANDOFF.md
141
HANDOFF.md
@@ -77,69 +77,60 @@ e l'unica che richiede una decisione, non del lavoro.
|
||||
|
||||
## Lavori in corso — 12 ottobre 2026 (riprendere da qui)
|
||||
|
||||
### Già fatto e committato su `dev` (commit `d433c93`, NON ancora pushato)
|
||||
Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo):
|
||||
- `d433c93` — Modulo S-13 sul modulo ufficiale (regole in
|
||||
`app/Services/Registro/ModuloS13.php`) e blocco 1 della revisione
|
||||
(integrità dati).
|
||||
- `056d95c` — Editor piantine: bozza automatica, un solo "Pubblica" con
|
||||
anteprima, stati Bozza / Pubblicata / Modifiche da pubblicare. Come funziona:
|
||||
[Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
|
||||
- Il commit successivo chiude i blocchi 2 e 3 della revisione (sotto).
|
||||
|
||||
- **Modulo S-13** riscritto su modulo ufficiale (FPDI), regole decise con l'utente:
|
||||
20 righe/foglio, tutti i territori attivi, ciclo da 4 con riporto, riporto
|
||||
iniziale = ultimo rientro prima dell'anno, assegnazioni a cavallo d'anno su
|
||||
entrambi i fogli (sul vecchio senza rientro), nomi "Cognome I.", scelta per
|
||||
data. Codice in `app/Services/Registro/ModuloS13.php`.
|
||||
- **Blocco 1 della revisione** (integrità dati): date export/import che
|
||||
slittavano di un giorno, import XML che accettava file estranei, caratteri di
|
||||
controllo, territori senza zona scartati, admin estromesso dall'import,
|
||||
doppia assegnazione (`Assegnazione::sovrappostaA()` + lock), ultimo admin,
|
||||
`APP_KEY` sovrascritta dall'entrypoint (`APP_KEY_ROTATE=1`). Vedi la tabella
|
||||
"Bug corretti". Suite: 328 test verdi.
|
||||
- Il push su `dev` fa partire il deploy su demo: va fatto quando si decide.
|
||||
Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC di
|
||||
sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
|
||||
|
||||
### Finito, NON ancora committato: riprogettazione dell'Editor Piantine
|
||||
### Revisione profonda: blocchi 2 e 3 chiusi
|
||||
|
||||
Decisioni dell'utente: due pagine separate (gestione / editor); **un solo
|
||||
pulsante "Pubblica"** (immagine + PDF) con **anteprima obbligatoria** da
|
||||
confermare, disponibile da editor e gestione; dall'editor **si modificano i
|
||||
dati del territorio** (salvati subito nel gestionale); uso soprattutto da PC ma
|
||||
anche **tablet**; **stato di bozza**: in bozza va solo il disegno, salvata
|
||||
**automaticamente**; stati Bozza / Pubblicata / Modifiche da pubblicare.
|
||||
Come funziona: sezione [Le piantine sono una sotto-app](#le-piantine-sono-una-sotto-app-renderer-esterno-agganci-minimi).
|
||||
| Problema | Correzione |
|
||||
|---|---|
|
||||
| Esiti delle azioni Livewire persi o mostrati alla pagina dopo (11 componenti) | trait `Avvisa` + `<x-avvisi>`; un test vieta `session()->flash()` senza redirect |
|
||||
| PDF, miniature e versioni scaricabili da `/storage/territori-*` senza login (nomi indovinabili, cache Cloudflare 7 giorni) | nginx risponde 404 su `/storage/territori-`; `TerritorioFileController` con permessi della pagina e cache privata; `Territorio::pdfUrl()` / `miniaturaUrl()` |
|
||||
| Ripristino non atomico | estrazione verificata (CRC, byte) → una transazione con import, piantine, blocco account e scambio file (`rename`, annullabile) → file vecchi cancellati solo dopo il commit |
|
||||
| Piantine, storico PDF, area KML fuori dal backup | formato archivio 2 (`piantine.json`, `piantine/…`, `PiantineBackup`); gli archivi di formato 1 si ripristinano lo stesso |
|
||||
| Blocco "una sola operazione" ignorato da import XML/PDF, e controllo+presa in due passi | `BackupOperationState::occupa()` sotto `Cache::lock`, usato da tutte le porte; rilascio anche su errore e in `failed()` |
|
||||
| Import PDF da cartella: `1-A` → territorio 1, `12 B` ambiguo, solo 20 file, `Thumbs.db` blocca tutto, `__MACOSX` | abbinamento "cifre + al massimo una lettera"; ambigui scartati; caricamento a lotti dal browser; file non PDF ignorati |
|
||||
| Import PDF e caricamento dalla scheda cancellavano il vecchio PDF prima di scrivere il nuovo | prima il nuovo, poi il vecchio nello storico (`PiantinaPdfService::archiviaCorrente`, origini `import` / `manuale`) |
|
||||
| L'import XML lasciava sul disco i file dei territori ricreati (nel PC: 549 file, 80 MB) | l'import elimina i file non più usati; per i vecchi c'è `php artisan territori:file-orfani` (elenca; `--elimina` cancella) — **da lanciare quando si decide** |
|
||||
| Azzeramento lasciava piantine orfane, area KML, anteprime | cancellate anche quelle |
|
||||
| `/cambia-password` senza password attuale | richiesta, tranne dopo un ripristino (cambio obbligatorio) |
|
||||
| Sessioni aperte altrove non chiuse al cambio password | `AuthenticateSession` nel gruppo web; il ripristino conserva l'hash di chi lo esegue, così non viene buttato fuori |
|
||||
| Nomi dei proclamatori in chiaro nel registro attività | si salva `proclamatore_id`, il registro mostra il nome attuale; migrazione `2026_10_12_000010` ripulisce le voci vecchie |
|
||||
| Export XML con `remember_token`; l'import XML non obbligava a cambiare password | token non esportato e azzerato all'import; chi riceve l'hash dal file deve cambiare password |
|
||||
| Campagne: sparivano l'ultimo giorno; percentuale oltre il 100% | confronto per data; si contano i territori attivi distinti |
|
||||
| `retry_after` 90 s con job fino a 900 s | `config/queue.php`: 960 s |
|
||||
| Worker ucciso dopo 10 s al riavvio | `stop_grace_period: 16m` |
|
||||
| Redis `allkeys-lru` poteva sfrattare i job in coda | `volatile-lru` |
|
||||
| Cache statica di `Setting` vecchia nel worker | svuotata a ogni job (`Queue::before`) |
|
||||
| Pulsanti profilo/menu che non si nascondevano (barra che trabocca su telefono) | `.tm-btn` sta fuori dai layer di Tailwind e vince su `hidden`: si nasconde il contenitore |
|
||||
|
||||
Stato al 12/10/2026:
|
||||
- Codice completo: modello, servizio di pubblicazione + job, finestra condivisa,
|
||||
editor e gestione riscritti, JS e CSS. Migrazione
|
||||
`2026_10_09_000010_bozza_e_pubblicazione_piantine`.
|
||||
- Test: `PiantineEditorTest` (22), `PiantineManagerTest`,
|
||||
`PubblicazionePiantinaTest` (nuovo). Suite intera: **360 test verdi**.
|
||||
- Provato in browser (Playwright + Edge, in locale) su PC 1440, iPad
|
||||
orizzontale e verticale, iPhone: disegno, modifica vertici (8 maniglie su
|
||||
poligono), collegamento, anteprima generata dal renderer vero, conferma,
|
||||
stato "Pubblicata", filtri della gestione. Corretti lì: tendina territori che
|
||||
segnava tutti "(disattivato)" (colonna `attivo` non caricata), zoom sopra la
|
||||
ricerca dell'elenco, barra strumenti sopra il pannello su tablet e fuori
|
||||
schermo su telefono, crediti mappa stampati sul foglio del pannello.
|
||||
- Aggiunto `.gitattributes` (`*.sh text eol=lf`): con `core.autocrlf=true`
|
||||
l'entrypoint usciva con CRLF e il container locale non partiva ("no such
|
||||
file or directory"). Nel repository era già LF: il deploy non era a rischio.
|
||||
- **Non provato:** modifica vertici al tocco su un tablet vero, MultiPolygon
|
||||
in modifica vertici (si modifica il primo poligono).
|
||||
- Visto e **non** corretto (fuori tema): su telefono la barra superiore
|
||||
dell'app trabocca con nome/email lunghi ("Esci" fuori schermo);
|
||||
`/favicon.ico` risponde 404.
|
||||
Non cambiato, di proposito: `trustProxies('*')` (commento in
|
||||
`bootstrap/app.php`: con `real_ip` di nginx, restringerlo romperebbe HTTPS).
|
||||
|
||||
### Revisione profonda: blocchi ancora aperti
|
||||
Suite: **427 test verdi**. Provati in browser in locale: file protetti (404
|
||||
senza login, PDF e miniature con login), caricamento a lotti (25 PDF in 2
|
||||
lotti, `Thumbs.db` ignorato, nessun territorio toccato, posto liberato).
|
||||
|
||||
- **Blocco 2:** avvisi in tutte le ~10 pagine che oggi li perdono (usare il
|
||||
trait `Avvisa`); PDF dei territori serviti da nginx su `/storage` senza
|
||||
autenticazione (spostare su disco privato); ripristino non atomico e blocco
|
||||
"una sola operazione" ignorato da import XML/PDF; piantine e storico PDF
|
||||
assenti dal backup; import PDF da cartella (`1-A` sovrascrive `1`, solo 20
|
||||
file per `max_file_uploads`, `Thumbs.db` blocca tutto, `12 B` ambiguo).
|
||||
- **Blocco 3:** `/cambia-password` senza password attuale, sessioni non
|
||||
invalidate, `trustProxies('*')`, nomi in chiaro nell'activity log, export XML
|
||||
con hash/token, campagne (ultimo giorno, percentuali), `retry_after` 90 s,
|
||||
`stop_grace_period` del worker, Redis `allkeys-lru`, cache statica di
|
||||
`Setting` nel worker, azzeramento che lascia piantine e KML.
|
||||
- Produzione: al 12/10/2026 non esiste ancora (il sito gira in Docker sul PC
|
||||
di sviluppo, più la demo da `dev`). Nessuna correzione una tantum delle date
|
||||
slittate da fare.
|
||||
**Non provato / aperto:**
|
||||
- chiusura delle sessioni con due browser veri; coda e worker con un riavvio
|
||||
reale; modifica vertici al tocco su tablet vero;
|
||||
- dopo un ripristino le piantine pubblicate risultano "Modifiche da pubblicare"
|
||||
(l'import ricrea i territori con id nuovi): verso sicuro, basta ripubblicare;
|
||||
- `corrente()` / `liberaCorrente()` di `BackupOperationState` fanno get+forget
|
||||
senza mutex (finestra di millisecondi);
|
||||
- l'upload Livewire ha `throttle:60,1`: oltre ~1200 file al minuto qualche
|
||||
lotto riceve 429 (il browser lo segnala);
|
||||
- numeri di territorio con lettere davanti ("A12") non si abbinano da nome
|
||||
file: il PDF si carica dalla scheda.
|
||||
|
||||
---
|
||||
|
||||
@@ -748,13 +739,16 @@ Scelte non ovvie:
|
||||
cancellazioni scrivono righe nuove — e subito dopo si scrive l'unica riga che
|
||||
sopravvive, quella dell'azzeramento stesso.
|
||||
|
||||
- **Ordine del restore: staging → database → file.** I file si estraggono
|
||||
prima in `storage/app/backup-restore/<uuid>`, così un archivio corrotto o
|
||||
fuori limite fallisce mentre i dati correnti sono ancora intatti; poi
|
||||
`importXmlFromContent()` in transazione; poi la sostituzione dei file. Il
|
||||
disco `public` viene **svuotato**, non fuso: i percorsi vivono nelle righe
|
||||
appena riscritte, e tenere i vecchi file lascerebbe PDF orfani che nessuna
|
||||
schermata mostra e nessuna cancellazione raggiunge.
|
||||
- **Ordine del restore: verifica → una transazione → pulizia.** Dal 12/10/2026
|
||||
(`RestoreBackupArchive`): si estrae e si verifica tutto (CRC, byte, il
|
||||
`piantine.json` del formato 2) prima di toccare qualcosa; poi una sola
|
||||
`DB::transaction` contiene import XML, tabelle delle piantine, blocco degli
|
||||
account e, per ultimo, lo scambio dei file (`BackupArchive::scambiaFile()`:
|
||||
solo `rename()` sullo stesso volume, annotati e annullabili). Se qualcosa
|
||||
fallisce si torna allo stato di prima; i file vecchi aspettano in
|
||||
`storage/app/backup-precedenti/<uuid>` e si cancellano solo dopo il commit.
|
||||
Il disco `public` viene sostituito, non fuso: i percorsi vivono nelle righe
|
||||
appena riscritte.
|
||||
|
||||
- **Limiti sull'archivio in ingresso.** Un `.zip` è compresso: senza tetti su
|
||||
numero di entry, dimensione della singola entry e totale decompresso, pochi
|
||||
@@ -1245,21 +1239,12 @@ pubblicate da Laravel sono state rimosse perche' i paginatori sono tutti dentro
|
||||
componenti Livewire, che risolvono la vista dal namespace `livewire::` e non da
|
||||
`pagination::`. L'esclusione resta a guardia di future viste di terze parti.
|
||||
|
||||
### 7. Il backup non contiene l'area KML delle piantine
|
||||
### 7. Area KML, piantine e storico nel backup — chiuso il 12/10/2026
|
||||
|
||||
`BackupArchive` impacchetta il solo disco `public` (`storage/app/public`), e
|
||||
`PiantineArea` scrive in `storage/app/piantine/` — una cartella **sorella**, non
|
||||
dentro. Verificato: dopo un ripristino su volumi vuoti l'area va ricaricata a
|
||||
mano dal KML originale, che quindi conviene conservare fuori di qui.
|
||||
|
||||
I disegni invece si salvano: stanno in `territorio_mappe`, e da quando la chiave
|
||||
esterna è `nullOnDelete` sopravvivono anche allo svuotamento di `territori` che
|
||||
fa ogni import XML — scollegati, ma recuperabili.
|
||||
|
||||
Sistemarlo vuol dire estendere `BackupArchive` a una seconda cartella, cosa che
|
||||
tocca anche il formato dell'archivio (`FORMAT_VERSION`) e il ripristino. Non è
|
||||
stato fatto perché il KML è un file che l'utente ha già sul suo computer: la
|
||||
perdita è un fastidio, non un dato irrecuperabile.
|
||||
Dal formato 2 l'archivio contiene `piantine.json` (righe di `territorio_mappe`
|
||||
e `territorio_pdf_versioni`, con il numero del territorio per ricollegarle) e
|
||||
la cartella `piantine/` dell'area KML. Un archivio di formato 1 si ripristina
|
||||
lo stesso: piantine e storico restano vuoti, l'area in uso non si tocca.
|
||||
|
||||
---
|
||||
|
||||
|
||||
79
app/Console/Commands/TerritoriFileOrfani.php
Normal file
79
app/Console/Commands/TerritoriFileOrfani.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Models\Setting;
|
||||
use App\Models\TerritorioMappa;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Elenca (e su richiesta cancella) i file dei territori che nessuna riga usa.
|
||||
*
|
||||
* Fino all'ottobre 2026 l'import XML ricreava i territori con id nuovi senza
|
||||
* toccare i loro file: PDF, miniature e versioni dei territori di prima
|
||||
* restavano sul disco, e passavano da un backup all'altro perché l'archivio
|
||||
* copia tutto il disco pubblico. Oggi l'import li elimina da sé; questo
|
||||
* comando serve a ripulire quelli accumulati prima.
|
||||
*
|
||||
* Di default non cancella niente: elenca. Con --elimina cancella.
|
||||
*/
|
||||
class TerritoriFileOrfani extends Command
|
||||
{
|
||||
protected $signature = 'territori:file-orfani {--elimina : Cancella i file trovati invece di elencarli}';
|
||||
|
||||
protected $description = 'Elenca (o cancella con --elimina) i file dei territori che nessuna riga usa più';
|
||||
|
||||
private const CARTELLE = ['territori-pdf', 'territori-thumbnails', 'territori-pdf-versioni', 'territori-mappe'];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$usati = array_flip(array_merge(
|
||||
XmlExchange::fileDeiTerritori(),
|
||||
array_filter([Setting::instance()->logo_path]),
|
||||
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
|
||||
));
|
||||
|
||||
$disco = Storage::disk('public');
|
||||
$orfani = [];
|
||||
$byte = 0;
|
||||
|
||||
foreach (self::CARTELLE as $cartella) {
|
||||
foreach ($disco->files($cartella) as $file) {
|
||||
if (! isset($usati[$file])) {
|
||||
$orfani[] = $file;
|
||||
$byte += $disco->size($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$peso = number_format($byte / 1048576, 1, ',', '.') . ' MB';
|
||||
|
||||
if ($orfani === []) {
|
||||
$this->info('Nessun file orfano.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('elimina')) {
|
||||
foreach ($orfani as $file) {
|
||||
$this->line($file);
|
||||
}
|
||||
$this->info(count($orfani) . " file orfani ({$peso}). Rilancia con --elimina per cancellarli.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$cancellati = 0;
|
||||
foreach ($orfani as $file) {
|
||||
if ($disco->delete($file)) {
|
||||
$cancellati++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("{$cancellati} file orfani cancellati ({$peso}).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -43,23 +43,30 @@ class PasswordChangeController extends Controller
|
||||
|
||||
public function store(Request $request)
|
||||
{
|
||||
// La password attuale NON viene richiesta, ed e' una scelta.
|
||||
$user = $request->user();
|
||||
|
||||
// La password attuale si chiede solo a chi arriva qui di sua
|
||||
// iniziativa, come fa il profilo.
|
||||
//
|
||||
// Il ripristino sovrascrive gli hash con quelli del backup: subito dopo,
|
||||
// la "password attuale" di un account e' quella che aveva all'epoca del
|
||||
// backup, che l'utente puo' benissimo non ricordare — l'amministratore
|
||||
// che ha appena fatto il restore per primo. Chiederla bloccherebbe fuori
|
||||
// proprio le persone che questa schermata deve sbloccare. La garanzia
|
||||
// qui e' la sessione: si arriva a questa pagina gia' autenticati.
|
||||
$validated = $request->validate([
|
||||
'password' => ['required', 'confirmed', Password::min(8)],
|
||||
], [
|
||||
// Dopo un ripristino no: gli hash sono quelli del backup, e la
|
||||
// "password attuale" di un account e' quella che aveva all'epoca, che
|
||||
// l'utente puo' benissimo non ricordare. Chiederla bloccherebbe fuori
|
||||
// proprio le persone che questa schermata deve sbloccare; la garanzia li'
|
||||
// e' la sessione. Fuori da quel caso, invece, bastava una sessione
|
||||
// lasciata aperta per cambiare la password a qualcun altro e chiuderlo
|
||||
// fuori dal suo account.
|
||||
$regole = ['password' => ['required', 'confirmed', Password::min(8)]];
|
||||
if (! $user->password_change_required) {
|
||||
$regole['current_password'] = ['required', 'current_password'];
|
||||
}
|
||||
|
||||
$validated = $request->validate($regole, [
|
||||
'password.required' => 'Inserisci la nuova password.',
|
||||
'password.confirmed' => 'Le due password non coincidono.',
|
||||
'current_password.required' => 'Inserisci la password attuale.',
|
||||
'current_password.current_password' => 'La password attuale non è corretta.',
|
||||
]);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
// Il cast `hashed` sul modello cifra il valore al salvataggio.
|
||||
$user->update([
|
||||
'password' => $validated['password'],
|
||||
@@ -68,7 +75,10 @@ class PasswordChangeController extends Controller
|
||||
|
||||
// La sessione corrente sopravvive al cambio, le altre no: dopo un
|
||||
// ripristino le vecchie credenziali sono da considerarsi compromesse e
|
||||
// una sessione aperta altrove lo e' altrettanto.
|
||||
// una sessione aperta altrove lo e' altrettanto. Le altre le chiude
|
||||
// AuthenticateSession (bootstrap/app.php) alla loro prossima richiesta,
|
||||
// perche' l'hash della password e' cambiato; qui si cambia l'id di
|
||||
// questa e si invalida il "ricordami".
|
||||
$request->session()->regenerate();
|
||||
$user->setRememberToken(null);
|
||||
$user->save();
|
||||
|
||||
@@ -110,30 +110,45 @@ class BackupController extends Controller
|
||||
'backupZip.max' => 'Il pacchetto supera i ' . self::maxUploadMb() . ' MB accettati dal server.',
|
||||
]);
|
||||
|
||||
$operationId = (string) Str::uuid();
|
||||
|
||||
// Stesso blocco del componente Livewire, ripetuto qui perche' questa e'
|
||||
// una porta d'ingresso separata: un ripristino avviato mentre gira un
|
||||
// azzeramento riscriverebbe le tabelle che l'altro sta cancellando.
|
||||
if ($this->stato->corrente() !== null) {
|
||||
// Il posto si prende subito e in un passo solo (occupa()): controllarlo
|
||||
// e prenderlo solo dopo aver salvato il file lasciava una finestra in cui
|
||||
// un import o un secondo ripristino passavano entrambi.
|
||||
$inCorso = $this->stato->occupa($operationId, BackupOperationState::TIPO_RESTORE, 'In coda');
|
||||
if ($inCorso !== null) {
|
||||
return response()->json([
|
||||
'message' => 'C\'e\' gia\' un\'operazione in corso: attendi che finisca.',
|
||||
'message' => $this->stato->messaggioOccupato($inCorso),
|
||||
], 409);
|
||||
}
|
||||
|
||||
$operationId = (string) Str::uuid();
|
||||
try {
|
||||
// Il file dell'upload vive in una cartella temporanea che sparisce a
|
||||
// fine richiesta: il job lo troverebbe gia' cancellato.
|
||||
$percorso = $request->file('backupZip')->storeAs(
|
||||
'backup-restore-in',
|
||||
$operationId . '.zip',
|
||||
'local'
|
||||
);
|
||||
} catch (\Throwable $e) {
|
||||
// Il posto e' gia' preso: senza chiuderlo resterebbe occupato da un
|
||||
// ripristino che non partira' mai, per l'ora intera del TTL.
|
||||
$this->stato->markFailed($operationId, 'Archivio non salvato sul server: ' . $e->getMessage());
|
||||
|
||||
// Il file dell'upload vive in una cartella temporanea che sparisce a
|
||||
// fine richiesta: il job lo troverebbe gia' cancellato.
|
||||
$percorso = $request->file('backupZip')->storeAs(
|
||||
'backup-restore-in',
|
||||
$operationId . '.zip',
|
||||
'local'
|
||||
);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if ($percorso === false) {
|
||||
$this->stato->markFailed($operationId, 'Archivio non salvato sul server.');
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Il server non è riuscito a salvare il pacchetto ricevuto (spazio o permessi sul disco).',
|
||||
], 500);
|
||||
}
|
||||
|
||||
$this->stato->initialize(
|
||||
$operationId,
|
||||
BackupOperationState::TIPO_RESTORE,
|
||||
'In coda'
|
||||
);
|
||||
$this->stato->log($operationId, 'Archivio ricevuto. In attesa del worker di coda.');
|
||||
|
||||
RestoreBackupArchive::dispatch(
|
||||
|
||||
@@ -4,10 +4,21 @@ namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Services\BackupOperationState;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class XmlExchangeUploadController extends Controller
|
||||
{
|
||||
/**
|
||||
* Quanto resta occupato il posto unico se la richiesta di import muore senza
|
||||
* liberarlo (timeout, errore fatale: casi in cui il `finally` non gira).
|
||||
* L'import e' sincrono e PHP lo ferma comunque dopo max_execution_time
|
||||
* (120 s): dieci minuti bastano a coprirlo, invece dell'ora intera pensata
|
||||
* per i job in coda.
|
||||
*/
|
||||
private const TTL_POSTO_IMPORT_XML = 600;
|
||||
|
||||
public function convertSqlToXml(Request $request)
|
||||
{
|
||||
$request->validate([
|
||||
@@ -51,13 +62,44 @@ class XmlExchangeUploadController extends Controller
|
||||
return back()->withErrors(['xmlImport' => 'File vuoto o non leggibile.']);
|
||||
}
|
||||
|
||||
$exchange = new XmlExchange();
|
||||
$result = $exchange->importXmlFromContent($content);
|
||||
// L'import svuota e riscrive le tabelle: non puo' girare insieme a un
|
||||
// ripristino (che fa lo stesso), a un azzeramento, a un backup che le sta
|
||||
// fotografando o a un import PDF che sta aggiornando i territori. Il
|
||||
// ripristino chiama importXmlFromContent() per conto suo, gia' dentro il
|
||||
// proprio posto: per questo il blocco sta qui, sulla porta d'ingresso, e
|
||||
// non dentro l'import.
|
||||
$stato = app(BackupOperationState::class);
|
||||
$operationId = (string) Str::uuid();
|
||||
$inCorso = $stato->occupa($operationId, BackupOperationState::TIPO_IMPORT_XML, 'Import XML', self::TTL_POSTO_IMPORT_XML);
|
||||
|
||||
if ($inCorso !== null) {
|
||||
return back()->withErrors(['xmlImport' => $stato->messaggioOccupato($inCorso) . ' Nessun dato è stato modificato.']);
|
||||
}
|
||||
|
||||
$stato->markRunning($operationId);
|
||||
|
||||
try {
|
||||
$exchange = new XmlExchange();
|
||||
$fileDiPrima = XmlExchange::fileDeiTerritori();
|
||||
$result = $exchange->importXmlFromContent($content);
|
||||
|
||||
if (! isset($result['error'])) {
|
||||
$result['stats']['file_rimossi'] = XmlExchange::eliminaFileNonPiuUsati($fileDiPrima);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$stato->markFailed($operationId, $e->getMessage());
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$stato->markFailed($operationId, $result['error']);
|
||||
|
||||
return back()->withErrors(['xmlImport' => $result['error']]);
|
||||
}
|
||||
|
||||
$stato->markCompleted($operationId);
|
||||
|
||||
$stats = $result['stats'];
|
||||
$issues = $result['issues'];
|
||||
|
||||
@@ -65,6 +107,9 @@ class XmlExchangeUploadController extends Controller
|
||||
if (($stats['duplicate_territori'] ?? 0) > 0 || ($stats['assegnazioni_saltate'] ?? 0) > 0) {
|
||||
$message .= ' Territori duplicati saltati: ' . $stats['duplicate_territori'] . '. Assegnazioni saltate: ' . $stats['assegnazioni_saltate'] . '.';
|
||||
}
|
||||
if (($stats['file_rimossi'] ?? 0) > 0) {
|
||||
$message .= ' Eliminati ' . $stats['file_rimossi'] . ' file (PDF e miniature) dei territori sostituiti, che i dati importati non usano più.';
|
||||
}
|
||||
|
||||
return redirect()->route('xml.exchange')
|
||||
->with('success', $message)
|
||||
|
||||
62
app/Http/Controllers/TerritorioFileController.php
Normal file
62
app/Http/Controllers/TerritorioFileController.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
/**
|
||||
* PDF, miniature e versioni archiviate dei territori, solo per chi è dentro.
|
||||
*
|
||||
* I file stanno sul disco `public`, ma nginx non serve più le cartelle
|
||||
* `territori-*` (docker/nginx/default.conf): prima chiunque scaricava
|
||||
* /storage/territori-pdf/territorio-12-34.pdf senza login, con nomi facili da
|
||||
* indovinare, e Cloudflare li teneva in cache pubblica per una settimana. I
|
||||
* proclamatori continuano a riceverli dal link con codice e scadenza
|
||||
* (AssignmentPdfController); da qui passano solo gli utenti autenticati, con i
|
||||
* permessi della pagina che li mostra.
|
||||
*/
|
||||
class TerritorioFileController extends Controller
|
||||
{
|
||||
public function pdf(Territorio $territorio): Response
|
||||
{
|
||||
return $this->servi($territorio->pdf_path, 'application/pdf', 'territorio-' . $territorio->numero . '.pdf');
|
||||
}
|
||||
|
||||
public function miniatura(Territorio $territorio): Response
|
||||
{
|
||||
return $this->servi($territorio->thumbnail_path, null, null);
|
||||
}
|
||||
|
||||
public function versione(TerritorioPdfVersione $versione): Response
|
||||
{
|
||||
$numero = $versione->territorio?->numero ?? 'senza-territorio';
|
||||
|
||||
return $this->servi($versione->pdf_path, 'application/pdf', "territorio-{$numero}-versione-{$versione->id}.pdf");
|
||||
}
|
||||
|
||||
private function servi(?string $percorso, ?string $tipo, ?string $nome): Response
|
||||
{
|
||||
$disco = Storage::disk('public');
|
||||
|
||||
abort_unless($percorso && $disco->exists($percorso), 404);
|
||||
|
||||
$intestazioni = [];
|
||||
if ($tipo) {
|
||||
$intestazioni['Content-Type'] = $tipo;
|
||||
}
|
||||
if ($nome) {
|
||||
$intestazioni['Content-Disposition'] = 'inline; filename="' . $nome . '"';
|
||||
}
|
||||
|
||||
// Privata: niente cache intermedie (Cloudflare). Va impostata dopo,
|
||||
// perché BinaryFileResponse si dichiara pubblica da sola e
|
||||
// sovrascriverebbe un Cache-Control passato fra le intestazioni.
|
||||
// L'indirizzo porta l'impronta del percorso (?v=), che cambia col file.
|
||||
return response()->file($disco->path($percorso), $intestazioni)
|
||||
->setPrivate()
|
||||
->setMaxAge(86400);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Jobs;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Services\BackupArchive;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\Piantine\PiantineBackup;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -44,7 +45,7 @@ class CreateBackupArchive implements ShouldQueue
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
|
||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void
|
||||
{
|
||||
$stato->markRunning($this->operationId);
|
||||
$stato->fase($this->operationId, 'Lettura dei dati gestionali');
|
||||
@@ -55,11 +56,13 @@ class CreateBackupArchive implements ShouldQueue
|
||||
$this->rimuoviArchiviVecchi($archivio);
|
||||
|
||||
$xml = $exchange->currentXmlBackup();
|
||||
$datiPiantine = $piantine->esporta();
|
||||
|
||||
$stato->fase($this->operationId, 'Compressione dei file');
|
||||
|
||||
$archivio->create(
|
||||
$xml,
|
||||
$datiPiantine,
|
||||
$destinazione,
|
||||
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
||||
);
|
||||
|
||||
@@ -4,6 +4,8 @@ namespace App\Jobs;
|
||||
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\Piantine\PiantinaPdfService;
|
||||
use App\Services\TerritorioPdfImportState;
|
||||
use App\Services\TerritorioThumbnailService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@@ -35,14 +37,21 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService): void
|
||||
public function handle(TerritorioPdfImportState $stateService, TerritorioThumbnailService $thumbnailService, BackupOperationState $postoUnico, PiantinaPdfService $pdfService): void
|
||||
{
|
||||
$stateService->markRunning($this->importId);
|
||||
$postoUnico->markRunning($this->importId);
|
||||
$stateService->appendLog($this->importId, 'Worker avviato. Inizio elaborazione dei PDF.');
|
||||
|
||||
// Una chiave puo' raccogliere piu' territori ("12B" e "12 B"): non si
|
||||
// tiene l'ultimo arrivato, si tengono tutti, cosi' l'abbinamento li vede
|
||||
// e scarta il file come ambiguo invece di sceglierne uno a caso.
|
||||
$territoriMap = [];
|
||||
foreach (Territorio::withTrashed()->get() as $territorio) {
|
||||
$territoriMap[$this->normalizeTerritoryNumber($territorio->numero)] = $territorio;
|
||||
$chiave = $this->chiaveTerritorio((string) $territorio->numero);
|
||||
if ($chiave !== '') {
|
||||
$territoriMap[$chiave][] = $territorio;
|
||||
}
|
||||
}
|
||||
|
||||
$actor = User::find($this->actorId);
|
||||
@@ -78,9 +87,26 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalizedNumber = $territoryMatch['normalized_number'];
|
||||
if (($territoryMatch['type'] ?? 'single') === 'unknown-suffix') {
|
||||
$codici = implode(', ', $territoryMatch['codes']);
|
||||
$esistenti = implode(', ', $territoryMatch['matched_numbers']);
|
||||
$stateService->increment($this->importId, 'skipped');
|
||||
$stateService->appendLog($this->importId, '[SKIP] ' . $originalName . ' - il nome indica il territorio ' . $codici . ', che non esiste (esiste ' . $esistenti . '): file non associato, per non sostituire il PDF di un altro territorio.');
|
||||
$stateService->addIssue($this->importId, [
|
||||
'file' => $originalName,
|
||||
'type' => 'unknown-suffix',
|
||||
'message' => 'Il nome indica il territorio ' . $codici . ', che non esiste: non associato a ' . $esistenti . '.',
|
||||
'matched_numbers' => $territoryMatch['matched_numbers'],
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$matchedNumber = $territoryMatch['matched_number'];
|
||||
$territorio = $territoryMatch['territorio'];
|
||||
// Per id, non per numero: due file che nominano lo stesso
|
||||
// territorio scritto in due modi ("12B.pdf", "12 B.pdf") sono
|
||||
// comunque un doppione.
|
||||
$normalizedNumber = $territorio->id;
|
||||
|
||||
if (isset($seenNumbers[$normalizedNumber])) {
|
||||
$stateService->increment($this->importId, 'skipped');
|
||||
@@ -111,24 +137,27 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
}
|
||||
|
||||
try {
|
||||
if ($territorio->pdf_path) {
|
||||
Storage::disk('public')->delete($territorio->pdf_path);
|
||||
}
|
||||
|
||||
if ($territorio->thumbnail_path) {
|
||||
$thumbnailService->delete($territorio->thumbnail_path);
|
||||
}
|
||||
|
||||
// The territory id is part of the filename because the slug
|
||||
// alone is not unique: "1 A" and "1-A" are distinct
|
||||
// territory numbers that both slug to "territorio-1-a".
|
||||
// Without it the second import overwrites the first's PDF
|
||||
// and both rows point at one file — then removing the PDF
|
||||
// from either territory breaks the other.
|
||||
//
|
||||
// Il nuovo file si scrive per primo, con un nome che non
|
||||
// coincide con quello in vigore; solo dopo il vecchio va
|
||||
// nello storico, come per le piantine. Prima si cancellava
|
||||
// il vecchio e poi si scriveva: con una scrittura fallita il
|
||||
// territorio restava senza PDF, e un import sbagliato non si
|
||||
// poteva annullare.
|
||||
$storedFilename = Str::slug('territorio-' . $territorio->numero)
|
||||
. '-' . $territorio->id . '.pdf';
|
||||
. '-' . $territorio->id . '-' . now()->format('YmdHis') . '.pdf';
|
||||
$publicPath = 'territori-pdf/' . $storedFilename;
|
||||
Storage::disk('public')->put($publicPath, file_get_contents($sourcePath));
|
||||
if (! Storage::disk('public')->put($publicPath, file_get_contents($sourcePath))) {
|
||||
throw new \RuntimeException('impossibile scrivere il file sul disco: il PDF in vigore non è stato toccato.');
|
||||
}
|
||||
|
||||
$pdfService->archiviaCorrente($territorio, 'import');
|
||||
$thumbnailPath = $thumbnailService->generate($publicPath);
|
||||
|
||||
$territorio->update([
|
||||
@@ -166,10 +195,12 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
|
||||
$stateService->appendLog($this->importId, 'Import completato.');
|
||||
$stateService->markCompleted($this->importId);
|
||||
$postoUnico->markCompleted($this->importId);
|
||||
} catch (\Throwable $exception) {
|
||||
$stateService->increment($this->importId, 'errors');
|
||||
$stateService->appendLog($this->importId, '[ERR] Errore fatale del job: ' . $exception->getMessage());
|
||||
$stateService->markFailed($this->importId);
|
||||
$postoUnico->markFailed($this->importId, $exception->getMessage());
|
||||
|
||||
throw $exception;
|
||||
} finally {
|
||||
@@ -183,6 +214,10 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
* Without this the import state is stranded in "running" — the UI polls it
|
||||
* until the 24h cache entry expires — and the uploaded temp directory is
|
||||
* never reclaimed, since handle()'s finally never executes.
|
||||
*
|
||||
* Libera anche il posto unico: senza, un import ucciso dal timeout
|
||||
* lascerebbe backup, ripristino e azzeramento bloccati per l'ora intera
|
||||
* del TTL, con un'operazione che non avanza piu'.
|
||||
*/
|
||||
public function failed(?\Throwable $exception): void
|
||||
{
|
||||
@@ -193,79 +228,128 @@ class ImportTerritoryPdfFolder implements ShouldQueue
|
||||
'[ERR] Job terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
|
||||
);
|
||||
$stateService->markFailed($this->importId);
|
||||
app(BackupOperationState::class)->markFailed(
|
||||
$this->importId,
|
||||
'Import PDF terminato senza completare: ' . ($exception?->getMessage() ?? 'timeout')
|
||||
);
|
||||
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $this->importId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abbina il nome di un file al territorio, oppure spiega perche' non lo fa.
|
||||
*
|
||||
* La regola: nel nome si cercano i "numeri di territorio", cioe' cifre
|
||||
* seguite al massimo da UNA lettera, attaccata o separata da uno spazio, un
|
||||
* trattino, un trattino basso o un punto ("12", "012", "12B", "12 B",
|
||||
* "12-b"). Le forme scritte in modo diverso dello stesso numero sono lo
|
||||
* stesso numero: "12 B", "12B" e "12-B" sono tutte 12B, mai 12. Il numero
|
||||
* deve stare da solo — "T12" o "12AB" non contano — e quelli che non
|
||||
* corrispondono a nessun territorio (un anno, un CAP) vengono ignorati.
|
||||
*
|
||||
* Prima bastava che il numero di un territorio *comparisse* nel nome: "1-A"
|
||||
* conteneva "1", e in assenza di un territorio 1A il PDF finiva sul
|
||||
* territorio 1, sostituendone la mappa senza avvisare. Adesso una lettera
|
||||
* dopo il numero fa parte del numero, e se quel territorio non esiste il
|
||||
* file si scarta: si nomina il territorio 1 nel messaggio, ma non lo si
|
||||
* tocca.
|
||||
*
|
||||
* Il file si scarta anche quando l'abbinamento non e' univoco: due numeri
|
||||
* di territori esistenti nello stesso nome, o due territori che si
|
||||
* scrivono diversi ma sono lo stesso numero ("12B" e "12 B"). Sostituire
|
||||
* la mappa sbagliata e' peggio che non sostituirne nessuna: un file
|
||||
* scartato si ricarica, un PDF sovrascritto non torna.
|
||||
*
|
||||
* I territori con numeri di altra forma (lettere davanti, piu' lettere in
|
||||
* fondo) non si abbinano da nome file: il PDF va caricato dalla loro
|
||||
* scheda.
|
||||
*/
|
||||
protected function resolveTerritoryFromFilename(string $filename, array $territoriMap): ?array
|
||||
{
|
||||
if ($territoriMap === []) {
|
||||
return null;
|
||||
}
|
||||
$trovati = [];
|
||||
$suffissiSconosciuti = [];
|
||||
|
||||
$basename = pathinfo($filename, PATHINFO_FILENAME);
|
||||
$normalizedBasename = mb_strtoupper($basename);
|
||||
$normalizedBasename = preg_replace('/[^A-Z0-9]+/u', ' ', $normalizedBasename);
|
||||
$normalizedBasename = trim(preg_replace('/\s+/', ' ', $normalizedBasename));
|
||||
foreach ($this->numeriNelNomeFile($filename) as $codice) {
|
||||
if (isset($territoriMap[$codice])) {
|
||||
foreach ($territoriMap[$codice] as $territorio) {
|
||||
$trovati[$territorio->id] = $territorio;
|
||||
}
|
||||
|
||||
$territoryKeys = array_keys($territoriMap);
|
||||
usort($territoryKeys, function (string $left, string $right) {
|
||||
$lengthComparison = mb_strlen($right) <=> mb_strlen($left);
|
||||
|
||||
if ($lengthComparison !== 0) {
|
||||
return $lengthComparison;
|
||||
}
|
||||
|
||||
return strnatcasecmp($left, $right);
|
||||
});
|
||||
|
||||
$matches = [];
|
||||
|
||||
foreach ($territoryKeys as $normalizedNumber) {
|
||||
if (! $this->filenameContainsTerritoryNumber($normalizedBasename, $normalizedNumber)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matches[] = [
|
||||
'normalized_number' => $normalizedNumber,
|
||||
'matched_number' => $territoriMap[$normalizedNumber]->numero,
|
||||
'territorio' => $territoriMap[$normalizedNumber],
|
||||
// "1A" che non esiste, mentre 1 si': e' il caso che prima
|
||||
// sovrascriveva il territorio 1. Lo si annota per spiegarlo.
|
||||
$base = preg_replace('/\p{L}$/u', '', $codice);
|
||||
if ($base !== $codice && isset($territoriMap[$base])) {
|
||||
$suffissiSconosciuti[$codice] = $territoriMap[$base];
|
||||
}
|
||||
}
|
||||
|
||||
$numeri = fn (array $territori) => array_values(array_unique(array_map(fn (Territorio $t) => $t->numero, $territori)));
|
||||
|
||||
if (count($trovati) > 1 || ($trovati !== [] && $suffissiSconosciuti !== [])) {
|
||||
return [
|
||||
'type' => 'ambiguous',
|
||||
'matched_numbers' => $numeri(array_merge(array_values($trovati), ...array_values($suffissiSconosciuti))),
|
||||
];
|
||||
}
|
||||
|
||||
if ($matches === []) {
|
||||
if ($trovati === [] && $suffissiSconosciuti !== []) {
|
||||
return [
|
||||
'type' => 'unknown-suffix',
|
||||
'codes' => array_keys($suffissiSconosciuti),
|
||||
'matched_numbers' => $numeri(array_merge(...array_values($suffissiSconosciuti))),
|
||||
];
|
||||
}
|
||||
|
||||
if ($trovati === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (count($matches) > 1) {
|
||||
return [
|
||||
'type' => 'ambiguous',
|
||||
'matched_numbers' => array_values(array_map(fn(array $match) => $match['matched_number'], $matches)),
|
||||
];
|
||||
}
|
||||
$territorio = reset($trovati);
|
||||
|
||||
return $matches[0];
|
||||
return [
|
||||
'type' => 'single',
|
||||
'normalized_number' => $this->chiaveTerritorio($territorio->numero),
|
||||
'matched_number' => $territorio->numero,
|
||||
'territorio' => $territorio,
|
||||
];
|
||||
}
|
||||
|
||||
protected function filenameContainsTerritoryNumber(string $normalizedBasename, string $normalizedNumber): bool
|
||||
/**
|
||||
* I numeri di territorio presenti nel nome del file, gia' nella forma di
|
||||
* chiaveTerritorio(). Vedi la regola in resolveTerritoryFromFilename().
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function numeriNelNomeFile(string $filename): array
|
||||
{
|
||||
$escapedNumber = preg_quote($normalizedNumber, '/');
|
||||
$nome = mb_strtoupper(pathinfo($filename, PATHINFO_FILENAME));
|
||||
|
||||
if (preg_match('/^\d+$/', $normalizedNumber)) {
|
||||
$escapedNumber = '0*' . preg_quote(ltrim($normalizedNumber, '0') ?: '0', '/');
|
||||
// Il lookahead finale e' quello che separa "12 B" (numero 12B) da
|
||||
// "12 Via Roma" (numero 12): la lettera vale come parte del numero solo
|
||||
// se dopo di lei la parola finisce.
|
||||
preg_match_all('/(?<![\p{L}\p{N}])(\d+)(?:[ _.\-]?(\p{L}))?(?![\p{L}\p{N}])/u', $nome, $trovati, PREG_SET_ORDER);
|
||||
|
||||
$codici = [];
|
||||
foreach ($trovati as $trovato) {
|
||||
$codici[] = (ltrim($trovato[1], '0') ?: '0') . ($trovato[2] ?? '');
|
||||
}
|
||||
|
||||
return (bool) preg_match('/(^| )' . $escapedNumber . '(?= |$)/', $normalizedBasename);
|
||||
return array_values(array_unique($codici));
|
||||
}
|
||||
|
||||
protected function normalizeTerritoryNumber(string $number): string
|
||||
/**
|
||||
* Forma di confronto di un numero di territorio: maiuscolo, senza
|
||||
* separatori, senza zeri iniziali. "12 B", "12-b" e "012B" diventano tutti
|
||||
* "12B"; due territori con la stessa chiave rendono ambiguo ogni file che
|
||||
* la nomina.
|
||||
*/
|
||||
protected function chiaveTerritorio(string $numero): string
|
||||
{
|
||||
$normalized = preg_replace('/\s+/', ' ', trim(mb_strtoupper($number)));
|
||||
$chiave = preg_replace('/[^\p{L}\p{N}]+/u', '', mb_strtoupper($numero));
|
||||
|
||||
if ($normalized !== '' && preg_match('/^\d+$/', $normalized)) {
|
||||
return ltrim($normalized, '0') ?: '0';
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
return preg_replace('/(?<!\d)0+(?=\d)/', '', $chiave);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ use App\Models\Campagna;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Models\Tipologia;
|
||||
use App\Models\User;
|
||||
use App\Models\Zona;
|
||||
@@ -21,6 +23,7 @@ use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* Riporta l'installazione allo stato di primo avvio: nessun dato gestionale,
|
||||
@@ -72,12 +75,17 @@ class ResetApplication implements ShouldQueue
|
||||
$stato->avanza($this->operationId, 1);
|
||||
$stato->log($this->operationId, $fileRimossi . ' file rimossi dal disco.');
|
||||
|
||||
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 8);
|
||||
$stato->fase($this->operationId, 'Cancellazione dei dati gestionali', 10);
|
||||
|
||||
DB::transaction(function () use ($stato) {
|
||||
$passi = [
|
||||
'Assegnazioni' => fn () => Assegnazione::query()->forceDelete(),
|
||||
'Campagne' => fn () => Campagna::query()->delete(),
|
||||
// Prima dei territori, e per esteso: le piantine hanno la
|
||||
// chiave a null on delete, quindi sopravvivevano scollegate
|
||||
// e con l'immagine che puntava a un file appena cancellato.
|
||||
'Piantine' => fn () => TerritorioMappa::query()->delete(),
|
||||
'Storico dei PDF' => fn () => TerritorioPdfVersione::query()->delete(),
|
||||
'Territori' => fn () => Territorio::query()->withTrashed()->forceDelete(),
|
||||
'Proclamatori' => fn () => Proclamatore::query()->withTrashed()->forceDelete(),
|
||||
'Tipologie' => fn () => Tipologia::query()->delete(),
|
||||
@@ -100,6 +108,13 @@ class ResetApplication implements ShouldQueue
|
||||
// ricreare la riga.
|
||||
Setting::flushCache();
|
||||
|
||||
// Area della città e anteprime delle pubblicazioni stanno sul disco
|
||||
// privato, che svuotaDiscoPubblico() non tocca: restavano lì e
|
||||
// l'editor ripartiva con la città di prima.
|
||||
Storage::disk('local')->deleteDirectory('piantine');
|
||||
Storage::disk('local')->deleteDirectory('piantine-anteprime');
|
||||
$stato->log($this->operationId, 'Area della città e anteprime delle piantine rimosse.');
|
||||
|
||||
$stato->fase($this->operationId, 'Rimozione degli altri utenti');
|
||||
$utentiRimossi = $this->rimuoviAltriUtenti($stato);
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Models\Setting;
|
||||
use App\Services\BackupArchive;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\Piantine\PiantineBackup;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@@ -14,6 +16,7 @@ use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
|
||||
/**
|
||||
@@ -41,14 +44,35 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
) {
|
||||
}
|
||||
|
||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange): void
|
||||
/**
|
||||
* Il ripristino e' tutto o niente. Tre tempi:
|
||||
*
|
||||
* 1. estrazione e verifica dell'archivio nello staging, piantine.json
|
||||
* compreso: un archivio rotto si ferma qui, a installazione intatta;
|
||||
* 2. un'unica transazione con l'import XML, le tabelle delle piantine, il
|
||||
* blocco degli account e — per ultimo, ancora dentro — lo scambio dei
|
||||
* file. Se lo scambio fallisce si disfa da solo e la transazione si
|
||||
* annulla; se fallisce il commit, i file si rimettono a posto qui;
|
||||
* 3. a commit avvenuto, si buttano i file precedenti.
|
||||
*
|
||||
* Prima import, blocco account e file erano tre passi separati: un errore
|
||||
* sui file lasciava il database gia' riscritto con il disco a meta', e un
|
||||
* errore sul blocco lasciava attive le password del backup.
|
||||
*
|
||||
* Niente TRUNCATE in nessuno dei passi: su MariaDB e' DDL e fa commit
|
||||
* implicito, chiudendo la transazione a meta'. L'import XML e
|
||||
* PiantineBackup cancellano con DELETE.
|
||||
*/
|
||||
public function handle(BackupOperationState $stato, BackupArchive $archivio, XmlExchange $exchange, PiantineBackup $piantine): void
|
||||
{
|
||||
$stato->markRunning($this->operationId);
|
||||
|
||||
$staging = null;
|
||||
$scambio = null;
|
||||
$confermato = false;
|
||||
|
||||
try {
|
||||
$stato->fase($this->operationId, 'Estrazione dell\'archivio');
|
||||
$stato->fase($this->operationId, 'Estrazione e verifica dell\'archivio');
|
||||
|
||||
$estratto = $archivio->extract(
|
||||
$this->zipPath,
|
||||
@@ -56,33 +80,46 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
);
|
||||
$staging = $estratto['staging'];
|
||||
|
||||
$piantine->valida($estratto['piantine']);
|
||||
|
||||
$stato->fase($this->operationId, 'Riscrittura del database', 1);
|
||||
|
||||
// importXmlFromContent() attribuisce a auth()->id() le assegnazioni
|
||||
// il cui autore non e' nel file. Qui non c'e' sessione, quindi
|
||||
// l'utente va reimpostato a mano: senza, l'audit finirebbe sul
|
||||
// primo id disponibile invece che su chi ha avviato il ripristino.
|
||||
//
|
||||
// E' anche cio' che tiene dentro chi ripristina: l'import riconosce
|
||||
// l'attore e ne conserva hash della password, remember_token e
|
||||
// ruolo di amministratore. Con AuthenticateSession attivo, un hash
|
||||
// cambiato chiuderebbe la sua sessione e lo costringerebbe a
|
||||
// rientrare con la password dell'epoca del backup, che puo' non
|
||||
// ricordare. Se il suo account nel backup non c'e', l'import (che
|
||||
// fa upsert per email, senza cancellare nessuno) non lo tocca
|
||||
// affatto: resta con password e ruoli attuali.
|
||||
Auth::onceUsingId($this->actorId);
|
||||
|
||||
$result = $exchange->importXmlFromContent($estratto['xml']);
|
||||
DB::transaction(function () use ($stato, $archivio, $exchange, $piantine, $estratto, &$result, &$esitoPiantine, &$utenti, &$scambio) {
|
||||
// La transazione dell'import diventa un savepoint di questa:
|
||||
// il suo commit non e' piu' definitivo.
|
||||
$result = $exchange->importXmlFromContent($estratto['xml']);
|
||||
|
||||
if (isset($result['error'])) {
|
||||
$stato->markFailed($this->operationId, $result['error']);
|
||||
if (isset($result['error'])) {
|
||||
throw new RuntimeException($result['error']);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
$esitoPiantine = $piantine->ripristina($estratto['piantine']);
|
||||
|
||||
$stato->avanza($this->operationId, 1, 1);
|
||||
$utenti = $this->richiediCambioPasswordATutti();
|
||||
|
||||
$stato->fase($this->operationId, 'Ripristino dei file');
|
||||
$stato->avanza($this->operationId, 1, 1);
|
||||
$stato->fase($this->operationId, 'Sostituzione dei file', 1);
|
||||
|
||||
$fileRipristinati = $archivio->restoreFiles(
|
||||
$staging,
|
||||
fn (int $fatti, int $totale) => $stato->avanza($this->operationId, $fatti, $totale)
|
||||
);
|
||||
$scambio = $archivio->scambiaFile($estratto);
|
||||
});
|
||||
|
||||
$stato->fase($this->operationId, 'Blocco degli account', 1);
|
||||
$utenti = $this->richiediCambioPasswordATutti();
|
||||
$confermato = true;
|
||||
$archivio->confermaScambio($scambio);
|
||||
|
||||
// L'import ricrea ruoli e assegnazioni, ma Spatie tiene in cache la
|
||||
// mappa permessi-ruoli: senza questo, i processi che l'hanno gia'
|
||||
@@ -92,8 +129,26 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
|
||||
$stato->avanza($this->operationId, 1, 1);
|
||||
} catch (\Throwable $e) {
|
||||
$messaggio = $e->getMessage();
|
||||
|
||||
// Lo scambio era riuscito ma il commit no: il database e' tornato
|
||||
// indietro, i file devono seguirlo.
|
||||
if ($scambio !== null && ! $confermato) {
|
||||
try {
|
||||
$archivio->annullaScambio($scambio, $e);
|
||||
} catch (\Throwable $annullamento) {
|
||||
$messaggio = $annullamento->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
// La transazione annullata non svuota le cache in memoria del
|
||||
// processo: Setting terrebbe l'istanza creata dall'import (una
|
||||
// riga che non esiste piu') e Spatie i ruoli letti durante l'import.
|
||||
Setting::flushCache();
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
Log::error('Ripristino del backup non riuscito.', ['exception' => $e]);
|
||||
$stato->markFailed($this->operationId, $e->getMessage());
|
||||
$stato->markFailed($this->operationId, $messaggio);
|
||||
|
||||
return;
|
||||
} finally {
|
||||
@@ -107,17 +162,31 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
}
|
||||
|
||||
$stats = $result['stats'];
|
||||
$stats['piantine_ripristinate'] = $esitoPiantine['mappe'];
|
||||
$stats['versioni_pdf_ripristinate'] = $esitoPiantine['versioni'];
|
||||
$fileRipristinati = $scambio['file'];
|
||||
|
||||
$messaggio = sprintf(
|
||||
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni e %d file ripristinati. '
|
||||
'Ripristino completato: %d territori, %d proclamatori, %d assegnazioni, %d piantine e %d file ripristinati. '
|
||||
. 'Per sicurezza %s impostare una nuova password.',
|
||||
$stats['territori_importati'] ?? 0,
|
||||
$stats['proclamatori_importati'] ?? 0,
|
||||
$stats['assegnazioni_importate'] ?? 0,
|
||||
$esitoPiantine['mappe'],
|
||||
$fileRipristinati,
|
||||
$utenti === 1 ? 'l\'unico account deve' : 'tutti i ' . $utenti . ' account devono'
|
||||
);
|
||||
|
||||
// Un archivio di formato 1 non conteneva le piantine: va detto, o
|
||||
// l'amministratore le cercherebbe senza sapere perche' sono sparite.
|
||||
if ($estratto['piantine'] === null) {
|
||||
$stato->log(
|
||||
$this->operationId,
|
||||
'Archivio di un formato precedente: non contiene piantine e storico dei PDF, che restano vuoti. '
|
||||
. 'L\'area KML in uso non e\' stata toccata.'
|
||||
);
|
||||
}
|
||||
|
||||
// Il messaggio non puo' viaggiare in sessione (qui non ce n'e' una) e la
|
||||
// pagina che dovra' mostrarlo non e' questa: appena il flag e' acceso,
|
||||
// il middleware dirotta l'amministratore sul cambio password. Lo si
|
||||
@@ -127,7 +196,7 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
$stato->log($this->operationId, $messaggio);
|
||||
$stato->markCompleted($this->operationId, [
|
||||
'stats' => $stats,
|
||||
'issues' => array_slice($result['issues'], 0, 50),
|
||||
'issues' => array_slice(array_merge($result['issues'], $esitoPiantine['issues']), 0, 50),
|
||||
'file_ripristinati' => $fileRipristinati,
|
||||
'messaggio' => $messaggio,
|
||||
]);
|
||||
@@ -138,17 +207,21 @@ class RestoreBackupArchive implements ShouldQueue
|
||||
* muore per cause che handle() non intercetta, cosi' il posto unico si
|
||||
* libera subito invece di restare occupato fino alla scadenza.
|
||||
*
|
||||
* Qui il messaggio dice anche in che stato si e' rimasti: un ripristino
|
||||
* interrotto a meta' e' l'unico caso in cui l'applicazione puo' trovarsi
|
||||
* con database e file disallineati, e chi legge deve sapere che la strada
|
||||
* e' rilanciare il ripristino, non ignorare l'errore.
|
||||
* Qui il messaggio dice anche in che stato si e' rimasti. Un processo
|
||||
* ucciso a meta' non arriva al commit, e il database annulla da se' la
|
||||
* transazione rimasta aperta: i dati restano quelli di prima. L'unica
|
||||
* finestra scoperta e' quella, di pochi millisecondi, fra lo scambio dei
|
||||
* file e il commit — in quel caso i file precedenti sono ancora in
|
||||
* storage/app/backup-precedenti/. La strada resta rilanciare il
|
||||
* ripristino, non ignorare l'errore.
|
||||
*/
|
||||
public function failed(?\Throwable $e): void
|
||||
{
|
||||
app(BackupOperationState::class)->markFailed(
|
||||
$this->operationId,
|
||||
($e?->getMessage() ?: 'Ripristino interrotto (timeout o errore del worker).')
|
||||
. ' I dati possono essere rimasti a meta\': rilancia il ripristino con lo stesso pacchetto.'
|
||||
. ' Un ripristino interrotto prima della conferma non modifica il database: rilancialo con lo stesso pacchetto. '
|
||||
. 'Se i file risultano diversi, quelli precedenti sono in storage/app/backup-precedenti/.'
|
||||
);
|
||||
|
||||
@unlink($this->zipPath);
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Models\Proclamatore;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class Assegna extends Component
|
||||
{
|
||||
@@ -99,9 +98,12 @@ class Assegna extends Component
|
||||
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($assegnazione)
|
||||
// L'id, non il nome: nome e cognome sono cifrati nel database, e
|
||||
// copiati qui in chiaro restavano leggibili anche dopo
|
||||
// l'anonimizzazione. Il registro mostra il nome di oggi.
|
||||
->withProperties([
|
||||
'territorio' => $territorio->numero,
|
||||
'proclamatore' => $proclamatore->nome_completo,
|
||||
'proclamatore_id' => $proclamatore->id,
|
||||
])
|
||||
->log('assigned');
|
||||
|
||||
@@ -123,7 +125,7 @@ class Assegna extends Component
|
||||
return null;
|
||||
}
|
||||
$t = Territorio::find($this->territorio_id);
|
||||
return $t?->thumbnail_path ? Storage::url($t->thumbnail_path) : null;
|
||||
return $t?->miniaturaUrl();
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -92,7 +92,7 @@ class Rientra extends Component
|
||||
->performedOn($this->assegnazione)
|
||||
->withProperties([
|
||||
'territorio' => $territorio->numero,
|
||||
'proclamatore' => $proclamatore->nome_completo,
|
||||
'proclamatore_id' => $proclamatore->id, // vedi Assegna: niente nomi in chiaro
|
||||
'giorni' => $this->assegnazione->giorni,
|
||||
'campagna' => $this->counted_in_campaign,
|
||||
])
|
||||
|
||||
@@ -48,8 +48,22 @@ class AuditLog extends Component
|
||||
$events = Activity::select('description')->distinct()->pluck('description');
|
||||
$users = \App\Models\User::orderBy('name')->get();
|
||||
|
||||
$activities = $query->paginate(30);
|
||||
|
||||
// Il log conserva solo l'id del proclamatore: il nome si legge adesso,
|
||||
// così chi è stato anonimizzato compare come tale anche nello storico.
|
||||
$idProclamatori = $activities->getCollection()
|
||||
->map(fn ($a) => data_get($a->properties, 'proclamatore_id'))
|
||||
->filter()
|
||||
->unique();
|
||||
$nomiProclamatori = \App\Models\Proclamatore::withTrashed()
|
||||
->whereIn('id', $idProclamatori)
|
||||
->get()
|
||||
->mapWithKeys(fn ($p) => [$p->id => $p->nome_completo]);
|
||||
|
||||
return view('livewire.audit-log', [
|
||||
'activities' => $query->paginate(30),
|
||||
'activities' => $activities,
|
||||
'nomiProclamatori' => $nomiProclamatori,
|
||||
'events' => $events,
|
||||
'users' => $users,
|
||||
]);
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Campagne;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Campagna;
|
||||
|
||||
class CampagnaIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public function deleteCampagna(int $id)
|
||||
@@ -19,7 +21,7 @@ class CampagnaIndex extends Component
|
||||
|
||||
$campagna = Campagna::findOrFail($id);
|
||||
$campagna->delete();
|
||||
session()->flash('success', "Campagna '{$campagna->descrizione}' eliminata.");
|
||||
$this->avvisa("Campagna '{$campagna->descrizione}' eliminata.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -18,12 +18,12 @@ class CampagnaShow extends Component
|
||||
|
||||
public function render()
|
||||
{
|
||||
// Assignments counted for this campaign:
|
||||
// - assigned on or after campaign start
|
||||
// - linked to this campaign (campaign_id), regardless of returned_at (retroactive returns allowed)
|
||||
$conteggiate = Assegnazione::where('campaign_id', $this->campagna->id)
|
||||
->where('counted_in_campaign', true)
|
||||
->where('assigned_at', '>=', $this->campagna->start_date)
|
||||
// Le assegnazioni contate per la campagna, cioè quelle per cui al
|
||||
// rientro si è risposto sì (campagnaApplicabile propone anche quelle
|
||||
// uscite poco prima dell'inizio e rientrate durante). Le stesse della
|
||||
// percentuale: prima qui si scartavano quelle uscite prima dell'inizio,
|
||||
// e l'elenco era più corto di quanto la percentuale contasse.
|
||||
$conteggiate = $this->campagna->assegnazioniConteggiate()
|
||||
->with(['territorio', 'proclamatore'])
|
||||
->orderBy('returned_at')
|
||||
->get();
|
||||
|
||||
@@ -174,22 +174,46 @@ trait SegueOperazioneBackup
|
||||
return true;
|
||||
}
|
||||
|
||||
// Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
|
||||
// premuto il pulsante vuole comunque vedere a che punto e'.
|
||||
$this->operationId = $inCorso;
|
||||
$this->logSpediti = 0;
|
||||
$this->aggiorna();
|
||||
$this->erroreOperazione = 'C\'e\' gia\' un\'operazione in corso: attendi che finisca prima di avviarne un\'altra.';
|
||||
$this->rifiutaPerOccupato($inCorso);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
protected function nuovaOperazione(string $tipo): string
|
||||
/**
|
||||
* Si aggancia a quella in corso invece di limitarsi a rifiutare: chi ha
|
||||
* premuto il pulsante vuole comunque vedere a che punto e'. Il messaggio
|
||||
* dice quale operazione occupa il posto, perche' puo' essere un import
|
||||
* avviato da un'altra zona della pagina, o da un altro amministratore.
|
||||
*/
|
||||
private function rifiutaPerOccupato(string $inCorso): void
|
||||
{
|
||||
$this->operationId = $inCorso;
|
||||
$this->logSpediti = 0;
|
||||
$this->aggiorna();
|
||||
$this->erroreOperazione = app(BackupOperationState::class)->messaggioOccupato($inCorso);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prende il posto e crea l'operazione; null se il posto e' occupato.
|
||||
*
|
||||
* postoLibero() resta il controllo rapido prima di validare il resto, ma fra
|
||||
* quel controllo e questo passo un'altra richiesta puo' arrivare prima — una
|
||||
* seconda scheda, un import partito da un'altra zona. E' qui, sotto il mutex
|
||||
* di BackupOperationState::occupa(), che si decide davvero.
|
||||
*/
|
||||
protected function nuovaOperazione(string $tipo): ?string
|
||||
{
|
||||
$operationId = (string) Str::uuid();
|
||||
|
||||
$stato = app(BackupOperationState::class);
|
||||
$stato->initialize($operationId, $tipo, 'In coda');
|
||||
$inCorso = $stato->occupa($operationId, $tipo, 'In coda');
|
||||
|
||||
if ($inCorso !== null) {
|
||||
$this->rifiutaPerOccupato($inCorso);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$stato->log($operationId, 'Operazione creata. In attesa del worker di coda.');
|
||||
|
||||
$this->logSpediti = 0;
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Proclamatore;
|
||||
|
||||
class ProclamatoreCestino extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public function restore(int $id)
|
||||
@@ -19,7 +21,7 @@ class ProclamatoreCestino extends Component
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($proclamatore)
|
||||
->log('restored');
|
||||
session()->flash('success', "Proclamatore ripristinato.");
|
||||
$this->avvisa("Proclamatore ripristinato.");
|
||||
}
|
||||
|
||||
public function forceDelete(int $id)
|
||||
@@ -29,14 +31,14 @@ class ProclamatoreCestino extends Component
|
||||
$proclamatore = Proclamatore::onlyTrashed()->findOrFail($id);
|
||||
|
||||
if ($proclamatore->assegnazioni()->exists()) {
|
||||
session()->flash('error', 'Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
|
||||
$this->avvisaErrore('Impossibile eliminare definitivamente: il proclamatore ha assegnazioni nello storico.');
|
||||
return;
|
||||
}
|
||||
|
||||
activity()->causedBy(auth()->user())
|
||||
->log('force_deleted_proclamatore');
|
||||
$proclamatore->forceDelete();
|
||||
session()->flash('success', 'Proclamatore eliminato definitivamente.');
|
||||
$this->avvisa('Proclamatore eliminato definitivamente.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Livewire\Proclamatori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Proclamatore;
|
||||
|
||||
class ProclamatoreIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
@@ -59,12 +61,12 @@ class ProclamatoreIndex extends Component
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
|
||||
if ($proclamatore->assegnazioni()->aperte()->exists()) {
|
||||
session()->flash('error', "Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
|
||||
$this->avvisaErrore("Il proclamatore {$proclamatore->nome_completo} ha assegnazioni aperte. Rientra prima i territori.");
|
||||
return;
|
||||
}
|
||||
|
||||
$proclamatore->delete();
|
||||
session()->flash('success', "Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
||||
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
||||
}
|
||||
|
||||
public function anonimizza(int $id)
|
||||
@@ -73,7 +75,7 @@ class ProclamatoreIndex extends Component
|
||||
|
||||
$proclamatore = Proclamatore::findOrFail($id);
|
||||
$proclamatore->anonimizza();
|
||||
session()->flash('success', "Dati personali del proclamatore anonimizzati (GDPR).");
|
||||
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Livewire\Component;
|
||||
|
||||
class Profile extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
|
||||
@@ -67,7 +69,7 @@ class Profile extends Component
|
||||
|
||||
$this->reset(['current_password', 'password', 'password_confirmation']);
|
||||
|
||||
session()->flash('password_success', 'Password aggiornata con successo.');
|
||||
$this->avvisa('Password aggiornata con successo.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -48,7 +48,11 @@ class BackupManager extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
|
||||
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_BACKUP);
|
||||
if ($operationId === null) {
|
||||
return;
|
||||
}
|
||||
$this->operationId = $operationId;
|
||||
|
||||
CreateBackupArchive::dispatch($this->operationId, false);
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use App\Models\Setting;
|
||||
|
||||
class SettingsEdit extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $congregazione_nome = '';
|
||||
public int $giorni_giacenza_da_assegnare = 120;
|
||||
public int $giorni_giacenza_prioritari = 180;
|
||||
@@ -55,7 +57,7 @@ class SettingsEdit extends Component
|
||||
'audit_retention_days' => $this->audit_retention_days,
|
||||
]);
|
||||
|
||||
session()->flash('success', 'Impostazioni aggiornate.');
|
||||
$this->avvisa('Impostazioni aggiornate.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use App\Models\Tipologia;
|
||||
|
||||
class TipologieIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $nuovaTipologia = '';
|
||||
public ?int $editingId = null;
|
||||
public string $editingNome = '';
|
||||
@@ -16,7 +18,7 @@ class TipologieIndex extends Component
|
||||
$this->validate(['nuovaTipologia' => 'required|string|max:100|unique:tipologie,nome']);
|
||||
Tipologia::create(['nome' => $this->nuovaTipologia, 'attivo' => true]);
|
||||
$this->nuovaTipologia = '';
|
||||
session()->flash('success', 'Tipologia aggiunta.');
|
||||
$this->avvisa('Tipologia aggiunta.');
|
||||
}
|
||||
|
||||
public function startEdit(int $id)
|
||||
@@ -33,7 +35,7 @@ class TipologieIndex extends Component
|
||||
$tipo->update(['nome' => $this->editingNome]);
|
||||
$this->editingId = null;
|
||||
$this->editingNome = '';
|
||||
session()->flash('success', 'Tipologia aggiornata.');
|
||||
$this->avvisa('Tipologia aggiornata.');
|
||||
}
|
||||
|
||||
public function cancelEdit()
|
||||
@@ -52,11 +54,11 @@ class TipologieIndex extends Component
|
||||
{
|
||||
$tipo = Tipologia::findOrFail($id);
|
||||
if ($tipo->territori()->exists()) {
|
||||
session()->flash('error', "Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
|
||||
$this->avvisaErrore("Impossibile eliminare: la tipologia '{$tipo->nome}' ha territori associati.");
|
||||
return;
|
||||
}
|
||||
$tipo->delete();
|
||||
session()->flash('success', "Tipologia '{$tipo->nome}' eliminata.");
|
||||
$this->avvisa("Tipologia '{$tipo->nome}' eliminata.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -13,6 +14,7 @@ use Spatie\Permission\Models\Role;
|
||||
|
||||
class UsersIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $name = '';
|
||||
public string $email = '';
|
||||
public string $password = '';
|
||||
@@ -83,7 +85,7 @@ class UsersIndex extends Component
|
||||
if (! empty($this->availableRoles)) {
|
||||
$this->selectedRole = $this->availableRoles[0];
|
||||
}
|
||||
session()->flash('success', 'Utente creato con successo.');
|
||||
$this->avvisa('Utente creato con successo.');
|
||||
}
|
||||
|
||||
public function startEdit(int $userId): void
|
||||
@@ -147,7 +149,7 @@ class UsersIndex extends Component
|
||||
$user->syncRoles([$validated['editSelectedRole']]);
|
||||
|
||||
$this->cancelEdit();
|
||||
session()->flash('success', 'Utente aggiornato con successo.');
|
||||
$this->avvisa('Utente aggiornato con successo.');
|
||||
}
|
||||
|
||||
public function deleteUser(int $userId): void
|
||||
@@ -156,12 +158,12 @@ class UsersIndex extends Component
|
||||
$user = User::query()->with('roles')->findOrFail($userId);
|
||||
|
||||
if (! $currentUser || $currentUser->id === $user->id) {
|
||||
session()->flash('error', 'Non puoi cancellare il tuo utente.');
|
||||
$this->avvisaErrore('Non puoi cancellare il tuo utente.');
|
||||
return;
|
||||
}
|
||||
|
||||
if ($user->hasRole('amministratore') && User::role('amministratore')->count() <= 1) {
|
||||
session()->flash('error', 'Non puoi cancellare l\'ultimo amministratore.');
|
||||
$this->avvisaErrore('Non puoi cancellare l\'ultimo amministratore.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,7 +203,7 @@ class UsersIndex extends Component
|
||||
$this->cancelEdit();
|
||||
}
|
||||
|
||||
session()->flash('success', 'Utente cancellato. I log sono stati preservati.');
|
||||
$this->avvisa('Utente cancellato. I log sono stati preservati.');
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use App\Jobs\ImportTerritoryPdfFolder;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
@@ -9,20 +10,27 @@ use App\Models\Campagna;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Models\Tipologia;
|
||||
use App\Models\User;
|
||||
use App\Models\Zona;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\TerritorioPdfImportDispatcher;
|
||||
use App\Services\TerritorioPdfImportState;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||
use Livewire\WithFileUploads;
|
||||
|
||||
class XmlExchange extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithFileUploads;
|
||||
|
||||
public array $importStats = [];
|
||||
@@ -52,39 +60,149 @@ class XmlExchange extends Component
|
||||
}
|
||||
}
|
||||
|
||||
public function importTerritoryPdfFolder(): void
|
||||
/**
|
||||
* Quanti file viaggiano in una singola richiesta di caricamento della
|
||||
* cartella.
|
||||
*
|
||||
* Il browser non manda la cartella tutta insieme: la spezza in lotti e li
|
||||
* aggiunge uno dopo l'altro a $pdfFolder. Un'unica richiesta con tutti i file
|
||||
* si scontrava con `max_file_uploads` di PHP (20 per default), che non
|
||||
* rifiuta: scarta in silenzio i file in piu', e l'import partiva con i primi
|
||||
* venti di una cartella da trecento. Alzare il limite non basterebbe: la
|
||||
* richiesta unica resterebbe soggetta al tetto di dimensione e al timeout di
|
||||
* 100 secondi di Cloudflare, e un errore a meta' farebbe ricominciare da capo.
|
||||
* A lotti, ogni richiesta e' piccola e un errore ne ferma una sola.
|
||||
*
|
||||
* Si legge il valore vero di PHP, perche' un'installazione con un limite piu'
|
||||
* basso non perda file in silenzio come prima.
|
||||
*/
|
||||
public function getFilePerLottoProperty(): int
|
||||
{
|
||||
$this->validate([
|
||||
'pdfFolder' => ['required', 'array', 'min:1'],
|
||||
'pdfFolder.*' => ['file', 'mimes:pdf', 'max:10240'],
|
||||
]);
|
||||
return max(1, min(20, (int) ini_get('max_file_uploads') ?: 20));
|
||||
}
|
||||
|
||||
$importId = (string) Str::uuid();
|
||||
$storedFiles = [];
|
||||
/**
|
||||
* Controllo rapido prima di caricare la cartella: senza, si caricherebbero
|
||||
* centinaia di file per poi sentirsi dire che c'e' un ripristino in corso.
|
||||
* Non prende il posto — lo prende importTerritoryPdfFolder(), a caricamento
|
||||
* finito — perche' una scheda chiusa a meta' caricamento lo terrebbe
|
||||
* occupato per un'ora.
|
||||
*/
|
||||
public function cartellaPuoPartire(): bool
|
||||
{
|
||||
$inCorso = app(BackupOperationState::class)->corrente();
|
||||
|
||||
foreach ($this->pdfFolder as $index => $file) {
|
||||
$originalName = $file->getClientOriginalName();
|
||||
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
|
||||
$extension = strtolower($file->getClientOriginalExtension() ?: 'pdf');
|
||||
$storedPath = $file->storeAs(
|
||||
'bulk-territori-imports/' . $importId,
|
||||
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.' . $extension,
|
||||
'local'
|
||||
);
|
||||
if ($inCorso === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$storedFiles[] = [
|
||||
'original_name' => $originalName,
|
||||
'stored_path' => $storedPath,
|
||||
];
|
||||
$this->avvisaErrore(app(BackupOperationState::class)->messaggioOccupato($inCorso));
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Il browser azzera i lotti di un caricamento precedente prima di iniziarne uno nuovo. */
|
||||
public function svuotaCartella(): void
|
||||
{
|
||||
foreach ($this->pdfFolder as $file) {
|
||||
if ($file instanceof TemporaryUploadedFile) {
|
||||
$file->delete();
|
||||
}
|
||||
}
|
||||
|
||||
$this->pdfFolder = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Mette in coda l'import dei lotti gia' caricati. Restituisce true se
|
||||
* l'import e' partito: il browser tiene la cartella scelta finche' non lo e',
|
||||
* cosi' dopo un rifiuto basta ripremere.
|
||||
*/
|
||||
public function importTerritoryPdfFolder(): bool
|
||||
{
|
||||
$this->validate([
|
||||
'pdfFolder' => ['required', 'array', 'min:1'],
|
||||
]);
|
||||
|
||||
$importId = (string) Str::uuid();
|
||||
$dispatcher = app(TerritorioPdfImportDispatcher::class);
|
||||
|
||||
try {
|
||||
$dispatcher->occupaPosto($importId);
|
||||
} catch (\RuntimeException $e) {
|
||||
// Il posto l'ha preso qualcun altro fra il controllo iniziale e la
|
||||
// fine del caricamento: si avvisa, e non si tocca niente.
|
||||
$this->avvisaErrore($e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$storedFiles = [];
|
||||
$ignorati = [];
|
||||
$scartati = [];
|
||||
|
||||
try {
|
||||
foreach ($this->pdfFolder as $index => $file) {
|
||||
$originalName = $file->getClientOriginalName();
|
||||
|
||||
// Thumbs.db, .DS_Store, desktop.ini e simili: prima uno solo di
|
||||
// questi faceva fallire la validazione e con lei l'intero import.
|
||||
if (TerritorioPdfImportDispatcher::daIgnorare($originalName)) {
|
||||
$ignorati[] = $originalName;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Un PDF rotto o troppo grande si scarta da solo, con il suo
|
||||
// motivo nell'esito: non e' una ragione per buttare gli altri.
|
||||
$verifica = Validator::make(['file' => $file], ['file' => ['file', 'mimes:pdf', 'max:10240']]);
|
||||
if ($verifica->fails()) {
|
||||
$scartati[] = [
|
||||
'file' => $originalName,
|
||||
'message' => 'File non valido: ' . $verifica->errors()->first('file'),
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
$safeName = Str::slug(pathinfo($originalName, PATHINFO_FILENAME));
|
||||
$storedPath = $file->storeAs(
|
||||
'bulk-territori-imports/' . $importId,
|
||||
str_pad((string) $index, 4, '0', STR_PAD_LEFT) . '-' . $safeName . '.pdf',
|
||||
'local'
|
||||
);
|
||||
|
||||
$storedFiles[] = [
|
||||
'original_name' => $originalName,
|
||||
'stored_path' => $storedPath,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$dispatcher->liberaPosto($importId, 'Import PDF non avviato: ' . $e->getMessage());
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$this->svuotaCartella();
|
||||
|
||||
if ($storedFiles === []) {
|
||||
$dispatcher->liberaPosto($importId, 'Nessun PDF da importare.');
|
||||
$this->avvisaErrore('Nella cartella non c\'è nessun PDF da importare'
|
||||
. ($ignorati !== [] ? ' (ignorati ' . count($ignorati) . ' file di sistema o di altro tipo)' : '')
|
||||
. ($scartati !== [] ? ' (' . count($scartati) . ' PDF non validi: ' . implode(', ', array_column($scartati, 'file')) . ')' : '')
|
||||
. '.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->dispatchPdfImport(
|
||||
$importId,
|
||||
$storedFiles,
|
||||
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.'
|
||||
'Cartella ricevuta: ' . count($storedFiles) . ' PDF in coda.',
|
||||
$ignorati,
|
||||
$scartati
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function refreshPdfImportStatus(): void
|
||||
@@ -114,8 +232,9 @@ class XmlExchange extends Component
|
||||
* contenuti non possono essere ri-renderizzati dal server senza cancellare
|
||||
* le righe scritte dal browser, quindi si comunica a eventi. E' lo stesso
|
||||
* meccanismo di SegueOperazioneBackup, da cui questo componente non eredita
|
||||
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState) e
|
||||
* non partecipa al posto unico: puo' girare mentre un backup lavora.
|
||||
* perche' l'import PDF ha uno stato tutto suo (TerritorioPdfImportState).
|
||||
* Del posto unico usa solo l'occupazione (BackupOperationState::occupa()):
|
||||
* non gira mai insieme a un backup, un ripristino o un azzeramento.
|
||||
*/
|
||||
protected function spedisciImportAllaConsole(): void
|
||||
{
|
||||
@@ -156,10 +275,10 @@ class XmlExchange extends Component
|
||||
return in_array($this->pdfImportStatus, ['queued', 'running'], true);
|
||||
}
|
||||
|
||||
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog): void
|
||||
protected function dispatchPdfImport(string $importId, array $storedFiles, string $initialLog, array $ignorati = [], array $scartati = []): void
|
||||
{
|
||||
$state = app(TerritorioPdfImportDispatcher::class)
|
||||
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog);
|
||||
->dispatchStoredFiles($importId, $storedFiles, auth()->id(), $initialLog, $ignorati, $scartati);
|
||||
|
||||
$this->currentPdfImportId = $importId;
|
||||
$this->pdfImportStatus = $state['status'] ?? 'queued';
|
||||
@@ -172,13 +291,13 @@ class XmlExchange extends Component
|
||||
$this->logPdfSpediti = 0;
|
||||
$this->refreshPdfImportStatus();
|
||||
|
||||
session()->flash('success', 'Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
|
||||
$this->avvisa('Import PDF avviato in background. L\'avanzamento è nella console in fondo alla pagina.');
|
||||
}
|
||||
|
||||
public function downloadImportLogPdf()
|
||||
{
|
||||
if (empty($this->importStats)) {
|
||||
session()->flash('error', 'Nessun log da scaricare. Esegui prima un import XML.');
|
||||
$this->avvisaErrore('Nessun log da scaricare. Esegui prima un import XML.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -236,6 +355,67 @@ class XmlExchange extends Component
|
||||
return $this->datasetToXml($dataset, $source);
|
||||
}
|
||||
|
||||
/**
|
||||
* I file sul disco `public` a cui puntano i territori e il loro storico PDF.
|
||||
*
|
||||
* Serve all'import XML per sapere, dopo, quali file ha reso orfani. L'import
|
||||
* cancella tutti i territori e li ricrea con id nuovi; i PDF portano l'id nel
|
||||
* nome (territorio-142-141.pdf), quindi un file che arriva da un'altra
|
||||
* installazione, o da un dump della vecchia applicazione senza PDF, lascia i
|
||||
* file dei territori cancellati sul disco: nessuna schermata li mostra,
|
||||
* nessuna cancellazione li raggiunge, e finiscono dentro ogni backup
|
||||
* successivo. E' cosi' che la cartella territori-pdf si era trovata con due
|
||||
* PDF per territorio.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function fileDeiTerritori(): array
|
||||
{
|
||||
return Territorio::withTrashed()->get(['pdf_path', 'thumbnail_path'])
|
||||
->concat(TerritorioPdfVersione::query()->get(['pdf_path', 'thumbnail_path']))
|
||||
->flatMap(fn ($riga) => [$riga->pdf_path, $riga->thumbnail_path])
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Elimina i file che prima dell'import appartenevano a un territorio e che
|
||||
* adesso nessuna riga richiama piu'.
|
||||
*
|
||||
* Si toccano solo i file elencati in $fileDiPrima: un file sul disco che
|
||||
* nessuno conosceva non e' affare dell'import. E prima di cancellare si
|
||||
* ricontrolla ogni riferimento possibile — anche logo e piantine — cosi' un
|
||||
* XML che riporta gli stessi percorsi (un export di questa stessa
|
||||
* installazione) non perde niente.
|
||||
*
|
||||
* @param array<int, string> $fileDiPrima
|
||||
*/
|
||||
public static function eliminaFileNonPiuUsati(array $fileDiPrima): int
|
||||
{
|
||||
$usati = array_flip(array_merge(
|
||||
self::fileDeiTerritori(),
|
||||
array_filter([Setting::instance()->logo_path]),
|
||||
TerritorioMappa::query()->whereNotNull('image_path')->pluck('image_path')->all(),
|
||||
));
|
||||
|
||||
$disco = Storage::disk('public');
|
||||
$eliminati = 0;
|
||||
|
||||
foreach ($fileDiPrima as $percorso) {
|
||||
if (isset($usati[$percorso]) || ! $disco->exists($percorso)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($disco->delete($percorso)) {
|
||||
$eliminati++;
|
||||
}
|
||||
}
|
||||
|
||||
return $eliminati;
|
||||
}
|
||||
|
||||
public function importXmlFromContent(string $content): array
|
||||
{
|
||||
$this->importStats = [];
|
||||
@@ -370,14 +550,20 @@ class XmlExchange extends Component
|
||||
// BCRYPT_ROUNDS), and Eloquent does not stamp its own timestamps.
|
||||
// Per chi sta importando password e token restano i suoi:
|
||||
// quelli del file possono essere di mesi fa.
|
||||
//
|
||||
// Chi riceve l'hash dal file deve scegliere una password nuova,
|
||||
// come dopo un ripristino: chi ha avuto il file fra le mani ha
|
||||
// potuto tentarne il crack con calma. Il "ricordami" si azzera
|
||||
// e basta: non si esporta più, e quello di un file vecchio
|
||||
// sarebbe una credenziale persistente regalata a chi lo tiene.
|
||||
$meta = [];
|
||||
$passwordHash = (string) ($userNode->password ?? '');
|
||||
if ($passwordHash !== '' && ! $eAttore) {
|
||||
$meta['password'] = $passwordHash;
|
||||
$meta['password_change_required'] = true;
|
||||
}
|
||||
if (! $eAttore) {
|
||||
$rememberToken = (string) ($userNode->remember_token ?? '');
|
||||
$meta['remember_token'] = $rememberToken !== '' ? $rememberToken : null;
|
||||
$meta['remember_token'] = null;
|
||||
}
|
||||
$meta['email_verified_at'] = $this->normalizeDateTimeForDb((string) ($userNode->email_verified_at ?? ''));
|
||||
|
||||
@@ -620,7 +806,8 @@ class XmlExchange extends Component
|
||||
'email' => (string) $u->email,
|
||||
'password' => (string) $u->getAuthPassword(),
|
||||
'email_verified_at' => optional($u->email_verified_at)->toIso8601String() ?? '',
|
||||
'remember_token' => (string) ($u->getRememberToken() ?? ''),
|
||||
// remember_token no: all'import si azzera comunque, e nel file
|
||||
// sarebbe solo una credenziale in più per chi lo tiene.
|
||||
'roles' => $u->roles->pluck('name')->all(),
|
||||
'created_at' => optional($u->created_at)->toIso8601String() ?? '',
|
||||
'updated_at' => optional($u->updated_at)->toIso8601String() ?? '',
|
||||
@@ -1030,7 +1217,6 @@ class XmlExchange extends Component
|
||||
$this->addXmlText($node, 'email', (string) ($user['email'] ?? ''));
|
||||
$this->addXmlText($node, 'password', (string) ($user['password'] ?? ''));
|
||||
$node->addChild('email_verified_at', (string) ($user['email_verified_at'] ?? ''));
|
||||
$this->addXmlText($node, 'remember_token', (string) ($user['remember_token'] ?? ''));
|
||||
$node->addChild('created_at', (string) ($user['created_at'] ?? ''));
|
||||
$node->addChild('updated_at', (string) ($user['updated_at'] ?? ''));
|
||||
$rolesNode = $node->addChild('roles');
|
||||
|
||||
@@ -98,8 +98,13 @@ class ZonaPericolosa extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
|
||||
if ($operationId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->confermaReset = '';
|
||||
$this->operationId = $this->nuovaOperazione(BackupOperationState::TIPO_RESET);
|
||||
$this->operationId = $operationId;
|
||||
|
||||
CreateBackupArchive::dispatch($this->operationId, true);
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
namespace App\Livewire\Settings;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use App\Models\Zona;
|
||||
|
||||
class ZoneIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
public string $nuovaZona = '';
|
||||
public ?int $editingId = null;
|
||||
public string $editingNome = '';
|
||||
@@ -16,7 +18,7 @@ class ZoneIndex extends Component
|
||||
$this->validate(['nuovaZona' => 'required|string|max:100|unique:zone,nome']);
|
||||
Zona::create(['nome' => $this->nuovaZona, 'attivo' => true]);
|
||||
$this->nuovaZona = '';
|
||||
session()->flash('success', 'Zona aggiunta.');
|
||||
$this->avvisa('Zona aggiunta.');
|
||||
}
|
||||
|
||||
public function startEdit(int $id)
|
||||
@@ -33,7 +35,7 @@ class ZoneIndex extends Component
|
||||
$zona->update(['nome' => $this->editingNome]);
|
||||
$this->editingId = null;
|
||||
$this->editingNome = '';
|
||||
session()->flash('success', 'Zona aggiornata.');
|
||||
$this->avvisa('Zona aggiornata.');
|
||||
}
|
||||
|
||||
public function cancelEdit()
|
||||
@@ -52,11 +54,11 @@ class ZoneIndex extends Component
|
||||
{
|
||||
$zona = Zona::findOrFail($id);
|
||||
if ($zona->territori()->exists()) {
|
||||
session()->flash('error', "Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
|
||||
$this->avvisaErrore("Impossibile eliminare: la zona '{$zona->nome}' ha territori associati.");
|
||||
return;
|
||||
}
|
||||
$zona->delete();
|
||||
session()->flash('success', "Zona '{$zona->nome}' eliminata.");
|
||||
$this->avvisa("Zona '{$zona->nome}' eliminata.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
use App\Models\Territorio;
|
||||
@@ -10,6 +11,7 @@ use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class TerritorioCestino extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public function restore(int $id)
|
||||
@@ -21,7 +23,7 @@ class TerritorioCestino extends Component
|
||||
activity()->causedBy(auth()->user())
|
||||
->performedOn($territorio)
|
||||
->log('restored');
|
||||
session()->flash('success', "Territorio {$territorio->numero} ripristinato.");
|
||||
$this->avvisa("Territorio {$territorio->numero} ripristinato.");
|
||||
}
|
||||
|
||||
public function forceDelete(int $id)
|
||||
@@ -31,7 +33,7 @@ class TerritorioCestino extends Component
|
||||
$territorio = Territorio::onlyTrashed()->findOrFail($id);
|
||||
|
||||
if ($territorio->assegnazioni()->exists()) {
|
||||
session()->flash('error', 'Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
|
||||
$this->avvisaErrore('Impossibile eliminare definitivamente: il territorio ha assegnazioni nello storico.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -48,7 +50,7 @@ class TerritorioCestino extends Component
|
||||
$this->rimuoviFile($territorio);
|
||||
|
||||
$territorio->forceDelete();
|
||||
session()->flash('success', 'Territorio eliminato definitivamente.');
|
||||
$this->avvisa('Territorio eliminato definitivamente.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -64,14 +64,17 @@ class TerritorioEdit extends Component
|
||||
|
||||
if ($this->pdf) {
|
||||
$thumbService = app(TerritorioThumbnailService::class);
|
||||
// Remove old PDF and thumbnail
|
||||
if ($this->territorio->pdf_path) {
|
||||
Storage::disk('public')->delete($this->territorio->pdf_path);
|
||||
}
|
||||
if ($this->territorio->thumbnail_path) {
|
||||
$thumbService->delete($this->territorio->thumbnail_path);
|
||||
}
|
||||
// Prima il nuovo, poi il vecchio nello storico (ripristinabile
|
||||
// dalla pagina Piantine). Prima il vecchio si cancellava subito:
|
||||
// un caricamento sbagliato non si poteva disfare, e con il
|
||||
// salvataggio fallito il territorio restava senza PDF.
|
||||
$pdfPath = $this->pdf->store('territori-pdf', 'public');
|
||||
if (! $pdfPath) {
|
||||
$this->addError('pdf', 'Non è stato possibile salvare il PDF: quello in uso non è stato toccato.');
|
||||
|
||||
return;
|
||||
}
|
||||
app(\App\Services\Piantine\PiantinaPdfService::class)->archiviaCorrente($this->territorio, 'manuale');
|
||||
$data['pdf_path'] = $pdfPath;
|
||||
$data['thumbnail_path'] = $thumbService->generate($pdfPath);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Territori;
|
||||
|
||||
use App\Livewire\Concerns\Avvisa;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
@@ -11,6 +12,7 @@ use App\Models\Tipologia;
|
||||
|
||||
class TerritorioIndex extends Component
|
||||
{
|
||||
use Avvisa;
|
||||
use WithPagination;
|
||||
|
||||
public string $search = '';
|
||||
@@ -127,7 +129,7 @@ class TerritorioIndex extends Component
|
||||
->performedOn($territorio)
|
||||
->log('soft_deleted');
|
||||
$territorio->delete();
|
||||
session()->flash('success', "Territorio {$territorio->numero} spostato nel cestino.");
|
||||
$this->avvisa("Territorio {$territorio->numero} spostato nel cestino.");
|
||||
}
|
||||
|
||||
public function render()
|
||||
|
||||
@@ -46,8 +46,13 @@ class Campagna extends Model
|
||||
*/
|
||||
public static function attiva(): ?static
|
||||
{
|
||||
return static::where('start_date', '<=', now())
|
||||
->where('end_date', '>=', now())
|
||||
// Confronto sulla data, non sull'ora: end_date vale la mezzanotte del
|
||||
// suo giorno, e contro now() la campagna spariva dalla home proprio
|
||||
// nel suo ultimo giorno.
|
||||
$oggi = now()->toDateString();
|
||||
|
||||
return static::whereDate('start_date', '<=', $oggi)
|
||||
->whereDate('end_date', '>=', $oggi)
|
||||
->first();
|
||||
}
|
||||
|
||||
@@ -70,9 +75,11 @@ class Campagna extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Campaign coverage percentage.
|
||||
* Numerator: assignments counted for campaign
|
||||
* Denominator: total active territories
|
||||
* Quota dei territori attivi percorsi durante la campagna.
|
||||
*
|
||||
* Si contano i territori, non le assegnazioni: un territorio percorso due
|
||||
* volte valeva doppio, e insieme ai territori poi disattivati (fuori dal
|
||||
* denominatore ma ancora nel numeratore) la percentuale superava il 100%.
|
||||
*/
|
||||
public function getPercentualePercorrenzaAttribute(): float
|
||||
{
|
||||
@@ -82,13 +89,17 @@ class Campagna extends Model
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
$conteggiate = $this->assegnazioniConteggiate()->count();
|
||||
$percorsi = $this->assegnazioniConteggiate()
|
||||
->whereHas('territorio', fn ($q) => $q->where('attivo', true))
|
||||
->distinct()
|
||||
->count('territorio_id');
|
||||
|
||||
return round(($conteggiate / $totaleAttivi) * 100, 1);
|
||||
return round(($percorsi / $totaleAttivi) * 100, 1);
|
||||
}
|
||||
|
||||
public function scopeCompletate($query)
|
||||
{
|
||||
return $query->where('end_date', '<', now())->orderByDesc('end_date');
|
||||
// Una campagna che finisce oggi è ancora in corso.
|
||||
return $query->whereDate('end_date', '<', now()->toDateString())->orderByDesc('end_date');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,4 +220,24 @@ class Territorio extends Model
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Indirizzi dei file (serviti da TerritorioFileController) ──────────
|
||||
|
||||
/**
|
||||
* ?v= cambia con il file: l'indirizzo resta per territorio, e senza la
|
||||
* cache del browser mostrerebbe il PDF di prima dopo una sostituzione.
|
||||
*/
|
||||
public function pdfUrl(): ?string
|
||||
{
|
||||
return $this->pdf_path
|
||||
? route('territori.file.pdf', ['territorio' => $this->id, 'v' => substr(md5($this->pdf_path), 0, 8)])
|
||||
: null;
|
||||
}
|
||||
|
||||
public function miniaturaUrl(): ?string
|
||||
{
|
||||
return $this->thumbnail_path
|
||||
? route('territori.file.miniatura', ['territorio' => $this->id, 'v' => substr(md5($this->thumbnail_path), 0, 8)])
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Livewire\Livewire;
|
||||
use RuntimeException;
|
||||
@@ -17,6 +19,13 @@ class AppServiceProvider extends ServiceProvider
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
// Setting tiene le impostazioni in una variabile statica, svuotata
|
||||
// solo quando le salva lo stesso processo. Il worker resta vivo fino a
|
||||
// un'ora: senza questo, ogni job usava le impostazioni lette dal primo,
|
||||
// anche dopo che l'amministratore le aveva cambiate dal browser o un
|
||||
// ripristino le aveva sostituite.
|
||||
Queue::before(fn () => Setting::flushCache());
|
||||
|
||||
// Pin generated URLs to APP_URL.
|
||||
//
|
||||
// Behind a trusted proxy, Symfony derives the port from
|
||||
|
||||
@@ -20,8 +20,15 @@ use ZipArchive;
|
||||
*
|
||||
* manifest.json formato, data, conteggi (letto per validare il file)
|
||||
* data.xml lo stesso XML dell'export "Esporta XML"
|
||||
* piantine.json disegni e storico dei PDF (Piantine\PiantineBackup)
|
||||
* storage/<percorso> ogni file del disco `public`, con lo stesso percorso
|
||||
* relativo che sta in pdf_path/thumbnail_path/logo_path
|
||||
* piantine/<percorso> l'area KML delle piantine (storage/app/piantine),
|
||||
* che sta accanto al disco `public`, non dentro
|
||||
*
|
||||
* piantine.json e piantine/ esistono dal formato 2. Un archivio di formato 1
|
||||
* si ripristina lo stesso: le tabelle delle piantine restano vuote e l'area
|
||||
* KML in uso non si tocca (vedi scambiaFile()).
|
||||
*/
|
||||
class BackupArchive
|
||||
{
|
||||
@@ -29,11 +36,23 @@ class BackupArchive
|
||||
* Versione del formato. L'import rifiuta i pacchetti piu' recenti di
|
||||
* quello che sa leggere invece di ripristinarli a meta'.
|
||||
*/
|
||||
public const FORMAT_VERSION = 1;
|
||||
public const FORMAT_VERSION = 2;
|
||||
|
||||
private const ENTRY_MANIFEST = 'manifest.json';
|
||||
private const ENTRY_DATA = 'data.xml';
|
||||
private const ENTRY_PIANTINE = 'piantine.json';
|
||||
private const PREFIX_STORAGE = 'storage/';
|
||||
private const PREFIX_PIANTINE = 'piantine/';
|
||||
|
||||
/**
|
||||
* Sottocartelle dello staging, una per ogni cartella che il ripristino
|
||||
* sostituisce (vedi radiciDaSostituire()).
|
||||
*/
|
||||
private const STAGING_PUBBLICO = 'pubblico';
|
||||
private const STAGING_PIANTINE = 'piantine';
|
||||
|
||||
/** Cartella dell'area KML sul disco `local` (vedi PiantineArea). */
|
||||
private const CARTELLA_PIANTINE = 'piantine';
|
||||
|
||||
/**
|
||||
* Limiti sull'archivio in ingresso. Un .zip e' un formato compresso: senza
|
||||
@@ -65,12 +84,32 @@ class BackupArchive
|
||||
* Costruisce il pacchetto in $destinazione e ne restituisce il percorso.
|
||||
*
|
||||
* @param string $xml Contenuto di data.xml (l'export XML gia' pronto).
|
||||
* @param array $piantine Contenuto di piantine.json (PiantineBackup::esporta()).
|
||||
* Obbligatorio e non facoltativo di proposito: un
|
||||
* chiamante che se ne dimentica produrrebbe archivi
|
||||
* che al ripristino svuotano le piantine.
|
||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||
*/
|
||||
public function create(string $xml, string $destinazione, ?callable $onProgress = null): string
|
||||
public function create(string $xml, array $piantine, string $destinazione, ?callable $onProgress = null): string
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
$files = $disk->allFiles();
|
||||
$local = Storage::disk('local');
|
||||
|
||||
// Due cartelle sorgente, un solo elenco di terne: percorso relativo
|
||||
// (per i messaggi), percorso sul disco, nome dell'entry.
|
||||
$files = [];
|
||||
foreach ($disk->allFiles() as $relativePath) {
|
||||
$files[] = [$relativePath, $disk->path($relativePath), self::PREFIX_STORAGE . $relativePath];
|
||||
}
|
||||
$filePubblici = count($files);
|
||||
|
||||
// allFiles('piantine') restituisce gia' "piantine/area.kml": il
|
||||
// percorso relativo al disco coincide con il nome dell'entry.
|
||||
foreach ($local->allFiles(self::CARTELLA_PIANTINE) as $relativePath) {
|
||||
$files[] = [$relativePath, $local->path($relativePath), $relativePath];
|
||||
}
|
||||
$filePiantine = count($files) - $filePubblici;
|
||||
|
||||
$totale = count($files);
|
||||
|
||||
$cartella = dirname($destinazione);
|
||||
@@ -84,12 +123,14 @@ class BackupArchive
|
||||
}
|
||||
|
||||
$zip->addFromString(self::ENTRY_DATA, $xml);
|
||||
$zip->addFromString(self::ENTRY_PIANTINE, json_encode($piantine, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR));
|
||||
$zip->addFromString(self::ENTRY_MANIFEST, json_encode([
|
||||
'format_version' => self::FORMAT_VERSION,
|
||||
'application' => 'TerManager2',
|
||||
'generated_at' => now()->toIso8601String(),
|
||||
'app_url' => (string) config('app.url'),
|
||||
'files_count' => $totale,
|
||||
'files_count' => $filePubblici,
|
||||
'piantine_files_count' => $filePiantine,
|
||||
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
|
||||
|
||||
try {
|
||||
@@ -110,12 +151,12 @@ class BackupArchive
|
||||
|
||||
try {
|
||||
$this->conArchivioAperto($destinazione, function (ZipArchive $zip) use (
|
||||
$disk, $files, $totale, $perLotto, $onProgress, $destinazione
|
||||
$files, $totale, $perLotto, $onProgress, $destinazione
|
||||
) {
|
||||
$nelLotto = 0;
|
||||
$fatti = 0;
|
||||
|
||||
foreach ($files as $relativePath) {
|
||||
foreach ($files as [$relativePath, $sorgente, $entry]) {
|
||||
// addFile() legge dal disco durante close(): i PDF non
|
||||
// passano mai interi per la memoria del processo.
|
||||
//
|
||||
@@ -133,7 +174,7 @@ class BackupArchive
|
||||
// raggiunto mai, e l'amministratore leggerebbe la stringa
|
||||
// grezza di PHP. Stesso problema, e stessa scelta, del
|
||||
// close() avvolto in chiudi().
|
||||
if (! @$zip->addFile($disk->path($relativePath), self::PREFIX_STORAGE . $relativePath)) {
|
||||
if (! @$zip->addFile($sorgente, $entry)) {
|
||||
throw new RuntimeException(
|
||||
'Impossibile aggiungere "' . $relativePath . '" al backup: '
|
||||
. 'il file non e\' leggibile o e\' stato rimosso durante la creazione.'
|
||||
@@ -157,7 +198,7 @@ class BackupArchive
|
||||
}
|
||||
|
||||
try {
|
||||
$this->verificaArchivio($destinazione, $totale);
|
||||
$this->verificaArchivio($destinazione, $filePubblici, $filePiantine);
|
||||
} catch (\Throwable $e) {
|
||||
@unlink($destinazione);
|
||||
|
||||
@@ -178,7 +219,7 @@ class BackupArchive
|
||||
* non dei contenuti — quindi e' un controllo a buon mercato che copre
|
||||
* troncamenti, chiusure a meta' e lotti persi.
|
||||
*/
|
||||
private function verificaArchivio(string $path, int $fileAttesi): void
|
||||
private function verificaArchivio(string $path, int $fileAttesi, int $piantineAttesi): void
|
||||
{
|
||||
$zip = new ZipArchive();
|
||||
|
||||
@@ -187,27 +228,31 @@ class BackupArchive
|
||||
}
|
||||
|
||||
$trovati = 0;
|
||||
$piantineTrovati = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$nome = $zip->getNameIndex($i);
|
||||
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE)) {
|
||||
$trovati++;
|
||||
} elseif ($nome !== false && str_starts_with($nome, self::PREFIX_PIANTINE)) {
|
||||
$piantineTrovati++;
|
||||
}
|
||||
}
|
||||
|
||||
$haDati = $zip->locateName(self::ENTRY_DATA) !== false;
|
||||
$haManifest = $zip->locateName(self::ENTRY_MANIFEST) !== false;
|
||||
$haPiantine = $zip->locateName(self::ENTRY_PIANTINE) !== false;
|
||||
|
||||
$zip->close();
|
||||
|
||||
if (! $haDati || ! $haManifest) {
|
||||
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml o manifest.json.');
|
||||
if (! $haDati || ! $haManifest || ! $haPiantine) {
|
||||
throw new RuntimeException('Il backup appena creato e\' incompleto: manca data.xml, piantine.json o manifest.json.');
|
||||
}
|
||||
|
||||
if ($trovati !== $fileAttesi) {
|
||||
if ($trovati !== $fileAttesi || $piantineTrovati !== $piantineAttesi) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Il backup appena creato contiene %d file invece di %d: archivio scartato.',
|
||||
$trovati,
|
||||
$fileAttesi
|
||||
$trovati + $piantineTrovati,
|
||||
$fileAttesi + $piantineAttesi
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -294,8 +339,15 @@ class BackupArchive
|
||||
* l'archivio e' corrotto o supera i limiti, il fallimento avviene mentre i
|
||||
* dati correnti sono ancora intatti.
|
||||
*
|
||||
* E' anche la verifica completa dell'archivio: ogni entry si estrae per
|
||||
* intero e se ne confrontano dimensione e CRC con quelli dichiarati. Un
|
||||
* file troncato, un'entry corrotta o il disco che si riempie durante
|
||||
* l'estrazione fermano tutto qui — mentre lo staging sta sullo stesso
|
||||
* volume dei file veri, quindi se c'e' posto per estrarre c'e' posto
|
||||
* anche per lo scambio, che e' solo una serie di rename().
|
||||
*
|
||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||
* @return array{xml: string, staging: string, files: int}
|
||||
* @return array{xml: string, piantine: array|null, formato: int, staging: string, pubblico: string, files: int, file_piantine: int}
|
||||
*/
|
||||
public function extract(string $zipPath, ?callable $onProgress = null): array
|
||||
{
|
||||
@@ -334,12 +386,35 @@ class BackupArchive
|
||||
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_DATA . '.');
|
||||
}
|
||||
|
||||
// Dal formato 2 piantine.json e' obbligatorio: la sua assenza non
|
||||
// e' "nessuna piantina" ma un archivio monco, e ripristinarlo
|
||||
// svuoterebbe disegni e storico senza che nessuno l'abbia chiesto.
|
||||
// Null resta riservato al formato 1, che le piantine non le aveva.
|
||||
$piantine = null;
|
||||
if ($formatVersion >= 2) {
|
||||
$piantineRaw = $zip->getFromName(self::ENTRY_PIANTINE);
|
||||
$piantine = $piantineRaw !== false ? json_decode($piantineRaw, true) : null;
|
||||
|
||||
if (! is_array($piantine)) {
|
||||
throw new RuntimeException('Archivio incompleto: manca ' . self::ENTRY_PIANTINE . ' o non e\' leggibile.');
|
||||
}
|
||||
}
|
||||
|
||||
// Le entry che si estraggono, prefisso => sottocartella dello
|
||||
// staging. L'area KML solo dal formato 2: un archivio vecchio non
|
||||
// la porta, e non deve poterla portare per caso.
|
||||
$prefissi = [self::PREFIX_STORAGE => self::STAGING_PUBBLICO];
|
||||
if ($formatVersion >= 2) {
|
||||
$prefissi[self::PREFIX_PIANTINE] = self::STAGING_PIANTINE;
|
||||
}
|
||||
|
||||
if ($zip->numFiles > self::MAX_ENTRIES) {
|
||||
throw new RuntimeException('L\'archivio contiene piu\' di ' . self::MAX_ENTRIES . ' file.');
|
||||
}
|
||||
|
||||
$totalBytes = 0;
|
||||
$extracted = 0;
|
||||
$estrattiPiantine = 0;
|
||||
|
||||
// Il totale si conta scorrendo i nomi, non come "numFiles meno le
|
||||
// due entry di servizio": un archivio con una entry in piu' (una
|
||||
@@ -348,7 +423,7 @@ class BackupArchive
|
||||
$daEstrarre = 0;
|
||||
for ($i = 0; $i < $zip->numFiles; $i++) {
|
||||
$nome = $zip->getNameIndex($i);
|
||||
if ($nome !== false && str_starts_with($nome, self::PREFIX_STORAGE) && ! str_ends_with($nome, '/')) {
|
||||
if ($nome !== false && ! str_ends_with($nome, '/') && $this->prefissoDi($nome, $prefissi) !== null) {
|
||||
$daEstrarre++;
|
||||
}
|
||||
}
|
||||
@@ -361,11 +436,12 @@ class BackupArchive
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with($entryName, self::PREFIX_STORAGE)) {
|
||||
$prefisso = $this->prefissoDi($entryName, $prefissi);
|
||||
if ($prefisso === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$relativePath = substr($entryName, strlen(self::PREFIX_STORAGE));
|
||||
$relativePath = substr($entryName, strlen($prefisso));
|
||||
$safePath = $this->safeRelativePath($relativePath);
|
||||
|
||||
if ($safePath === null) {
|
||||
@@ -375,7 +451,8 @@ class BackupArchive
|
||||
// La dimensione dichiarata si legge *prima* di estrarre: e'
|
||||
// l'unico momento in cui si puo' rifiutare un'entry che, una
|
||||
// volta decompressa, riempirebbe il disco.
|
||||
$size = (int) ($zip->statIndex($index)['size'] ?? 0);
|
||||
$stat = $zip->statIndex($index);
|
||||
$size = (int) ($stat['size'] ?? 0);
|
||||
|
||||
if ($size > self::MAX_ENTRY_BYTES) {
|
||||
throw new RuntimeException(sprintf(
|
||||
@@ -390,7 +467,7 @@ class BackupArchive
|
||||
throw new RuntimeException('L\'archivio decompresso supera il limite consentito.');
|
||||
}
|
||||
|
||||
$destination = $staging . '/' . $safePath;
|
||||
$destination = $staging . '/' . $prefissi[$prefisso] . '/' . $safePath;
|
||||
$directory = dirname($destination);
|
||||
|
||||
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
|
||||
@@ -409,12 +486,40 @@ class BackupArchive
|
||||
throw new RuntimeException('Impossibile scrivere "' . $safePath . '" durante il ripristino.');
|
||||
}
|
||||
|
||||
stream_copy_to_stream($stream, $target);
|
||||
// Il risultato della copia si controlla, ed e' qui che il
|
||||
// ripristino diventa sicuro: prima un disco pieno o un'entry
|
||||
// corrotta lasciavano nello staging un file troncato senza
|
||||
// nessun errore, e quel file finiva al posto del PDF buono.
|
||||
// La @ per lo stesso motivo di addFile(): il warning di PHP
|
||||
// ("CRC error") arriverebbe all'amministratore al posto del
|
||||
// messaggio che dice quale file.
|
||||
$copiati = @stream_copy_to_stream($stream, $target);
|
||||
fclose($stream);
|
||||
fclose($target);
|
||||
$chiuso = fclose($target);
|
||||
|
||||
$extracted++;
|
||||
$onProgress && $onProgress($extracted, $daEstrarre);
|
||||
if ($copiati === false || $copiati !== $size || ! $chiuso) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Impossibile estrarre "%s" per intero (%s byte su %d): l\'archivio e\' danneggiato o lo spazio su disco e\' esaurito. Nessun dato e\' stato modificato.',
|
||||
$safePath,
|
||||
$copiati === false ? '0' : (string) $copiati,
|
||||
$size
|
||||
));
|
||||
}
|
||||
|
||||
// Il CRC lo controlla gia' libzip a fine lettura, ma solo se la
|
||||
// lettura arriva in fondo e come warning: ricalcolarlo sul file
|
||||
// scritto costa una rilettura e non dipende da come libzip
|
||||
// segnala l'errore.
|
||||
if (isset($stat['crc']) && hash_file('crc32b', $destination) !== sprintf('%08x', $stat['crc'] & 0xFFFFFFFF)) {
|
||||
throw new RuntimeException('Il file "' . $safePath . '" nell\'archivio e\' danneggiato (CRC non corrispondente). Nessun dato e\' stato modificato.');
|
||||
}
|
||||
|
||||
if ($prefissi[$prefisso] === self::STAGING_PIANTINE) {
|
||||
$estrattiPiantine++;
|
||||
} else {
|
||||
$extracted++;
|
||||
}
|
||||
$onProgress && $onProgress($extracted + $estrattiPiantine, $daEstrarre);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$zip->close();
|
||||
@@ -425,63 +530,180 @@ class BackupArchive
|
||||
|
||||
$zip->close();
|
||||
|
||||
return ['xml' => $xml, 'staging' => $staging, 'files' => $extracted];
|
||||
return [
|
||||
'xml' => $xml,
|
||||
'piantine' => $piantine,
|
||||
'formato' => $formatVersion,
|
||||
'staging' => $staging,
|
||||
'pubblico' => $staging . '/' . self::STAGING_PUBBLICO,
|
||||
'files' => $extracted,
|
||||
'file_piantine' => $estrattiPiantine,
|
||||
];
|
||||
}
|
||||
|
||||
/** Il prefisso fra quelli ammessi con cui comincia l'entry, o null. */
|
||||
private function prefissoDi(string $entryName, array $prefissi): ?string
|
||||
{
|
||||
foreach (array_keys($prefissi) as $prefisso) {
|
||||
if (str_starts_with($entryName, $prefisso)) {
|
||||
return $prefisso;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sostituisce il contenuto del disco `public` con quello estratto.
|
||||
* Mette i file estratti al posto di quelli in uso, tenendo da parte i
|
||||
* vecchi finche' il ripristino non e' confermato.
|
||||
*
|
||||
* La sostituzione e' totale, non un merge: i percorsi dei file vivono nelle
|
||||
* righe del database, che il ripristino ha appena riscritto per intero.
|
||||
* Tenere i file precedenti lascerebbe sul volume PDF non piu' referenziati
|
||||
* da nulla, che nessuna schermata mostra e nessuna cancellazione raggiunge.
|
||||
*
|
||||
* @param callable|null $onProgress fn(int $fatti, int $totale): void
|
||||
* Prima si svuotava il disco e poi si ricopiava dallo staging file per
|
||||
* file: un errore a meta' (disco pieno, permessi) lasciava il database
|
||||
* nuovo, una parte dei file nuovi e nessuno dei vecchi — irrecuperabile.
|
||||
* Adesso sono solo rename() sullo stesso volume, per voce di primo livello
|
||||
* (territori-pdf/, territori-mappe/, logo...): istantanei, senza bisogno
|
||||
* di spazio, e ognuno annotato in un registro che annullaScambio()
|
||||
* ripercorre a ritroso. Se uno fallisce, quelli gia' fatti si disfano qui
|
||||
* e si solleva: il chiamante annulla la transazione e l'installazione
|
||||
* resta com'era.
|
||||
*
|
||||
* Si spostano le voci e non la cartella radice: a quella del disco
|
||||
* `public` punta il symlink public/storage.
|
||||
*
|
||||
* @param array $estratto Il risultato di extract().
|
||||
* @return array{file: int, accantonati: string, spostamenti: list<array{0: string, 1: string}>}
|
||||
* Da passare a confermaScambio() a database confermato, o ad
|
||||
* annullaScambio() se qualcosa fallisce dopo.
|
||||
*/
|
||||
public function restoreFiles(string $staging, ?callable $onProgress = null): int
|
||||
public function scambiaFile(array $estratto): array
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
// Fuori da backup-restore/ di proposito: quella cartella la ripulisce
|
||||
// backup:cleanup dopo un giorno, e qui dentro, se l'annullamento
|
||||
// stesso fallisse, resterebbero gli unici file buoni.
|
||||
$scambio = [
|
||||
'file' => $estratto['files'] + $estratto['file_piantine'],
|
||||
'accantonati' => storage_path('app/backup-precedenti/' . Str::uuid()),
|
||||
'spostamenti' => [],
|
||||
];
|
||||
|
||||
$this->svuotaDiscoPubblico();
|
||||
try {
|
||||
foreach ($this->radiciDaSostituire($estratto['formato']) as $nome => $radice) {
|
||||
$this->preparaCartella($radice);
|
||||
$this->preparaCartella($scambio['accantonati'] . '/' . $nome);
|
||||
|
||||
if (! is_dir($staging)) {
|
||||
return 0;
|
||||
foreach ($this->vociDi($radice) as $voce) {
|
||||
$this->sposta($radice . '/' . $voce, $scambio['accantonati'] . '/' . $nome . '/' . $voce, $scambio);
|
||||
}
|
||||
|
||||
// Una sottocartella assente nello staging vuol dire "nessun
|
||||
// file": la radice resta vuota, come nell'installazione
|
||||
// salvata.
|
||||
$nuovi = $estratto['staging'] . '/' . $nome;
|
||||
foreach ($this->vociDi($nuovi) as $voce) {
|
||||
$this->sposta($nuovi . '/' . $voce, $radice . '/' . $voce, $scambio);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->annullaScambio($scambio, $e);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$restored = 0;
|
||||
$totale = iterator_count(new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
||||
));
|
||||
$onProgress && $onProgress(0, $totale);
|
||||
return $scambio;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($staging, \FilesystemIterator::SKIP_DOTS)
|
||||
);
|
||||
/** Il ripristino e' confermato: i file precedenti non servono piu'. */
|
||||
public function confermaScambio(array $scambio): void
|
||||
{
|
||||
$this->deleteDirectory($scambio['accantonati']);
|
||||
}
|
||||
|
||||
foreach ($iterator as $file) {
|
||||
/** @var \SplFileInfo $file */
|
||||
if (! $file->isFile()) {
|
||||
continue;
|
||||
/**
|
||||
* Rimette ogni file dov'era prima di scambiaFile(), ripercorrendo gli
|
||||
* spostamenti a ritroso: i nuovi tornano nello staging, i vecchi al loro
|
||||
* posto.
|
||||
*
|
||||
* Se anche questo fallisce non si cancella niente: i file precedenti
|
||||
* restano nella cartella degli accantonati, e il messaggio dice dove, cosi'
|
||||
* si possono rimettere a mano invece di darli per persi.
|
||||
*/
|
||||
public function annullaScambio(array $scambio, ?\Throwable $causa = null): void
|
||||
{
|
||||
try {
|
||||
foreach (array_reverse($scambio['spostamenti']) as [$da, $a]) {
|
||||
if (! @rename($a, $da)) {
|
||||
throw new RuntimeException('impossibile rimettere "' . $da . '"');
|
||||
}
|
||||
}
|
||||
|
||||
$relativePath = str_replace('\\', '/', substr($file->getPathname(), strlen($staging) + 1));
|
||||
|
||||
$handle = fopen($file->getPathname(), 'rb');
|
||||
if ($handle === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$disk->writeStream($relativePath, $handle);
|
||||
|
||||
if (is_resource($handle)) {
|
||||
fclose($handle);
|
||||
}
|
||||
|
||||
$restored++;
|
||||
$onProgress && $onProgress($restored, $totale);
|
||||
} catch (\Throwable $e) {
|
||||
throw new RuntimeException(
|
||||
($causa ? rtrim($causa->getMessage(), '. ') . '. ' : '')
|
||||
. 'Ripristino dei file precedenti non riuscito (' . $e->getMessage() . '): '
|
||||
. 'li trovi in ' . $scambio['accantonati'] . ' e vanno rimessi a mano.',
|
||||
0,
|
||||
$causa ?? $e
|
||||
);
|
||||
}
|
||||
|
||||
return $restored;
|
||||
$this->deleteDirectory($scambio['accantonati']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Le cartelle che il ripristino sostituisce: sottocartella dello staging
|
||||
* => cartella vera.
|
||||
*
|
||||
* L'area KML solo con un archivio di formato 2, che la porta sempre (anche
|
||||
* vuota, se l'installazione salvata non ne aveva). Un archivio di formato
|
||||
* 1 non sa niente dell'area, e svuotarla per questo vorrebbe dire
|
||||
* buttare un file che con i dati ripristinati non ha nessun legame: nessuna
|
||||
* riga lo cita, e' la mappa della citta'.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function radiciDaSostituire(int $formato): array
|
||||
{
|
||||
$radici = [self::STAGING_PUBBLICO => rtrim(Storage::disk('public')->path(''), '/\\')];
|
||||
|
||||
if ($formato >= 2) {
|
||||
$radici[self::STAGING_PIANTINE] = rtrim(Storage::disk('local')->path(self::CARTELLA_PIANTINE), '/\\');
|
||||
}
|
||||
|
||||
return $radici;
|
||||
}
|
||||
|
||||
/** @return list<string> Le voci di primo livello di una cartella (vuoto se non esiste). */
|
||||
private function vociDi(string $cartella): array
|
||||
{
|
||||
if (! is_dir($cartella)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_diff(scandir($cartella) ?: [], ['.', '..']));
|
||||
}
|
||||
|
||||
private function preparaCartella(string $cartella): void
|
||||
{
|
||||
if (! is_dir($cartella) && ! @mkdir($cartella, 0775, true) && ! is_dir($cartella)) {
|
||||
throw new RuntimeException('Impossibile preparare la cartella "' . $cartella . '" per il ripristino.');
|
||||
}
|
||||
}
|
||||
|
||||
private function sposta(string $da, string $a, array &$scambio): void
|
||||
{
|
||||
if (! @rename($da, $a)) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Impossibile spostare "%s" durante il ripristino. Di solito e\' un problema di permessi: '
|
||||
. 'controlla che il volume storage appartenga a www-data. Nessun dato e\' stato modificato.',
|
||||
basename($da)
|
||||
));
|
||||
}
|
||||
|
||||
$scambio['spostamenti'][] = [$da, $a];
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Contracts\Cache\LockTimeoutException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,18 @@ class BackupOperationState
|
||||
public const TIPO_RESTORE = 'restore';
|
||||
public const TIPO_RESET = 'reset';
|
||||
|
||||
/**
|
||||
* Gli import non sono operazioni di backup, ma occupano lo stesso posto:
|
||||
* l'import XML svuota e riscrive le tabelle, l'import PDF sostituisce file
|
||||
* e righe dei territori. Uno dei due in parallelo a un ripristino (o a un
|
||||
* backup che sta copiando quegli stessi file) produce uno stato che nessuno
|
||||
* ha chiesto. Il loro avanzamento non sta qui: l'import PDF ha
|
||||
* TerritorioPdfImportState, l'import XML e' sincrono. Qui c'e' solo
|
||||
* l'occupazione del posto.
|
||||
*/
|
||||
public const TIPO_IMPORT_PDF = 'import-pdf';
|
||||
public const TIPO_IMPORT_XML = 'import-xml';
|
||||
|
||||
/** L'archivio e' pronto ma l'azzeramento aspetta che venga scaricato. */
|
||||
public const FASE_ATTESA_DOWNLOAD = 'attesa-download';
|
||||
|
||||
@@ -48,6 +61,16 @@ class BackupOperationState
|
||||
private const CHIAVE_CORRENTE = 'backup-op:corrente';
|
||||
private const TTL_CORRENTE = 3600;
|
||||
|
||||
/**
|
||||
* Mutex attorno a "il posto e' libero? allora lo prendo".
|
||||
*
|
||||
* corrente() seguito da initialize() sono due passi: due richieste che
|
||||
* arrivano insieme vedono entrambe il posto libero, ed entrambe lo prendono
|
||||
* — la seconda sovrascrive il puntatore della prima, e i due lavori girano
|
||||
* in parallelo proprio come il posto unico doveva impedire.
|
||||
*/
|
||||
private const CHIAVE_AVVIO = 'backup-op:avvio';
|
||||
|
||||
public function initialize(string $id, string $tipo, string $fase, int $total = 0): array
|
||||
{
|
||||
$state = [
|
||||
@@ -99,6 +122,66 @@ class BackupOperationState
|
||||
return $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prende il posto unico se e' libero, in un passo solo.
|
||||
*
|
||||
* Restituisce null se il posto e' stato preso (e l'operazione inizializzata
|
||||
* come farebbe initialize()), altrimenti l'id dell'operazione che lo occupa.
|
||||
* Tutte le porte d'ingresso passano da qui proprio per il mutex: un
|
||||
* controllo con corrente() e un initialize() separati lasciano aperta la
|
||||
* finestra fra i due.
|
||||
*
|
||||
* $ttlPosto serve all'import XML, che e' sincrono: se il processo PHP muore a
|
||||
* meta' (timeout, errore fatale) nessun failed() chiude l'operazione, e il
|
||||
* posto resterebbe occupato per l'ora intera del TTL ordinario.
|
||||
*/
|
||||
public function occupa(string $id, string $tipo, string $fase, ?int $ttlPosto = null): ?string
|
||||
{
|
||||
try {
|
||||
return Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id, $tipo, $fase, $ttlPosto) {
|
||||
$inCorso = $this->corrente();
|
||||
|
||||
if ($inCorso !== null) {
|
||||
return $inCorso;
|
||||
}
|
||||
|
||||
$this->initialize($id, $tipo, $fase);
|
||||
|
||||
if ($ttlPosto !== null) {
|
||||
Cache::put(self::CHIAVE_CORRENTE, $id, $ttlPosto);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
} catch (LockTimeoutException) {
|
||||
// Qualcun altro sta prendendo il posto proprio adesso: il mutex
|
||||
// copre pochi millisecondi, quindi cinque secondi d'attesa vogliono
|
||||
// dire che il posto non e' disponibile, non che va forzato.
|
||||
return $this->corrente() ?? 'avvio-in-corso';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Il rifiuto spiegato: chi si vede negare un import deve sapere che cosa lo
|
||||
* blocca, perche' l'operazione in corso puo' essere stata avviata da un
|
||||
* altro amministratore in un'altra zona della pagina.
|
||||
*/
|
||||
public function messaggioOccupato(string $idInCorso): string
|
||||
{
|
||||
$tipo = $this->get($idInCorso)['tipo'] ?? null;
|
||||
|
||||
$cosa = match ($tipo) {
|
||||
self::TIPO_BACKUP => ' (un backup)',
|
||||
self::TIPO_RESTORE => ' (un ripristino)',
|
||||
self::TIPO_RESET => ' (un azzeramento)',
|
||||
self::TIPO_IMPORT_PDF => ' (un import dei PDF dei territori)',
|
||||
self::TIPO_IMPORT_XML => ' (un import XML)',
|
||||
default => '',
|
||||
};
|
||||
|
||||
return 'C\'è già un\'operazione in corso' . $cosa . ': attendi che finisca prima di avviarne un\'altra.';
|
||||
}
|
||||
|
||||
public function get(string $id): ?array
|
||||
{
|
||||
return Cache::get($this->key($id));
|
||||
|
||||
@@ -257,8 +257,11 @@ class PiantinaPdfService
|
||||
|
||||
/**
|
||||
* Sposta il PDF attuale del territorio (se esiste) nello storico.
|
||||
*
|
||||
* Pubblica perché la usa anche l'import dei PDF da cartella: chi
|
||||
* sostituisce un PDF lo archivia sempre allo stesso modo.
|
||||
*/
|
||||
private function archiviaCorrente(Territorio $territorio, string $origine): void
|
||||
public function archiviaCorrente(Territorio $territorio, string $origine): void
|
||||
{
|
||||
$disk = Storage::disk('public');
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ use RuntimeException;
|
||||
* attiva, caricare un KML nuovo la sostituisce e i poligoni già disegnati
|
||||
* restano al loro posto.
|
||||
*
|
||||
* ATTENZIONE: NON entra nel backup completo. BackupArchive impacchetta il solo
|
||||
* disco `public` (storage/app/public), e questa cartella gli sta accanto, non
|
||||
* dentro. Dopo un ripristino su volumi vuoti l'area va ricaricata a mano dal
|
||||
* KML originale — che quindi conviene conservare fuori di qui.
|
||||
* Questa cartella sta accanto al disco `public`, non dentro: BackupArchive la
|
||||
* impacchetta a parte (entry piantine/, dal formato 2 dell'archivio) e il
|
||||
* ripristino la sostituisce insieme al disco. Un archivio di formato 1 non la
|
||||
* contiene, e ripristinandolo l'area in uso resta com'e'.
|
||||
*/
|
||||
class PiantineArea
|
||||
{
|
||||
|
||||
183
app/Services/Piantine/PiantineBackup.php
Normal file
183
app/Services/Piantine/PiantineBackup.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Piantine;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Le tabelle della sotto-app delle piantine dentro il backup completo:
|
||||
* i disegni (`territorio_mappe`) e lo storico dei PDF sostituiti
|
||||
* (`territorio_pdf_versioni`).
|
||||
*
|
||||
* Non passano dall'XML del gestionale di proposito: l'export XML e' il formato
|
||||
* di scambio dei dati gestionali, e la sotto-app deve restare rimovibile senza
|
||||
* toccarlo. Viaggiano in `piantine.json` accanto a `data.xml`; i file che
|
||||
* citano (immagini in territori-mappe/, PDF in territori-pdf-versioni/) stanno
|
||||
* sul disco `public` e sono gia' nel pacchetto con tutti gli altri.
|
||||
*
|
||||
* Il collegamento al territorio si salva come *numero*, non solo come id:
|
||||
* l'import XML ricrea i territori con id nuovi (autoincremento), quindi l'id
|
||||
* del backup dopo il ripristino punterebbe al territorio sbagliato o a nessuno.
|
||||
* Il numero e' quello che l'import usa per riconoscere i territori, ed e'
|
||||
* unico.
|
||||
*/
|
||||
class PiantineBackup
|
||||
{
|
||||
public const TABELLA_MAPPE = 'territorio_mappe';
|
||||
|
||||
public const TABELLA_VERSIONI = 'territorio_pdf_versioni';
|
||||
|
||||
/**
|
||||
* Le righe delle due tabelle cosi' come sono nel database, piu' il numero
|
||||
* del territorio collegato.
|
||||
*
|
||||
* Query builder e non Eloquent: si vuole la riga grezza, timestamp
|
||||
* compresi, da reinserire identica — non i valori passati per i cast.
|
||||
*
|
||||
* @return array<string, list<array<string, mixed>>>
|
||||
*/
|
||||
public function esporta(): array
|
||||
{
|
||||
return [
|
||||
self::TABELLA_MAPPE => $this->righe(self::TABELLA_MAPPE),
|
||||
self::TABELLA_VERSIONI => $this->righe(self::TABELLA_VERSIONI),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlla la struttura di piantine.json prima che il ripristino tocchi
|
||||
* qualunque cosa. Null e' ammesso: e' l'archivio di formato 1, che le
|
||||
* piantine non le conteneva.
|
||||
*/
|
||||
public function valida(?array $dati): void
|
||||
{
|
||||
if ($dati === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ([self::TABELLA_MAPPE, self::TABELLA_VERSIONI] as $tabella) {
|
||||
if (! isset($dati[$tabella]) || ! is_array($dati[$tabella]) || ! array_is_list($dati[$tabella])) {
|
||||
throw new RuntimeException('Archivio non valido: in piantine.json manca l\'elenco "' . $tabella . '".');
|
||||
}
|
||||
|
||||
foreach ($dati[$tabella] as $posizione => $riga) {
|
||||
if (! is_array($riga) || ! isset($riga['id']) || ! is_numeric($riga['id'])) {
|
||||
throw new RuntimeException(sprintf(
|
||||
'Archivio non valido: la riga %d di "%s" in piantine.json non e\' leggibile.',
|
||||
$posizione + 1,
|
||||
$tabella
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sostituisce il contenuto delle due tabelle con quello del backup.
|
||||
*
|
||||
* Va chiamato dentro la transazione del ripristino e *dopo* l'import XML:
|
||||
* servono i territori gia' ricreati per ritrovare i collegamenti. DELETE e
|
||||
* non TRUNCATE: su MariaDB TRUNCATE e' DDL e fa commit implicito, cioe'
|
||||
* chiuderebbe a meta' la transazione che deve poter annullare tutto.
|
||||
*
|
||||
* Con $dati null (archivio di formato 1) le tabelle si svuotano soltanto.
|
||||
* Tenere le righe dell'installazione corrente vorrebbe dire piantine che
|
||||
* citano immagini del disco appena sostituito — file che non ci sono piu'
|
||||
* — accanto ai dati di un'altra epoca: proprio lo stato misto che il
|
||||
* ripristino deve evitare. Lo storico, del resto, lo svuotava gia' la
|
||||
* cascata sulla cancellazione dei territori.
|
||||
*
|
||||
* @return array{mappe: int, versioni: int, issues: list<array{entity: string, legacy_id: string, reason: string}>}
|
||||
*/
|
||||
public function ripristina(?array $dati): array
|
||||
{
|
||||
DB::table(self::TABELLA_VERSIONI)->delete();
|
||||
DB::table(self::TABELLA_MAPPE)->delete();
|
||||
|
||||
$esito = ['mappe' => 0, 'versioni' => 0, 'issues' => []];
|
||||
|
||||
if ($dati === null) {
|
||||
return $esito;
|
||||
}
|
||||
|
||||
// withTrashed implicito: il query builder non conosce i soft delete, e
|
||||
// una piantina collegata a un territorio nel cestino deve restarlo.
|
||||
$territori = DB::table('territori')->pluck('id', 'numero')
|
||||
->mapWithKeys(fn ($id, $numero) => [trim((string) $numero) => (int) $id])
|
||||
->all();
|
||||
|
||||
$colonneMappe = array_flip(Schema::getColumnListing(self::TABELLA_MAPPE));
|
||||
$collegati = [];
|
||||
|
||||
foreach ($dati[self::TABELLA_MAPPE] as $riga) {
|
||||
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
|
||||
$territorioId = null;
|
||||
|
||||
if (($riga['territorio_id'] ?? null) !== null) {
|
||||
$territorioId = $territori[$numero] ?? null;
|
||||
|
||||
// territorio_id e' unico: due piantine sullo stesso numero
|
||||
// (possibile solo se il backup aveva territori con numero
|
||||
// ripetuto, che l'import scarta) non possono restare collegate
|
||||
// entrambe.
|
||||
if ($territorioId !== null && isset($collegati[$territorioId])) {
|
||||
$territorioId = null;
|
||||
}
|
||||
|
||||
if ($territorioId === null) {
|
||||
// Il disegno non si butta: resta scollegato, con il numero
|
||||
// come etichetta per riconoscerlo.
|
||||
if (trim((string) ($riga['nome'] ?? '')) === '' && $numero !== '') {
|
||||
$riga['nome'] = 'Terr. ' . $numero;
|
||||
}
|
||||
$esito['issues'][] = $this->problema('piantina', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: piantina ripristinata scollegata');
|
||||
} else {
|
||||
$collegati[$territorioId] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$riga['territorio_id'] = $territorioId;
|
||||
DB::table(self::TABELLA_MAPPE)->insert(array_intersect_key($riga, $colonneMappe));
|
||||
$esito['mappe']++;
|
||||
}
|
||||
|
||||
$colonneVersioni = array_flip(Schema::getColumnListing(self::TABELLA_VERSIONI));
|
||||
|
||||
foreach ($dati[self::TABELLA_VERSIONI] as $riga) {
|
||||
$numero = trim((string) ($riga['territorio_numero'] ?? ''));
|
||||
$territorioId = $territori[$numero] ?? null;
|
||||
|
||||
// Una versione senza territorio non e' ripristinabile da nessuna
|
||||
// schermata (e la colonna non ammette NULL): si scarta, e lo si dice.
|
||||
if ($territorioId === null) {
|
||||
$esito['issues'][] = $this->problema('versione PDF', $riga, 'Territorio ' . ($numero !== '' ? $numero : 'sconosciuto') . ' non trovato: versione non ripristinata');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$riga['territorio_id'] = $territorioId;
|
||||
DB::table(self::TABELLA_VERSIONI)->insert(array_intersect_key($riga, $colonneVersioni));
|
||||
$esito['versioni']++;
|
||||
}
|
||||
|
||||
return $esito;
|
||||
}
|
||||
|
||||
private function righe(string $tabella): array
|
||||
{
|
||||
return DB::table($tabella)
|
||||
->leftJoin('territori', 'territori.id', '=', $tabella . '.territorio_id')
|
||||
->orderBy($tabella . '.id')
|
||||
->get([$tabella . '.*', 'territori.numero as territorio_numero'])
|
||||
->map(fn (object $riga) => (array) $riga)
|
||||
->all();
|
||||
}
|
||||
|
||||
/** Stessa forma delle segnalazioni dell'import XML, per mostrarle insieme. */
|
||||
private function problema(string $entita, array $riga, string $motivo): array
|
||||
{
|
||||
return ['entity' => $entita, 'legacy_id' => (string) ($riga['id'] ?? ''), 'reason' => $motivo];
|
||||
}
|
||||
}
|
||||
@@ -19,15 +19,106 @@ class TerritorioPdfImportDispatcher
|
||||
|
||||
public function __construct(
|
||||
protected TerritorioPdfImportState $stateService,
|
||||
protected BackupOperationState $postoUnico,
|
||||
) {
|
||||
}
|
||||
|
||||
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog): array
|
||||
/**
|
||||
* Prende il posto unico per questo import, o solleva spiegando chi lo occupa.
|
||||
*
|
||||
* L'import PDF sostituisce file e righe dei territori: in parallelo a un
|
||||
* ripristino scriverebbe su tabelle che l'altro sta riscrivendo, e durante
|
||||
* un backup l'archivio fotograferebbe meta' PDF vecchi e meta' nuovi (o
|
||||
* fallirebbe su un file cancellato fra l'elenco e la compressione). Il posto
|
||||
* si libera nel job, a fine lavoro o in failed().
|
||||
*/
|
||||
public function occupaPosto(string $importId): void
|
||||
{
|
||||
$inCorso = $this->postoUnico->occupa($importId, BackupOperationState::TIPO_IMPORT_PDF, 'In coda');
|
||||
|
||||
if ($inCorso !== null) {
|
||||
throw new RuntimeException($this->postoUnico->messaggioOccupato($inCorso));
|
||||
}
|
||||
}
|
||||
|
||||
/** Libera il posto preso da un import che non e' arrivato alla coda. */
|
||||
public function liberaPosto(string $importId, string $motivo): void
|
||||
{
|
||||
$this->postoUnico->markFailed($importId, $motivo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vero per i file che non vanno nemmeno considerati: quelli che non sono
|
||||
* PDF e quelli di sistema o nascosti.
|
||||
*
|
||||
* Una cartella vera non contiene solo PDF: Windows ci lascia Thumbs.db e
|
||||
* desktop.ini, macOS .DS_Store e — dentro gli ZIP — una copia "._nome.pdf"
|
||||
* di ogni file sotto __MACOSX/. Prima uno solo di questi faceva fallire la
|
||||
* validazione dell'intero caricamento; peggio, "._1.pdf" ha l'estensione
|
||||
* giusta e sarebbe finito come mappa del territorio 1.
|
||||
*/
|
||||
public static function daIgnorare(string $percorso): bool
|
||||
{
|
||||
$segmenti = array_values(array_filter(preg_split('#[/\\\\]+#', $percorso) ?: [], fn ($s) => $s !== ''));
|
||||
|
||||
if ($segmenti === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($segmenti as $segmento) {
|
||||
if (str_starts_with($segmento, '.') || strcasecmp($segmento, '__MACOSX') === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// "~$" e' il prefisso dei file temporanei di Office.
|
||||
$nome = end($segmenti);
|
||||
|
||||
return str_starts_with($nome, '~$')
|
||||
|| strtolower(pathinfo($nome, PATHINFO_EXTENSION)) !== 'pdf';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mette in coda l'import. Il posto unico deve essere gia' stato preso con
|
||||
* occupaPosto(): se l'invio alla coda fallisce lo si libera qui.
|
||||
*
|
||||
* @param array<int, string> $ignorati nomi dei file scartati da daIgnorare(), solo per il log
|
||||
* @param array<int, array{file: string, message: string}> $scartati PDF rifiutati dalla validazione
|
||||
*/
|
||||
public function dispatchStoredFiles(string $importId, array $storedFiles, ?int $actorId, string $initialLog, array $ignorati = [], array $scartati = []): array
|
||||
{
|
||||
$state = $this->stateService->initialize($importId, count($storedFiles));
|
||||
$this->stateService->appendLog($importId, $initialLog);
|
||||
|
||||
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
|
||||
if ($ignorati !== []) {
|
||||
$elenco = array_slice($ignorati, 0, 10);
|
||||
$altri = count($ignorati) - count($elenco);
|
||||
$this->stateService->appendLog(
|
||||
$importId,
|
||||
'Ignorati ' . count($ignorati) . ' file che non sono PDF da importare (file di sistema, nascosti o di altro tipo): '
|
||||
. implode(', ', $elenco) . ($altri > 0 ? ' e altri ' . $altri : '') . '.'
|
||||
);
|
||||
}
|
||||
|
||||
foreach ($scartati as $scartato) {
|
||||
$this->stateService->appendLog($importId, '[SKIP] ' . $scartato['file'] . ' - ' . $scartato['message']);
|
||||
$this->stateService->addIssue($importId, [
|
||||
'file' => $scartato['file'],
|
||||
'type' => 'invalid-file',
|
||||
'message' => $scartato['message'],
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
ImportTerritoryPdfFolder::dispatch($importId, $storedFiles, $actorId);
|
||||
} catch (\Throwable $exception) {
|
||||
// Il job non partira' mai, quindi non sara' lui a liberare il posto.
|
||||
$this->stateService->markFailed($importId);
|
||||
$this->liberaPosto($importId, 'Import PDF non messo in coda: ' . $exception->getMessage());
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
return $this->stateService->get($importId) ?? $state;
|
||||
}
|
||||
@@ -35,6 +126,27 @@ class TerritorioPdfImportDispatcher
|
||||
public function dispatchUploadedZip(UploadedFile $zipFile, ?int $actorId): string
|
||||
{
|
||||
$importId = (string) Str::uuid();
|
||||
|
||||
// Il posto si prende prima di salvare ed estrarre: e' la parte lenta, e
|
||||
// farla per poi sentirsi rispondere "c'e' un ripristino in corso" sarebbe
|
||||
// lavoro buttato.
|
||||
$this->occupaPosto($importId);
|
||||
|
||||
try {
|
||||
return $this->estraiEAccoda($zipFile, $importId, $actorId);
|
||||
} catch (\Throwable $exception) {
|
||||
// Se il job e' partito il posto e' suo e lo libera lui; altrimenti
|
||||
// resterebbe occupato da un import che non esiste.
|
||||
if ($this->stateService->get($importId) === null) {
|
||||
$this->liberaPosto($importId, 'Import PDF non avviato: ' . $exception->getMessage());
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
private function estraiEAccoda(UploadedFile $zipFile, string $importId, ?int $actorId): string
|
||||
{
|
||||
$zipStoredPath = $zipFile->storeAs(
|
||||
'bulk-territori-imports/' . $importId,
|
||||
'archivio-' . $importId . '.zip',
|
||||
@@ -45,10 +157,13 @@ class TerritorioPdfImportDispatcher
|
||||
$zip = new ZipArchive();
|
||||
|
||||
if ($zip->open($zipAbsolutePath) !== true) {
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||
|
||||
throw new RuntimeException('Impossibile aprire il file ZIP.');
|
||||
}
|
||||
|
||||
$storedFiles = [];
|
||||
$ignorati = [];
|
||||
$entryIndex = 0;
|
||||
|
||||
try {
|
||||
@@ -59,7 +174,8 @@ class TerritorioPdfImportDispatcher
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strtolower(pathinfo($entryName, PATHINFO_EXTENSION)) !== 'pdf') {
|
||||
if (self::daIgnorare($entryName)) {
|
||||
$ignorati[] = basename($entryName);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -111,6 +227,8 @@ class TerritorioPdfImportDispatcher
|
||||
}
|
||||
|
||||
if ($storedFiles === []) {
|
||||
Storage::disk('local')->deleteDirectory('bulk-territori-imports/' . $importId);
|
||||
|
||||
throw new RuntimeException('Lo ZIP non contiene file PDF validi.');
|
||||
}
|
||||
|
||||
@@ -118,7 +236,8 @@ class TerritorioPdfImportDispatcher
|
||||
$importId,
|
||||
$storedFiles,
|
||||
$actorId,
|
||||
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.'
|
||||
'Archivio ZIP ricevuto: ' . count($storedFiles) . ' PDF estratti e messi in coda per l\'elaborazione.',
|
||||
$ignorati
|
||||
);
|
||||
|
||||
return $importId;
|
||||
|
||||
@@ -12,6 +12,13 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware) {
|
||||
// '*' is deliberate. The container is only reachable through Traefik
|
||||
// (no published port in Dokploy), and nginx's real_ip module has
|
||||
// already rewritten REMOTE_ADDR to the client's address. Restricting
|
||||
// this to private ranges would therefore stop Laravel from honouring
|
||||
// the X-Forwarded-Proto that nginx sets server-side, and every signed
|
||||
// URL and secure cookie would break behind HTTPS.
|
||||
//
|
||||
// X_FORWARDED_HOST is deliberately excluded. Trusting it lets a client
|
||||
// set an arbitrary Host and have route()/url() emit links pointing at
|
||||
// an attacker's domain — including the public assignment PDF links,
|
||||
@@ -39,7 +46,16 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
// Dopo il ripristino di un backup ogni account resta bloccato finche'
|
||||
// non imposta una password nuova. In coda al gruppo web, quindi la
|
||||
// sessione e' gia' avviata e l'utente risolto quando gira.
|
||||
//
|
||||
// AuthenticateSession prima: ogni sessione ricorda l'impronta della
|
||||
// password, e quando la password cambia (profilo, cambio obbligatorio,
|
||||
// l'amministratore che la reimposta) le sessioni aperte altrove si
|
||||
// chiudono alla richiesta successiva. Quella che ha fatto il cambio
|
||||
// resta aperta: il middleware ci registra l'impronta nuova. Senza,
|
||||
// session()->regenerate() cambiava solo l'id della sessione corrente e
|
||||
// le altre restavano valide su Redis.
|
||||
$middleware->web(append: [
|
||||
\Illuminate\Session\Middleware\AuthenticateSession::class,
|
||||
\App\Http\Middleware\RequirePasswordChange::class,
|
||||
]);
|
||||
|
||||
|
||||
26
config/queue.php
Normal file
26
config/queue.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* Solo la connessione redis: il resto resta quello del framework (Laravel 11
|
||||
* unisce queue.connections con i propri valori predefiniti).
|
||||
*/
|
||||
return [
|
||||
|
||||
'connections' => [
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
|
||||
'queue' => env('REDIS_QUEUE', 'default'),
|
||||
// Più lungo del job più lungo (import PDF: 900 s). Con i 90 s del
|
||||
// framework un backup, un ripristino o un import ancora in corso
|
||||
// tornava disponibile in coda e poteva partire una seconda volta
|
||||
// sugli stessi dati — per esempio dopo un riavvio del worker.
|
||||
'retry_after' => (int) env('REDIS_QUEUE_RETRY_AFTER', 960),
|
||||
'block_for' => null,
|
||||
'after_commit' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Toglie dal registro attività i nomi dei proclamatori scritti in chiaro.
|
||||
*
|
||||
* Assegnazioni e rientri registravano "proclamatore" => nome e cognome:
|
||||
* dati che nel database sono cifrati, e che lì restavano leggibili anche
|
||||
* dopo l'anonimizzazione. Al loro posto va l'id del proclamatore, preso
|
||||
* dall'assegnazione a cui la voce si riferisce; il registro mostra il nome
|
||||
* attuale. Se l'assegnazione non c'è più il nome si toglie e basta.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
$tabella = config('activitylog.table_name', 'activity_log');
|
||||
|
||||
DB::table($tabella)
|
||||
->whereIn('description', ['assigned', 'returned'])
|
||||
->where('properties', 'like', '%"proclamatore"%')
|
||||
->orderBy('id')
|
||||
->each(function ($voce) use ($tabella) {
|
||||
$proprieta = json_decode((string) $voce->properties, true);
|
||||
|
||||
if (! is_array($proprieta) || ! array_key_exists('proclamatore', $proprieta)) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($proprieta['proclamatore']);
|
||||
|
||||
$idProclamatore = $voce->subject_id
|
||||
? DB::table('assegnazioni')->where('id', $voce->subject_id)->value('proclamatore_id')
|
||||
: null;
|
||||
if ($idProclamatore) {
|
||||
$proprieta['proclamatore_id'] = (int) $idProclamatore;
|
||||
}
|
||||
|
||||
DB::table($tabella)->where('id', $voce->id)->update([
|
||||
'properties' => json_encode($proprieta, JSON_UNESCAPED_UNICODE),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// I nomi tolti non si rimettono: era proprio il punto.
|
||||
}
|
||||
};
|
||||
@@ -71,6 +71,11 @@ services:
|
||||
- --tries=3
|
||||
- --timeout=600
|
||||
- --max-time=3600
|
||||
# Al riavvio (ogni deploy) il worker riceve SIGTERM e finisce il job in
|
||||
# corso prima di uscire; Docker pero' di default aspetta 10 secondi e poi
|
||||
# lo uccide. Un ripristino o un import interrotto cosi' lascia i dati a
|
||||
# meta'. 16 minuti coprono il job piu' lungo (900 s).
|
||||
stop_grace_period: 16m
|
||||
# Sostituisce l'healthcheck dell'immagine, che interroga nginx: qui non c'e'.
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pgrep -f 'queue:work' > /dev/null || exit 1"]
|
||||
@@ -166,9 +171,11 @@ services:
|
||||
- ${REDIS_PASSWORD:?manca REDIS_PASSWORD nel .env}
|
||||
- --maxmemory
|
||||
- 256mb
|
||||
# Qui vivono sessioni e cache: meglio sfrattare chiavi che esaurire memoria.
|
||||
# Qui vivono sessioni, cache e la coda. volatile-lru sfratta solo le
|
||||
# chiavi con scadenza (sessioni, cache): con allkeys-lru, a memoria
|
||||
# piena, potevano sparire anche i job in coda, senza errori.
|
||||
- --maxmemory-policy
|
||||
- allkeys-lru
|
||||
- volatile-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
|
||||
@@ -38,8 +38,16 @@ server {
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Uploaded territory PDFs and thumbnails (public/storage -> storage/app/public).
|
||||
# Served directly by nginx rather than round-tripping through PHP.
|
||||
# Territory files (PDFs, archived versions, thumbnails, map images) are NOT
|
||||
# public: anyone could download /storage/territori-pdf/territorio-12-34.pdf
|
||||
# without logging in, the names are guessable, and the public cache header
|
||||
# below let Cloudflare keep them for a week. Logged-in users get them from
|
||||
# TerritorioFileController, publishers from the expiring assignment link.
|
||||
location ^~ /storage/territori- {
|
||||
return 404;
|
||||
}
|
||||
|
||||
# What is left on the public disk (the congregation logo).
|
||||
location /storage/ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public";
|
||||
|
||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#4a45a8"/><text x="16" y="21.5" font-family="system-ui,-apple-system,Segoe UI,Roboto,sans-serif" font-size="14" font-weight="700" fill="#fff" text-anchor="middle">T2</text></svg>
|
||||
|
After Width: | Height: | Size: 281 B |
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Link scaduto — TerManager2</title>
|
||||
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5">
|
||||
<title>Territorio {{ $assignment->territorio?->numero }}</title>
|
||||
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf_viewer.min.css" integrity="sha512-kMgaLfnBSAM0MFgr8fMDCMr2SYGQiMIFRbkBxRfFEqDqw/0hNh2GpcjYKjR0z4VoVVhYx1VlJdvfO1HCkhpg==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
@@ -48,9 +48,19 @@
|
||||
sanno a quale account associare la credenziale che salvano. --}}
|
||||
<input type="hidden" name="email" autocomplete="username" value="{{ $user->email }}">
|
||||
|
||||
@unless($forced)
|
||||
{{-- Dopo un ripristino non si chiede: la password attuale e'
|
||||
quella del backup, che si puo' non ricordare. --}}
|
||||
<div>
|
||||
<label for="current_password" class="block text-sm font-medium text-gray-700">Password attuale</label>
|
||||
<input type="password" name="current_password" id="current_password" required autofocus autocomplete="current-password"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
</div>
|
||||
@endunless
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-medium text-gray-700">Nuova password</label>
|
||||
<input type="password" name="password" id="password" required autofocus autocomplete="new-password"
|
||||
<input type="password" name="password" id="password" required @if($forced) autofocus @endif autocomplete="new-password"
|
||||
class="mt-1 block w-full rounded-lg border-gray-300 shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm">
|
||||
<p class="mt-1 text-xs text-gray-500">Almeno 8 caratteri.</p>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'TerManager2' }}</title>
|
||||
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
@@ -17,13 +18,16 @@
|
||||
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex h-16 items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<x-btn tono="velato" icona class="lg:hidden"
|
||||
{{-- Nascosto dal contenitore: vedi il profilo qui sotto. --}}
|
||||
<div class="lg:hidden">
|
||||
<x-btn tono="velato" icona
|
||||
x-data x-on:click="$dispatch('toggle-sidebar')"
|
||||
aria-label="Apri il menu di navigazione">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:22px;height:22px">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/>
|
||||
</svg>
|
||||
</x-btn>
|
||||
</div>
|
||||
@php $settings = \App\Models\Setting::first(); @endphp
|
||||
@if($settings && $settings->logo_path)
|
||||
<img src="{{ asset('storage/' . $settings->logo_path) }}" alt="Logo" class="h-9 w-9 rounded-lg shadow-sm" style="border:2px solid rgba(255,255,255,0.3)">
|
||||
@@ -35,14 +39,21 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
{{-- Il contenitore si nasconde, non il pulsante: .tm-btn sta
|
||||
fuori dai layer di Tailwind e il suo display vince
|
||||
su "hidden", così su telefono nome ed email
|
||||
spingevano "Esci" fuori dallo schermo. Il profilo
|
||||
resta raggiungibile dal menu. --}}
|
||||
<div class="hidden sm:block">
|
||||
<x-btn tono="velato" dimensione="sm" :href="route('profile')" title="Il mio profilo"
|
||||
class="hidden sm:flex {{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}">
|
||||
class="{{ request()->routeIs('profile') ? 'ring-1 ring-white/40' : '' }}">
|
||||
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" style="width:20px;height:20px;opacity:.75"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
|
||||
<span class="flex flex-col leading-tight text-left">
|
||||
<span class="text-sm font-medium">{{ auth()->user()->name }}</span>
|
||||
<span class="text-xs" style="opacity:.7">{{ auth()->user()->email }} · {{ ucfirst(auth()->user()->roles->first()?->name ?? 'utente') }}</span>
|
||||
</span>
|
||||
</x-btn>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('logout') }}">
|
||||
@csrf
|
||||
<x-btn tono="velato" dimensione="sm" type="submit">
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'TerManager2' }}</title>
|
||||
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{{ $title ?? 'TerManager2' }}</title>
|
||||
<link rel="icon" href="{{ asset('favicon.svg') }}" type="image/svg+xml">
|
||||
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||
@livewireStyles
|
||||
</head>
|
||||
|
||||
@@ -83,7 +83,11 @@
|
||||
</div>
|
||||
@else
|
||||
@foreach($activity->properties->toArray() as $k => $v)
|
||||
<div>{{ $k }}: {{ is_string($v) ? $v : json_encode($v) }}</div>
|
||||
@if($k === 'proclamatore_id')
|
||||
<div>proclamatore: {{ $nomiProclamatori[$v] ?? '#' . $v }}</div>
|
||||
@else
|
||||
<div>{{ $k }}: {{ is_string($v) ? $v : json_encode($v) }}</div>
|
||||
@endif
|
||||
@endforeach
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
|
||||
<div class="tm-azioni-riga">
|
||||
@if($mappa->collegata() && $mappa->territorio->pdf_path)
|
||||
<a href="{{ asset('storage/' . $mappa->territorio->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
|
||||
<a href="{{ $mappa->territorio->pdfUrl() }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
|
||||
@endif
|
||||
@if($mappa->bozzaScartabile())
|
||||
<x-btn variante="ghost" dimensione="sm" wire:click="scartaModifiche"
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
@endif
|
||||
|
||||
@if($piantina->collegata() && $piantina->territorio->pdf_path)
|
||||
<a href="{{ asset('storage/' . $piantina->territorio->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
|
||||
<a href="{{ $piantina->territorio->pdfUrl() }}" target="_blank" rel="noopener" class="tm-collegamento">PDF in uso</a>
|
||||
@endif
|
||||
|
||||
<x-btn variante="danger" tono="tenue" dimensione="sm"
|
||||
@@ -167,7 +167,8 @@
|
||||
<div class="tm-card-testata">
|
||||
<h2 class="tm-card-titolo">Storico versioni PDF <span class="tm-conteggio">{{ $versioni->total() }}</span></h2>
|
||||
<p class="tm-card-nota">
|
||||
I PDF dei territori sostituiti da una pubblicazione o da un ripristino. Ripristinare una versione
|
||||
I PDF dei territori sostituiti: da una pubblicazione, da un caricamento (dalla scheda o
|
||||
dall'import da cartella) o da un ripristino. Ripristinare una versione
|
||||
rimette in uso quel PDF: la piantina del territorio torna "da pubblicare".
|
||||
</p>
|
||||
</div>
|
||||
@@ -195,7 +196,7 @@
|
||||
<td data-label="Sostituito il">{{ $versione->sostituito_il?->format('d/m/Y H:i') }}</td>
|
||||
<td data-label="Origine">{{ $versione->origine }}</td>
|
||||
<td class="cell-actions">
|
||||
<a href="{{ asset('storage/' . $versione->pdf_path) }}" target="_blank" rel="noopener" class="tm-collegamento">Apri</a>
|
||||
<a href="{{ route('territori.file.versione', $versione) }}" target="_blank" rel="noopener" class="tm-collegamento">Apri</a>
|
||||
<x-btn variante="secondary" tono="tenue" dimensione="sm"
|
||||
wire:click="ripristinaVersione({{ $versione->id }})"
|
||||
wire:confirm="Ripristinare questa versione per il territorio {{ $versione->territorio?->numero }}? Il PDF attuale finisce a sua volta nello storico.">
|
||||
|
||||
@@ -58,12 +58,6 @@
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-1">Cambia password</h3>
|
||||
<p class="text-sm text-gray-500 mb-4">Per motivi di sicurezza conferma la password attuale.</p>
|
||||
|
||||
@if(session('password_success'))
|
||||
<div class="mb-4 rounded-lg bg-green-50 border border-green-200 px-4 py-2.5 text-sm text-green-800">
|
||||
{{ session('password_success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form wire:submit="updatePassword" class="space-y-4">
|
||||
<div>
|
||||
<label for="current_password" class="block text-sm font-medium text-gray-700">Password attuale</label>
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<td data-label="Territorio" class="px-3 py-2">
|
||||
<div class="flex items-center gap-2">
|
||||
@if($a->territorio?->thumbnail_path)
|
||||
<img src="{{ asset('storage/' . $a->territorio->thumbnail_path) }}"
|
||||
<img src="{{ $a->territorio->miniaturaUrl() }}"
|
||||
alt="Thumbnail territorio {{ $a->territorio?->numero }}"
|
||||
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none">
|
||||
@endif
|
||||
|
||||
@@ -14,12 +14,6 @@
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 max-w-lg">
|
||||
@if (session()->has('success'))
|
||||
<div x-data="{ show: true }" x-show="show" x-init="setTimeout(() => show = false, 3000)" x-transition.duration.500ms
|
||||
class="mb-4 rounded-lg bg-green-50 p-4 text-sm text-green-700 border border-green-200">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<form wire:submit="save" class="space-y-5">
|
||||
<div>
|
||||
|
||||
@@ -4,12 +4,6 @@
|
||||
<p class="text-sm text-gray-500 mt-1">Crea utenti e assegna un ruolo applicativo.</p>
|
||||
</div>
|
||||
|
||||
@if (session()->has('success'))
|
||||
<div class="rounded-lg bg-green-50 p-3 text-sm text-green-700 border border-green-200">{{ session('success') }}</div>
|
||||
@endif
|
||||
@if (session()->has('error'))
|
||||
<div class="rounded-lg bg-red-50 p-3 text-sm text-red-700 border border-red-200">{{ session('error') }}</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 mb-4">Nuovo utente</h2>
|
||||
|
||||
@@ -61,15 +61,124 @@
|
||||
zipInCorso: false,
|
||||
zipPercento: 0,
|
||||
|
||||
{{-- La cartella viaggia a lotti: vedi XmlExchange::getFilePerLottoProperty().
|
||||
Il tetto in byte tiene ogni richiesta lontana dai 100 MB
|
||||
che Cloudflare accetta per un singolo caricamento. --}}
|
||||
fileCartella: [],
|
||||
cartellaInCorso: false,
|
||||
caricatiCartella: 0,
|
||||
filePerLotto: {{ $this->filePerLotto }},
|
||||
byteMassimiLotto: 40 * 1000 * 1000,
|
||||
|
||||
log(riga, livello) {
|
||||
this.$dispatch('console-riga', { testo: riga, livello: livello || 'info', sorgente: 'import-pdf' });
|
||||
},
|
||||
|
||||
/*
|
||||
* Stessa regola di TerritorioPdfImportDispatcher::daIgnorare():
|
||||
* Thumbs.db, desktop.ini, .DS_Store e gli altri file di sistema
|
||||
* o nascosti non partono nemmeno. Il server li scarta comunque,
|
||||
* ma caricarli sarebbe solo traffico.
|
||||
*/
|
||||
daIgnorare(file) {
|
||||
const segmenti = (file.webkitRelativePath || file.name).split('/').filter((s) => s !== '');
|
||||
if (segmenti.some((s) => s.startsWith('.') || s.toUpperCase() === '__MACOSX')) {
|
||||
return true;
|
||||
}
|
||||
const nome = segmenti[segmenti.length - 1] || '';
|
||||
return nome.startsWith('~$') || !nome.toLowerCase().endsWith('.pdf');
|
||||
},
|
||||
|
||||
cartellaScelta(event) {
|
||||
this.fileScelti = event.target.files.length;
|
||||
if (this.fileScelti > 0) {
|
||||
this.log(`Cartella scelta: ${this.fileScelti} file.`);
|
||||
this.log(`In attesa del caricamento temporaneo di Livewire...`, 'attesa');
|
||||
const tutti = Array.from(event.target.files);
|
||||
this.fileCartella = tutti.filter((file) => !this.daIgnorare(file));
|
||||
this.fileScelti = this.fileCartella.length;
|
||||
this.caricatiCartella = 0;
|
||||
const ignorati = tutti.length - this.fileCartella.length;
|
||||
if (tutti.length > 0) {
|
||||
this.log(`Cartella scelta: ${tutti.length} file, ${this.fileScelti} PDF da importare`
|
||||
+ (ignorati > 0 ? ` (${ignorati} ignorati: file di sistema, nascosti o non PDF).` : `.`));
|
||||
}
|
||||
},
|
||||
|
||||
lotti() {
|
||||
const lotti = [];
|
||||
let lotto = [];
|
||||
let byte = 0;
|
||||
for (const file of this.fileCartella) {
|
||||
if (lotto.length > 0 && (lotto.length >= this.filePerLotto || byte + file.size > this.byteMassimiLotto)) {
|
||||
lotti.push(lotto);
|
||||
lotto = [];
|
||||
byte = 0;
|
||||
}
|
||||
lotto.push(file);
|
||||
byte += file.size;
|
||||
}
|
||||
if (lotto.length > 0) {
|
||||
lotti.push(lotto);
|
||||
}
|
||||
return lotti;
|
||||
},
|
||||
|
||||
/*
|
||||
* Un lotto alla volta, aggiunto (append) a quelli gia' arrivati.
|
||||
* La guardia esiste perche' Livewire non ascolta gli errori di
|
||||
* rete del caricamento: una connessione caduta lascerebbe la
|
||||
* promessa appesa per sempre, con la pagina muta.
|
||||
*/
|
||||
caricaLotto(lotto) {
|
||||
return new Promise((ok, ko) => {
|
||||
let ultimoSegno = Date.now();
|
||||
const guardia = setInterval(() => {
|
||||
if (Date.now() - ultimoSegno > 120000) {
|
||||
clearInterval(guardia);
|
||||
this.$wire.cancelUpload('pdfFolder');
|
||||
ko(new Error(`nessun avanzamento da due minuti`));
|
||||
}
|
||||
}, 5000);
|
||||
this.$wire.uploadMultiple('pdfFolder', lotto,
|
||||
() => { clearInterval(guardia); ok(); },
|
||||
() => { clearInterval(guardia); ko(new Error(`il server ha rifiutato il lotto`)); },
|
||||
() => { ultimoSegno = Date.now(); },
|
||||
() => { clearInterval(guardia); ko(new Error(`caricamento annullato`)); },
|
||||
true);
|
||||
});
|
||||
},
|
||||
|
||||
async inviaCartella() {
|
||||
if (this.fileCartella.length === 0 || this.cartellaInCorso) {
|
||||
return;
|
||||
}
|
||||
this.$dispatch('console-apri');
|
||||
|
||||
if (!(await this.$wire.cartellaPuoPartire())) {
|
||||
this.log(`Import non avviato: c'è già un'operazione in corso.`, 'errore');
|
||||
return;
|
||||
}
|
||||
|
||||
this.cartellaInCorso = true;
|
||||
this.caricatiCartella = 0;
|
||||
try {
|
||||
await this.$wire.svuotaCartella();
|
||||
const lotti = this.lotti();
|
||||
this.log(`Caricamento di ${this.fileCartella.length} PDF in ${lotti.length} lotti...`);
|
||||
for (let i = 0; i < lotti.length; i++) {
|
||||
await this.caricaLotto(lotti[i]);
|
||||
this.caricatiCartella += lotti[i].length;
|
||||
this.log(`Lotto ${i + 1}/${lotti.length} caricato: ${this.caricatiCartella}/${this.fileCartella.length} file.`);
|
||||
}
|
||||
this.log(`Cartella caricata. Preparazione import sul server...`, 'ok');
|
||||
if (await this.$wire.importTerritoryPdfFolder()) {
|
||||
this.fileCartella = [];
|
||||
this.fileScelti = 0;
|
||||
this.$refs.cartella.value = '';
|
||||
} else {
|
||||
this.log(`Import non avviato: il motivo è nell'avviso a comparsa. La cartella resta scelta, puoi ripremere.`, 'errore');
|
||||
}
|
||||
} catch (errore) {
|
||||
this.log(`ERRORE durante il caricamento della cartella: ${errore.message}. Nessun import è partito: riprova.`, 'errore');
|
||||
} finally {
|
||||
this.cartellaInCorso = false;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -139,17 +248,15 @@
|
||||
|
||||
xhr.send(new FormData(form));
|
||||
},
|
||||
}"
|
||||
x-on:livewire-upload-start="if ($event.detail.id === 'pdfFolder') { $dispatch('console-apri'); log('Caricamento della cartella avviato...'); }"
|
||||
x-on:livewire-upload-progress="if ($event.detail.id === 'pdfFolder') { log('Caricamento cartella: ' + $event.detail.progress + '%'); }"
|
||||
x-on:livewire-upload-finish="if ($event.detail.id === 'pdfFolder') { log('Cartella caricata. Preparazione import sul server...', 'ok'); }"
|
||||
x-on:livewire-upload-error="if ($event.detail.id === 'pdfFolder') { log('ERRORE durante il caricamento temporaneo dei file.', 'errore'); }">
|
||||
}">
|
||||
|
||||
<div>
|
||||
<h2 id="titolo-pdf" class="text-lg font-semibold text-gray-900">3 · Import PDF territori</h2>
|
||||
<p class="text-xs text-gray-500 mt-1">
|
||||
Associa i PDF ai territori già presenti e ne genera le miniature. Il nome del file può
|
||||
variare: basta che contenga il numero di un territorio esistente.
|
||||
Associa i PDF ai territori già presenti e ne genera le miniature. Il nome del file deve
|
||||
contenere il numero del territorio: una lettera subito dopo il numero ne fa parte («12 B»,
|
||||
«12B» e «12-b» sono tutti il 12B, mai il 12). I file che non si abbinano con
|
||||
certezza a un solo territorio vengono saltati e segnalati nell’esito.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -186,30 +293,32 @@
|
||||
</form>
|
||||
|
||||
{{-- Strada alternativa --}}
|
||||
<form wire:submit.prevent="importTerritoryPdfFolder" class="rounded-lg border border-gray-200 p-4 space-y-3">
|
||||
{{-- Niente wire:model sull'input: con wire:model Livewire spedisce
|
||||
tutta la cartella in una sola richiesta, e PHP ne tiene solo i
|
||||
primi max_file_uploads (20). La spedisce inviaCartella(), a lotti. --}}
|
||||
<form @submit.prevent="inviaCartella()" class="rounded-lg border border-gray-200 p-4 space-y-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-gray-900">Da cartella</h3>
|
||||
<p class="mt-1 text-xs text-gray-500">Comoda per pochi file. Ogni PDF viaggia come caricamento temporaneo a sé.</p>
|
||||
<p class="mt-1 text-xs text-gray-500">I PDF partono a gruppi di {{ $this->filePerLotto }}; i file che non sono PDF (Thumbs.db, desktop.ini…) vengono ignorati.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="pdfFolder" class="sr-only">Cartella di PDF</label>
|
||||
<input wire:model="pdfFolder" id="pdfFolder" type="file" multiple webkitdirectory directory
|
||||
accept=".pdf,application/pdf"
|
||||
<input id="pdfFolder" type="file" multiple webkitdirectory directory
|
||||
x-ref="cartella"
|
||||
data-file-per-lotto="{{ $this->filePerLotto }}"
|
||||
x-on:change="cartellaScelta($event)"
|
||||
x-bind:disabled="cartellaInCorso"
|
||||
class="block w-full text-sm text-gray-600 file:mr-3 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-gray-100 file:text-gray-700 hover:file:bg-gray-200">
|
||||
@error('pdfFolder') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
|
||||
@error('pdfFolder.*') <p class="text-red-600 text-xs mt-1" role="alert">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500" x-show="fileScelti > 0" x-cloak x-text="fileScelti + ' file selezionati'"></p>
|
||||
<p class="text-xs text-gray-500" x-show="fileScelti > 0" x-cloak
|
||||
x-text="cartellaInCorso ? caricatiCartella + ' / ' + fileScelti + ' PDF caricati' : fileScelti + ' PDF da importare'"></p>
|
||||
|
||||
<x-btn variante="secondary" type="submit"
|
||||
x-on:click="$dispatch('console-apri')"
|
||||
wire:loading.attr="disabled"
|
||||
{{-- Vedi la nota in components/btn.blade.php: dentro un
|
||||
tag di componente serve :disabled, non @disabled. --}}
|
||||
:disabled="empty($pdfFolder)">
|
||||
x-bind:disabled="fileScelti === 0 || cartellaInCorso">
|
||||
Importa cartella
|
||||
</x-btn>
|
||||
<span wire:loading wire:target="importTerritoryPdfFolder" class="text-xs text-indigo-700">Preparazione import...</span>
|
||||
@@ -329,7 +438,8 @@
|
||||
<h3 class="text-sm font-semibold text-gray-900">Importa</h3>
|
||||
<p class="text-xs text-gray-500">
|
||||
<strong>Sostituisce</strong> zone, tipologie, proclamatori, territori, anni, campagne,
|
||||
assegnazioni e impostazioni.
|
||||
assegnazioni e impostazioni. I PDF e le miniature dei territori sostituiti che il file non
|
||||
richiama più vengono eliminati.
|
||||
</p>
|
||||
|
||||
<form action="{{ route('xml.import-xml') }}" method="POST" enctype="multipart/form-data" class="space-y-3"
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<td data-label="N°" class="px-4 py-3 text-sm font-semibold text-gray-900">
|
||||
<div class="flex items-center gap-2">
|
||||
@if($territorio->thumbnail_path)
|
||||
<img src="{{ asset('storage/' . $territorio->thumbnail_path) }}"
|
||||
<img src="{{ $territorio->miniaturaUrl() }}"
|
||||
alt="Thumbnail territorio {{ $territorio->numero }}"
|
||||
class="h-8 w-6 rounded border border-gray-200 object-cover bg-gray-50 flex-none shadow-sm">
|
||||
@endif
|
||||
|
||||
@@ -132,10 +132,10 @@
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 mb-3">PDF Territorio</h3>
|
||||
<div class="w-full" style="min-height: 500px;">
|
||||
<embed src="{{ asset('storage/' . $territorio->pdf_path) }}" type="application/pdf" width="100%" height="500px">
|
||||
<embed src="{{ $territorio->pdfUrl() }}" type="application/pdf" width="100%" height="500px">
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
Se il PDF non è visibile:
|
||||
<a href="{{ asset('storage/' . $territorio->pdf_path) }}" target="_blank" class="text-indigo-600 hover:underline">Scarica PDF</a>
|
||||
<a href="{{ $territorio->pdfUrl() }}" target="_blank" class="text-indigo-600 hover:underline">Scarica PDF</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,7 @@ use App\Http\Controllers\Settings\TerritoryPdfImportController;
|
||||
use App\Http\Controllers\Settings\XmlExchangeUploadController;
|
||||
use App\Http\Controllers\AssignmentPdfController;
|
||||
use App\Http\Controllers\ShortPdfLinkController;
|
||||
use App\Http\Controllers\TerritorioFileController;
|
||||
use App\Http\Controllers\Auth\LoginController;
|
||||
use App\Http\Controllers\Auth\PasswordChangeController;
|
||||
use App\Livewire\Home;
|
||||
@@ -100,6 +101,22 @@ Route::middleware('auth')->group(function () {
|
||||
Route::get('territori/{territorio}/edit', TerritorioEdit::class)->name('territori.edit');
|
||||
});
|
||||
|
||||
// File dei territori: nginx non li serve più da /storage (vedi
|
||||
// TerritorioFileController). withTrashed: cestino e registro mostrano
|
||||
// anche territori cancellati.
|
||||
Route::middleware('permission:territori.assign')->group(function () {
|
||||
Route::get('file/territori/{territorio}/pdf', [TerritorioFileController::class, 'pdf'])
|
||||
->withTrashed()->name('territori.file.pdf');
|
||||
});
|
||||
Route::middleware('permission:territori.assign|registro.view')->group(function () {
|
||||
Route::get('file/territori/{territorio}/miniatura', [TerritorioFileController::class, 'miniatura'])
|
||||
->withTrashed()->name('territori.file.miniatura');
|
||||
});
|
||||
Route::middleware('permission:settings.manage')->group(function () {
|
||||
Route::get('file/versioni-pdf/{versione}', [TerritorioFileController::class, 'versione'])
|
||||
->name('territori.file.versione');
|
||||
});
|
||||
|
||||
// Proclamatori — visualizzazione (lista + dettaglio) per chi ha
|
||||
// proclamatori.manage; sola lettura per i non-admin.
|
||||
Route::middleware('permission:proclamatori.manage')->group(function () {
|
||||
|
||||
73
tests/Feature/AvvisiTest.php
Normal file
73
tests/Feature/AvvisiTest.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Settings\ZoneIndex;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use App\Models\Zona;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Gli esiti delle azioni che non cambiano pagina arrivano come evento
|
||||
* "avviso". Con session()->flash() il messaggio non si vedeva subito e
|
||||
* compariva alla pagina successiva, fuori contesto.
|
||||
*/
|
||||
class AvvisiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function admin(): User
|
||||
{
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
$utente = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
|
||||
$utente->assignRole('amministratore');
|
||||
|
||||
return $utente;
|
||||
}
|
||||
|
||||
public function test_un_eliminazione_rifiutata_avvisa_subito_e_non_lascia_flash(): void
|
||||
{
|
||||
$zona = Zona::create(['nome' => 'Nord', 'attivo' => true]);
|
||||
Territorio::create(['numero' => '1', 'attivo' => true, 'zona_id' => $zona->id]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(ZoneIndex::class)
|
||||
->call('deleteZona', $zona->id)
|
||||
->assertDispatched('avviso', tipo: 'errore', testo: "Impossibile eliminare: la zona 'Nord' ha territori associati.");
|
||||
|
||||
$this->assertFalse(session()->has('error'));
|
||||
$this->assertNotNull($zona->fresh());
|
||||
}
|
||||
|
||||
public function test_un_azione_riuscita_avvisa_con_un_conferma(): void
|
||||
{
|
||||
$zona = Zona::create(['nome' => 'Sud', 'attivo' => true]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(ZoneIndex::class)
|
||||
->call('deleteZona', $zona->id)
|
||||
->assertDispatched('avviso', tipo: 'ok');
|
||||
|
||||
$this->assertFalse(session()->has('success'));
|
||||
}
|
||||
|
||||
public function test_nessun_componente_lascia_flash_senza_cambiare_pagina(): void
|
||||
{
|
||||
// Un flash senza redirect subito dopo non si vede fino alla pagina
|
||||
// successiva: in quei casi si usa il trait Avvisa.
|
||||
foreach (glob(app_path('Livewire') . '/{,*/}*.php', GLOB_BRACE) as $file) {
|
||||
$righe = file($file);
|
||||
foreach ($righe as $i => $riga) {
|
||||
if (! preg_match('/^\s*session\(\)->flash\(/', $riga)) {
|
||||
continue;
|
||||
}
|
||||
$seguito = implode('', array_slice($righe, $i + 1, 5));
|
||||
$this->assertStringContainsString('redirect', $seguito, basename($file) . ':' . ($i + 1) . ' usa un flash senza redirect');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
58
tests/Feature/AzzeramentoPiantineTest.php
Normal file
58
tests/Feature/AzzeramentoPiantineTest.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\ResetApplication;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Models\User;
|
||||
use App\Services\BackupArchive;
|
||||
use App\Services\BackupOperationState;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* L'azzeramento porta via anche le piantine: righe, storico dei PDF, area
|
||||
* della città e anteprime. Prima restavano piantine orfane e l'editor
|
||||
* ripartiva con la città di prima.
|
||||
*/
|
||||
class AzzeramentoPiantineTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_l_azzeramento_non_lascia_piantine_ne_area(): void
|
||||
{
|
||||
Storage::fake('public');
|
||||
Storage::fake('local');
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
||||
$admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
|
||||
$admin->assignRole('amministratore');
|
||||
|
||||
$territorio = Territorio::create(['numero' => '7', 'attivo' => true]);
|
||||
TerritorioMappa::create(['territorio_id' => $territorio->id, 'geojson' => '{"type":"Polygon","coordinates":[]}']);
|
||||
TerritorioMappa::create(['nome' => 'Scollegata', 'geojson' => '{"type":"Polygon","coordinates":[]}']);
|
||||
TerritorioPdfVersione::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'pdf_path' => 'territori-pdf-versioni/vecchia.pdf',
|
||||
'origine' => 'piantine',
|
||||
'sostituito_il' => now(),
|
||||
]);
|
||||
Storage::disk('local')->put('piantine/area.geojson', '{}');
|
||||
Storage::disk('local')->put('piantine-anteprime/abc.pdf', '%PDF');
|
||||
|
||||
$stato = app(BackupOperationState::class);
|
||||
$stato->initialize('azzeramento-di-prova', 'azzeramento', 'In attesa');
|
||||
|
||||
(new ResetApplication('azzeramento-di-prova', $admin->id))->handle($stato, app(BackupArchive::class));
|
||||
|
||||
$this->assertSame('completed', $stato->get('azzeramento-di-prova')['status'] ?? null, json_encode($stato->get('azzeramento-di-prova')));
|
||||
$this->assertSame(0, TerritorioMappa::count());
|
||||
$this->assertSame(0, TerritorioPdfVersione::count());
|
||||
Storage::disk('local')->assertMissing('piantine/area.geojson');
|
||||
Storage::disk('local')->assertMissing('piantine-anteprime/abc.pdf');
|
||||
}
|
||||
}
|
||||
123
tests/Feature/CambioPasswordTest.php
Normal file
123
tests/Feature/CambioPasswordTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Cambio password: la password attuale si chiede a chi cambia di sua
|
||||
* iniziativa, e cambiare la password chiude le sessioni aperte altrove.
|
||||
*/
|
||||
class CambioPasswordTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
|
||||
}
|
||||
|
||||
private function utente(bool $obbligato = false): User
|
||||
{
|
||||
$utente = User::create([
|
||||
'name' => 'Mario Rossi',
|
||||
'email' => 'mario@example.test',
|
||||
'password' => 'vecchia-password',
|
||||
]);
|
||||
$utente->forceFill(['password_change_required' => $obbligato])->save();
|
||||
$utente->assignRole('operatore');
|
||||
|
||||
return $utente;
|
||||
}
|
||||
|
||||
public function test_chi_cambia_di_sua_iniziativa_deve_dare_la_password_attuale(): void
|
||||
{
|
||||
$utente = $this->utente();
|
||||
|
||||
$this->actingAs($utente)
|
||||
->post(route('password.change.store'), [
|
||||
'password' => 'nuova-password-1',
|
||||
'password_confirmation' => 'nuova-password-1',
|
||||
])
|
||||
->assertSessionHasErrors('current_password');
|
||||
|
||||
$this->actingAs($utente)
|
||||
->post(route('password.change.store'), [
|
||||
'current_password' => 'sbagliata',
|
||||
'password' => 'nuova-password-1',
|
||||
'password_confirmation' => 'nuova-password-1',
|
||||
])
|
||||
->assertSessionHasErrors('current_password');
|
||||
|
||||
$this->assertTrue(Hash::check('vecchia-password', $utente->fresh()->password));
|
||||
|
||||
$this->actingAs($utente)
|
||||
->post(route('password.change.store'), [
|
||||
'current_password' => 'vecchia-password',
|
||||
'password' => 'nuova-password-1',
|
||||
'password_confirmation' => 'nuova-password-1',
|
||||
])
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
$this->assertTrue(Hash::check('nuova-password-1', $utente->fresh()->password));
|
||||
}
|
||||
|
||||
public function test_dopo_un_ripristino_la_password_attuale_non_si_chiede(): void
|
||||
{
|
||||
$utente = $this->utente(obbligato: true);
|
||||
|
||||
$this->actingAs($utente)
|
||||
->get(route('password.change'))
|
||||
->assertOk()
|
||||
->assertDontSee('name="current_password"', false);
|
||||
|
||||
$this->actingAs($utente)
|
||||
->post(route('password.change.store'), [
|
||||
'password' => 'nuova-password-1',
|
||||
'password_confirmation' => 'nuova-password-1',
|
||||
])
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
$this->assertFalse((bool) $utente->fresh()->password_change_required);
|
||||
}
|
||||
|
||||
public function test_una_sessione_aperta_altrove_si_chiude_quando_cambia_la_password(): void
|
||||
{
|
||||
$utente = $this->utente();
|
||||
$hashVecchio = $utente->password;
|
||||
|
||||
// La sessione "altrove" ha registrato l'impronta della password di allora.
|
||||
$utente->update(['password' => 'cambiata-da-un-altra-parte']);
|
||||
|
||||
$this->actingAs($utente)
|
||||
->withSession(['password_hash_web' => $hashVecchio])
|
||||
->get(route('dashboard'))
|
||||
->assertRedirect(route('login'));
|
||||
|
||||
$this->assertGuest();
|
||||
}
|
||||
|
||||
public function test_la_sessione_che_cambia_la_password_resta_aperta(): void
|
||||
{
|
||||
$utente = $this->utente();
|
||||
|
||||
$this->actingAs($utente)
|
||||
->withSession(['password_hash_web' => $utente->password])
|
||||
->post(route('password.change.store'), [
|
||||
'current_password' => 'vecchia-password',
|
||||
'password' => 'nuova-password-1',
|
||||
'password_confirmation' => 'nuova-password-1',
|
||||
])
|
||||
->assertRedirect(route('dashboard'));
|
||||
|
||||
$this->get(route('dashboard'))->assertOk();
|
||||
}
|
||||
}
|
||||
87
tests/Feature/CampagnaCalcoliTest.php
Normal file
87
tests/Feature/CampagnaCalcoliTest.php
Normal file
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Campagna;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Campagne: l'ultimo giorno è ancora campagna, e la percentuale conta i
|
||||
* territori percorsi, non le assegnazioni.
|
||||
*/
|
||||
class CampagnaCalcoliTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_nell_ultimo_giorno_la_campagna_e_ancora_attiva(): void
|
||||
{
|
||||
$campagna = Campagna::create([
|
||||
'descrizione' => 'Commemorazione',
|
||||
'start_date' => '2026-03-01',
|
||||
'end_date' => '2026-03-31',
|
||||
]);
|
||||
|
||||
Carbon::setTestNow('2026-03-31 18:30:00');
|
||||
|
||||
$this->assertTrue($campagna->fresh()->is_attiva);
|
||||
$this->assertSame($campagna->id, Campagna::attiva()?->id);
|
||||
$this->assertSame(0, Campagna::completate()->count());
|
||||
|
||||
Carbon::setTestNow('2026-04-01 08:00:00');
|
||||
|
||||
$this->assertNull(Campagna::attiva());
|
||||
$this->assertSame(1, Campagna::completate()->count());
|
||||
}
|
||||
|
||||
public function test_la_percentuale_conta_i_territori_attivi_una_volta_sola(): void
|
||||
{
|
||||
$campagna = Campagna::create([
|
||||
'descrizione' => 'Commemorazione',
|
||||
'start_date' => '2026-03-01',
|
||||
'end_date' => '2026-03-31',
|
||||
]);
|
||||
$proclamatore = Proclamatore::create(['nome' => 'Mario', 'cognome' => 'Rossi', 'attivo' => true]);
|
||||
$utente = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
|
||||
$anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
|
||||
|
||||
$t1 = Territorio::create(['numero' => '1', 'attivo' => true]);
|
||||
$t2 = Territorio::create(['numero' => '2', 'attivo' => true]);
|
||||
Territorio::create(['numero' => '3', 'attivo' => true]);
|
||||
Territorio::create(['numero' => '4', 'attivo' => true]);
|
||||
$dismesso = Territorio::create(['numero' => '5', 'attivo' => true]);
|
||||
|
||||
$conta = fn (Territorio $t, string $uscita, string $rientro) => Assegnazione::create([
|
||||
'anno_teocratico_id' => $anno->id,
|
||||
'created_by' => $utente->id,
|
||||
'territorio_id' => $t->id,
|
||||
'proclamatore_id' => $proclamatore->id,
|
||||
'assigned_at' => $uscita,
|
||||
'returned_at' => $rientro,
|
||||
'campaign_id' => $campagna->id,
|
||||
'counted_in_campaign' => true,
|
||||
]);
|
||||
|
||||
// Il territorio 1 percorso due volte, il 2 una, il 5 poi disattivato.
|
||||
$conta($t1, '2026-03-02', '2026-03-10');
|
||||
$conta($t1, '2026-03-12', '2026-03-20');
|
||||
$conta($t2, '2026-03-03', '2026-03-15');
|
||||
$conta($dismesso, '2026-03-04', '2026-03-16');
|
||||
$dismesso->update(['attivo' => false]);
|
||||
|
||||
// 2 territori attivi percorsi su 4 attivi: prima diceva 4 su 4, 100%.
|
||||
$this->assertSame(50.0, $campagna->fresh()->percentuale_percorrenza);
|
||||
}
|
||||
}
|
||||
46
tests/Feature/CodaTest.php
Normal file
46
tests/Feature/CodaTest.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Setting;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Queue\Events\JobProcessing;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Coda e worker: un job lungo non deve ripartire mentre gira ancora, e un
|
||||
* worker vivo da un'ora non deve lavorare con impostazioni vecchie.
|
||||
*/
|
||||
class CodaTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_retry_after_supera_il_job_piu_lungo(): void
|
||||
{
|
||||
$piuLungo = 0;
|
||||
foreach (glob(app_path('Jobs') . '/*.php') as $file) {
|
||||
$classe = 'App\\Jobs\\' . basename($file, '.php');
|
||||
$proprieta = (new \ReflectionClass($classe))->getDefaultProperties();
|
||||
$piuLungo = max($piuLungo, (int) ($proprieta['timeout'] ?? 0));
|
||||
}
|
||||
|
||||
$this->assertGreaterThan(0, $piuLungo);
|
||||
$this->assertGreaterThan($piuLungo, config('queue.connections.redis.retry_after'));
|
||||
}
|
||||
|
||||
public function test_il_worker_rilegge_le_impostazioni_a_ogni_job(): void
|
||||
{
|
||||
Setting::instance()->update(['congregazione_nome' => 'Prima']);
|
||||
$this->assertSame('Prima', Setting::instance()->congregazione_nome);
|
||||
|
||||
// Cambiata da un altro processo (il browser): la cache statica di
|
||||
// questo non lo sa.
|
||||
DB::table('settings')->update(['congregazione_nome' => 'Dopo']);
|
||||
$this->assertSame('Prima', Setting::instance()->congregazione_nome);
|
||||
|
||||
event(new JobProcessing('redis', $this->createMock(\Illuminate\Contracts\Queue\Job::class)));
|
||||
|
||||
$this->assertSame('Dopo', Setting::instance()->congregazione_nome);
|
||||
}
|
||||
}
|
||||
333
tests/Feature/ImportPdfTerritoriTest.php
Normal file
333
tests/Feature/ImportPdfTerritoriTest.php
Normal file
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\ImportTerritoryPdfFolder;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use App\Services\TerritorioPdfImportState;
|
||||
use App\Services\TerritorioThumbnailService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Import dei PDF dei territori: da cartella e da ZIP.
|
||||
*
|
||||
* Il difetto che costa di piu' qui non fa rumore: un PDF abbinato al territorio
|
||||
* sbagliato ne sostituisce la mappa, e ce ne si accorge solo quando qualcuno va
|
||||
* in un territorio con la cartina di un altro. Per questo l'abbinamento scarta
|
||||
* invece di indovinare, e questi test fissano la regola caso per caso.
|
||||
*/
|
||||
class ImportPdfTerritoriTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const PDF = "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n";
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('public');
|
||||
|
||||
// pdftoppm non serve a niente qui, e su un PDF finto fallirebbe comunque.
|
||||
$this->mock(TerritorioThumbnailService::class, function ($mock) {
|
||||
$mock->shouldReceive('generate')->andReturn(null);
|
||||
$mock->shouldReceive('delete');
|
||||
});
|
||||
|
||||
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$this->admin = User::create([
|
||||
'name' => 'Amministratore',
|
||||
'email' => 'admin@example.test',
|
||||
'password' => 'password-di-prova',
|
||||
]);
|
||||
$this->admin->assignRole('amministratore');
|
||||
$this->actingAs($this->admin);
|
||||
}
|
||||
|
||||
// ─── Abbinamento nome file → territorio ─────────────────────
|
||||
|
||||
/**
|
||||
* Il caso segnalato: "1-A" conteneva "1", e senza un territorio 1A il PDF
|
||||
* finiva sul territorio 1 sostituendone la mappa.
|
||||
*/
|
||||
public function test_un_file_1_A_non_sostituisce_il_pdf_del_territorio_1(): void
|
||||
{
|
||||
$uno = $this->territorioConPdf('1');
|
||||
|
||||
$stato = $this->importa(['1-A.pdf']);
|
||||
|
||||
$this->assertSame('territori-pdf/originale-1.pdf', $uno->fresh()->pdf_path);
|
||||
Storage::disk('public')->assertExists('territori-pdf/originale-1.pdf');
|
||||
$this->assertSame(1, $stato['stats']['skipped']);
|
||||
$this->assertSame('unknown-suffix', $stato['issues'][0]['type']);
|
||||
$this->assertSame(['1'], $stato['issues'][0]['matched_numbers']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prima il vecchio PDF si cancellava prima di scrivere il nuovo: un import
|
||||
* sbagliato non si poteva disfare.
|
||||
*/
|
||||
public function test_il_pdf_sostituito_finisce_nello_storico(): void
|
||||
{
|
||||
$uno = $this->territorioConPdf('1');
|
||||
|
||||
$this->importa(['1.pdf']);
|
||||
|
||||
$uno->refresh();
|
||||
$this->assertNotSame('territori-pdf/originale-1.pdf', $uno->pdf_path);
|
||||
Storage::disk('public')->assertExists($uno->pdf_path);
|
||||
|
||||
$versione = \App\Models\TerritorioPdfVersione::where('territorio_id', $uno->id)->sole();
|
||||
$this->assertSame('import', $versione->origine);
|
||||
$this->assertSame('PDF ORIGINALE', Storage::disk('public')->get($versione->pdf_path));
|
||||
}
|
||||
|
||||
public function test_un_file_1_A_va_al_territorio_1_A_quando_esiste(): void
|
||||
{
|
||||
$uno = $this->territorioConPdf('1');
|
||||
$unoA = Territorio::create(['numero' => '1-A', 'attivo' => true]);
|
||||
|
||||
$this->importa(['1-A.pdf']);
|
||||
|
||||
$this->assertNotNull($unoA->fresh()->pdf_path);
|
||||
$this->assertSame('territori-pdf/originale-1.pdf', $uno->fresh()->pdf_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* "12 B", "12B" e "12-b" sono lo stesso numero, e non e' il 12: la lettera
|
||||
* dopo il numero fa parte del numero.
|
||||
*/
|
||||
public function test_12_B_con_lo_spazio_e_il_territorio_12B_e_non_il_12(): void
|
||||
{
|
||||
$dodici = $this->territorioConPdf('12');
|
||||
$dodiciB = Territorio::create(['numero' => '12B', 'attivo' => true]);
|
||||
|
||||
$stato = $this->importa(['12 B.pdf']);
|
||||
|
||||
$this->assertSame(1, $stato['stats']['updated']);
|
||||
$this->assertNotNull($dodiciB->fresh()->pdf_path);
|
||||
$this->assertSame('territori-pdf/originale-12.pdf', $dodici->fresh()->pdf_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Due territori che si scrivono diversi ma sono lo stesso numero: il file
|
||||
* che li nomina non si puo' abbinare con certezza, quindi si scarta.
|
||||
*/
|
||||
public function test_due_territori_con_lo_stesso_numero_scritto_diverso_rendono_il_file_ambiguo(): void
|
||||
{
|
||||
$attaccato = Territorio::create(['numero' => '12B', 'attivo' => true]);
|
||||
$staccato = Territorio::create(['numero' => '12 B', 'attivo' => true]);
|
||||
|
||||
$stato = $this->importa(['12b.pdf']);
|
||||
|
||||
$this->assertNull($attaccato->fresh()->pdf_path);
|
||||
$this->assertNull($staccato->fresh()->pdf_path);
|
||||
$this->assertSame('ambiguous', $stato['issues'][0]['type']);
|
||||
$this->assertEqualsCanonicalizing(['12B', '12 B'], $stato['issues'][0]['matched_numbers']);
|
||||
}
|
||||
|
||||
public function test_due_numeri_di_territori_esistenti_nello_stesso_nome_rendono_il_file_ambiguo(): void
|
||||
{
|
||||
$tre = Territorio::create(['numero' => '3', 'attivo' => true]);
|
||||
$dodici = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
|
||||
$stato = $this->importa(['Zona 3 - Territorio 12.pdf']);
|
||||
|
||||
$this->assertNull($tre->fresh()->pdf_path);
|
||||
$this->assertNull($dodici->fresh()->pdf_path);
|
||||
$this->assertSame('ambiguous', $stato['issues'][0]['type']);
|
||||
}
|
||||
|
||||
/** Le forme di sempre devono continuare a funzionare. */
|
||||
public function test_zeri_iniziali_testo_intorno_e_numeri_estranei_non_impediscono_l_abbinamento(): void
|
||||
{
|
||||
$sette = Territorio::create(['numero' => '7', 'attivo' => true]);
|
||||
$dodici = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
|
||||
$stato = $this->importa(['Territorio 007 - Via Roma.pdf', '2024_territorio_12.pdf']);
|
||||
|
||||
$this->assertSame(2, $stato['stats']['updated']);
|
||||
$this->assertNotNull($sette->fresh()->pdf_path);
|
||||
$this->assertNotNull($dodici->fresh()->pdf_path);
|
||||
}
|
||||
|
||||
public function test_lo_stesso_territorio_scritto_in_due_modi_nella_stessa_cartella_e_un_doppione(): void
|
||||
{
|
||||
$dodiciB = Territorio::create(['numero' => '12B', 'attivo' => true]);
|
||||
|
||||
$stato = $this->importa(['12B.pdf', '12 B.pdf']);
|
||||
|
||||
$this->assertSame(1, $stato['stats']['updated']);
|
||||
$this->assertSame('duplicate-in-batch', $stato['issues'][0]['type']);
|
||||
$this->assertNotNull($dodiciB->fresh()->pdf_path);
|
||||
}
|
||||
|
||||
// ─── Cartella ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Thumbs.db e simili facevano fallire la validazione `mimes:pdf` di tutta la
|
||||
* cartella: nessun PDF importato per colpa di un file che Windows crea da sé.
|
||||
*/
|
||||
public function test_thumbs_db_e_i_file_nascosti_nella_cartella_non_bloccano_l_import(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
|
||||
|
||||
Livewire::test(XmlExchange::class)
|
||||
->set('pdfFolder', [
|
||||
UploadedFile::fake()->createWithContent('1.pdf', self::PDF),
|
||||
UploadedFile::fake()->createWithContent('Thumbs.db', 'non un pdf'),
|
||||
UploadedFile::fake()->createWithContent('desktop.ini', '[.ShellClassInfo]'),
|
||||
UploadedFile::fake()->createWithContent('.DS_Store', 'non un pdf'),
|
||||
])
|
||||
->call('importTerritoryPdfFolder')
|
||||
->assertHasNoErrors()
|
||||
->assertSet('pdfImportStatus', 'completed');
|
||||
|
||||
$this->assertNotNull($uno->fresh()->pdf_path);
|
||||
Storage::disk('public')->assertExists($uno->fresh()->pdf_path);
|
||||
}
|
||||
|
||||
public function test_un_pdf_non_valido_nella_cartella_si_scarta_da_solo(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
|
||||
$due = Territorio::create(['numero' => '2', 'attivo' => true]);
|
||||
|
||||
$componente = Livewire::test(XmlExchange::class)
|
||||
->set('pdfFolder', [
|
||||
UploadedFile::fake()->createWithContent('1.pdf', self::PDF),
|
||||
// Un'immagine rinominata. Nei test Livewire prende il tipo
|
||||
// dichiarato dal file finto, non dal contenuto: va detto.
|
||||
UploadedFile::fake()->createWithContent('2.pdf', "\x89PNG\r\n\x1a\n" . str_repeat("\0", 64))->mimeType('image/png'),
|
||||
])
|
||||
->call('importTerritoryPdfFolder')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertNotNull($uno->fresh()->pdf_path);
|
||||
$this->assertNull($due->fresh()->pdf_path);
|
||||
$this->assertSame('invalid-file', $componente->get('pdfImportIssues')[0]['type']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Con wire:model la cartella partiva in una richiesta sola, e PHP ne teneva
|
||||
* i primi `max_file_uploads` (20) scartando gli altri in silenzio. Il browser
|
||||
* la spedisce a lotti: la pagina deve dirgli quanto grandi, e l'input non
|
||||
* deve avere wire:model.
|
||||
*/
|
||||
public function test_la_cartella_parte_a_lotti_dentro_il_limite_di_php(): void
|
||||
{
|
||||
$this->withoutVite();
|
||||
|
||||
$lotto = min(20, (int) ini_get('max_file_uploads'));
|
||||
|
||||
$this->get(route('xml.exchange'))
|
||||
->assertOk()
|
||||
->assertSee('data-file-per-lotto="' . $lotto . '"', false)
|
||||
->assertSee('uploadMultiple(', false)
|
||||
->assertDontSee('wire:model="pdfFolder"', false);
|
||||
}
|
||||
|
||||
/** Lotti aggiunti uno dopo l'altro: il server deve importarli tutti. */
|
||||
public function test_una_cartella_di_piu_di_venti_pdf_viene_importata_per_intero(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$file = [];
|
||||
for ($numero = 1; $numero <= 25; $numero++) {
|
||||
Territorio::create(['numero' => (string) $numero, 'attivo' => true]);
|
||||
$file[] = UploadedFile::fake()->createWithContent($numero . '.pdf', self::PDF);
|
||||
}
|
||||
|
||||
Livewire::test(XmlExchange::class)
|
||||
->set('pdfFolder', $file)
|
||||
->call('importTerritoryPdfFolder')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertSame(25, Territorio::whereNotNull('pdf_path')->count());
|
||||
}
|
||||
|
||||
// ─── ZIP ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Uno ZIP fatto su macOS porta con se' __MACOSX/._1.pdf: estensione giusta,
|
||||
* contenuto spazzatura. Arrivando per primo, diventava la mappa del
|
||||
* territorio 1 e il PDF vero veniva scartato come doppione.
|
||||
*/
|
||||
public function test_lo_zip_ignora_file_di_sistema_e_copie_macosx(): void
|
||||
{
|
||||
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
|
||||
|
||||
$zip = $this->zip([
|
||||
'__MACOSX/._1.pdf' => 'risorse di macOS, non un PDF',
|
||||
'Thumbs.db' => 'non un pdf',
|
||||
'territori/1.pdf' => self::PDF,
|
||||
]);
|
||||
|
||||
$this->postJson(route('imports.territori.pdf-zip'), ['pdfZip' => $zip])
|
||||
->assertOk();
|
||||
|
||||
$percorso = $uno->fresh()->pdf_path;
|
||||
$this->assertNotNull($percorso);
|
||||
$this->assertSame(self::PDF, Storage::disk('public')->get($percorso));
|
||||
}
|
||||
|
||||
// ─── Supporto ───────────────────────────────────────────────
|
||||
|
||||
private function territorioConPdf(string $numero): Territorio
|
||||
{
|
||||
$percorso = 'territori-pdf/originale-' . Str::slug($numero) . '.pdf';
|
||||
Storage::disk('public')->put($percorso, 'PDF ORIGINALE');
|
||||
|
||||
return Territorio::create(['numero' => $numero, 'attivo' => true, 'pdf_path' => $percorso]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Esegue il job su file con i nomi dati, come se fossero arrivati da una
|
||||
* cartella, e restituisce lo stato finale dell'import.
|
||||
*/
|
||||
private function importa(array $nomi): array
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$importId = (string) Str::uuid();
|
||||
$file = [];
|
||||
foreach (array_values($nomi) as $indice => $nome) {
|
||||
$percorso = 'bulk-territori-imports/' . $importId . '/' . $indice . '.pdf';
|
||||
Storage::disk('local')->put($percorso, self::PDF);
|
||||
$file[] = ['original_name' => $nome, 'stored_path' => $percorso];
|
||||
}
|
||||
|
||||
app(TerritorioPdfImportState::class)->initialize($importId, count($file));
|
||||
ImportTerritoryPdfFolder::dispatchSync($importId, $file, $this->admin->id);
|
||||
|
||||
return app(TerritorioPdfImportState::class)->get($importId);
|
||||
}
|
||||
|
||||
private function zip(array $voci): UploadedFile
|
||||
{
|
||||
$percorso = tempnam(sys_get_temp_dir(), 'zip-pdf-');
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($percorso, ZipArchive::OVERWRITE);
|
||||
foreach ($voci as $nome => $contenuto) {
|
||||
$zip->addFromString($nome, $contenuto);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return new UploadedFile($percorso, 'territori.zip', 'application/zip', null, true);
|
||||
}
|
||||
}
|
||||
129
tests/Feature/ImportXmlFileOrfaniTest.php
Normal file
129
tests/Feature/ImportXmlFileOrfaniTest.php
Normal file
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* L'import XML non lascia file orfani sul disco.
|
||||
*
|
||||
* L'import cancella i territori e li ricrea con id nuovi, e i PDF portano l'id
|
||||
* nel nome. Un XML con percorsi diversi da quelli correnti — da un'altra
|
||||
* installazione, o un dump convertito della vecchia applicazione — lasciava sul
|
||||
* disco i PDF e le miniature dei territori cancellati: invisibili, irraggiungibili
|
||||
* da ogni cancellazione, e copiati in ogni backup successivo. Sul container di
|
||||
* sviluppo erano 545 PDF per 276 territori (territorio-142-141.pdf accanto a
|
||||
* territorio-142-415.pdf).
|
||||
*/
|
||||
class ImportXmlFileOrfaniTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('public');
|
||||
|
||||
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$admin = User::create([
|
||||
'name' => 'Amministratore',
|
||||
'email' => 'admin@example.test',
|
||||
'password' => 'password-di-prova',
|
||||
]);
|
||||
$admin->assignRole('amministratore');
|
||||
$this->actingAs($admin);
|
||||
}
|
||||
|
||||
public function test_l_import_elimina_i_file_dei_territori_che_sostituisce(): void
|
||||
{
|
||||
$disco = Storage::disk('public');
|
||||
$disco->put('territori-pdf/territorio-142-141.pdf', 'vecchio');
|
||||
$disco->put('territori-thumbnails/territorio-142-141.png', 'vecchia');
|
||||
$disco->put('territori-pdf-versioni/territorio-141-20260101.pdf', 'storico');
|
||||
$disco->put('territori-pdf/caricato-a-mano.pdf', 'di nessuno');
|
||||
|
||||
$territorio = Territorio::create([
|
||||
'numero' => '142',
|
||||
'attivo' => true,
|
||||
'pdf_path' => 'territori-pdf/territorio-142-141.pdf',
|
||||
'thumbnail_path' => 'territori-thumbnails/territorio-142-141.png',
|
||||
]);
|
||||
TerritorioPdfVersione::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'pdf_path' => 'territori-pdf-versioni/territorio-141-20260101.pdf',
|
||||
'sostituito_il' => now(),
|
||||
]);
|
||||
|
||||
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('142', 'territori-pdf/territorio-142-415.pdf')])
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$disco->assertMissing('territori-pdf/territorio-142-141.pdf');
|
||||
$disco->assertMissing('territori-thumbnails/territorio-142-141.png');
|
||||
// Lo storico se ne va con il territorio (cascata sulla riga): il file
|
||||
// restava sul disco.
|
||||
$disco->assertMissing('territori-pdf-versioni/territorio-141-20260101.pdf');
|
||||
// Un file che nessun territorio richiamava non e' affare dell'import.
|
||||
$disco->assertExists('territori-pdf/caricato-a-mano.pdf');
|
||||
}
|
||||
|
||||
/** Il caso di tutti i giorni: reimportare l'export di questa stessa installazione. */
|
||||
public function test_reimportare_l_export_della_stessa_installazione_non_cancella_niente(): void
|
||||
{
|
||||
$disco = Storage::disk('public');
|
||||
$disco->put('territori-pdf/territorio-7-3.pdf', 'pdf');
|
||||
$disco->put('territori-thumbnails/territorio-7-3.png', 'png');
|
||||
|
||||
Territorio::create([
|
||||
'numero' => '7',
|
||||
'attivo' => true,
|
||||
'pdf_path' => 'territori-pdf/territorio-7-3.pdf',
|
||||
'thumbnail_path' => 'territori-thumbnails/territorio-7-3.png',
|
||||
]);
|
||||
|
||||
$export = (new XmlExchange())->currentXmlBackup();
|
||||
|
||||
$this->post(route('xml.import-xml'), ['xmlImport' => UploadedFile::fake()->createWithContent('export.xml', $export)])
|
||||
->assertSessionHasNoErrors();
|
||||
|
||||
$disco->assertExists('territori-pdf/territorio-7-3.pdf');
|
||||
$disco->assertExists('territori-thumbnails/territorio-7-3.png');
|
||||
$this->assertSame('territori-pdf/territorio-7-3.pdf', Territorio::sole()->pdf_path);
|
||||
}
|
||||
|
||||
public function test_un_import_rifiutato_non_cancella_niente(): void
|
||||
{
|
||||
$disco = Storage::disk('public');
|
||||
$disco->put('territori-pdf/territorio-1-1.pdf', 'pdf');
|
||||
Territorio::create(['numero' => '1', 'attivo' => true, 'pdf_path' => 'territori-pdf/territorio-1-1.pdf']);
|
||||
|
||||
$this->post(route('xml.import-xml'), [
|
||||
'xmlImport' => UploadedFile::fake()->createWithContent('estraneo.xml', '<?xml version="1.0"?><sitemap/>'),
|
||||
])->assertSessionHasErrors('xmlImport');
|
||||
|
||||
$disco->assertExists('territori-pdf/territorio-1-1.pdf');
|
||||
}
|
||||
|
||||
private function xml(string $numero, string $pdfPath): UploadedFile
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?><termanager2 version="1.0">'
|
||||
. '<settings/><users/><zones/><tipologie/><proclamatori/>'
|
||||
. '<territori><territorio legacy_id="1"><numero>' . $numero . '</numero><attivo>1</attivo>'
|
||||
. '<pdf_path>' . $pdfPath . '</pdf_path></territorio></territori>'
|
||||
. '<anni_teocratici/><campagne/><assegnazioni/>'
|
||||
. '</termanager2>';
|
||||
|
||||
return UploadedFile::fake()->createWithContent('export.xml', $xml);
|
||||
}
|
||||
}
|
||||
88
tests/Feature/LogSenzaNomiTest.php
Normal file
88
tests/Feature/LogSenzaNomiTest.php
Normal file
@@ -0,0 +1,88 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\AuditLog;
|
||||
use App\Models\AnnoTeocratico;
|
||||
use App\Models\Assegnazione;
|
||||
use App\Models\Proclamatore;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Nel registro attività niente nomi di proclamatori in chiaro: sono cifrati
|
||||
* nel database e devono sparire con l'anonimizzazione.
|
||||
*/
|
||||
class LogSenzaNomiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
private Assegnazione $assegnazione;
|
||||
|
||||
private Proclamatore $proclamatore;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
|
||||
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'password-di-prova']);
|
||||
$this->admin->assignRole('amministratore');
|
||||
|
||||
$anno = AnnoTeocratico::create(['label' => '2025-2026', 'start_date' => '2025-09-01', 'end_date' => '2026-08-31']);
|
||||
$this->proclamatore = Proclamatore::create(['nome' => 'Giuseppe', 'cognome' => 'Verdi', 'attivo' => true]);
|
||||
$territorio = Territorio::create(['numero' => '7', 'attivo' => true]);
|
||||
$this->assegnazione = Assegnazione::create([
|
||||
'anno_teocratico_id' => $anno->id,
|
||||
'territorio_id' => $territorio->id,
|
||||
'proclamatore_id' => $this->proclamatore->id,
|
||||
'assigned_at' => '2026-01-10',
|
||||
'created_by' => $this->admin->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_le_voci_vecchie_perdono_il_nome_e_tengono_l_id(): void
|
||||
{
|
||||
activity()->performedOn($this->assegnazione)
|
||||
->withProperties(['territorio' => '7', 'proclamatore' => 'Giuseppe Verdi'])
|
||||
->log('assigned');
|
||||
|
||||
$migrazione = require database_path('migrations/2026_10_12_000010_nomi_proclamatori_fuori_dal_log.php');
|
||||
$migrazione->up();
|
||||
|
||||
$proprieta = json_decode(DB::table('activity_log')->where('description', 'assigned')->value('properties'), true);
|
||||
$this->assertArrayNotHasKey('proclamatore', $proprieta);
|
||||
$this->assertSame($this->proclamatore->id, $proprieta['proclamatore_id']);
|
||||
$this->assertStringNotContainsString('Verdi', DB::table('activity_log')->where('description', 'assigned')->value('properties'));
|
||||
}
|
||||
|
||||
public function test_il_registro_mostra_il_nome_di_oggi_anche_dopo_l_anonimizzazione(): void
|
||||
{
|
||||
activity()->performedOn($this->assegnazione)
|
||||
->withProperties(['territorio' => '7', 'proclamatore_id' => $this->proclamatore->id])
|
||||
->log('assigned');
|
||||
|
||||
Livewire::actingAs($this->admin)->test(AuditLog::class)->assertSee('Verdi Giuseppe');
|
||||
|
||||
$this->proclamatore->anonimizza();
|
||||
|
||||
Livewire::actingAs($this->admin)->test(AuditLog::class)
|
||||
->assertDontSee('Verdi Giuseppe')
|
||||
->assertSee('Anonimo');
|
||||
}
|
||||
|
||||
public function test_assegnare_non_scrive_il_nome_nel_log(): void
|
||||
{
|
||||
$sorgente = file_get_contents(app_path('Livewire/Assegnazioni/Assegna.php'))
|
||||
. file_get_contents(app_path('Livewire/Assegnazioni/Rientra.php'));
|
||||
|
||||
$this->assertStringNotContainsString("'proclamatore' => \$proclamatore->nome_completo", $sorgente);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,8 @@ class PiantineEditorTest extends TestCase
|
||||
$utente->assignRole('utente');
|
||||
|
||||
$this->actingAs($utente)->get(route('piantine.editor'))->assertForbidden();
|
||||
// Due utenti nella stessa sessione: AuthenticateSession butterebbe fuori il secondo.
|
||||
$this->flushSession();
|
||||
$this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk();
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ class PiantineManagerTest extends TestCase
|
||||
$utente->assignRole('utente');
|
||||
|
||||
$this->actingAs($utente)->get(route('piantine.index'))->assertForbidden();
|
||||
// Due utenti nella stessa sessione: AuthenticateSession butterebbe fuori il secondo.
|
||||
$this->flushSession();
|
||||
$this->actingAs($this->admin())->get(route('piantine.index'))->assertOk();
|
||||
}
|
||||
|
||||
|
||||
268
tests/Feature/PostoUnicoImportTest.php
Normal file
268
tests/Feature/PostoUnicoImportTest.php
Normal file
@@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\CreateBackupArchive;
|
||||
use App\Jobs\ImportTerritoryPdfFolder;
|
||||
use App\Jobs\RestoreBackupArchive;
|
||||
use App\Livewire\Settings\BackupManager;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Livewire\Settings\ZonaPericolosa;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\User;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\TerritorioPdfImportDispatcher;
|
||||
use App\Services\TerritorioThumbnailService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Il posto unico vale anche per gli import.
|
||||
*
|
||||
* Backup, ripristino e azzeramento si escludevano a vicenda, ma l'import XML e
|
||||
* l'import PDF passavano accanto al blocco: un import XML durante un ripristino
|
||||
* riscriveva le stesse tabelle, un import PDF durante un backup cambiava i file
|
||||
* mentre l'archivio li copiava. Qui si verifica il blocco nelle due direzioni e
|
||||
* che il posto si liberi anche quando l'import fallisce.
|
||||
*/
|
||||
class PostoUnicoImportTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const PDF = "%PDF-1.4\n1 0 obj << /Type /Catalog >> endobj\ntrailer << /Root 1 0 R >>\n%%EOF\n";
|
||||
|
||||
private User $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('public');
|
||||
Storage::fake('local');
|
||||
|
||||
$this->mock(TerritorioThumbnailService::class, function ($mock) {
|
||||
$mock->shouldReceive('generate')->andReturn(null);
|
||||
$mock->shouldReceive('delete');
|
||||
});
|
||||
|
||||
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$this->admin = User::create([
|
||||
'name' => 'Amministratore',
|
||||
'email' => 'admin@example.test',
|
||||
'password' => 'password-di-prova',
|
||||
]);
|
||||
$this->admin->assignRole('amministratore');
|
||||
$this->actingAs($this->admin);
|
||||
}
|
||||
|
||||
// ─── Un'operazione di backup blocca gli import ──────────────
|
||||
|
||||
public function test_l_import_pdf_da_cartella_non_parte_durante_un_ripristino(): void
|
||||
{
|
||||
Queue::fake();
|
||||
app(BackupOperationState::class)->initialize('op-restore', BackupOperationState::TIPO_RESTORE, 'Estrazione', 10);
|
||||
Territorio::create(['numero' => '1', 'attivo' => true]);
|
||||
|
||||
Livewire::test(XmlExchange::class)
|
||||
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
|
||||
->call('importTerritoryPdfFolder')
|
||||
->assertDispatched('avviso', fn ($nome, $parametri) => $parametri['tipo'] === 'errore'
|
||||
&& str_contains($parametri['testo'], 'ripristino'));
|
||||
|
||||
Queue::assertNotPushed(ImportTerritoryPdfFolder::class);
|
||||
$this->assertSame('op-restore', app(BackupOperationState::class)->corrente());
|
||||
}
|
||||
|
||||
public function test_il_controllo_prima_di_caricare_la_cartella_avvisa_se_il_posto_e_occupato(): void
|
||||
{
|
||||
app(BackupOperationState::class)->initialize('op-backup', BackupOperationState::TIPO_BACKUP, 'Compressione', 10);
|
||||
|
||||
Livewire::test(XmlExchange::class)
|
||||
->call('cartellaPuoPartire')
|
||||
->assertReturned(false)
|
||||
->assertDispatched('avviso', fn ($nome, $parametri) => $parametri['tipo'] === 'errore');
|
||||
}
|
||||
|
||||
public function test_l_import_zip_non_parte_durante_un_azzeramento(): void
|
||||
{
|
||||
Queue::fake();
|
||||
app(BackupOperationState::class)->initialize('op-reset', BackupOperationState::TIPO_RESET, 'Backup preventivo', 3);
|
||||
|
||||
$this->postJson(route('imports.territori.pdf-zip'), ['pdfZip' => $this->zip(['1.pdf' => self::PDF])])
|
||||
->assertStatus(422)
|
||||
->assertJsonFragment(['message' => app(BackupOperationState::class)->messaggioOccupato('op-reset')]);
|
||||
|
||||
Queue::assertNotPushed(ImportTerritoryPdfFolder::class);
|
||||
$this->assertSame('op-reset', app(BackupOperationState::class)->corrente());
|
||||
}
|
||||
|
||||
public function test_l_import_xml_non_parte_durante_un_backup_e_non_tocca_i_dati(): void
|
||||
{
|
||||
app(BackupOperationState::class)->initialize('op-backup', BackupOperationState::TIPO_BACKUP, 'Compressione', 10);
|
||||
Territorio::create(['numero' => '99', 'attivo' => true]);
|
||||
|
||||
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('1')])
|
||||
->assertSessionHasErrors('xmlImport');
|
||||
|
||||
$this->assertSame(['99'], Territorio::pluck('numero')->all());
|
||||
$this->assertSame('op-backup', app(BackupOperationState::class)->corrente());
|
||||
}
|
||||
|
||||
// ─── Un import blocca le operazioni di backup ───────────────
|
||||
|
||||
public function test_un_import_pdf_in_coda_blocca_backup_ripristino_e_azzeramento(): void
|
||||
{
|
||||
Queue::fake();
|
||||
Territorio::create(['numero' => '1', 'attivo' => true]);
|
||||
|
||||
Livewire::test(XmlExchange::class)
|
||||
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
|
||||
->call('importTerritoryPdfFolder');
|
||||
|
||||
Queue::assertPushed(ImportTerritoryPdfFolder::class);
|
||||
$stato = app(BackupOperationState::class);
|
||||
$import = $stato->corrente();
|
||||
$this->assertNotNull($import, 'L\'import PDF deve occupare il posto unico finche\' il job non finisce.');
|
||||
$this->assertSame(BackupOperationState::TIPO_IMPORT_PDF, $stato->get($import)['tipo']);
|
||||
|
||||
Livewire::test(BackupManager::class)
|
||||
->call('avviaBackup')
|
||||
->assertSet('erroreOperazione', $stato->messaggioOccupato($import));
|
||||
Queue::assertNotPushed(CreateBackupArchive::class);
|
||||
|
||||
Livewire::test(ZonaPericolosa::class)
|
||||
->set('confermaReset', ZonaPericolosa::CONFERMA_AZZERAMENTO)
|
||||
->call('avviaAzzeramento');
|
||||
Queue::assertNotPushed(CreateBackupArchive::class);
|
||||
|
||||
$this->postJson(route('backup.restore'), [
|
||||
'backupZip' => $this->zip(['manifest.json' => '{}']),
|
||||
])->assertStatus(409);
|
||||
Queue::assertNotPushed(RestoreBackupArchive::class);
|
||||
|
||||
$this->assertSame($import, $stato->corrente());
|
||||
}
|
||||
|
||||
public function test_un_secondo_import_non_parte_mentre_il_primo_e_in_coda(): void
|
||||
{
|
||||
Queue::fake();
|
||||
Territorio::create(['numero' => '1', 'attivo' => true]);
|
||||
|
||||
Livewire::test(XmlExchange::class)
|
||||
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
|
||||
->call('importTerritoryPdfFolder');
|
||||
|
||||
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('1')])
|
||||
->assertSessionHasErrors('xmlImport');
|
||||
|
||||
Queue::assertPushed(ImportTerritoryPdfFolder::class, 1);
|
||||
}
|
||||
|
||||
// ─── Il posto si libera ─────────────────────────────────────
|
||||
|
||||
public function test_l_import_pdf_libera_il_posto_quando_finisce(): void
|
||||
{
|
||||
$uno = Territorio::create(['numero' => '1', 'attivo' => true]);
|
||||
|
||||
Livewire::test(XmlExchange::class)
|
||||
->set('pdfFolder', [UploadedFile::fake()->createWithContent('1.pdf', self::PDF)])
|
||||
->call('importTerritoryPdfFolder')
|
||||
->assertSet('pdfImportStatus', 'completed');
|
||||
|
||||
$this->assertNotNull($uno->fresh()->pdf_path);
|
||||
$this->assertNull(app(BackupOperationState::class)->corrente());
|
||||
}
|
||||
|
||||
/**
|
||||
* failed() e' quello che Laravel chiama quando il worker uccide il job per
|
||||
* timeout: handle() non arriva a liberare niente.
|
||||
*/
|
||||
public function test_un_import_pdf_ucciso_dal_worker_libera_il_posto(): void
|
||||
{
|
||||
app(TerritorioPdfImportDispatcher::class)->occupaPosto('import-1');
|
||||
$this->assertSame('import-1', app(BackupOperationState::class)->corrente());
|
||||
|
||||
(new ImportTerritoryPdfFolder('import-1', [], $this->admin->id))
|
||||
->failed(new \RuntimeException('Job has timed out.'));
|
||||
|
||||
$this->assertNull(app(BackupOperationState::class)->corrente());
|
||||
}
|
||||
|
||||
public function test_l_import_xml_libera_il_posto_quando_riesce_e_quando_fallisce(): void
|
||||
{
|
||||
$this->post(route('xml.import-xml'), ['xmlImport' => $this->xml('1')])
|
||||
->assertSessionHasNoErrors();
|
||||
$this->assertNull(app(BackupOperationState::class)->corrente());
|
||||
|
||||
$this->post(route('xml.import-xml'), [
|
||||
'xmlImport' => UploadedFile::fake()->createWithContent('rotto.xml', '<?xml version="1.0"?><sitemap/>'),
|
||||
])->assertSessionHasErrors('xmlImport');
|
||||
$this->assertNull(app(BackupOperationState::class)->corrente());
|
||||
}
|
||||
|
||||
public function test_uno_zip_senza_pdf_libera_il_posto(): void
|
||||
{
|
||||
// L'estrazione legge lo ZIP da storage_path(), non dal disco finto.
|
||||
Storage::forgetDisk('local');
|
||||
|
||||
$this->postJson(route('imports.territori.pdf-zip'), ['pdfZip' => $this->zip(['Thumbs.db' => 'x', '.DS_Store' => 'x'])])
|
||||
->assertStatus(422)
|
||||
->assertJsonFragment(['message' => 'Lo ZIP non contiene file PDF validi.']);
|
||||
|
||||
$this->assertNull(app(BackupOperationState::class)->corrente());
|
||||
}
|
||||
|
||||
// ─── Presa del posto ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* La presa e' un passo solo: chi arriva secondo trova il posto occupato
|
||||
* anche se il primo non ha ancora finito di avviare il proprio lavoro.
|
||||
*/
|
||||
public function test_chi_arriva_secondo_trova_il_posto_occupato(): void
|
||||
{
|
||||
$stato = app(BackupOperationState::class);
|
||||
|
||||
$this->assertNull($stato->occupa('primo', BackupOperationState::TIPO_IMPORT_PDF, 'In coda'));
|
||||
$this->assertSame('primo', $stato->occupa('secondo', BackupOperationState::TIPO_RESTORE, 'In coda'));
|
||||
$this->assertNull($stato->get('secondo'), 'Chi non prende il posto non deve lasciare un\'operazione fantasma.');
|
||||
|
||||
$stato->markCompleted('primo');
|
||||
$this->assertNull($stato->occupa('terzo', BackupOperationState::TIPO_BACKUP, 'In coda'));
|
||||
}
|
||||
|
||||
// ─── Supporto ───────────────────────────────────────────────
|
||||
|
||||
private function xml(string $numero): UploadedFile
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?><termanager2 version="1.0">'
|
||||
. '<settings/><users/><zones/><tipologie/>'
|
||||
. '<proclamatori/>'
|
||||
. '<territori><territorio legacy_id="1"><numero>' . $numero . '</numero><attivo>1</attivo></territorio></territori>'
|
||||
. '<anni_teocratici/><campagne/><assegnazioni/>'
|
||||
. '</termanager2>';
|
||||
|
||||
return UploadedFile::fake()->createWithContent('export.xml', $xml);
|
||||
}
|
||||
|
||||
private function zip(array $voci): UploadedFile
|
||||
{
|
||||
$percorso = tempnam(sys_get_temp_dir(), 'zip-pdf-');
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($percorso, ZipArchive::OVERWRITE);
|
||||
foreach ($voci as $nome => $contenuto) {
|
||||
$zip->addFromString($nome, $contenuto);
|
||||
}
|
||||
$zip->close();
|
||||
|
||||
return new UploadedFile($percorso, 'territori.zip', 'application/zip', null, true);
|
||||
}
|
||||
}
|
||||
351
tests/Feature/RipristinoBackupTest.php
Normal file
351
tests/Feature/RipristinoBackupTest.php
Normal file
@@ -0,0 +1,351 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\RestoreBackupArchive;
|
||||
use App\Livewire\Settings\XmlExchange;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Models\User;
|
||||
use App\Models\Zona;
|
||||
use App\Services\BackupArchive;
|
||||
use App\Services\BackupOperationState;
|
||||
use App\Services\Piantine\PiantineBackup;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* Il ripristino da .zip, job intero: tutto o niente, piantine comprese.
|
||||
*
|
||||
* Prima import, file e blocco degli account erano tre passi separati e il
|
||||
* disco si svuotava prima di ricopiarlo: un errore a meta' lasciava il
|
||||
* database del backup con i file a meta'. E il backup non conteneva ne'
|
||||
* le piantine ne' lo storico dei PDF ne' l'area KML.
|
||||
*
|
||||
* Ogni test fa un backup "vero" (stessi servizi del job di backup), cambia
|
||||
* qualcosa, e ripristina.
|
||||
*/
|
||||
class RipristinoBackupTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $admin;
|
||||
|
||||
private User $altro;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('public');
|
||||
Storage::fake('local');
|
||||
|
||||
Role::create(['name' => 'amministratore', 'guard_name' => 'web']);
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$this->admin = User::create(['name' => 'Admin', 'email' => 'admin@example.test', 'password' => 'admin-al-backup']);
|
||||
$this->admin->assignRole('amministratore');
|
||||
|
||||
$this->altro = User::create(['name' => 'Altro', 'email' => 'altro@example.test', 'password' => 'altro-al-backup']);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$archivio = new BackupArchive();
|
||||
foreach (['backup-restore', 'backup-precedenti', 'testing-ripristino'] as $cartella) {
|
||||
$archivio->deleteDirectory(storage_path('app/' . $cartella));
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
// ─── Piantine nel backup ────────────────────────────────────
|
||||
|
||||
public function test_piantine_storico_e_area_kml_sopravvivono_al_ripristino(): void
|
||||
{
|
||||
[$territorio] = $this->installazioneConPiantine();
|
||||
$zip = $this->backup();
|
||||
|
||||
// Dopo il backup cambia tutto: piantine e storico spariscono, l'area
|
||||
// e' un'altra, compare un file estraneo.
|
||||
TerritorioMappa::query()->delete();
|
||||
TerritorioPdfVersione::query()->delete();
|
||||
Storage::disk('local')->put('piantine/area.kml', 'area successiva');
|
||||
Storage::disk('public')->put('territori-pdf/estraneo.pdf', 'dopo il backup');
|
||||
|
||||
$stato = $this->ripristina($zip);
|
||||
|
||||
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
|
||||
|
||||
// L'import ricrea i territori con id nuovi: il collegamento si ritrova
|
||||
// dal numero, non dall'id del backup.
|
||||
$nuovo = Territorio::where('numero', '12')->firstOrFail();
|
||||
$this->assertNotSame($territorio->id, $nuovo->id);
|
||||
|
||||
$mappa = TerritorioMappa::sole();
|
||||
$this->assertSame($nuovo->id, $mappa->territorio_id);
|
||||
$this->assertSame('{"type":"Polygon","coordinates":[[[13.3,38.1],[13.4,38.1],[13.4,38.2],[13.3,38.1]]]}', $mappa->geojson);
|
||||
$this->assertSame('{"type":"Polygon"}', $mappa->geojson_pubblicato);
|
||||
$this->assertSame(str_repeat('a', 64), $mappa->impronta_pubblicata);
|
||||
$this->assertSame('2026-10-01 10:00:00', $mappa->pubblicata_at->format('Y-m-d H:i:s'));
|
||||
$this->assertSame('territori-mappe/piantina-1.jpg', $mappa->image_path);
|
||||
|
||||
$versione = TerritorioPdfVersione::sole();
|
||||
$this->assertSame($nuovo->id, $versione->territorio_id);
|
||||
$this->assertTrue(Storage::disk('public')->exists($versione->pdf_path));
|
||||
|
||||
$this->assertSame('<kml>area al backup</kml>', Storage::disk('local')->get('piantine/area.kml'));
|
||||
$this->assertFalse(Storage::disk('public')->exists('territori-pdf/estraneo.pdf'));
|
||||
$this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibilita' all'indietro: un archivio di formato 1 si ripristina.
|
||||
* Non contiene piantine e storico, che restano vuoti — tenere quelle
|
||||
* correnti vorrebbe dire righe che citano immagini del disco appena
|
||||
* sostituito. L'area KML non e' legata a nessuna riga e non si tocca.
|
||||
*/
|
||||
public function test_un_archivio_di_formato_1_si_ripristina_con_piantine_vuote(): void
|
||||
{
|
||||
$this->installazioneConPiantine();
|
||||
$zip = $this->archivioFormato1();
|
||||
|
||||
$stato = $this->ripristina($zip);
|
||||
|
||||
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
|
||||
$this->assertTrue(Territorio::where('numero', '12')->exists());
|
||||
$this->assertSame(0, TerritorioMappa::count());
|
||||
$this->assertSame(0, TerritorioPdfVersione::count());
|
||||
$this->assertSame('<kml>area al backup</kml>', Storage::disk('local')->get('piantine/area.kml'));
|
||||
$this->assertSame(['territori-pdf/dal-formato-1.pdf'], Storage::disk('public')->allFiles());
|
||||
}
|
||||
|
||||
// ─── Tutto o niente ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Lo scambio dei file fallisce (la cartella dell'area e' occupata da un
|
||||
* file e non si puo' creare) dopo che l'import ha gia' riscritto le
|
||||
* tabelle. Prima l'import era gia' confermato a quel punto e il disco gia'
|
||||
* svuotato: database del backup, file a meta'. Adesso torna tutto com'era,
|
||||
* database e file.
|
||||
*/
|
||||
public function test_un_errore_sui_file_annulla_anche_il_database(): void
|
||||
{
|
||||
$this->installazioneConPiantine();
|
||||
$zip = $this->backup();
|
||||
|
||||
$this->cambiaDopoIlBackup();
|
||||
Storage::disk('local')->deleteDirectory('piantine');
|
||||
Storage::disk('local')->put('piantine', 'un file al posto della cartella');
|
||||
|
||||
$stato = $this->ripristina($zip);
|
||||
|
||||
$this->assertSame('failed', $stato['status']);
|
||||
$this->assertSituazioneDopoIlBackupIntatta();
|
||||
}
|
||||
|
||||
/**
|
||||
* Un dato delle piantine che il database rifiuta arriva dopo l'import
|
||||
* XML, nella stessa transazione: deve trascinare indietro anche l'import,
|
||||
* e i file non devono essere stati toccati.
|
||||
*/
|
||||
public function test_un_errore_sulle_piantine_annulla_l_import_e_non_tocca_i_file(): void
|
||||
{
|
||||
$this->installazioneConPiantine();
|
||||
$zip = $this->backup(function (array $piantine) {
|
||||
$piantine['territorio_mappe'][0]['geojson'] = null;
|
||||
|
||||
return $piantine;
|
||||
});
|
||||
|
||||
$this->cambiaDopoIlBackup();
|
||||
|
||||
$stato = $this->ripristina($zip);
|
||||
|
||||
$this->assertSame('failed', $stato['status']);
|
||||
$this->assertSituazioneDopoIlBackupIntatta();
|
||||
}
|
||||
|
||||
/**
|
||||
* Un archivio troncato o alterato si ferma all'estrazione: niente e'
|
||||
* stato ancora toccato.
|
||||
*/
|
||||
public function test_un_archivio_corrotto_non_tocca_niente(): void
|
||||
{
|
||||
$this->installazioneConPiantine();
|
||||
$zip = $this->backup();
|
||||
|
||||
$this->cambiaDopoIlBackup();
|
||||
|
||||
// Tronca l'archivio: la directory centrale sta in fondo, e senza
|
||||
// quella non si apre.
|
||||
$contenuto = file_get_contents($zip);
|
||||
file_put_contents($zip, substr($contenuto, 0, (int) (strlen($contenuto) * 0.6)));
|
||||
|
||||
$stato = $this->ripristina($zip);
|
||||
|
||||
$this->assertSame('failed', $stato['status']);
|
||||
$this->assertSituazioneDopoIlBackupIntatta();
|
||||
}
|
||||
|
||||
// ─── Chi ripristina resta dentro ────────────────────────────
|
||||
|
||||
/**
|
||||
* Con AuthenticateSession attivo, un hash della password cambiato chiude
|
||||
* la sessione: se il ripristino desse all'amministratore che lo esegue
|
||||
* l'hash del backup, verrebbe buttato fuori e dovrebbe rientrare con una
|
||||
* password di mesi fa. Gli altri prendono quella del backup — e devono
|
||||
* comunque cambiarla, attore compreso.
|
||||
*/
|
||||
public function test_chi_ripristina_conserva_la_sua_password_gli_altri_prendono_quella_del_backup(): void
|
||||
{
|
||||
$hashAltroAlBackup = $this->hash($this->altro);
|
||||
$zip = $this->backup();
|
||||
|
||||
$this->admin->update(['password' => 'admin-attuale']);
|
||||
$this->altro->update(['password' => 'altro-attuale']);
|
||||
$hashAdminAttuale = $this->hash($this->admin);
|
||||
|
||||
$stato = $this->ripristina($zip);
|
||||
|
||||
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
|
||||
$this->assertSame($hashAdminAttuale, $this->hash($this->admin));
|
||||
$this->assertSame($hashAltroAlBackup, $this->hash($this->altro));
|
||||
$this->assertTrue($this->admin->fresh()->hasRole('amministratore'));
|
||||
$this->assertSame(2, User::where('password_change_required', true)->count());
|
||||
}
|
||||
|
||||
/**
|
||||
* L'attore non c'e' nel backup (account creato dopo): l'import fa upsert
|
||||
* per email e non cancella nessuno, quindi il suo account resta com'e',
|
||||
* password e ruolo compresi.
|
||||
*/
|
||||
public function test_chi_ripristina_e_non_e_nel_backup_resta_con_password_e_ruolo(): void
|
||||
{
|
||||
$zip = $this->backup();
|
||||
|
||||
$nuovoAdmin = User::create(['name' => 'Nuovo', 'email' => 'nuovo@example.test', 'password' => 'nuovo-admin']);
|
||||
$nuovoAdmin->assignRole('amministratore');
|
||||
$hashNuovo = $this->hash($nuovoAdmin);
|
||||
|
||||
$stato = $this->ripristina($zip, $nuovoAdmin);
|
||||
|
||||
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
|
||||
$this->assertSame($hashNuovo, $this->hash($nuovoAdmin));
|
||||
$this->assertTrue($nuovoAdmin->fresh()->hasRole('amministratore'));
|
||||
$this->assertTrue((bool) $nuovoAdmin->fresh()->password_change_required);
|
||||
}
|
||||
|
||||
// ─── helper ─────────────────────────────────────────────────
|
||||
|
||||
/** @return array{0: Territorio} */
|
||||
private function installazioneConPiantine(): array
|
||||
{
|
||||
$zona = Zona::create(['nome' => 'Centro', 'attivo' => true]);
|
||||
|
||||
Storage::disk('public')->put('territori-pdf/piantina-12.pdf', 'pdf in vigore');
|
||||
Storage::disk('public')->put('territori-pdf-versioni/territorio-12-vecchio.pdf', 'pdf sostituito');
|
||||
Storage::disk('public')->put('territori-mappe/piantina-1.jpg', 'immagine');
|
||||
Storage::disk('local')->put('piantine/area.kml', '<kml>area al backup</kml>');
|
||||
|
||||
$territorio = Territorio::create([
|
||||
'numero' => '12',
|
||||
'zona_id' => $zona->id,
|
||||
'attivo' => true,
|
||||
'pdf_path' => 'territori-pdf/piantina-12.pdf',
|
||||
]);
|
||||
|
||||
TerritorioMappa::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'geojson' => '{"type":"Polygon","coordinates":[[[13.3,38.1],[13.4,38.1],[13.4,38.2],[13.3,38.1]]]}',
|
||||
'geojson_pubblicato' => '{"type":"Polygon"}',
|
||||
'impronta_pubblicata' => str_repeat('a', 64),
|
||||
'pubblicata_at' => '2026-10-01 10:00:00',
|
||||
'image_path' => 'territori-mappe/piantina-1.jpg',
|
||||
]);
|
||||
|
||||
TerritorioPdfVersione::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'pdf_path' => 'territori-pdf-versioni/territorio-12-vecchio.pdf',
|
||||
'origine' => 'piantine',
|
||||
'sostituito_il' => '2026-09-01 09:00:00',
|
||||
]);
|
||||
|
||||
return [$territorio];
|
||||
}
|
||||
|
||||
/** Come CreateBackupArchive, con la possibilita' di alterare piantine.json. */
|
||||
private function backup(?callable $altera = null): string
|
||||
{
|
||||
$piantine = app(PiantineBackup::class)->esporta();
|
||||
if ($altera) {
|
||||
$piantine = $altera($piantine);
|
||||
}
|
||||
|
||||
$destinazione = storage_path('app/testing-ripristino/backup.zip');
|
||||
(new BackupArchive())->create((new XmlExchange())->currentXmlBackup(), $piantine, $destinazione);
|
||||
|
||||
return $destinazione;
|
||||
}
|
||||
|
||||
/** Un pacchetto come li produceva l'applicazione prima delle piantine. */
|
||||
private function archivioFormato1(): string
|
||||
{
|
||||
$destinazione = storage_path('app/testing-ripristino/formato-1.zip');
|
||||
@mkdir(dirname($destinazione), 0775, true);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($destinazione, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('manifest.json', json_encode(['format_version' => 1, 'application' => 'TerManager2', 'files_count' => 1]));
|
||||
$zip->addFromString('data.xml', (new XmlExchange())->currentXmlBackup());
|
||||
$zip->addFromString('storage/territori-pdf/dal-formato-1.pdf', 'pdf');
|
||||
$zip->close();
|
||||
|
||||
return $destinazione;
|
||||
}
|
||||
|
||||
private function cambiaDopoIlBackup(): void
|
||||
{
|
||||
Territorio::create(['numero' => '99', 'attivo' => true]);
|
||||
TerritorioMappa::create(['nome' => 'Disegnata dopo', 'geojson' => '{}']);
|
||||
$this->altro->update(['password' => 'altro-attuale']);
|
||||
Storage::disk('public')->put('territori-pdf/estraneo.pdf', 'dopo il backup');
|
||||
}
|
||||
|
||||
/** Lo stato lasciato da cambiaDopoIlBackup(), che un ripristino fallito deve conservare. */
|
||||
private function assertSituazioneDopoIlBackupIntatta(): void
|
||||
{
|
||||
$this->assertTrue(Territorio::where('numero', '99')->exists(), 'L\'import non e\' stato annullato.');
|
||||
$this->assertTrue(TerritorioMappa::where('nome', 'Disegnata dopo')->exists());
|
||||
$this->assertSame(2, TerritorioMappa::count());
|
||||
$this->assertSame(1, TerritorioPdfVersione::count());
|
||||
$this->assertTrue(password_verify('altro-attuale', $this->hash($this->altro)));
|
||||
$this->assertSame(0, User::where('password_change_required', true)->count(), 'Gli account non vanno bloccati se il ripristino non avviene.');
|
||||
|
||||
$this->assertTrue(Storage::disk('public')->exists('territori-pdf/estraneo.pdf'), 'I file sono stati sostituiti nonostante il fallimento.');
|
||||
$this->assertTrue(Storage::disk('public')->exists('territori-pdf/piantina-12.pdf'));
|
||||
$this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg'));
|
||||
$this->assertSame([], glob(storage_path('app/backup-precedenti/*')) ?: []);
|
||||
}
|
||||
|
||||
private function ripristina(string $zip, ?User $attore = null): array
|
||||
{
|
||||
$operazione = 'ripristino-di-prova';
|
||||
app(BackupOperationState::class)->initialize($operazione, BackupOperationState::TIPO_RESTORE, 'In coda');
|
||||
|
||||
RestoreBackupArchive::dispatchSync($operazione, $zip, ($attore ?? $this->admin)->id);
|
||||
|
||||
return app(BackupOperationState::class)->get($operazione);
|
||||
}
|
||||
|
||||
private function hash(User $utente): string
|
||||
{
|
||||
return (string) DB::table('users')->where('id', $utente->id)->value('password');
|
||||
}
|
||||
}
|
||||
61
tests/Feature/TerritoriFileOrfaniTest.php
Normal file
61
tests/Feature/TerritoriFileOrfaniTest.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TerritoriFileOrfaniTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('public');
|
||||
|
||||
$disco = Storage::disk('public');
|
||||
$disco->put('territori-pdf/usato.pdf', 'x');
|
||||
$disco->put('territori-pdf/orfano.pdf', 'x');
|
||||
$disco->put('territori-thumbnails/usata.jpg', 'x');
|
||||
$disco->put('territori-pdf-versioni/versione.pdf', 'x');
|
||||
$disco->put('logo.png', 'x');
|
||||
|
||||
$territorio = Territorio::create([
|
||||
'numero' => '1',
|
||||
'attivo' => true,
|
||||
'pdf_path' => 'territori-pdf/usato.pdf',
|
||||
'thumbnail_path' => 'territori-thumbnails/usata.jpg',
|
||||
]);
|
||||
TerritorioPdfVersione::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'pdf_path' => 'territori-pdf-versioni/versione.pdf',
|
||||
'origine' => 'import',
|
||||
'sostituito_il' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_senza_elimina_elenca_e_non_tocca_niente(): void
|
||||
{
|
||||
$this->artisan('territori:file-orfani')
|
||||
->expectsOutputToContain('territori-pdf/orfano.pdf')
|
||||
->assertSuccessful();
|
||||
|
||||
Storage::disk('public')->assertExists('territori-pdf/orfano.pdf');
|
||||
}
|
||||
|
||||
public function test_con_elimina_cancella_solo_gli_orfani(): void
|
||||
{
|
||||
$this->artisan('territori:file-orfani --elimina')->assertSuccessful();
|
||||
|
||||
$disco = Storage::disk('public');
|
||||
$disco->assertMissing('territori-pdf/orfano.pdf');
|
||||
$disco->assertExists('territori-pdf/usato.pdf');
|
||||
$disco->assertExists('territori-thumbnails/usata.jpg');
|
||||
$disco->assertExists('territori-pdf-versioni/versione.pdf');
|
||||
$disco->assertExists('logo.png');
|
||||
}
|
||||
}
|
||||
134
tests/Feature/TerritorioFileTest.php
Normal file
134
tests/Feature/TerritorioFileTest.php
Normal file
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioPdfVersione;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* I file dei territori si scaricano solo da dentro, con i permessi della
|
||||
* pagina che li mostra. Prima nginx li serviva a chiunque da /storage.
|
||||
*/
|
||||
class TerritorioFileTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('public');
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
}
|
||||
|
||||
private function utente(string $ruolo): User
|
||||
{
|
||||
$utente = User::create([
|
||||
'name' => ucfirst($ruolo),
|
||||
'email' => $ruolo . '@example.test',
|
||||
'password' => 'password-di-prova',
|
||||
]);
|
||||
$utente->assignRole($ruolo);
|
||||
|
||||
return $utente;
|
||||
}
|
||||
|
||||
private function territorioConFile(): Territorio
|
||||
{
|
||||
Storage::disk('public')->put('territori-pdf/territorio-1-1.pdf', '%PDF-1.4 prova');
|
||||
Storage::disk('public')->put('territori-thumbnails/territorio-1.jpg', "\xFF\xD8\xFFprova");
|
||||
|
||||
return Territorio::create([
|
||||
'numero' => '12',
|
||||
'attivo' => true,
|
||||
'pdf_path' => 'territori-pdf/territorio-1-1.pdf',
|
||||
'thumbnail_path' => 'territori-thumbnails/territorio-1.jpg',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_senza_login_i_file_non_si_scaricano(): void
|
||||
{
|
||||
$territorio = $this->territorioConFile();
|
||||
|
||||
$this->get($territorio->pdfUrl())->assertRedirect(route('login'));
|
||||
$this->get($territorio->miniaturaUrl())->assertRedirect(route('login'));
|
||||
}
|
||||
|
||||
public function test_un_operatore_scarica_pdf_e_miniatura_in_cache_privata(): void
|
||||
{
|
||||
$territorio = $this->territorioConFile();
|
||||
$operatore = $this->utente('operatore');
|
||||
|
||||
$risposta = $this->actingAs($operatore)->get($territorio->pdfUrl());
|
||||
$risposta->assertOk();
|
||||
$risposta->assertHeader('Content-Type', 'application/pdf');
|
||||
$this->assertStringContainsString('private', $risposta->headers->get('Cache-Control'));
|
||||
$this->assertStringContainsString('territorio-12.pdf', $risposta->headers->get('Content-Disposition'));
|
||||
|
||||
$this->actingAs($operatore)->get($territorio->miniaturaUrl())->assertOk();
|
||||
}
|
||||
|
||||
public function test_un_territorio_nel_cestino_resta_raggiungibile(): void
|
||||
{
|
||||
$territorio = $this->territorioConFile();
|
||||
$territorio->delete();
|
||||
|
||||
$this->actingAs($this->utente('assistente'))
|
||||
->get(route('territori.file.miniatura', $territorio->id))
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_un_file_mancante_risponde_404(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '13', 'attivo' => true, 'pdf_path' => 'territori-pdf/sparito.pdf']);
|
||||
|
||||
$this->actingAs($this->utente('operatore'))
|
||||
->get(route('territori.file.pdf', $territorio->id))
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_le_versioni_archiviate_sono_solo_dell_amministratore(): void
|
||||
{
|
||||
$territorio = $this->territorioConFile();
|
||||
Storage::disk('public')->put('territori-pdf-versioni/vecchia.pdf', '%PDF-1.4 vecchia');
|
||||
$versione = TerritorioPdfVersione::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'pdf_path' => 'territori-pdf-versioni/vecchia.pdf',
|
||||
'origine' => 'piantine',
|
||||
'sostituito_il' => now(),
|
||||
]);
|
||||
|
||||
$this->actingAs($this->utente('operatore'))
|
||||
->get(route('territori.file.versione', $versione))
|
||||
->assertForbidden();
|
||||
|
||||
// Due utenti nella stessa sessione: AuthenticateSession butterebbe fuori il secondo.
|
||||
$this->flushSession();
|
||||
$this->actingAs($this->utente('amministratore'))
|
||||
->get(route('territori.file.versione', $versione))
|
||||
->assertOk();
|
||||
}
|
||||
|
||||
public function test_l_indirizzo_cambia_quando_cambia_il_file(): void
|
||||
{
|
||||
$territorio = $this->territorioConFile();
|
||||
$prima = $territorio->pdfUrl();
|
||||
|
||||
$territorio->update(['pdf_path' => 'territori-pdf/territorio-1-2.pdf']);
|
||||
|
||||
$this->assertNotSame($prima, $territorio->pdfUrl());
|
||||
$this->assertNull(Territorio::create(['numero' => '99', 'attivo' => true])->pdfUrl());
|
||||
}
|
||||
|
||||
/** nginx non deve servire le cartelle dei territori (il logo sì). */
|
||||
public function test_nginx_blocca_le_cartelle_dei_territori(): void
|
||||
{
|
||||
$conf = file_get_contents(base_path('docker/nginx/default.conf'));
|
||||
|
||||
$this->assertMatchesRegularExpression('#location \^~ /storage/territori- \{\s*return 404;#', $conf);
|
||||
}
|
||||
}
|
||||
@@ -186,6 +186,41 @@ class XmlExchangeImportTest extends TestCase
|
||||
$this->assertTrue($altro->hasRole('assistente'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le credenziali che arrivano da un file non sono più segrete: chi riceve
|
||||
* l'hash dal file deve cambiare password, e il "ricordami" non viaggia.
|
||||
*/
|
||||
public function test_le_credenziali_del_file_obbligano_a_cambiare_password(): void
|
||||
{
|
||||
$this->admin->forceFill(['remember_token' => 'token-attuale'])->save();
|
||||
$hash = Hash::make('password-del-file');
|
||||
$xml = $this->xmlMinimo(
|
||||
utenti: '<user legacy_id="9"><name>Amministratore</name><email>admin@example.test</email><password>' . $hash . '</password><remember_token>vecchio</remember_token><roles></roles></user>'
|
||||
. '<user legacy_id="10"><name>Altro</name><email>altro@example.test</email><password>' . $hash . '</password><remember_token>rubato</remember_token><roles><role>assistente</role></roles></user>',
|
||||
);
|
||||
|
||||
$this->assertArrayNotHasKey('error', $this->importa($xml));
|
||||
|
||||
$altro = User::where('email', 'altro@example.test')->sole();
|
||||
$this->assertTrue((bool) $altro->password_change_required);
|
||||
$this->assertNull($altro->remember_token);
|
||||
|
||||
// Chi importa resta com'era.
|
||||
$admin = $this->admin->fresh();
|
||||
$this->assertFalse((bool) $admin->password_change_required);
|
||||
$this->assertSame('token-attuale', $admin->getRememberToken());
|
||||
}
|
||||
|
||||
public function test_l_export_non_contiene_i_remember_token(): void
|
||||
{
|
||||
$this->admin->forceFill(['remember_token' => 'token-segreto'])->save();
|
||||
|
||||
$xml = (new XmlExchange())->currentXmlBackup();
|
||||
|
||||
$this->assertStringNotContainsString('remember_token', $xml);
|
||||
$this->assertStringNotContainsString('token-segreto', $xml);
|
||||
}
|
||||
|
||||
// ─── helper ─────────────────────────────────────────────────
|
||||
|
||||
private function reimporta(?string $xml = null): void
|
||||
|
||||
@@ -19,6 +19,11 @@ abstract class TestCase extends BaseTestCase
|
||||
|
||||
$app->make(Kernel::class)->bootstrap();
|
||||
|
||||
// Setting tiene l'istanza in una variabile statica, che sopravvive da
|
||||
// un test all'altro nello stesso processo: un test si ritrovava il
|
||||
// modello di quello prima, con una riga che nel suo database non c'e'.
|
||||
\App\Models\Setting::flushCache();
|
||||
|
||||
return $app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,9 @@ use ZipArchive;
|
||||
*/
|
||||
class BackupArchiveTest extends TestCase
|
||||
{
|
||||
/** piantine.json di un'installazione senza piantine. */
|
||||
private const PIANTINE_VUOTE = ['territorio_mappe' => [], 'territorio_pdf_versioni' => []];
|
||||
|
||||
private BackupArchive $archivio;
|
||||
|
||||
/** Cartella dei file .zip prodotti dai test, ripulita a ogni giro. */
|
||||
@@ -31,6 +34,9 @@ class BackupArchiveTest extends TestCase
|
||||
parent::setUp();
|
||||
|
||||
Storage::fake('public');
|
||||
// Anche il disco `local`: l'archivio ci legge l'area KML delle
|
||||
// piantine, e senza finto prenderebbe quella vera della macchina.
|
||||
Storage::fake('local');
|
||||
|
||||
$this->archivio = new BackupArchive();
|
||||
$this->lavoro = storage_path('app/testing-backup');
|
||||
@@ -47,6 +53,7 @@ class BackupArchiveTest extends TestCase
|
||||
// reale, non il disco finto, quindi va ripulito a mano.
|
||||
$this->archivio->deleteDirectory($this->lavoro);
|
||||
$this->archivio->deleteDirectory(storage_path('app/backup-restore'));
|
||||
$this->archivio->deleteDirectory(storage_path('app/backup-precedenti'));
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
@@ -60,7 +67,7 @@ class BackupArchiveTest extends TestCase
|
||||
Storage::disk('public')->put('logo.svg', 'logo');
|
||||
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
|
||||
|
||||
$this->assertFileExists($destinazione);
|
||||
|
||||
@@ -68,9 +75,9 @@ class BackupArchiveTest extends TestCase
|
||||
|
||||
$this->assertSame('<dataset/>', $esito['xml']);
|
||||
$this->assertSame(3, $esito['files']);
|
||||
$this->assertStringEqualsFile($esito['staging'] . '/territori-pdf/1.pdf', 'contenuto-uno');
|
||||
$this->assertStringEqualsFile($esito['staging'] . '/territori-thumbnails/1.png', 'miniatura-uno');
|
||||
$this->assertStringEqualsFile($esito['staging'] . '/logo.svg', 'logo');
|
||||
$this->assertStringEqualsFile($esito['pubblico'] . '/territori-pdf/1.pdf', 'contenuto-uno');
|
||||
$this->assertStringEqualsFile($esito['pubblico'] . '/territori-thumbnails/1.png', 'miniatura-uno');
|
||||
$this->assertStringEqualsFile($esito['pubblico'] . '/logo.svg', 'logo');
|
||||
}
|
||||
|
||||
public function test_il_manifest_dichiara_versione_di_formato_e_conteggio(): void
|
||||
@@ -79,7 +86,7 @@ class BackupArchiveTest extends TestCase
|
||||
Storage::disk('public')->put('territori-pdf/2.pdf', 'y');
|
||||
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$this->assertTrue($zip->open($destinazione) === true);
|
||||
@@ -94,7 +101,7 @@ class BackupArchiveTest extends TestCase
|
||||
public function test_il_pacchetto_di_un_installazione_senza_file_contiene_comunque_i_dati(): void
|
||||
{
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
|
||||
|
||||
$esito = $this->archivio->extract($destinazione);
|
||||
|
||||
@@ -116,6 +123,7 @@ class BackupArchiveTest extends TestCase
|
||||
$avanzamenti = [];
|
||||
$this->archivio->create(
|
||||
'<dataset/>',
|
||||
self::PIANTINE_VUOTE,
|
||||
$this->lavoro . '/backup.zip',
|
||||
function (int $fatti, int $totale) use (&$avanzamenti) {
|
||||
$avanzamenti[] = [$fatti, $totale];
|
||||
@@ -156,7 +164,7 @@ class BackupArchiveTest extends TestCase
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
|
||||
try {
|
||||
$this->archivio->create('<dataset/>', $destinazione);
|
||||
$this->archivio->create('<dataset/>', self::PIANTINE_VUOTE, $destinazione);
|
||||
$this->fail('create() avrebbe dovuto sollevare su un file non leggibile.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->assertStringContainsString('territori-pdf/sparito.pdf', $e->getMessage());
|
||||
@@ -290,9 +298,9 @@ class BackupArchiveTest extends TestCase
|
||||
$esito = $this->archivio->extract($zip);
|
||||
|
||||
$this->assertSame(1, $esito['files']);
|
||||
$this->assertFileExists($esito['staging'] . '/territori-pdf/1.pdf');
|
||||
$this->assertFileDoesNotExist($esito['staging'] . '/README.txt');
|
||||
$this->assertDirectoryDoesNotExist($esito['staging'] . '/appunti');
|
||||
$this->assertFileExists($esito['pubblico'] . '/territori-pdf/1.pdf');
|
||||
$this->assertFileDoesNotExist($esito['pubblico'] . '/README.txt');
|
||||
$this->assertDirectoryDoesNotExist($esito['pubblico'] . '/appunti');
|
||||
}
|
||||
|
||||
// ─── safeRelativePath(): la regola dello zip slip ───────────
|
||||
@@ -335,7 +343,109 @@ class BackupArchiveTest extends TestCase
|
||||
];
|
||||
}
|
||||
|
||||
// ─── restoreFiles() / svuotaDiscoPubblico() ─────────────────
|
||||
/**
|
||||
* Un'entry alterata dentro l'archivio deve fermare il ripristino durante
|
||||
* l'estrazione, a installazione intatta. Prima il risultato della copia
|
||||
* non si guardava: un file troncato o corrotto finiva nello staging e da
|
||||
* li' al posto del PDF buono, con il ripristino dichiarato riuscito.
|
||||
*/
|
||||
public function test_un_entry_corrotta_ferma_l_estrazione(): void
|
||||
{
|
||||
$path = $this->lavoro . '/corrotto.zip';
|
||||
|
||||
$zip = new ZipArchive();
|
||||
$zip->open($path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
|
||||
$zip->addFromString('manifest.json', $this->manifest());
|
||||
$zip->addFromString('data.xml', '<dataset/>');
|
||||
$zip->addFromString('piantine.json', json_encode(self::PIANTINE_VUOTE));
|
||||
$zip->addFromString('storage/territori-pdf/1.pdf', 'contenuto-originale-del-pdf');
|
||||
// Senza compressione il contenuto sta in chiaro nel file: lo si puo'
|
||||
// alterare a mano lasciando intatti intestazioni e CRC dichiarato.
|
||||
$zip->setCompressionName('storage/territori-pdf/1.pdf', ZipArchive::CM_STORE);
|
||||
$zip->close();
|
||||
|
||||
file_put_contents($path, str_replace(
|
||||
'contenuto-originale-del-pdf',
|
||||
'contenuto-ALTERATO--del-pdf',
|
||||
file_get_contents($path)
|
||||
));
|
||||
|
||||
try {
|
||||
$this->archivio->extract($path);
|
||||
$this->fail('extract() avrebbe dovuto accorgersi dell\'entry alterata.');
|
||||
} catch (RuntimeException $e) {
|
||||
$this->assertStringContainsString('territori-pdf/1.pdf', $e->getMessage());
|
||||
}
|
||||
|
||||
$radice = storage_path('app/backup-restore');
|
||||
$this->assertTrue(! is_dir($radice) || glob($radice . '/*') === []);
|
||||
}
|
||||
|
||||
// ─── piantine nel pacchetto ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* L'area KML sta in storage/app/piantine, accanto al disco `public` e non
|
||||
* dentro: prima nessun backup la conteneva, e dopo un ripristino su volumi
|
||||
* vuoti andava ricaricata a mano.
|
||||
*/
|
||||
public function test_il_pacchetto_porta_l_area_kml_e_i_dati_delle_piantine(): void
|
||||
{
|
||||
Storage::disk('local')->put('piantine/area.kml', '<kml/>');
|
||||
Storage::disk('local')->put('piantine/config.json', '{"kml_nome":"citta.kml"}');
|
||||
Storage::disk('public')->put('territori-mappe/piantina-1.jpg', 'immagine');
|
||||
|
||||
$dati = ['territorio_mappe' => [['id' => 1, 'geojson' => '{}']], 'territorio_pdf_versioni' => []];
|
||||
|
||||
$destinazione = $this->lavoro . '/backup.zip';
|
||||
$this->archivio->create('<dataset/>', $dati, $destinazione);
|
||||
|
||||
$esito = $this->archivio->extract($destinazione);
|
||||
|
||||
$this->assertSame($dati, $esito['piantine']);
|
||||
$this->assertSame(1, $esito['files']);
|
||||
$this->assertSame(2, $esito['file_piantine']);
|
||||
$this->assertStringEqualsFile($esito['staging'] . '/piantine/area.kml', '<kml/>');
|
||||
$this->assertStringEqualsFile($esito['pubblico'] . '/territori-mappe/piantina-1.jpg', 'immagine');
|
||||
}
|
||||
|
||||
public function test_dal_formato_2_piantine_json_e_obbligatorio(): void
|
||||
{
|
||||
$zip = $this->zipCon([
|
||||
'manifest.json' => $this->manifest(),
|
||||
'data.xml' => '<dataset/>',
|
||||
'piantine.json' => null,
|
||||
]);
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessageMatches('/piantine\.json/');
|
||||
|
||||
$this->archivio->extract($zip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibilita' all'indietro: un archivio di formato 1 non ha
|
||||
* piantine.json, e si estrae lo stesso. Le entry piantine/ non si
|
||||
* prendono: quel formato non le prevedeva.
|
||||
*/
|
||||
public function test_un_archivio_di_formato_1_si_estrae_senza_piantine(): void
|
||||
{
|
||||
$zip = $this->zipCon([
|
||||
'manifest.json' => $this->manifest(1),
|
||||
'data.xml' => '<dataset/>',
|
||||
'piantine.json' => null,
|
||||
'storage/territori-pdf/1.pdf' => 'pdf',
|
||||
'piantine/area.kml' => '<kml/>',
|
||||
]);
|
||||
|
||||
$esito = $this->archivio->extract($zip);
|
||||
|
||||
$this->assertNull($esito['piantine']);
|
||||
$this->assertSame(1, $esito['formato']);
|
||||
$this->assertSame(0, $esito['file_piantine']);
|
||||
$this->assertDirectoryDoesNotExist($esito['staging'] . '/piantine');
|
||||
}
|
||||
|
||||
// ─── scambiaFile() / svuotaDiscoPubblico() ──────────────────
|
||||
|
||||
/**
|
||||
* La sostituzione del disco e' totale, non un merge: i percorsi vivono
|
||||
@@ -346,20 +456,89 @@ class BackupArchiveTest extends TestCase
|
||||
{
|
||||
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'da buttare');
|
||||
Storage::disk('public')->put('logo.svg', 'logo vecchio');
|
||||
Storage::disk('local')->put('piantine/area.kml', 'area vecchia');
|
||||
|
||||
$staging = $this->lavoro . '/staging';
|
||||
mkdir($staging . '/territori-pdf', 0775, true);
|
||||
file_put_contents($staging . '/territori-pdf/nuovo.pdf', 'da tenere');
|
||||
file_put_contents($staging . '/logo.svg', 'logo nuovo');
|
||||
$estratto = $this->staging([
|
||||
'pubblico/territori-pdf/nuovo.pdf' => 'da tenere',
|
||||
'pubblico/logo.svg' => 'logo nuovo',
|
||||
]);
|
||||
|
||||
$ripristinati = $this->archivio->restoreFiles($staging);
|
||||
$scambio = $this->archivio->scambiaFile($estratto);
|
||||
$this->archivio->confermaScambio($scambio);
|
||||
|
||||
$this->assertSame(2, $ripristinati);
|
||||
$this->assertSame(2, $scambio['file']);
|
||||
$this->assertSame(
|
||||
['logo.svg', 'territori-pdf/nuovo.pdf'],
|
||||
collect(Storage::disk('public')->allFiles())->sort()->values()->all()
|
||||
);
|
||||
$this->assertSame('logo nuovo', Storage::disk('public')->get('logo.svg'));
|
||||
|
||||
// Formato 2 senza area nell'archivio: l'installazione salvata non ne
|
||||
// aveva, e il ripristino la rispecchia.
|
||||
$this->assertSame([], Storage::disk('local')->allFiles('piantine'));
|
||||
$this->assertDirectoryDoesNotExist($scambio['accantonati'], 'A ripristino confermato i file vecchi non servono piu\'.');
|
||||
}
|
||||
|
||||
public function test_un_archivio_di_formato_1_non_tocca_l_area_kml(): void
|
||||
{
|
||||
Storage::disk('local')->put('piantine/area.kml', 'area in uso');
|
||||
|
||||
$this->archivio->confermaScambio($this->archivio->scambiaFile($this->staging([], 1)));
|
||||
|
||||
$this->assertSame('area in uso', Storage::disk('local')->get('piantine/area.kml'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Il cuore della correzione: prima il disco si svuotava e poi si
|
||||
* ricopiava file per file, quindi un errore a meta' lasciava una parte
|
||||
* dei file nuovi e nessuno dei vecchi. Qui lo scambio fallisce dopo aver
|
||||
* gia' sostituito il disco `public` (la cartella dell'area e' occupata da
|
||||
* un file, mkdir non puo' crearla) e deve rimettere tutto com'era.
|
||||
*/
|
||||
public function test_uno_scambio_fallito_a_meta_rimette_i_file_vecchi(): void
|
||||
{
|
||||
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'pdf in uso');
|
||||
Storage::disk('local')->put('piantine', 'un file al posto della cartella');
|
||||
|
||||
$estratto = $this->staging([
|
||||
'pubblico/territori-pdf/nuovo.pdf' => 'pdf del backup',
|
||||
'piantine/area.kml' => 'area del backup',
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->archivio->scambiaFile($estratto);
|
||||
$this->fail('scambiaFile() avrebbe dovuto sollevare.');
|
||||
} catch (RuntimeException) {
|
||||
// atteso
|
||||
}
|
||||
|
||||
$this->assertSame(['territori-pdf/vecchio.pdf'], Storage::disk('public')->allFiles());
|
||||
$this->assertSame('pdf in uso', Storage::disk('public')->get('territori-pdf/vecchio.pdf'));
|
||||
$this->assertFileExists($estratto['staging'] . '/pubblico/territori-pdf/nuovo.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Lo scambio e' riuscito ma la transazione non va in porto (commit
|
||||
* fallito): il job chiama annullaScambio() e i file devono seguire il
|
||||
* database, cioe' tornare quelli di prima.
|
||||
*/
|
||||
public function test_l_annullamento_dopo_uno_scambio_riuscito_rimette_i_file_vecchi(): void
|
||||
{
|
||||
Storage::disk('public')->put('territori-pdf/vecchio.pdf', 'pdf in uso');
|
||||
Storage::disk('local')->put('piantine/area.kml', 'area in uso');
|
||||
|
||||
$scambio = $this->archivio->scambiaFile($this->staging([
|
||||
'pubblico/territori-pdf/nuovo.pdf' => 'pdf del backup',
|
||||
'piantine/area.kml' => 'area del backup',
|
||||
]));
|
||||
|
||||
$this->assertSame(['territori-pdf/nuovo.pdf'], Storage::disk('public')->allFiles());
|
||||
|
||||
$this->archivio->annullaScambio($scambio);
|
||||
|
||||
$this->assertSame(['territori-pdf/vecchio.pdf'], Storage::disk('public')->allFiles());
|
||||
$this->assertSame('area in uso', Storage::disk('local')->get('piantine/area.kml'));
|
||||
$this->assertDirectoryDoesNotExist($scambio['accantonati']);
|
||||
}
|
||||
|
||||
public function test_lo_svuotamento_conta_i_file_rimossi_e_lascia_il_disco_vuoto(): void
|
||||
@@ -382,10 +561,18 @@ class BackupArchiveTest extends TestCase
|
||||
// ─── helper ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Un archivio con data.xml riceve anche un piantine.json vuoto, se non
|
||||
* ne ha uno: dal formato 2 e' obbligatorio, e senza ogni test sui rifiuti
|
||||
* si fermerebbe su quello invece che sul difetto che vuole provare.
|
||||
*
|
||||
* @param array<string, string> $entries
|
||||
*/
|
||||
private function zipCon(array $entries): string
|
||||
{
|
||||
if (isset($entries['data.xml']) && ! array_key_exists('piantine.json', $entries)) {
|
||||
$entries['piantine.json'] = json_encode(self::PIANTINE_VUOTE);
|
||||
}
|
||||
|
||||
$path = $this->lavoro . '/costruito-' . count($entries) . '-' . md5(serialize($entries)) . '.zip';
|
||||
|
||||
$zip = new ZipArchive();
|
||||
@@ -395,7 +582,10 @@ class BackupArchiveTest extends TestCase
|
||||
);
|
||||
|
||||
foreach ($entries as $nome => $contenuto) {
|
||||
$zip->addFromString($nome, $contenuto);
|
||||
// null: l'entry non c'e' (serve a togliere piantine.json).
|
||||
if ($contenuto !== null) {
|
||||
$zip->addFromString($nome, $contenuto);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
@@ -403,6 +593,36 @@ class BackupArchiveTest extends TestCase
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uno staging come lo lascia extract(), costruito a mano.
|
||||
*
|
||||
* @param array<string, string> $file percorso nello staging => contenuto
|
||||
*/
|
||||
private function staging(array $file, int $formato = BackupArchive::FORMAT_VERSION): array
|
||||
{
|
||||
$staging = storage_path('app/backup-restore/prova-' . md5(serialize($file) . $formato));
|
||||
|
||||
$pubblici = 0;
|
||||
$piantine = 0;
|
||||
foreach ($file as $percorso => $contenuto) {
|
||||
@mkdir(dirname($staging . '/' . $percorso), 0775, true);
|
||||
file_put_contents($staging . '/' . $percorso, $contenuto);
|
||||
if (str_starts_with($percorso, 'piantine/')) {
|
||||
$piantine++;
|
||||
} else {
|
||||
$pubblici++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'formato' => $formato,
|
||||
'staging' => $staging,
|
||||
'pubblico' => $staging . '/pubblico',
|
||||
'files' => $pubblici,
|
||||
'file_piantine' => $piantine,
|
||||
];
|
||||
}
|
||||
|
||||
private function manifest(?int $formatVersion = null): string
|
||||
{
|
||||
return json_encode([
|
||||
|
||||
Reference in New Issue
Block a user