Piantine pubblicate anche dopo un ripristino; posto unico senza gare
- Lo stato "Pubblicata" non dipende più dall'id del territorio, che il ripristino ricrea: l'impronta usa il numero, e la piantina ricorda il percorso del PDF pubblicato (pdf_pubblicato, con migrazione che riconosce le piantine già pubblicate). - BackupOperationState: corrente() in sola lettura e liberaCorrente() sotto lo stesso mutex di occupa(), così una lettura non cancella il puntatore di un'operazione appena avviata. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
11
HANDOFF.md
11
HANDOFF.md
@@ -84,7 +84,8 @@ Commit su `dev`, **nessuno pubblicato** (il push fa partire il deploy sulla demo
|
||||
- `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).
|
||||
- `426a952` — blocchi 2 e 3 della revisione (sotto); le ultime due righe
|
||||
della tabella sono nel commit successivo.
|
||||
|
||||
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.
|
||||
@@ -111,22 +112,20 @@ sviluppo, più la demo da `dev`). Nessuna correzione una tantum da fare.
|
||||
| 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`) |
|
||||
| Dopo un ripristino le piantine pubblicate risultavano "da pubblicare" (lo stato dipendeva dall'id del territorio) | impronta con il numero, non l'id; la piantina ricorda il PDF pubblicato (`pdf_pubblicato`, migrazione `2026_10_12_000020`) |
|
||||
| `corrente()` / `liberaCorrente()` potevano cancellare il puntatore di un'operazione appena avviata | `corrente()` in sola lettura; `liberaCorrente()` sotto lo stesso mutex di `occupa()` |
|
||||
| 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 |
|
||||
|
||||
Non cambiato, di proposito: `trustProxies('*')` (commento in
|
||||
`bootstrap/app.php`: con `real_ip` di nginx, restringerlo romperebbe HTTPS).
|
||||
|
||||
Suite: **427 test verdi**. Provati in browser in locale: file protetti (404
|
||||
Suite: **429 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).
|
||||
|
||||
**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
|
||||
|
||||
@@ -33,6 +33,7 @@ class TerritorioMappa extends Model
|
||||
'geojson',
|
||||
'geojson_pubblicato',
|
||||
'impronta_pubblicata',
|
||||
'pdf_pubblicato',
|
||||
'pubblicata_at',
|
||||
'image_path',
|
||||
'rendered_at',
|
||||
@@ -110,9 +111,11 @@ class TerritorioMappa extends Model
|
||||
{
|
||||
$territorio = $this->territorio;
|
||||
|
||||
// Il territorio entra con il numero, non con l'id: il numero è unico e
|
||||
// il ripristino di un backup ricrea i territori con id nuovi, che
|
||||
// facevano risultare da pubblicare ogni piantina appena ripristinata.
|
||||
return hash('sha256', json_encode([
|
||||
$this->geometria(),
|
||||
$this->territorio_id,
|
||||
$territorio ? trim((string) $territorio->numero) : null,
|
||||
$territorio?->zona?->nome,
|
||||
$territorio ? trim((string) $territorio->confini) : null,
|
||||
@@ -131,15 +134,19 @@ class TerritorioMappa extends Model
|
||||
}
|
||||
|
||||
/**
|
||||
* Il PDF in vigore del territorio è uno composto da una piantina. Se nel
|
||||
* frattempo è stato sostituito per altre vie (ripristino di una versione,
|
||||
* caricamento a mano, import), quello che arriva ai proclamatori non è
|
||||
* più questo disegno, anche se l'impronta torna.
|
||||
* Il PDF in vigore del territorio è ancora quello prodotto dall'ultima
|
||||
* pubblicazione. Se nel frattempo è stato sostituito per altre vie
|
||||
* (ripristino di una versione, caricamento a mano, import), quello che
|
||||
* arriva ai proclamatori non è più questo disegno, anche se l'impronta
|
||||
* torna. Si confronta il percorso, che il backup conserva: prima si
|
||||
* cercava l'id del territorio nel nome del file, e dopo un ripristino
|
||||
* (id nuovi) non tornava più.
|
||||
*/
|
||||
public function pdfInUsoDallaPiantina(): bool
|
||||
{
|
||||
return $this->territorio_id !== null
|
||||
&& str_starts_with((string) $this->territorio?->pdf_path, 'territori-pdf/piantina-' . $this->territorio_id . '-');
|
||||
return $this->pdf_pubblicato !== null
|
||||
&& $this->territorio !== null
|
||||
&& $this->territorio->pdf_path === $this->pdf_pubblicato;
|
||||
}
|
||||
|
||||
/** Il testo dell'etichetta di stato, uguale in ogni pagina. */
|
||||
|
||||
@@ -112,10 +112,12 @@ class BackupOperationState
|
||||
$stato = $this->get($id);
|
||||
|
||||
// Puntatore orfano (stato scaduto) o operazione gia' chiusa: il posto
|
||||
// e' libero, e il puntatore va tolto di mezzo.
|
||||
// e' libero. Il puntatore pero' qui non si tocca: corrente() la
|
||||
// chiamano anche le pagine che si aggiornano, fuori dal mutex di
|
||||
// occupa(), e cancellarlo poteva togliere quello che un'altra
|
||||
// richiesta aveva appena scritto per un'operazione nuova. Lo
|
||||
// sovrascrive initialize() al prossimo avvio.
|
||||
if ($stato === null || in_array($stato['status'] ?? '', ['completed', 'failed'], true)) {
|
||||
Cache::forget(self::CHIAVE_CORRENTE);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -296,9 +298,18 @@ class BackupOperationState
|
||||
*/
|
||||
private function liberaCorrente(string $id): void
|
||||
{
|
||||
// Leggi-e-cancella sotto lo stesso mutex di occupa(): fra i due passi
|
||||
// un'altra richiesta poteva prendere il posto, e il forget le toglieva
|
||||
// il puntatore. Se il mutex non arriva si lascia stare: il puntatore a
|
||||
// un'operazione chiusa vale già come posto libero (vedi corrente()).
|
||||
try {
|
||||
Cache::lock(self::CHIAVE_AVVIO, 10)->block(5, function () use ($id) {
|
||||
if (Cache::get(self::CHIAVE_CORRENTE) === $id) {
|
||||
Cache::forget(self::CHIAVE_CORRENTE);
|
||||
}
|
||||
});
|
||||
} catch (LockTimeoutException) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -176,6 +176,9 @@ class PubblicazionePiantina
|
||||
$mappa->update([
|
||||
'geojson_pubblicato' => $mappa->geojson,
|
||||
'impronta_pubblicata' => $stato['impronta'],
|
||||
// Il PDF appena messo in vigore: finché il territorio usa questo
|
||||
// file, la piantina è "Pubblicata" (vedi pdfInUsoDallaPiantina()).
|
||||
'pdf_pubblicato' => $mappa->territorio->fresh()->pdf_path,
|
||||
'pubblicata_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
use App\Models\TerritorioMappa;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* La piantina ricorda il percorso del PDF che ha pubblicato.
|
||||
*
|
||||
* Prima "Pubblicata" si riconosceva dall'id del territorio, sia
|
||||
* nell'impronta sia nel nome del file (territori-pdf/piantina-{id}-…): il
|
||||
* ripristino di un backup ricrea i territori con id nuovi, e ogni piantina
|
||||
* pubblicata tornava "da pubblicare". Ora l'impronta usa il numero e lo stato
|
||||
* confronta il percorso del PDF in vigore con quello registrato qui; entrambi
|
||||
* attraversano il ripristino intatti.
|
||||
*
|
||||
* Le piantine già pubblicate restano tali solo se lo erano davvero: impronta
|
||||
* corrispondente (con la formula di prima, o con la nuova se la migrazione
|
||||
* precedente gira adesso su un'installazione nuova) e PDF in vigore composto
|
||||
* da questa piantina.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->string('pdf_pubblicato')->nullable()->after('impronta_pubblicata');
|
||||
});
|
||||
|
||||
TerritorioMappa::with('territorio.zona')
|
||||
->whereNotNull('pubblicata_at')
|
||||
->whereNotNull('territorio_id')
|
||||
->get()
|
||||
->each(function (TerritorioMappa $mappa) {
|
||||
$territorio = $mappa->territorio;
|
||||
$pdf = (string) $territorio?->pdf_path;
|
||||
|
||||
$improntaVecchia = hash('sha256', json_encode([
|
||||
$mappa->geometria(),
|
||||
$mappa->territorio_id,
|
||||
$territorio ? trim((string) $territorio->numero) : null,
|
||||
$territorio?->zona?->nome,
|
||||
$territorio ? trim((string) $territorio->confini) : null,
|
||||
]));
|
||||
$improntaNuova = $mappa->impronta();
|
||||
|
||||
$eraPubblicata = in_array($mappa->impronta_pubblicata, [$improntaVecchia, $improntaNuova], true)
|
||||
&& str_starts_with($pdf, 'territori-pdf/piantina-' . $mappa->territorio_id . '-');
|
||||
|
||||
if (! $eraPubblicata) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mappa->forceFill([
|
||||
'impronta_pubblicata' => $improntaNuova,
|
||||
'pdf_pubblicato' => $pdf,
|
||||
])->saveQuietly();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('territorio_mappe', function (Blueprint $table) {
|
||||
$table->dropColumn('pdf_pubblicato');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -212,6 +212,7 @@ XML;
|
||||
$mappa->update([
|
||||
'geojson_pubblicato' => $mappa->geojson,
|
||||
'impronta_pubblicata' => $mappa->impronta(),
|
||||
'pdf_pubblicato' => $pdf,
|
||||
'pubblicata_at' => now(),
|
||||
]);
|
||||
|
||||
|
||||
@@ -105,6 +105,26 @@ class RipristinoBackupTest extends TestCase
|
||||
$this->assertTrue(Storage::disk('public')->exists('territori-mappe/piantina-1.jpg'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Una piantina pubblicata resta "Pubblicata" anche se il ripristino ricrea
|
||||
* il suo territorio con un id nuovo: prima lo stato dipendeva dall'id.
|
||||
*/
|
||||
public function test_una_piantina_pubblicata_resta_pubblicata_dopo_il_ripristino(): void
|
||||
{
|
||||
$this->installazioneConPiantine();
|
||||
$mappa = TerritorioMappa::with('territorio.zona')->sole();
|
||||
$mappa->update([
|
||||
'impronta_pubblicata' => $mappa->impronta(),
|
||||
'pdf_pubblicato' => $mappa->territorio->pdf_path,
|
||||
]);
|
||||
$this->assertSame(TerritorioMappa::STATO_PUBBLICATA, $mappa->fresh()->stato());
|
||||
|
||||
$stato = $this->ripristina($this->backup());
|
||||
|
||||
$this->assertSame('completed', $stato['status'], (string) $stato['error']);
|
||||
$this->assertSame(TerritorioMappa::STATO_PUBBLICATA, TerritorioMappa::sole()->stato());
|
||||
}
|
||||
|
||||
/**
|
||||
* Compatibilita' all'indietro: un archivio di formato 1 si ripristina.
|
||||
* Non contiene piantine e storico, che restano vuoti — tenere quelle
|
||||
|
||||
@@ -65,15 +65,27 @@ class BackupOperationStateTest extends TestCase
|
||||
|
||||
/**
|
||||
* Il caso che il TTL da solo non copre: lo stato dell'operazione e' scaduto
|
||||
* ma il puntatore no. Senza questa pulizia il posto resterebbe occupato da
|
||||
* un'operazione che non esiste piu'.
|
||||
* ma il puntatore no. Il posto deve risultare libero e potersi prendere;
|
||||
* il puntatore lo sovrascrive l'operazione nuova (corrente() non lo
|
||||
* cancella: girando fuori dal mutex poteva togliere quello di un'altra).
|
||||
*/
|
||||
public function test_un_puntatore_orfano_non_tiene_occupato_il_posto(): void
|
||||
{
|
||||
Cache::put(self::CHIAVE_CORRENTE, 'op-svanita', 3600);
|
||||
|
||||
$this->assertNull($this->stato->corrente());
|
||||
$this->assertNull(Cache::get(self::CHIAVE_CORRENTE), 'Il puntatore orfano va anche rimosso.');
|
||||
$this->assertNull($this->stato->occupa('op-nuova', BackupOperationState::TIPO_BACKUP, 'In coda'));
|
||||
$this->assertSame('op-nuova', $this->stato->corrente());
|
||||
}
|
||||
|
||||
/** Un corrente() fuori dal mutex non deve togliere il puntatore appena scritto. */
|
||||
public function test_corrente_non_cancella_il_puntatore(): void
|
||||
{
|
||||
Cache::put(self::CHIAVE_CORRENTE, 'op-svanita', 3600);
|
||||
|
||||
$this->stato->corrente();
|
||||
|
||||
$this->assertSame('op-svanita', Cache::get(self::CHIAVE_CORRENTE));
|
||||
}
|
||||
|
||||
public function test_un_puntatore_a_un_operazione_gia_chiusa_non_tiene_occupato_il_posto(): void
|
||||
@@ -90,7 +102,7 @@ class BackupOperationStateTest extends TestCase
|
||||
Cache::put(self::CHIAVE_CORRENTE, 'op-1', 3600);
|
||||
|
||||
$this->assertNull($this->stato->corrente());
|
||||
$this->assertNull(Cache::get(self::CHIAVE_CORRENTE));
|
||||
$this->assertNull($this->stato->occupa('op-2', BackupOperationState::TIPO_BACKUP, 'In coda'));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user