diff --git a/app/Controllers/GeneriController.php b/app/Controllers/GeneriController.php index 3976987ca..c3da02ee6 100644 --- a/app/Controllers/GeneriController.php +++ b/app/Controllers/GeneriController.php @@ -153,17 +153,24 @@ public function update(Request $request, Response $response, \mysqli $db, int $i return $response->withHeader('Location', "/admin/genres/{$id}")->withStatus(302); } - public function destroy(Request $_request, Response $response, \mysqli $db, int $id): Response + public function destroy(Request $request, Response $response, \mysqli $db, int $id): Response { // CSRF validated by CsrfMiddleware $repo = new GenereRepository($db); + $data = $request->getParsedBody() ?? []; + $cascadeDelete = !empty($data['cascade_delete']); try { - if (!$repo->delete($id)) { - throw new \RuntimeException('delete() returned false'); + $deleted = $cascadeDelete + ? $repo->cascadeDelete($id) + : $repo->delete($id); + if (!$deleted) { + throw new \RuntimeException(($cascadeDelete ? 'cascadeDelete' : 'delete') . '() returned false'); } - $_SESSION['success_message'] = __('Genere eliminato con successo!'); + $_SESSION['success_message'] = $cascadeDelete + ? __('Genere e sottogeneri eliminati con successo!') + : __('Genere eliminato con successo!'); return $response->withHeader('Location', url('/admin/genres'))->withStatus(302); } catch (\Throwable $e) { \App\Support\SecureLogger::error('GeneriController::destroy error', ['id' => $id, 'message' => $e->getMessage()]); diff --git a/app/Models/GenereRepository.php b/app/Models/GenereRepository.php index cb5de27a7..72d543ea2 100644 --- a/app/Models/GenereRepository.php +++ b/app/Models/GenereRepository.php @@ -178,6 +178,99 @@ public function delete(int $id): bool return $result; } + public function cascadeDelete(int $id): bool + { + $ids = $this->collectSubtreeIds($id); + if (empty($ids)) { + return false; + } + + $this->db->begin_transaction(); + + try { + $placeholders = implode(',', array_fill(0, count($ids), '?')); + + $stmt = $this->db->prepare(" + UPDATE libri + SET genere_id = IF(genere_id IN ({$placeholders}), NULL, genere_id), + sottogenere_id = IF(sottogenere_id IN ({$placeholders}), NULL, sottogenere_id) + WHERE genere_id IN ({$placeholders}) + OR sottogenere_id IN ({$placeholders}) + "); + $this->bindIntParams($stmt, array_merge($ids, $ids, $ids, $ids)); + if (!$stmt->execute()) { + throw new \RuntimeException('Errore nello scollegamento dei libri dai generi'); + } + + $stmt = $this->db->prepare("UPDATE mensole SET genere_id = NULL WHERE genere_id IN ({$placeholders})"); + $this->bindIntParams($stmt, $ids); + if (!$stmt->execute()) { + throw new \RuntimeException('Errore nello scollegamento delle mensole dai generi'); + } + + $stmt = $this->db->prepare("DELETE FROM generi WHERE id = ?"); + $stmt->bind_param('i', $id); + $result = $stmt->execute(); + + $this->db->commit(); + QueryCache::clearByPrefix('genre_tree_'); + return $result; + } catch (\Throwable $e) { + $this->db->rollback(); + throw $e; + } + } + + /** + * @return array + */ + private function collectSubtreeIds(int $id): array + { + $ids = []; + $queue = [$id]; + $stmt = $this->db->prepare("SELECT id FROM generi WHERE parent_id = ?"); + if (!$stmt) { + throw new \RuntimeException('Errore nella preparazione della query dei sottogeneri'); + } + + while ($queue) { + $currentId = array_shift($queue); + if (isset($ids[$currentId])) { + continue; + } + + $genre = $this->getById($currentId); + if (!$genre) { + continue; + } + + $ids[$currentId] = $currentId; + $stmt->bind_param('i', $currentId); + $stmt->execute(); + $result = $stmt->get_result(); + while ($row = $result->fetch_assoc()) { + $queue[] = (int)$row['id']; + } + } + + $stmt->close(); + return array_values($ids); + } + + /** + * @param array $values + */ + private function bindIntParams(\mysqli_stmt $stmt, array $values): void + { + $types = str_repeat('i', count($values)); + $refs = [$types]; + foreach ($values as $key => $value) { + $values[$key] = (int)$value; + $refs[] = &$values[$key]; + } + $stmt->bind_param(...$refs); + } + public function getChildren(int $parent_id): array { $stmt = $this->db->prepare(" diff --git a/app/Views/generi/dettaglio_genere.php b/app/Views/generi/dettaglio_genere.php index ef2f610bc..62e2a57e9 100644 --- a/app/Views/generi/dettaglio_genere.php +++ b/app/Views/generi/dettaglio_genere.php @@ -221,13 +221,24 @@ -

+ +

+
" + data-swal-confirm="" + data-swal-confirm-button=""> + + + +
+

" data-swal-confirm="" @@ -237,9 +248,9 @@
+
- diff --git a/locale/de_DE.json b/locale/de_DE.json index 580f993c8..b8b9bf579 100644 --- a/locale/de_DE.json +++ b/locale/de_DE.json @@ -6565,5 +6565,9 @@ "Controlla i campi personalizzati: un valore inserito non è valido.": "Überprüfe die benutzerdefinierten Felder: einer der eingegebenen Werte ist ungültig.", "Eliminare i campi personalizzati selezionati? I valori salvati dagli utenti per questi campi verranno rimossi definitivamente.": "Ausgewählte benutzerdefinierte Felder löschen? Die von Benutzern gespeicherten Werte für diese Felder werden dauerhaft entfernt.", "I file dell'applicazione non sono scrivibili dall'utente del web server e Pinakes gira dentro un container. Sull'immagine ufficiale (fabiodalez/pinakes) il codice è incluso nell'immagine e in sola lettura per scelta: aggiorna scaricando la nuova immagine con «docker compose pull && docker compose up -d» (il database e i volumi storage/uploads restano al sicuro). Se usi un'immagine community che monta il codice da un volume scrivibile questo pulsante funziona normalmente — se vedi questo messaggio quel volume è al momento in sola lettura, controlla il suo mount.": "Die Anwendungsdateien sind für den Webserver-Benutzer nicht beschreibbar und Pinakes läuft in einem Container. Im offiziellen Image (fabiodalez/pinakes) ist der Code im Image enthalten und absichtlich schreibgeschützt: Aktualisiere, indem du das neue Image ziehst mit «docker compose pull && docker compose up -d» (Datenbank und die Volumes storage/uploads bleiben erhalten). Wenn du ein Community-Image verwendest, das den Code aus einem beschreibbaren Volume einbindet, funktioniert diese Schaltfläche normal — erscheint diese Meldung, ist dieses Volume derzeit schreibgeschützt, prüfe dessen Mount.", - "https://t.me/tuocanale": "https://t.me/deinkanal" + "https://t.me/tuocanale": "https://t.me/deinkanal", + "Genere e sottogeneri eliminati con successo!": "Genre und Untergenres erfolgreich gelöscht!", + "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Diese Aktion löscht das Genre und alle seine Untergenres dauerhaft. Zugeordnete Bücher werden von diesen Genres getrennt.", + "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Sind Sie sicher, dass Sie dieses Genre und alle seine Untergenres löschen möchten? Zugeordnete Bücher werden von diesen Genres getrennt.", + "Elimina genere e sottogeneri": "Genre und Untergenres löschen" } diff --git a/locale/en_US.json b/locale/en_US.json index 6dc0f941e..991350015 100644 --- a/locale/en_US.json +++ b/locale/en_US.json @@ -6565,5 +6565,9 @@ "Controlla i campi personalizzati: un valore inserito non è valido.": "Check the custom fields: one of the values entered is not valid.", "Eliminare i campi personalizzati selezionati? I valori salvati dagli utenti per questi campi verranno rimossi definitivamente.": "Delete the selected custom fields? The values users saved for these fields will be permanently removed.", "I file dell'applicazione non sono scrivibili dall'utente del web server e Pinakes gira dentro un container. Sull'immagine ufficiale (fabiodalez/pinakes) il codice è incluso nell'immagine e in sola lettura per scelta: aggiorna scaricando la nuova immagine con «docker compose pull && docker compose up -d» (il database e i volumi storage/uploads restano al sicuro). Se usi un'immagine community che monta il codice da un volume scrivibile questo pulsante funziona normalmente — se vedi questo messaggio quel volume è al momento in sola lettura, controlla il suo mount.": "The application files aren't writable by the web-server user and Pinakes is running inside a container. On the official image (fabiodalez/pinakes) the code is baked into the image and read-only by design: update by pulling the new image with «docker compose pull && docker compose up -d» (your database and the storage/uploads volumes are preserved). If you use a community image that mounts the code from a writable volume this button works normally — seeing this message means that volume is currently read-only, so check its mount.", - "https://t.me/tuocanale": "https://t.me/yourchannel" + "https://t.me/tuocanale": "https://t.me/yourchannel", + "Genere e sottogeneri eliminati con successo!": "Genre and subgenres deleted successfully!", + "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "This action permanently deletes the genre and all of its subgenres. Associated books will be unlinked from these genres.", + "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Are you sure you want to delete this genre and all of its subgenres? Associated books will be unlinked from these genres.", + "Elimina genere e sottogeneri": "Delete genre and subgenres" } diff --git a/locale/fr_FR.json b/locale/fr_FR.json index 6989a71b9..5c2a28ca8 100644 --- a/locale/fr_FR.json +++ b/locale/fr_FR.json @@ -6565,5 +6565,9 @@ "Controlla i campi personalizzati: un valore inserito non è valido.": "Vérifiez les champs personnalisés : une des valeurs saisies n'est pas valide.", "Eliminare i campi personalizzati selezionati? I valori salvati dagli utenti per questi campi verranno rimossi definitivamente.": "Supprimer les champs personnalisés sélectionnés ? Les valeurs enregistrées par les utilisateurs pour ces champs seront définitivement supprimées.", "I file dell'applicazione non sono scrivibili dall'utente del web server e Pinakes gira dentro un container. Sull'immagine ufficiale (fabiodalez/pinakes) il codice è incluso nell'immagine e in sola lettura per scelta: aggiorna scaricando la nuova immagine con «docker compose pull && docker compose up -d» (il database e i volumi storage/uploads restano al sicuro). Se usi un'immagine community che monta il codice da un volume scrivibile questo pulsante funziona normalmente — se vedi questo messaggio quel volume è al momento in sola lettura, controlla il suo mount.": "Les fichiers de l'application ne sont pas modifiables par l'utilisateur du serveur web et Pinakes s'exécute dans un conteneur. Sur l'image officielle (fabiodalez/pinakes) le code est inclus dans l'image et en lecture seule par choix : mettez à jour en récupérant la nouvelle image avec «docker compose pull && docker compose up -d» (la base de données et les volumes storage/uploads sont préservés). Si vous utilisez une image communautaire qui monte le code depuis un volume accessible en écriture, ce bouton fonctionne normalement — si ce message apparaît, ce volume est actuellement en lecture seule, vérifiez son montage.", - "https://t.me/tuocanale": "https://t.me/votrecanal" + "https://t.me/tuocanale": "https://t.me/votrecanal", + "Genere e sottogeneri eliminati con successo!": "Genre et sous-genres supprimés avec succès !", + "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Cette action supprime définitivement le genre et tous ses sous-genres. Les livres associés seront dissociés de ces genres.", + "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Voulez-vous vraiment supprimer ce genre et tous ses sous-genres ? Les livres associés seront dissociés de ces genres.", + "Elimina genere e sottogeneri": "Supprimer le genre et les sous-genres" } diff --git a/locale/it_IT.json b/locale/it_IT.json index ac35649ca..5f34bbabb 100644 --- a/locale/it_IT.json +++ b/locale/it_IT.json @@ -6565,5 +6565,9 @@ "Controlla i campi personalizzati: un valore inserito non è valido.": "Controlla i campi personalizzati: un valore inserito non è valido.", "Eliminare i campi personalizzati selezionati? I valori salvati dagli utenti per questi campi verranno rimossi definitivamente.": "Eliminare i campi personalizzati selezionati? I valori salvati dagli utenti per questi campi verranno rimossi definitivamente.", "I file dell'applicazione non sono scrivibili dall'utente del web server e Pinakes gira dentro un container. Sull'immagine ufficiale (fabiodalez/pinakes) il codice è incluso nell'immagine e in sola lettura per scelta: aggiorna scaricando la nuova immagine con «docker compose pull && docker compose up -d» (il database e i volumi storage/uploads restano al sicuro). Se usi un'immagine community che monta il codice da un volume scrivibile questo pulsante funziona normalmente — se vedi questo messaggio quel volume è al momento in sola lettura, controlla il suo mount.": "I file dell'applicazione non sono scrivibili dall'utente del web server e Pinakes gira dentro un container. Sull'immagine ufficiale (fabiodalez/pinakes) il codice è incluso nell'immagine e in sola lettura per scelta: aggiorna scaricando la nuova immagine con «docker compose pull && docker compose up -d» (il database e i volumi storage/uploads restano al sicuro). Se usi un'immagine community che monta il codice da un volume scrivibile questo pulsante funziona normalmente — se vedi questo messaggio quel volume è al momento in sola lettura, controlla il suo mount.", - "https://t.me/tuocanale": "https://t.me/tuocanale" + "https://t.me/tuocanale": "https://t.me/tuocanale", + "Genere e sottogeneri eliminati con successo!": "Genere e sottogeneri eliminati con successo!", + "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.": "Questa azione elimina il genere e tutti i suoi sottogeneri in modo permanente. I libri associati verranno scollegati da questi generi.", + "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.": "Sei sicuro di voler eliminare questo genere e tutti i suoi sottogeneri? I libri associati verranno scollegati da questi generi.", + "Elimina genere e sottogeneri": "Elimina genere e sottogeneri" } diff --git a/tests/genre-bugs.spec.js b/tests/genre-bugs.spec.js index 4c5809a51..815e2ed28 100644 --- a/tests/genre-bugs.spec.js +++ b/tests/genre-bugs.spec.js @@ -114,21 +114,21 @@ test.describe('Issue #64: Genre Edit/Update', () => { expect([401, 403]).toContain(resp.status()); }); - test('delete section visible only for leaf genres (no children)', async ({ page }) => { + test('delete section supports cascade for genre groups', async ({ page }) => { await loginAsAdmin(page); - // Get a root genre — should NOT show delete (has children) + // Get a root genre — should show cascade delete (has children) const rootResp = await page.request.get(`${BASE}/api/generi?only_parents=1&limit=5`); const roots = await rootResp.json(); const rootWithChildren = roots.find(g => g.children_count > 0); if (rootWithChildren) { await page.goto(`${BASE}/admin/genres/${rootWithChildren.id}`); - // Delete button should NOT be visible for genres with children const deleteForm = page.locator('form[action*="/delete"]'); - await expect(deleteForm).toBeHidden(); + await expect(deleteForm).toBeVisible(); + await expect(deleteForm.locator('input[name="cascade_delete"]')).toHaveValue('1'); } - // Get a leaf genre (no children) — should show delete + // Get a leaf genre (no children) — should show normal delete const allResp = await page.request.get(`${BASE}/api/generi?limit=100`); const allGenres = await allResp.json(); const leafGenre = allGenres.find(g => g.children_count === 0); @@ -136,6 +136,7 @@ test.describe('Issue #64: Genre Edit/Update', () => { await page.goto(`${BASE}/admin/genres/${leafGenre.id}`); const deleteForm = page.locator('form[action*="/delete"]'); await expect(deleteForm).toBeVisible(); + await expect(deleteForm.locator('input[name="cascade_delete"]')).toHaveCount(0); } }); }); diff --git a/tests/genre-cascade-delete.unit.php b/tests/genre-cascade-delete.unit.php new file mode 100644 index 000000000..ca2bde1da --- /dev/null +++ b/tests/genre-cascade-delete.unit.php @@ -0,0 +1,144 @@ +set_charset('utf8mb4'); +} catch (\Throwable $e) { + echo "SKIP: database not reachable (" . $e->getMessage() . ")\n"; + exit(0); +} + +$testNo = 0; +$check = static function (bool $condition, string $label) use (&$testNo): void { + if (!$condition) { + throw new \RuntimeException("assertion failed: {$label}"); + } + $testNo++; + printf("[%02d] PASS: %s\n", $testNo, $label); +}; + +$prefix = 'zz_cascade_' . bin2hex(random_bytes(4)); +$repo = new GenereRepository($db); + +$cleanup = static function () use ($db, $prefix): void { + $like = $prefix . '%'; + $stmt = $db->prepare('DELETE FROM libri WHERE titolo LIKE ?'); + $stmt->bind_param('s', $like); + $stmt->execute(); + + $stmt = $db->prepare('SELECT id FROM scaffali WHERE codice LIKE ?'); + $stmt->bind_param('s', $like); + $stmt->execute(); + $ids = []; + $res = $stmt->get_result(); + while ($row = $res->fetch_assoc()) { + $ids[] = (int)$row['id']; + } + foreach ($ids as $id) { + $stmt = $db->prepare('DELETE FROM scaffali WHERE id = ?'); + $stmt->bind_param('i', $id); + $stmt->execute(); + } + + $stmt = $db->prepare('DELETE FROM generi WHERE nome LIKE ?'); + $stmt->bind_param('s', $like); + $stmt->execute(); +}; + +set_exception_handler(static function (\Throwable $e) use ($cleanup): void { + try { + $cleanup(); + } catch (\Throwable) { + } + fwrite(STDERR, "FAIL: " . $e->getMessage() . "\n"); + exit(1); +}); + +$cleanup(); + +$rootId = $repo->create(['nome' => $prefix . '_root']); +$childId = $repo->create(['nome' => $prefix . '_child', 'parent_id' => $rootId]); +$leafId = $repo->create(['nome' => $prefix . '_leaf', 'parent_id' => $childId]); + +$blocked = false; +try { + $repo->delete($rootId); +} catch (\RuntimeException $e) { + $blocked = str_contains($e->getMessage(), 'sottogeneri'); +} +$check($blocked, 'non-cascade delete still rejects genre groups'); + +$stmt = $db->prepare('INSERT INTO libri (titolo, genere_id, sottogenere_id) VALUES (?, ?, ?)'); +$bookTitle = $prefix . '_book'; +$stmt->bind_param('sii', $bookTitle, $childId, $leafId); +$stmt->execute(); +$bookId = $db->insert_id; + +$stmt = $db->prepare('INSERT INTO scaffali (codice, nome, lettera) VALUES (?, ?, ?)'); +$shelfCode = $prefix . '_shelf'; +$shelfName = $prefix . '_Shelf'; +$letter = 'Z'; +$stmt->bind_param('sss', $shelfCode, $shelfName, $letter); +$stmt->execute(); +$scaffaleId = $db->insert_id; + +$stmt = $db->prepare('INSERT INTO mensole (scaffale_id, numero_livello, genere_id) VALUES (?, ?, ?)'); +$level = 1; +$stmt->bind_param('iii', $scaffaleId, $level, $leafId); +$stmt->execute(); +$mensolaId = $db->insert_id; + +$check($repo->cascadeDelete($rootId), 'cascade delete succeeds for a deep genre tree'); + +$stmt = $db->prepare('SELECT COUNT(*) AS cnt FROM generi WHERE nome LIKE ?'); +$like = $prefix . '%'; +$stmt->bind_param('s', $like); +$stmt->execute(); +$check((int)$stmt->get_result()->fetch_assoc()['cnt'] === 0, 'root and descendants are deleted'); + +$stmt = $db->prepare('SELECT genere_id, sottogenere_id FROM libri WHERE id = ?'); +$stmt->bind_param('i', $bookId); +$stmt->execute(); +$book = $stmt->get_result()->fetch_assoc(); +$check($book !== null && $book['genere_id'] === null && $book['sottogenere_id'] === null, 'book genre references are unlinked'); + +$stmt = $db->prepare('SELECT genere_id FROM mensole WHERE id = ?'); +$stmt->bind_param('i', $mensolaId); +$stmt->execute(); +$mensola = $stmt->get_result()->fetch_assoc(); +$check($mensola !== null && $mensola['genere_id'] === null, 'shelf genre reference is unlinked'); + +$cleanup(); +printf("\nALL %d PASS\n", $testNo);