++ gruppi di servizio: utenza dell'ex sorvegliante riattivabile, cestino e anonimizzazione, registro senza nomi
Un ex sorvegliante puo' guidare un gruppo nuovo: si riattiva la sua utenza. Il sorvegliante di un gruppo attivo non si cestina ne' si anonimizza; anonimizzando un ex sorvegliante si anonimizza anche l'utenza. Nel registro delle attivita' nome ed email dell'utenza del sorvegliante diventano "proclamatore #id". La voce "Il mio gruppo" dipende dal ruolo: l'amministratore passa ogni @can. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
14
HANDOFF.md
14
HANDOFF.md
@@ -157,6 +157,20 @@ e non vede nient'altro. Scelte dell'utente:
|
|||||||
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I
|
`LimitaSorvegliante`), gruppo archiviato (`sciolto_at`) per lo storico. I
|
||||||
territori già dati restano ai membri. Si riparte con un gruppo nuovo.
|
territori già dati restano ai membri. Si riparte con un gruppo nuovo.
|
||||||
|
|
||||||
|
Casi aggiunti di mio, da far confermare all'utente:
|
||||||
|
- Un ex sorvegliante può guidare un gruppo nuovo: `crea()` **riattiva** la
|
||||||
|
sua utenza disattivata (un proclamatore, un'utenza: `users.proclamatore_id`
|
||||||
|
è unico), con email e password temporanea nuove.
|
||||||
|
- Il sorvegliante di un gruppo **attivo** non si sposta nel cestino né si
|
||||||
|
anonimizza (`ProclamatoreIndex`): prima si scioglie il gruppo. Un membro
|
||||||
|
spostato nel cestino lascia il gruppo. `Proclamatore::anonimizza()`
|
||||||
|
anonimizza anche l'utenza dell'ex sorvegliante (nome, email, password,
|
||||||
|
ruoli).
|
||||||
|
- L'utenza del sorvegliante porta nome ed email del proclamatore: nel registro
|
||||||
|
delle attività `User::tapActivity()` li sostituisce con "proclamatore #id",
|
||||||
|
come vale per tutti i proclamatori. Restano invece in chiaro nella tabella
|
||||||
|
`users`, come per ogni utente.
|
||||||
|
|
||||||
Dove sta:
|
Dove sta:
|
||||||
- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella
|
- Migrazione `2026_10_20_000010_gruppi_di_servizio`: tabella
|
||||||
`gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e
|
`gruppi_servizio`, colonne `gruppo_servizio_id` su proclamatori, territori e
|
||||||
|
|||||||
@@ -758,7 +758,10 @@ docker compose --profile dev up -d --build
|
|||||||
ancora dati tornano in reparto, i membri si liberano, l'utenza viene
|
ancora dati tornano in reparto, i membri si liberano, l'utenza viene
|
||||||
disattivata (anche una sessione aperta si chiude) e il gruppo resta nello
|
disattivata (anche una sessione aperta si chiude) e il gruppo resta nello
|
||||||
storico. I territori già dati restano ai membri. Per ripartire si crea un
|
storico. I territori già dati restano ai membri. Per ripartire si crea un
|
||||||
gruppo nuovo
|
gruppo nuovo; se il sorvegliante è lo stesso, si riattiva la sua utenza
|
||||||
|
- Il sorvegliante di un gruppo attivo non si sposta nel cestino né si
|
||||||
|
anonimizza: prima si scioglie il gruppo. Anonimizzando un ex sorvegliante
|
||||||
|
si anonimizza anche la sua utenza
|
||||||
|
|
||||||
### Campagne
|
### Campagne
|
||||||
- CRUD con date di inizio e fine
|
- CRUD con date di inizio e fine
|
||||||
@@ -858,7 +861,8 @@ una revoca ha effetto immediato senza bisogno di ricaricare la pagina.
|
|||||||
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
|
- **Diritto all'oblio**: funzione di anonimizzazione irreversibile
|
||||||
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
|
- **Retention**: cancellazione automatica dei log oltre il periodo configurato
|
||||||
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
|
- **No dati sensibili nei log**: l'audit registra gli identificativi, non i nomi
|
||||||
in chiaro
|
in chiaro (anche per l'utenza di un sorvegliante di gruppo, che porta il nome
|
||||||
|
di un proclamatore)
|
||||||
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
|
- **Attenzione ai backup**: il pacchetto `.zip` e l'export XML contengono i dati
|
||||||
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
|
dei proclamatori **in chiaro** (l'export li legge decifrati). Vanno custoditi
|
||||||
in un luogo protetto e cancellati quando non servono
|
in un luogo protetto e cancellati quando non servono
|
||||||
|
|||||||
@@ -77,10 +77,10 @@ class GruppiIndex extends Component
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
// Possono diventare sorveglianti i proclamatori attivi senza gruppo e
|
// Possono diventare sorveglianti i proclamatori attivi senza gruppo e
|
||||||
// senza già un'utenza.
|
// senza un'utenza attiva (quella di un gruppo sciolto si riattiva).
|
||||||
$candidati = Proclamatore::attivi()
|
$candidati = Proclamatore::attivi()
|
||||||
->whereNull('gruppo_servizio_id')
|
->whereNull('gruppo_servizio_id')
|
||||||
->whereDoesntHave('utente')
|
->whereDoesntHave('utente', fn ($q) => $q->whereNull('disattivato_at'))
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
return view('livewire.gruppi.gruppi-index', [
|
return view('livewire.gruppi.gruppi-index', [
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Livewire\Concerns\Avvisa;
|
|||||||
use Livewire\Attributes\Url;
|
use Livewire\Attributes\Url;
|
||||||
use Livewire\Component;
|
use Livewire\Component;
|
||||||
use Livewire\WithPagination;
|
use Livewire\WithPagination;
|
||||||
|
use App\Models\GruppoServizio;
|
||||||
use App\Models\Proclamatore;
|
use App\Models\Proclamatore;
|
||||||
|
|
||||||
class ProclamatoreIndex extends Component
|
class ProclamatoreIndex extends Component
|
||||||
@@ -70,7 +71,12 @@ class ProclamatoreIndex extends Component
|
|||||||
$this->avvisaErrore("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;
|
return;
|
||||||
}
|
}
|
||||||
|
if ($this->sorveglianteDiUnGruppo($proclamatore, 'spostarlo nel cestino')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nel cestino non resta membro di un gruppo.
|
||||||
|
$proclamatore->update(['gruppo_servizio_id' => null]);
|
||||||
$proclamatore->delete();
|
$proclamatore->delete();
|
||||||
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
$this->avvisa("Proclamatore {$proclamatore->nome_completo} spostato nel cestino.");
|
||||||
}
|
}
|
||||||
@@ -80,10 +86,27 @@ class ProclamatoreIndex extends Component
|
|||||||
$this->authorize('settings.manage');
|
$this->authorize('settings.manage');
|
||||||
|
|
||||||
$proclamatore = Proclamatore::findOrFail($id);
|
$proclamatore = Proclamatore::findOrFail($id);
|
||||||
|
if ($this->sorveglianteDiUnGruppo($proclamatore, 'anonimizzarlo')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$proclamatore->anonimizza();
|
$proclamatore->anonimizza();
|
||||||
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
|
$this->avvisa("Dati personali del proclamatore anonimizzati (GDPR).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Il sorvegliante di un gruppo attivo si toglie sciogliendo il gruppo, non da qui. */
|
||||||
|
private function sorveglianteDiUnGruppo(Proclamatore $proclamatore, string $azione): bool
|
||||||
|
{
|
||||||
|
$gruppo = GruppoServizio::attivi()->where('sorvegliante_id', $proclamatore->id)->first();
|
||||||
|
if (! $gruppo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->avvisaErrore("{$proclamatore->nome_completo} è il sorvegliante del gruppo {$gruppo->nome}: per {$azione} va prima sciolto il gruppo (Gruppi di servizio).");
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public function render()
|
public function render()
|
||||||
{
|
{
|
||||||
// withCount rather than letting the view call
|
// withCount rather than letting the view call
|
||||||
|
|||||||
@@ -108,7 +108,22 @@ class Proclamatore extends Model
|
|||||||
$this->nome = 'Anonimo';
|
$this->nome = 'Anonimo';
|
||||||
$this->cognome = 'Proclamatore #' . $this->id;
|
$this->cognome = 'Proclamatore #' . $this->id;
|
||||||
$this->attivo = false;
|
$this->attivo = false;
|
||||||
|
$this->gruppo_servizio_id = null;
|
||||||
$this->save();
|
$this->save();
|
||||||
$this->delete(); // soft delete
|
$this->delete(); // soft delete
|
||||||
|
|
||||||
|
// L'utenza di un ex sorvegliante (già disattivata: il sorvegliante di
|
||||||
|
// un gruppo attivo non si anonimizza) porta nome ed email in chiaro.
|
||||||
|
$utente = $this->utente()->first();
|
||||||
|
if ($utente) {
|
||||||
|
$utente->syncRoles([]);
|
||||||
|
$utente->forceFill([
|
||||||
|
'name' => 'Anonimo',
|
||||||
|
'email' => "utente-{$utente->id}@anonimo.invalid",
|
||||||
|
'password' => \Illuminate\Support\Str::random(40),
|
||||||
|
'remember_token' => null,
|
||||||
|
'disattivato_at' => $utente->disattivato_at ?? now(),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,32 @@ class User extends Authenticatable implements MustVerifyEmail
|
|||||||
->dontSubmitEmptyLogs();
|
->dontSubmitEmptyLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'utenza di un sorvegliante porta nome (ed email, spesso) di un
|
||||||
|
* proclamatore: nel registro delle attività i proclamatori compaiono solo
|
||||||
|
* con l'identificativo, quindi anche qui.
|
||||||
|
*/
|
||||||
|
public function tapActivity(\Spatie\Activitylog\Contracts\Activity $activity, string $eventName): void
|
||||||
|
{
|
||||||
|
$proclamatoreId = $this->proclamatore_id ?? $this->getOriginal('proclamatore_id');
|
||||||
|
if (! $proclamatoreId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$activity->properties = collect($activity->properties)->map(function ($valori) use ($proclamatoreId) {
|
||||||
|
if (! is_array($valori)) {
|
||||||
|
return $valori;
|
||||||
|
}
|
||||||
|
foreach (['name', 'email'] as $campo) {
|
||||||
|
if (array_key_exists($campo, $valori)) {
|
||||||
|
$valori[$campo] = "proclamatore #{$proclamatoreId}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $valori;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected static function booted(): void
|
protected static function booted(): void
|
||||||
{
|
{
|
||||||
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
|
// Un indirizzo nuovo va confermato di nuovo, chiunque lo cambi.
|
||||||
|
|||||||
@@ -42,28 +42,32 @@ class GruppiServizio
|
|||||||
if ($sorvegliante->gruppo_servizio_id) {
|
if ($sorvegliante->gruppo_servizio_id) {
|
||||||
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo.");
|
throw new InvalidArgumentException("{$sorvegliante->nome_completo} fa già parte di un altro gruppo.");
|
||||||
}
|
}
|
||||||
if ($sorvegliante->utente()->exists()) {
|
// Chi è stato sorvegliante di un gruppo sciolto ha già un'utenza,
|
||||||
|
// disattivata: si riattiva quella (un proclamatore, un'utenza).
|
||||||
|
$esistente = $sorvegliante->utente()->first();
|
||||||
|
if ($esistente && ! $esistente->isDisattivato()) {
|
||||||
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
|
throw new InvalidArgumentException("{$sorvegliante->nome_completo} ha già un'utenza.");
|
||||||
}
|
}
|
||||||
if (User::where('email', $email)->exists()) {
|
if (User::where('email', $email)->when($esistente, fn ($q) => $q->whereKeyNot($esistente->id))->exists()) {
|
||||||
throw new InvalidArgumentException("L'email {$email} è già usata da un altro utente.");
|
throw new InvalidArgumentException("L'email {$email} è già usata da un altro utente.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$password = Str::password(12, symbols: false);
|
$password = Str::password(12, symbols: false);
|
||||||
|
|
||||||
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $email, $membri, $password) {
|
$gruppo = DB::transaction(function () use ($nome, $sorvegliante, $email, $membri, $password, $esistente) {
|
||||||
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
|
$gruppo = GruppoServizio::create(['nome' => $nome, 'sorvegliante_id' => $sorvegliante->id]);
|
||||||
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
|
$sorvegliante->update(['gruppo_servizio_id' => $gruppo->id]);
|
||||||
$this->aggiungiMembri($gruppo, $membri);
|
$this->aggiungiMembri($gruppo, $membri);
|
||||||
|
|
||||||
$utente = User::create([
|
$utente = $esistente ?? new User(['proclamatore_id' => $sorvegliante->id]);
|
||||||
|
$utente->forceFill([
|
||||||
'name' => $sorvegliante->nome_completo,
|
'name' => $sorvegliante->nome_completo,
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'password' => $password,
|
'password' => $password,
|
||||||
'password_change_required' => true,
|
'password_change_required' => true,
|
||||||
'proclamatore_id' => $sorvegliante->id,
|
'disattivato_at' => null,
|
||||||
]);
|
])->save();
|
||||||
$utente->assignRole('sorvegliante');
|
$utente->syncRoles(['sorvegliante']);
|
||||||
|
|
||||||
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
activity()->causedBy(auth()->user())->performedOn($gruppo)
|
||||||
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
|
->withProperties(['sorvegliante_id' => $sorvegliante->id, 'utente_id' => $utente->id])
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
<section>
|
<section>
|
||||||
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
|
<h2 class="text-lg font-semibold text-gray-900 mb-2">Conservazione dei dati</h2>
|
||||||
<ul class="list-disc list-inside space-y-1">
|
<ul class="list-disc list-inside space-y-1">
|
||||||
<li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile.</li>
|
<li>I dati di un proclamatore possono essere anonimizzati su richiesta: nome e cognome vengono cancellati in modo irreversibile, insieme a nome ed email dell'utenza se è stato sorvegliante di un gruppo.</li>
|
||||||
<li>Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.</li>
|
<li>Territori e proclamatori eliminati restano nel cestino finché non vengono eliminati definitivamente.</li>
|
||||||
<li>Il registro delle attività è conservato per <strong>{{ $auditRetention }} giorni</strong> (modificabile nelle impostazioni), poi cancellato automaticamente.</li>
|
<li>Il registro delle attività è conservato per <strong>{{ $auditRetention }} giorni</strong> (modificabile nelle impostazioni), poi cancellato automaticamente.</li>
|
||||||
<li>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>
|
<li>Lo storico delle assegnazioni è conservato per la durata necessaria alla gestione territoriale.</li>
|
||||||
|
|||||||
@@ -354,6 +354,72 @@ class GruppiServizioTest extends TestCase
|
|||||||
$this->actingAs($s->fresh())->get(route('profile'))->assertRedirect(route('login'));
|
$this->actingAs($s->fresh())->get(route('profile'))->assertRedirect(route('login'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_l_ex_sorvegliante_puo_guidare_un_gruppo_nuovo_con_la_stessa_utenza(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $vecchio, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->sciogli($vecchio);
|
||||||
|
|
||||||
|
$pagina = Livewire::test(GruppiIndex::class);
|
||||||
|
$this->assertContains((string) $rossi->id, collect($pagina->viewData('opzioniSorveglianti'))->pluck('valore')->all(), 'Torna tra i candidati');
|
||||||
|
|
||||||
|
[$nuovo, $password] = app(GruppiServizio::class)->crea('Zisa 2', $rossi->fresh(), 'mario.nuovo@example.test');
|
||||||
|
|
||||||
|
$utente = $s->fresh();
|
||||||
|
$this->assertSame(1, User::where('proclamatore_id', $rossi->id)->count(), 'Un proclamatore, un\'utenza');
|
||||||
|
$this->assertFalse($utente->isDisattivato());
|
||||||
|
$this->assertTrue($utente->hasRole('sorvegliante'));
|
||||||
|
$this->assertSame('mario.nuovo@example.test', $utente->email);
|
||||||
|
$this->assertTrue($utente->password_change_required);
|
||||||
|
$this->assertTrue(\Illuminate\Support\Facades\Hash::check($password, $utente->password));
|
||||||
|
$this->assertSame($nuovo->id, $utente->gruppoSorvegliato()?->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Proclamatori: cestino, anonimizzazione, registro ───────
|
||||||
|
|
||||||
|
public function test_il_sorvegliante_di_un_gruppo_attivo_non_si_cestina_ne_si_anonimizza(): void
|
||||||
|
{
|
||||||
|
['rossi' => $rossi, 'verdi' => $verdi] = $this->gruppo();
|
||||||
|
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)
|
||||||
|
->call('deleteProclamatore', $rossi->id)
|
||||||
|
->call('anonimizza', $rossi->id);
|
||||||
|
$this->assertFalse($rossi->fresh()->trashed());
|
||||||
|
$this->assertSame('Rossi', $rossi->fresh()->cognome);
|
||||||
|
|
||||||
|
// Un membro qualsiasi sì, e lascia il gruppo.
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('deleteProclamatore', $verdi->id);
|
||||||
|
$this->assertTrue($verdi->fresh()->trashed());
|
||||||
|
$this->assertNull($verdi->fresh()->gruppo_servizio_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_anonimizzare_un_ex_sorvegliante_anonimizza_anche_la_sua_utenza(): void
|
||||||
|
{
|
||||||
|
['gruppo' => $gruppo, 'rossi' => $rossi, 'sorvegliante' => $s] = $this->gruppo();
|
||||||
|
app(GruppiServizio::class)->sciogli($gruppo);
|
||||||
|
|
||||||
|
Livewire::test(\App\Livewire\Proclamatori\ProclamatoreIndex::class)->call('anonimizza', $rossi->id);
|
||||||
|
|
||||||
|
$utente = $s->fresh();
|
||||||
|
$this->assertSame('Anonimo', $utente->name);
|
||||||
|
$this->assertStringEndsWith('@anonimo.invalid', $utente->email);
|
||||||
|
$this->assertTrue($utente->isDisattivato());
|
||||||
|
$this->assertCount(0, $utente->roles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_nel_registro_delle_attivita_l_utenza_del_sorvegliante_non_ha_nome_ne_email(): void
|
||||||
|
{
|
||||||
|
['sorvegliante' => $s] = $this->gruppo();
|
||||||
|
$s->update(['email' => 'mario.rossi@example.test']);
|
||||||
|
|
||||||
|
$voci = \Spatie\Activitylog\Models\Activity::where('subject_type', User::class)->where('subject_id', $s->id)->get();
|
||||||
|
$this->assertNotEmpty($voci);
|
||||||
|
foreach ($voci as $voce) {
|
||||||
|
$json = json_encode($voce->properties);
|
||||||
|
$this->assertStringNotContainsString('Rossi', $json);
|
||||||
|
$this->assertStringNotContainsString('mario', $json);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Backup ─────────────────────────────────────────────────
|
// ─── Backup ─────────────────────────────────────────────────
|
||||||
|
|
||||||
public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void
|
public function test_l_xml_conserva_gruppi_membri_pacchetto_e_utenza(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user