Piantine staccate dai territori, editor a schermo intero, restyle
Le piantine non dipendono piu' da un territorio esistente: territorio_id e' nullable, la chiave esterna e' nullOnDelete invece di cascadeOnDelete, e `nome` fa da etichetta finche' il collegamento manca. Si disegna guardando le strade e si decide dopo di chi e' il disegno; se il territorio non c'e', lo si crea dall'editor con numero, zona, tipologia e confini — zona e tipologia comprese, create sul posto quando mancano. Il cambio di cascata chiude anche una perdita di dati silenziosa: ogni import XML svuota `territori` in blocco e finora si portava via tutte le piantine. Il disegno passa su /piantine/editor, a schermo intero con telaio proprio: su una mappa larga un terzo di schermo un confine si sbaglia, e finisce stampato. /piantine resta la gestione — area, generazione, collegamenti, storico. Cartolina S-12 reimpaginata: pagina 1 il modulo con la Localita' (che ora e' il nome della zona, non un campo duplicato) e i confini scritti nello spazio centrale, con il corpo del testo che si adatta alla lunghezza; pagina 2 la piantina sola al 98%. Aggiunta l'anteprima, che compone il PDF al volo senza toccare pdf_path ne' lo storico. Immagine: JPEG invece di PNG (0,9 MB contro 5 per territorio), composta alle tessere dello zoom piu' alto e ridotta con Lanczos, nelle proporzioni della pagina cosi' riempie il foglio invece di lasciare fasce bianche. Il tratto del confine e' espresso in pixel dell'immagine finale: si vede il confine e si legge la via che ci passa sotto. L'attribuzione OSM si dimensiona con l'immagine, come la ODbL richiede. Rimossa la rigenerazione massiva: job, stato in cache e interfaccia. Sistema visivo ripassato su tutta l'applicazione: le scale gray e indigo di Tailwind sono ridefinite nei token, cosi' le 37 viste si riallineano insieme invece di essere riscritte una per una. Grigi caldi, un accento solo con quattro significati, carattere servito dall'applicazione e non da Google Fonts, via il gradiente della testata. Aggiunto il link "salta al contenuto", che mancava. Corretto un guasto d'ambiente che bloccava TUTTI gli upload in sviluppo: dalla 1.26 il fastcgi_params di nginx passa HTTP_HOST come $host, che perde la porta, e ogni URL firmato smetteva di validare — l'endpoint di upload di Livewire rispondeva 401. 294 test verdi. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -67,6 +67,7 @@ class PagineApplicativeTest extends TestCase
|
||||
'tipologie.index',
|
||||
'xml.exchange',
|
||||
'piantine.index',
|
||||
'piantine.editor',
|
||||
'privacy',
|
||||
'assegnazioni.assegna',
|
||||
];
|
||||
|
||||
220
tests/Feature/PiantineEditorTest.php
Normal file
220
tests/Feature/PiantineEditorTest.php
Normal file
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Piantine\PiantineEditor;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\Tipologia;
|
||||
use App\Models\User;
|
||||
use App\Models\Zona;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Livewire\Livewire;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* L'editor a schermo intero: si disegna prima e si decide dopo a chi
|
||||
* appartiene il disegno — territorio compreso, che può nascere qui.
|
||||
*/
|
||||
class PiantineEditorTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const GEOMETRIA = [
|
||||
'type' => 'Polygon',
|
||||
'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]],
|
||||
];
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->withoutVite();
|
||||
Storage::fake('public');
|
||||
Storage::fake('local');
|
||||
}
|
||||
|
||||
public function test_la_pagina_e_riservata_all_amministratore(): void
|
||||
{
|
||||
Role::findOr('utente', fn () => Role::create(['name' => 'utente', 'guard_name' => 'web']));
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
$utente = User::create([
|
||||
'name' => 'Utente semplice',
|
||||
'email' => 'utente@example.test',
|
||||
'password' => 'password-di-prova',
|
||||
]);
|
||||
$utente->assignRole('utente');
|
||||
|
||||
$this->actingAs($utente)->get(route('piantine.editor'))->assertForbidden();
|
||||
$this->actingAs($this->admin())->get(route('piantine.editor'))->assertOk();
|
||||
}
|
||||
|
||||
public function test_una_piantina_si_salva_senza_nessun_territorio(): void
|
||||
{
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->set('nome', 'Zona porto — lato nord')
|
||||
->call('salva');
|
||||
|
||||
$mappa = TerritorioMappa::sole();
|
||||
$this->assertNull($mappa->territorio_id);
|
||||
$this->assertSame('Zona porto — lato nord', $mappa->nome);
|
||||
$this->assertSame(self::GEOMETRIA, $mappa->geometria());
|
||||
$this->assertSame('Zona porto — lato nord', $mappa->etichetta());
|
||||
}
|
||||
|
||||
public function test_il_territorio_si_crea_dalla_scheda_con_tutte_le_proprieta(): void
|
||||
{
|
||||
$zona = Zona::create(['nome' => 'Nord', 'attivo' => true]);
|
||||
$tipologia = Tipologia::create(['nome' => 'Residenziale', 'attivo' => true]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->set('territorioScelto', 'nuovo')
|
||||
->set('numero', '42')
|
||||
->set('zonaId', (string) $zona->id)
|
||||
->set('tipologiaId', (string) $tipologia->id)
|
||||
->set('confini', "Nord: via Roma\nSud: via Milano")
|
||||
->call('salva')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$territorio = Territorio::where('numero', '42')->sole();
|
||||
$this->assertTrue((bool) $territorio->attivo);
|
||||
$this->assertSame($zona->id, $territorio->zona_id);
|
||||
$this->assertSame($tipologia->id, $territorio->tipologia_id);
|
||||
$this->assertStringContainsString('via Roma', $territorio->confini);
|
||||
$this->assertSame($territorio->id, TerritorioMappa::sole()->territorio_id);
|
||||
}
|
||||
|
||||
public function test_la_scheda_aggiorna_un_territorio_gia_esistente(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->set('territorioScelto', (string) $territorio->id)
|
||||
->set('numero', '12')
|
||||
->set('confini', 'Nord: via Verdi')
|
||||
->call('salva')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$this->assertSame('Nord: via Verdi', $territorio->fresh()->confini);
|
||||
$this->assertSame(1, Territorio::count());
|
||||
}
|
||||
|
||||
public function test_zona_e_tipologia_si_creano_dall_editor_e_restano_selezionate(): void
|
||||
{
|
||||
$componente = Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->set('territorioScelto', 'nuovo')
|
||||
->set('nuovaZona', 'Litorale')
|
||||
->call('creaZona')
|
||||
->assertHasNoErrors()
|
||||
->set('nuovaTipologia', 'Commerciale')
|
||||
->call('creaTipologia')
|
||||
->assertHasNoErrors();
|
||||
|
||||
$zona = Zona::where('nome', 'Litorale')->sole();
|
||||
$tipologia = Tipologia::where('nome', 'Commerciale')->sole();
|
||||
|
||||
$componente->assertSet('zonaId', (string) $zona->id);
|
||||
$componente->assertSet('tipologiaId', (string) $tipologia->id);
|
||||
}
|
||||
|
||||
public function test_una_zona_gia_esistente_non_viene_duplicata(): void
|
||||
{
|
||||
Zona::create(['nome' => 'Nord', 'attivo' => true]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->set('nuovaZona', 'Nord')
|
||||
->call('creaZona')
|
||||
->assertHasErrors('nuovaZona');
|
||||
|
||||
$this->assertSame(1, Zona::where('nome', 'Nord')->count());
|
||||
}
|
||||
|
||||
public function test_un_numero_gia_in_uso_non_crea_un_secondo_territorio(): void
|
||||
{
|
||||
Territorio::create(['numero' => '42', 'attivo' => true]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->set('territorioScelto', 'nuovo')
|
||||
->set('numero', '42')
|
||||
->call('salva')
|
||||
->assertHasErrors('numero');
|
||||
|
||||
$this->assertSame(1, Territorio::where('numero', '42')->count());
|
||||
$this->assertSame(0, TerritorioMappa::count());
|
||||
}
|
||||
|
||||
public function test_ridisegnare_sostituisce_il_poligono_senza_duplicare_la_piantina(): void
|
||||
{
|
||||
$mappa = TerritorioMappa::create([
|
||||
'nome' => 'Zona porto',
|
||||
'geojson' => '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}',
|
||||
]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class, ['mappa' => $mappa->id])
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->call('salva');
|
||||
|
||||
$this->assertSame(1, TerritorioMappa::count());
|
||||
$this->assertSame(self::GEOMETRIA, $mappa->fresh()->geometria());
|
||||
}
|
||||
|
||||
public function test_una_geometria_non_valida_viene_ignorata(): void
|
||||
{
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: ['type' => 'Point', 'coordinates' => [1, 2]])
|
||||
->call('salva');
|
||||
|
||||
$this->assertSame(0, TerritorioMappa::count());
|
||||
}
|
||||
|
||||
public function test_non_si_ruba_il_territorio_a_un_altra_piantina(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
TerritorioMappa::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'geojson' => json_encode(self::GEOMETRIA),
|
||||
]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineEditor::class)
|
||||
->dispatch('poligono-disegnato', geometry: self::GEOMETRIA)
|
||||
->set('territorioScelto', (string) $territorio->id)
|
||||
->set('numero', $territorio->numero)
|
||||
->call('salva');
|
||||
|
||||
// La seconda piantina non deve essere stata creata.
|
||||
$this->assertSame(1, TerritorioMappa::count());
|
||||
}
|
||||
|
||||
private function admin(): User
|
||||
{
|
||||
Role::findOr('amministratore', fn () => Role::create(['name' => 'amministratore', 'guard_name' => 'web']));
|
||||
app(PermissionRegistrar::class)->forgetCachedPermissions();
|
||||
|
||||
Setting::firstOrCreate([], ['congregazione_nome' => 'Congregazione di prova']);
|
||||
|
||||
$utente = User::firstOrCreate(
|
||||
['email' => 'admin@example.test'],
|
||||
['name' => 'Amministratore', 'password' => 'password-di-prova'],
|
||||
);
|
||||
$utente->assignRole('amministratore');
|
||||
|
||||
return $utente;
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,13 @@
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Jobs\RigeneraPiantine;
|
||||
use App\Livewire\Piantine\PiantineManager;
|
||||
use App\Models\Setting;
|
||||
use App\Models\Territorio;
|
||||
use App\Models\TerritorioMappa;
|
||||
use App\Models\User;
|
||||
use App\Services\Piantine\PiantineOperationState;
|
||||
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;
|
||||
@@ -19,8 +16,8 @@ use Spatie\Permission\PermissionRegistrar;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* La pagina Piantine: accesso riservato, salvataggio dei poligoni disegnati,
|
||||
* caricamento del KML e avvio della rigenerazione massiva.
|
||||
* La pagina di gestione delle piantine: accesso riservato, area della città,
|
||||
* e il legame — che si può fare e disfare — fra una piantina e un territorio.
|
||||
*/
|
||||
class PiantineManagerTest extends TestCase
|
||||
{
|
||||
@@ -50,58 +47,6 @@ class PiantineManagerTest extends TestCase
|
||||
$this->actingAs($this->admin())->get(route('piantine.index'))->assertOk();
|
||||
}
|
||||
|
||||
public function test_il_poligono_disegnato_viene_salvato_sul_territorio(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
$geometry = [
|
||||
'type' => 'Polygon',
|
||||
'coordinates' => [[[13.35, 38.11], [13.37, 38.11], [13.37, 38.13], [13.35, 38.11]]],
|
||||
];
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->set('territorioSelezionato', $territorio->id)
|
||||
->dispatch('poligono-disegnato', geometry: $geometry)
|
||||
->call('salvaPoligono');
|
||||
|
||||
$mappa = TerritorioMappa::sole();
|
||||
$this->assertSame($territorio->id, $mappa->territorio_id);
|
||||
$this->assertSame($geometry, $mappa->geometria());
|
||||
}
|
||||
|
||||
public function test_ridisegnare_sostituisce_il_poligono_senza_duplicarlo(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
TerritorioMappa::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'geojson' => '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}',
|
||||
]);
|
||||
|
||||
$nuova = ['type' => 'Polygon', 'coordinates' => [[[5, 5], [6, 5], [6, 6], [5, 5]]]];
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->set('territorioSelezionato', $territorio->id)
|
||||
->dispatch('poligono-disegnato', geometry: $nuova)
|
||||
->call('salvaPoligono');
|
||||
|
||||
$this->assertSame(1, TerritorioMappa::count());
|
||||
$this->assertSame($nuova, TerritorioMappa::sole()->geometria());
|
||||
}
|
||||
|
||||
public function test_una_geometria_non_valida_viene_ignorata(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->set('territorioSelezionato', $territorio->id)
|
||||
->dispatch('poligono-disegnato', geometry: ['type' => 'Point', 'coordinates' => [1, 2]])
|
||||
->call('salvaPoligono');
|
||||
|
||||
$this->assertSame(0, TerritorioMappa::count());
|
||||
}
|
||||
|
||||
public function test_il_kml_caricato_diventa_l_area_attiva(): void
|
||||
{
|
||||
$kml = <<<'XML'
|
||||
@@ -132,41 +77,118 @@ XML;
|
||||
->assertHasErrors('kmlFile');
|
||||
}
|
||||
|
||||
public function test_rigenera_tutto_accoda_il_job_e_inizializza_lo_stato(): void
|
||||
public function test_una_piantina_si_collega_a_un_territorio(): void
|
||||
{
|
||||
Queue::fake();
|
||||
|
||||
$territorio = Territorio::create(['numero' => '3', 'attivo' => true]);
|
||||
TerritorioMappa::create([
|
||||
'territorio_id' => $territorio->id,
|
||||
'geojson' => '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]}',
|
||||
]);
|
||||
|
||||
$componente = Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->call('rigeneraTutto', true);
|
||||
|
||||
Queue::assertPushed(RigeneraPiantine::class, fn ($job) => $job->anchePdf === true);
|
||||
|
||||
$state = app(PiantineOperationState::class);
|
||||
$operazioneId = $componente->get('operazioneId');
|
||||
$this->assertNotNull($operazioneId);
|
||||
$this->assertSame(1, $state->get($operazioneId)['total']);
|
||||
|
||||
// Con un'operazione aperta, un secondo avvio deve essere rifiutato.
|
||||
$componente->call('rigeneraTutto', false);
|
||||
Queue::assertPushed(RigeneraPiantine::class, 1);
|
||||
}
|
||||
|
||||
public function test_rigenera_tutto_senza_poligoni_non_accoda_nulla(): void
|
||||
{
|
||||
Queue::fake();
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
$mappa = $this->piantina(['nome' => 'Zona porto']);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->call('rigeneraTutto', false);
|
||||
->call('apriCollegamento', $mappa->id)
|
||||
->set('territorioDaCollegare', $territorio->id)
|
||||
->call('collega', $mappa->id);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
$this->assertSame($territorio->id, $mappa->fresh()->territorio_id);
|
||||
}
|
||||
|
||||
public function test_un_territorio_che_ha_gia_una_piantina_non_ne_accetta_una_seconda(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
$this->piantina(['territorio_id' => $territorio->id]);
|
||||
$seconda = $this->piantina(['nome' => 'Doppione']);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->call('apriCollegamento', $seconda->id)
|
||||
->set('territorioDaCollegare', $territorio->id)
|
||||
->call('collega', $seconda->id);
|
||||
|
||||
$this->assertNull($seconda->fresh()->territorio_id);
|
||||
}
|
||||
|
||||
public function test_scollegare_conserva_la_piantina_e_le_da_un_nome(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
$mappa = $this->piantina(['territorio_id' => $territorio->id]);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->call('scollega', $mappa->id);
|
||||
|
||||
$mappa->refresh();
|
||||
$this->assertNull($mappa->territorio_id);
|
||||
$this->assertSame('Ex territorio 12', $mappa->nome);
|
||||
$this->assertDatabaseCount('territorio_mappe', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* La cascata di prima si portava via il disegno insieme al territorio — e
|
||||
* l'import XML svuota `territori` in blocco a ogni ripristino.
|
||||
*/
|
||||
public function test_cancellare_il_territorio_lascia_in_piedi_la_piantina(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '12', 'attivo' => true]);
|
||||
$mappa = $this->piantina(['territorio_id' => $territorio->id]);
|
||||
|
||||
$territorio->forceDelete();
|
||||
|
||||
$this->assertDatabaseCount('territorio_mappe', 1);
|
||||
$this->assertNull($mappa->fresh()->territorio_id);
|
||||
}
|
||||
|
||||
public function test_senza_territorio_il_pdf_non_si_genera(): void
|
||||
{
|
||||
$mappa = $this->piantina(['nome' => 'Zona porto']);
|
||||
|
||||
Livewire::actingAs($this->admin())
|
||||
->test(PiantineManager::class)
|
||||
->call('generaPdf', $mappa->id);
|
||||
|
||||
$this->assertDatabaseCount('territorio_pdf_versioni', 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* L'anteprima serve a guardare la cartolina PRIMA di mandarla in vigore:
|
||||
* se toccasse pdf_path o lo storico, non sarebbe un'anteprima.
|
||||
*/
|
||||
public function test_l_anteprima_mostra_il_pdf_senza_sostituire_niente(): void
|
||||
{
|
||||
$territorio = Territorio::create(['numero' => '7', 'attivo' => true, 'confini' => 'Nord: via Roma']);
|
||||
$mappa = $this->piantina([
|
||||
'territorio_id' => $territorio->id,
|
||||
'image_path' => 'territori-mappe/piantina-prova.png',
|
||||
]);
|
||||
|
||||
$immagine = imagecreatetruecolor(80, 50);
|
||||
ob_start();
|
||||
imagepng($immagine);
|
||||
Storage::disk('public')->put($mappa->image_path, ob_get_clean());
|
||||
imagedestroy($immagine);
|
||||
|
||||
$risposta = $this->actingAs($this->admin())->get(route('piantine.anteprima', $mappa));
|
||||
|
||||
$risposta->assertOk();
|
||||
$risposta->assertHeader('Content-Type', 'application/pdf');
|
||||
$this->assertStringStartsWith('%PDF', $risposta->getContent());
|
||||
|
||||
$this->assertNull($territorio->fresh()->pdf_path);
|
||||
$this->assertSame(0, \App\Models\TerritorioPdfVersione::count());
|
||||
}
|
||||
|
||||
public function test_l_anteprima_di_una_piantina_scollegata_non_esiste(): void
|
||||
{
|
||||
$mappa = $this->piantina(['nome' => 'Scollegata']);
|
||||
|
||||
$this->actingAs($this->admin())
|
||||
->get(route('piantine.anteprima', $mappa))
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
private function piantina(array $valori = []): TerritorioMappa
|
||||
{
|
||||
return TerritorioMappa::create(array_merge([
|
||||
'geojson' => '{"type":"Polygon","coordinates":[[[13.35,38.11],[13.37,38.11],[13.37,38.13],[13.35,38.11]]]}',
|
||||
], $valori));
|
||||
}
|
||||
|
||||
private function admin(): User
|
||||
|
||||
Reference in New Issue
Block a user