Skip to content

Commit 0f8a9b6

Browse files
Copilotzurdi15
andauthored
feat: add file deletion to FilesTab with backend endpoint and permission guard
- Add DELETE /roms/{rom_id}/files/{file_id} endpoint to files.py - Add deleteRomFile API function to frontend ROM service - Add delete button (guarded by rom.delete permission) to FilesTab selection toolbar - Show confirmation dialog before deleting; redirect to gallery when all files are removed - Add i18n keys for delete confirmation and feedback to all 18 locale files - Add backend tests for the new delete endpoint (success, wrong-rom 404, unknown-file 404, missing-disk-file tolerance, viewer 403) Closes #3752 Co-authored-by: zurdi15 <34356590+zurdi15@users.noreply.github.com>
1 parent 553b38b commit 0f8a9b6

22 files changed

Lines changed: 365 additions & 4 deletions

File tree

backend/endpoints/roms/files.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@
44
from fastapi import HTTPException
55
from fastapi import Path as PathVar
66
from fastapi import Request, status
7+
from fastapi.responses import Response
78
from starlette.responses import FileResponse
89

910
from config import DEV_MODE, DISABLE_DOWNLOAD_ENDPOINT_AUTH
1011
from decorators.auth import protected_route
1112
from endpoints.responses.rom import RomFileSchema
13+
from exceptions.endpoint_exceptions import RomNotFoundInDatabaseException
1214
from handler.auth.constants import Scope
1315
from handler.auth.dependencies import assert_rom_visible
1416
from handler.database import db_rom_handler
@@ -148,3 +150,53 @@ async def get_romfile_content(
148150
media_type=media_type,
149151
headers=headers,
150152
)
153+
154+
155+
@protected_route(
156+
router.delete,
157+
"/{rom_id}/files/{file_id}",
158+
[Scope.ROMS_WRITE],
159+
responses={status.HTTP_404_NOT_FOUND: {}},
160+
)
161+
async def delete_rom_file(
162+
request: Request,
163+
rom_id: Annotated[int, PathVar(description="Rom internal id.", ge=1)],
164+
file_id: Annotated[int, PathVar(description="Rom file internal id.", ge=1)],
165+
) -> Response:
166+
"""Delete a single file from a ROM."""
167+
168+
rom = db_rom_handler.get_rom(rom_id)
169+
if not rom:
170+
raise RomNotFoundInDatabaseException(rom_id)
171+
172+
rom_file = db_rom_handler.get_rom_file_by_id(file_id)
173+
if not rom_file or rom_file.rom_id != rom.id:
174+
raise HTTPException(
175+
status_code=status.HTTP_404_NOT_FOUND,
176+
detail="File not found",
177+
)
178+
179+
file_rel_path = rom_file.full_path
180+
181+
try:
182+
await fs_rom_handler.remove_file(file_rel_path)
183+
except FileNotFoundError:
184+
log.warning(
185+
f"ROM file {hl(file_rel_path)} not found on disk; "
186+
f"removing DB row anyway"
187+
)
188+
except Exception as exc:
189+
log.error(f"Error deleting ROM file {hl(file_rel_path)}", exc_info=exc)
190+
raise HTTPException(
191+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
192+
detail="There was an error deleting the file",
193+
) from exc
194+
195+
db_rom_handler.delete_rom_file(file_id)
196+
197+
log.info(
198+
f"Deleted file {hl(rom_file.file_name)} from "
199+
f"{hl(rom.name or 'ROM', color=BLUE)} [{hl(rom.fs_name)}]"
200+
)
201+
202+
return Response()

backend/tests/endpoints/roms/test_files.py

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
1+
from pathlib import Path
2+
from unittest.mock import AsyncMock
3+
4+
import pytest
15
from fastapi import status
26
from fastapi.testclient import TestClient
37

8+
from endpoints.roms import files as files_endpoint
49
from handler.database import db_rom_handler
510
from models.platform import Platform
611
from models.rom import Rom, RomFile, RomFileCategory
712
from models.user import User
813

9-
1014
def _auth(token: str) -> dict[str, str]:
1115
return {"Authorization": f"Bearer {token}"}
1216

@@ -157,3 +161,144 @@ def test_content_type_derived_from_db_not_path_param(
157161
assert r.status_code == status.HTTP_200_OK
158162
assert r.headers["content-type"].startswith("application/octet-stream")
159163
assert r.headers["content-disposition"].startswith("attachment")
164+
165+
166+
# ---------- DELETE /api/roms/{rom_id}/files/{file_id} ----------
167+
168+
169+
@pytest.fixture
170+
def files_fs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
171+
"""Mock fs_rom_handler so file operations hit a temporary directory."""
172+
library_dir = tmp_path / "library"
173+
library_dir.mkdir()
174+
175+
def validate_path(path: str) -> Path:
176+
return library_dir / Path(path).name
177+
178+
async def remove_file(path: str) -> None:
179+
target = library_dir / Path(path).name
180+
if target.exists():
181+
target.unlink()
182+
else:
183+
raise FileNotFoundError(path)
184+
185+
monkeypatch.setattr(files_endpoint.fs_rom_handler, "validate_path", validate_path)
186+
monkeypatch.setattr(
187+
files_endpoint.fs_rom_handler,
188+
"remove_file",
189+
AsyncMock(side_effect=remove_file),
190+
)
191+
return library_dir
192+
193+
194+
def test_delete_rom_file_success(
195+
client: TestClient,
196+
access_token: str,
197+
admin_user: User,
198+
platform: Platform,
199+
files_fs: Path,
200+
):
201+
rom = _make_rom(admin_user, platform)
202+
(files_fs / "game.bin").write_bytes(b"\x00" * 16)
203+
rom_file = _add_file(rom, "game.bin", RomFileCategory.GAME)
204+
205+
response = client.delete(
206+
f"/api/roms/{rom.id}/files/{rom_file.id}",
207+
headers=_auth(access_token),
208+
)
209+
210+
assert response.status_code == status.HTTP_200_OK
211+
assert db_rom_handler.get_rom_file_by_id(rom_file.id) is None
212+
assert not (files_fs / "game.bin").exists()
213+
214+
215+
def test_delete_rom_file_wrong_rom_returns_404(
216+
client: TestClient,
217+
access_token: str,
218+
admin_user: User,
219+
platform: Platform,
220+
files_fs: Path,
221+
):
222+
rom_a = _make_rom(admin_user, platform)
223+
# Use the game_folder_rom fixture name to avoid a duplicate fs_name constraint;
224+
# create a second ROM directly with a distinct slug and fs_name.
225+
rom_b = db_rom_handler.add_rom(
226+
Rom(
227+
platform_id=platform.id,
228+
name="other_rom",
229+
slug="other_rom_slug",
230+
fs_name="other_rom",
231+
fs_name_no_tags="other_rom",
232+
fs_name_no_ext="other_rom",
233+
fs_extension="",
234+
fs_path=f"{platform.slug}/roms",
235+
)
236+
)
237+
db_rom_handler.add_rom_user(rom_id=rom_b.id, user_id=admin_user.id)
238+
rom_file = _add_file(rom_a, "game.bin", RomFileCategory.GAME)
239+
240+
response = client.delete(
241+
f"/api/roms/{rom_b.id}/files/{rom_file.id}",
242+
headers=_auth(access_token),
243+
)
244+
245+
assert response.status_code == status.HTTP_404_NOT_FOUND
246+
# File must NOT have been deleted from the database.
247+
assert db_rom_handler.get_rom_file_by_id(rom_file.id) is not None
248+
249+
250+
def test_delete_rom_file_unknown_file_returns_404(
251+
client: TestClient,
252+
access_token: str,
253+
admin_user: User,
254+
platform: Platform,
255+
files_fs: Path,
256+
):
257+
rom = _make_rom(admin_user, platform)
258+
259+
response = client.delete(
260+
f"/api/roms/{rom.id}/files/999999",
261+
headers=_auth(access_token),
262+
)
263+
264+
assert response.status_code == status.HTTP_404_NOT_FOUND
265+
266+
267+
def test_delete_rom_file_tolerates_missing_disk_file(
268+
client: TestClient,
269+
access_token: str,
270+
admin_user: User,
271+
platform: Platform,
272+
files_fs: Path,
273+
):
274+
"""DB row must be dropped even when the on-disk file is already gone."""
275+
rom = _make_rom(admin_user, platform)
276+
rom_file = _add_file(rom, "missing.bin", RomFileCategory.GAME)
277+
278+
response = client.delete(
279+
f"/api/roms/{rom.id}/files/{rom_file.id}",
280+
headers=_auth(access_token),
281+
)
282+
283+
assert response.status_code == status.HTTP_200_OK
284+
assert db_rom_handler.get_rom_file_by_id(rom_file.id) is None
285+
286+
287+
def test_delete_rom_file_forbidden_viewer(
288+
client: TestClient,
289+
viewer_access_token: str,
290+
admin_user: User,
291+
platform: Platform,
292+
files_fs: Path,
293+
):
294+
rom = _make_rom(admin_user, platform)
295+
rom_file = _add_file(rom, "game.bin", RomFileCategory.GAME)
296+
297+
response = client.delete(
298+
f"/api/roms/{rom.id}/files/{rom_file.id}",
299+
headers=_auth(viewer_access_token),
300+
)
301+
302+
assert response.status_code == status.HTTP_403_FORBIDDEN
303+
# File must NOT have been deleted.
304+
assert db_rom_handler.get_rom_file_by_id(rom_file.id) is not None

frontend/src/locales/bg_BG/rom.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@
104104
"download-link-copy-failed": "Линкът за сваляне не можа да бъде копиран.",
105105
"download-named": "Свали {name}",
106106
"download-selected": "Свали избраните",
107+
"delete-files-confirm-title": "Delete {n} file | Delete {n} files",
108+
"delete-files-confirm-body": "The selected files will be permanently removed from the filesystem.",
109+
"files-deleted-n": "{n} file deleted. | {n} files deleted.",
110+
"file-delete-failed": "Failed to delete file: {error}",
107111
"edit-rom": "Редактирай ROM",
108112
"edit-rom-sections": "Редактирай секциите на ROM-а",
109113
"favorite": "Любим",

frontend/src/locales/cs_CZ/rom.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@
104104
"download-link-copy-failed": "Nepodařilo se zkopírovat odkaz ke stažení.",
105105
"download-named": "Stáhnout {name}",
106106
"download-selected": "Stáhnout vybrané",
107+
"delete-files-confirm-title": "Delete {n} file | Delete {n} files",
108+
"delete-files-confirm-body": "The selected files will be permanently removed from the filesystem.",
109+
"files-deleted-n": "{n} file deleted. | {n} files deleted.",
110+
"file-delete-failed": "Failed to delete file: {error}",
107111
"edit-rom": "Upravit ROM",
108112
"edit-rom-sections": "Upravit sekce ROM",
109113
"favorite": "Oblíbené",

frontend/src/locales/de_DE/rom.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@
104104
"download-link-copy-failed": "Download-Link konnte nicht kopiert werden.",
105105
"download-named": "{name} herunterladen",
106106
"download-selected": "Ausgewählte herunterladen",
107+
"delete-files-confirm-title": "Delete {n} file | Delete {n} files",
108+
"delete-files-confirm-body": "The selected files will be permanently removed from the filesystem.",
109+
"files-deleted-n": "{n} file deleted. | {n} files deleted.",
110+
"file-delete-failed": "Failed to delete file: {error}",
107111
"edit-rom": "ROM bearbeiten",
108112
"edit-rom-sections": "ROM-Bereiche bearbeiten",
109113
"favorite": "Favorit",

frontend/src/locales/en_GB/rom.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,10 @@
325325
"download-all": "Download all",
326326
"copy-link-action": "Copy link",
327327
"download-selected": "Download selected",
328+
"delete-files-confirm-title": "Delete {n} file | Delete {n} files",
329+
"delete-files-confirm-body": "The selected files will be permanently removed from the filesystem.",
330+
"files-deleted-n": "{n} file deleted. | {n} files deleted.",
331+
"file-delete-failed": "Failed to delete file: {error}",
328332
"download-link-copied": "Download link copied to clipboard.",
329333
"download-link-copy-failed": "Couldn't copy download link.",
330334
"upload-needs-folder": "Uploads need a folder-based ROM.",

frontend/src/locales/en_US/rom.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,10 @@
325325
"download-all": "Download all",
326326
"copy-link-action": "Copy link",
327327
"download-selected": "Download selected",
328+
"delete-files-confirm-title": "Delete {n} file | Delete {n} files",
329+
"delete-files-confirm-body": "The selected files will be permanently removed from the filesystem.",
330+
"files-deleted-n": "{n} file deleted. | {n} files deleted.",
331+
"file-delete-failed": "Failed to delete file: {error}",
328332
"download-link-copied": "Download link copied to clipboard.",
329333
"download-link-copy-failed": "Couldn't copy download link.",
330334
"upload-needs-folder": "Uploads need a folder-based ROM.",

frontend/src/locales/es_ES/rom.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@
104104
"download-link-copy-failed": "No se pudo copiar el enlace",
105105
"download-named": "Descargar {name}",
106106
"download-selected": "Descargar seleccionados",
107+
"delete-files-confirm-title": "Delete {n} file | Delete {n} files",
108+
"delete-files-confirm-body": "The selected files will be permanently removed from the filesystem.",
109+
"files-deleted-n": "{n} file deleted. | {n} files deleted.",
110+
"file-delete-failed": "Failed to delete file: {error}",
107111
"edit-rom": "Editar ROM",
108112
"edit-rom-sections": "Secciones de edición de ROM",
109113
"favorite": "Favorito",

frontend/src/locales/fr_FR/rom.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@
104104
"download-link-copy-failed": "Impossible de copier le lien de téléchargement.",
105105
"download-named": "Télécharger {name}",
106106
"download-selected": "Télécharger la sélection",
107+
"delete-files-confirm-title": "Delete {n} file | Delete {n} files",
108+
"delete-files-confirm-body": "The selected files will be permanently removed from the filesystem.",
109+
"files-deleted-n": "{n} file deleted. | {n} files deleted.",
110+
"file-delete-failed": "Failed to delete file: {error}",
107111
"edit-rom": "Modifier la ROM",
108112
"edit-rom-sections": "Modifier les sections de la ROM",
109113
"favorite": "Favori",

frontend/src/locales/hu_HU/rom.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@
104104
"download-link-copy-failed": "A letöltési link másolása sikertelen.",
105105
"download-named": "{name} letöltése",
106106
"download-selected": "Kiválasztottak letöltése",
107+
"delete-files-confirm-title": "Delete {n} file | Delete {n} files",
108+
"delete-files-confirm-body": "The selected files will be permanently removed from the filesystem.",
109+
"files-deleted-n": "{n} file deleted. | {n} files deleted.",
110+
"file-delete-failed": "Failed to delete file: {error}",
107111
"edit-rom": "ROM szerkesztése",
108112
"edit-rom-sections": "ROM szakaszainak szerkesztése",
109113
"favorite": "Kedvenc",

0 commit comments

Comments
 (0)