Skip to content

Commit 5608eb8

Browse files
authored
Merge pull request #3753 from rommapp/copilot/bug-fix-cannot-delete-file
feat: add file deletion to the Files tab on the game details page
2 parents b4bc84f + 9aac4c7 commit 5608eb8

22 files changed

Lines changed: 1018 additions & 651 deletions

File tree

backend/endpoints/roms/files.py

Lines changed: 54 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,55 @@ 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+
assert_rom_visible(request, rom, not_found_detail="File not found")
173+
174+
rom_file = db_rom_handler.get_rom_file_by_id(file_id)
175+
if not rom_file or rom_file.rom_id != rom.id:
176+
raise HTTPException(
177+
status_code=status.HTTP_404_NOT_FOUND,
178+
detail="File not found",
179+
)
180+
181+
file_rel_path = rom_file.full_path
182+
183+
try:
184+
await fs_rom_handler.remove_file(file_rel_path)
185+
except FileNotFoundError:
186+
log.warning(
187+
f"ROM file {hl(file_rel_path)} not found on disk; "
188+
f"removing DB row anyway"
189+
)
190+
except Exception as exc:
191+
log.error(f"Error deleting ROM file {hl(file_rel_path)}", exc_info=exc)
192+
raise HTTPException(
193+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
194+
detail="There was an error deleting the file",
195+
) from exc
196+
197+
db_rom_handler.delete_rom_file(file_id)
198+
199+
log.info(
200+
f"Deleted file {hl(rom_file.file_name)} from "
201+
f"{hl(rom.name or 'ROM', color=BLUE)} [{hl(rom.fs_name)}]"
202+
)
203+
204+
return Response()

backend/tests/endpoints/roms/test_files.py

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

frontend/src/locales/bg_BG/rom.json

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
"completion": "Завършеност",
6060
"completionist": "Перфекционист",
6161
"confirm-delete-note": "Сигурен ли си, че искаш да изтриеш бележката \"{title}\"?",
62+
"convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.",
63+
"convert-to-folder-title": "Convert to folder ROM?",
6264
"copy-download-link-title": "Копирай линк за сваляне",
6365
"copy-link": "Копирай линк за сваляне",
6466
"copy-link-action": "Копирай линка",
@@ -73,6 +75,8 @@
7375
"default-version": "Версия по подразбиране",
7476
"delete": "Изтрий",
7577
"delete-file": "Изтрий файла",
78+
"delete-files-confirm-body": "The selected files will be permanently removed from the filesystem.",
79+
"delete-files-confirm-title": "Delete {n} file | Delete {n} files",
7680
"delete-filesystem-warning": "Ще премахнеш {n} ROM от файловата си система. Това действие е необратимо! | Ще премахнеш {n} ROM-а от файловата си система. Това действие е необратимо!",
7781
"delete-from-disk-aria": "Изтрий {name} от диска",
7882
"delete-manual-button": "Изтрий",
@@ -108,11 +112,13 @@
108112
"edit-rom-sections": "Редактирай секциите на ROM-а",
109113
"favorite": "Любим",
110114
"file": "Файл",
115+
"file-delete-failed": "Failed to delete file: {error}",
111116
"file-info": "Информация за файла",
112117
"filename": "Име на файл",
113118
"filename-required": "Неуспешно запазване: името на файла е задължително",
114119
"files": "Файлове",
115120
"files-count-n": "{n} файл | {n} файла",
121+
"files-deleted-n": "{n} file deleted. | {n} files deleted.",
116122
"files-selected-of": "{selected} от {total} избрани",
117123
"folder-name": "Име на папка",
118124
"folder-root": "Корен",
@@ -150,9 +156,9 @@
150156
"manual-files-upload-success": "{count} manual file uploaded successfully ({failed} skipped/failed). | {count} manual files uploaded successfully ({failed} skipped/failed).",
151157
"manual-files-uploaded-n": "Качен е {n} файл от ръководството. | Качени са {n} файла от ръководството.",
152158
"manual-files-uploaded-with-failed": "Качен е {n} файл от ръководството, {failed} неуспешни. | Качени са {n} файла от ръководството, {failed} неуспешни.",
159+
"manual-load-failed": "Unable to load manual",
153160
"manual-match": "Ръчно разпознаване",
154161
"manual-redownload-failed": "Failed to re-download manual: {error}",
155-
"manual-load-failed": "Unable to load manual",
156162
"manual-redownloaded": "Manual re-downloaded successfully",
157163
"manual-remove-failed": "Грешка при премахване на ръководството: {error}",
158164
"manual-removed": "Ръководството е премахнато успешно",
@@ -200,7 +206,6 @@
200206
"metadata-sources-label": "Източници на метаданни",
201207
"metric-completion": "Завършеност",
202208
"metric-difficulty": "Трудност",
203-
"your-progress": "Your progress",
204209
"metric-rating": "Оценка",
205210
"missing-platform": "Липсваща платформа от файловата система",
206211
"more-actions": "Още действия",
@@ -429,7 +434,6 @@
429434
"versions-count": "{n} версии",
430435
"volume-mute": "Заглуши",
431436
"volume-unmute": "Включи звука",
432-
"youtube-video-id": "YouTube видео ID",
433-
"convert-to-folder-title": "Convert to folder ROM?",
434-
"convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible."
437+
"your-progress": "Your progress",
438+
"youtube-video-id": "YouTube видео ID"
435439
}

frontend/src/locales/cs_CZ/rom.json

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@
5959
"completion": "Dokončení",
6060
"completionist": "Kompletista",
6161
"confirm-delete-note": "Opravdu chcete smazat poznámku \"{title}\"?",
62+
"convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible.",
63+
"convert-to-folder-title": "Convert to folder ROM?",
6264
"copy-download-link-title": "Kopírovat odkaz ke stažení",
6365
"copy-link": "Kopírovat odkaz ke stažení",
6466
"copy-link-action": "Kopírovat odkaz",
@@ -73,6 +75,8 @@
7375
"default-version": "Výchozí verze",
7476
"delete": "Smazat",
7577
"delete-file": "Smazat soubor",
78+
"delete-files-confirm-body": "The selected files will be permanently removed from the filesystem.",
79+
"delete-files-confirm-title": "Delete {n} file | Delete {n} files",
7680
"delete-filesystem-warning": "Chystáte se odstranit {n} ROMů ze souborového systému. Akce je nevratná! | Chystáte se odstranit {n} ROM z vašeho souborového systému. Akce je nevratná! | Chystáte se odstranit {n} ROMy z vašeho souborového systému. Akce je nevratná! | Chystáte se odstranit {n} ROMů ze souborového systému. Akce je nevratná!",
7781
"delete-from-disk-aria": "Smazat {name} z disku",
7882
"delete-manual-button": "Smazat",
@@ -108,11 +112,13 @@
108112
"edit-rom-sections": "Upravit sekce ROM",
109113
"favorite": "Oblíbené",
110114
"file": "Soubor",
115+
"file-delete-failed": "Failed to delete file: {error}",
111116
"file-info": "Informace o souboru",
112117
"filename": "Název souboru",
113118
"filename-required": "Nelze uložit: název souboru je povinný",
114119
"files": "Soubory",
115120
"files-count-n": "{n} souborů | {n} soubor | {n} soubory | {n} souborů",
121+
"files-deleted-n": "{n} file deleted. | {n} files deleted.",
116122
"files-selected-of": "{selected} z {total} vybráno",
117123
"folder-name": "Název složky",
118124
"folder-root": "Kořen",
@@ -150,9 +156,9 @@
150156
"manual-files-upload-success": "{count} manual file uploaded successfully ({failed} skipped/failed). | {count} manual files uploaded successfully ({failed} skipped/failed).",
151157
"manual-files-uploaded-n": "Nahráno {n} souborů manuálu. | Nahrán {n} soubor manuálu. | Nahrány {n} soubory manuálu. | Nahráno {n} souborů manuálu.",
152158
"manual-files-uploaded-with-failed": "Nahráno {n} souborů manuálu, {failed} selhalo. | Nahrán {n} soubor manuálu, {failed} selhalo. | Nahrány {n} soubory manuálu, {failed} selhalo. | Nahráno {n} souborů manuálu, {failed} selhalo.",
159+
"manual-load-failed": "Unable to load manual",
153160
"manual-match": "Ruční přiřazení",
154161
"manual-redownload-failed": "Failed to re-download manual: {error}",
155-
"manual-load-failed": "Unable to load manual",
156162
"manual-redownloaded": "Manual re-downloaded successfully",
157163
"manual-remove-failed": "Nepodařilo se odstranit manuál: {error}",
158164
"manual-removed": "Manuál úspěšně odstraněn",
@@ -200,7 +206,6 @@
200206
"metadata-sources-label": "Zdroje metadat",
201207
"metric-completion": "Dokončení",
202208
"metric-difficulty": "Obtížnost",
203-
"your-progress": "Your progress",
204209
"metric-rating": "Hodnocení",
205210
"missing-platform": "Chybějící platforma ze souborového systému",
206211
"more-actions": "Další akce",
@@ -429,7 +434,6 @@
429434
"versions-count": "{n} verzí",
430435
"volume-mute": "Ztlumit",
431436
"volume-unmute": "Zrušit ztlumení",
432-
"youtube-video-id": "ID YouTube videa",
433-
"convert-to-folder-title": "Convert to folder ROM?",
434-
"convert-to-folder-body": "This action will convert the ROM to a multi-file ROM. This action is not reversible."
437+
"your-progress": "Your progress",
438+
"youtube-video-id": "ID YouTube videa"
435439
}

0 commit comments

Comments
 (0)