Coverage for src/local_deep_research/web/routers/library_delete.py: 100%

157 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +0000

1""" 

2Delete API Routes 

3 

4Provides endpoints for delete operations: 

5- Delete document 

6- Delete document blob only 

7- Delete documents in bulk 

8- Delete blobs in bulk 

9- Remove document from collection 

10- Delete collection 

11- Delete collection index only 

12""" 

13 

14import json 

15 

16from fastapi import APIRouter, Depends, Request 

17from fastapi.responses import JSONResponse 

18from ..dependencies.auth import require_auth 

19from ..dependencies.json_body import json_body_error 

20from ..dependencies.threadpool import run_db_sync 

21 

22from ...research_library.utils import handle_api_error 

23from ...research_library.deletion.services.document_deletion import ( 

24 DocumentDeletionService, 

25) 

26from ...research_library.deletion.services.collection_deletion import ( 

27 CollectionDeletionService, 

28 PROTECTED_COLLECTION_TYPES, 

29) 

30from ...research_library.deletion.services.bulk_deletion import ( 

31 BulkDeletionService, 

32) 

33from typing import Annotated 

34 

35router = APIRouter(prefix="/library/api", tags=["delete"]) 

36# NOTE: Routes use username (not .get()) intentionally. 

37# Depends(require_auth) guarantees the key exists; direct access fails 

38# fast if the dependency is ever removed. 

39 

40 

41async def _parse_json_body(request: Request): 

42 """Parse the request body as JSON, returning a 400 on malformed bytes. 

43 

44 ``await request.json()`` raises ``json.JSONDecodeError`` on a malformed 

45 body. The app registers a ``json.JSONDecodeError -> 400`` handler 

46 (fastapi_app.py), but every route below wraps its whole body in a broad 

47 ``except Exception -> handle_api_error(...)`` (a hardcoded 500) for 

48 genuine internal errors, which intercepts the decode error first and 

49 never lets it reach that handler. Parsing here, and converting the 

50 decode error to the same 400 shape the app-level handler would have 

51 produced, restores the correct status without touching that broad 

52 except (still needed for real internal errors). 

53 

54 Returns: 

55 tuple: (data, error_response) where error_response is None on 

56 success, or a JSONResponse (400) on a malformed body. 

57 """ 

58 try: 

59 data = await request.json() 

60 except json.JSONDecodeError: 

61 return None, json_body_error("success", "Invalid JSON body") 

62 return data, None 

63 

64 

65def _validate_document_ids_from_data(data): 

66 """Validate document_ids from an already-parsed JSON body. 

67 

68 Returns: 

69 tuple: (document_ids, error_response) where error_response is None 

70 on success, or a JSONResponse on failure. 

71 """ 

72 # ``isinstance(data, dict)`` (not just ``not data``) matters: a truthy 

73 # non-dict body (e.g. a bare int, or a bare string that happens to equal 

74 # "document_ids") reaches ``"document_ids" not in data`` / 

75 # ``data["document_ids"]`` below and raises TypeError for some inputs 

76 # (e.g. ``"document_ids" not in 42``) that the caller's broad 

77 # ``except Exception`` then turns into a 500. 

78 if not isinstance(data, dict) or "document_ids" not in data: 

79 return None, JSONResponse( 

80 { 

81 "success": False, 

82 "error": "document_ids required in request body", 

83 }, 

84 status_code=400, 

85 ) 

86 

87 document_ids = data["document_ids"] 

88 # Document IDs are always UUID strings (see Document.id, String(36)) — 

89 # requiring str elements here, not just "is a non-empty list", also 

90 # closes off crashes downstream: a huge int overflows the sqlite3 

91 # driver's 64-bit bind and an unhashable element (list/dict) blows up 

92 # the per-document delete lock's dict key, both surfacing as 500s from 

93 # services this router does not own. Rejecting non-string elements here 

94 # keeps every hostile element type out of that code entirely. 

95 if ( 

96 not isinstance(document_ids, list) 

97 or not document_ids 

98 or not all(isinstance(doc_id, str) for doc_id in document_ids) 

99 ): 

100 return None, JSONResponse( 

101 { 

102 "success": False, 

103 "error": "document_ids must be a non-empty list", 

104 }, 

105 status_code=400, 

106 ) 

107 

108 return document_ids, None 

109 

110 

111async def _validate_document_ids(request: Request): 

112 """Extract and validate document_ids from the JSON request body.""" 

113 data, error = await _parse_json_body(request) 

114 if error: 

115 return None, error 

116 return _validate_document_ids_from_data(data) 

117 

118 

119# ============================================================================= 

120# Document Delete Endpoints 

121# ============================================================================= 

122 

123 

124@router.delete("/document/{document_id}") 

125def delete_document( 

126 request: Request, 

127 document_id, 

128 username: Annotated[str, Depends(require_auth)], 

129): 

130 """ 

131 Delete a document and all related data. 

132 

133 Tooltip: "Permanently delete this document, including PDF and text content. 

134 This cannot be undone." 

135 

136 Returns: 

137 JSON with deletion details including chunks deleted, blob size freed 

138 """ 

139 try: 

140 service = DocumentDeletionService(username) 

141 result = service.delete_document(document_id) 

142 

143 if result.get("deleted"): 

144 return {"success": True, **result} 

145 # Note refusal (bypass guard): caller targeted a note via the 

146 # document API. Return 403 so the frontend can route them to 

147 # DELETE /api/notes/<id>, distinct from 404 (not found). 

148 if result.get("is_note"): 

149 return JSONResponse({"success": False, **result}, status_code=403) 

150 return JSONResponse({"success": False, **result}, status_code=404) 

151 

152 except Exception as e: 

153 return handle_api_error("deleting document", e) 

154 

155 

156@router.delete("/document/{document_id}/blob") 

157def delete_document_blob( 

158 request: Request, 

159 document_id, 

160 username: Annotated[str, Depends(require_auth)], 

161): 

162 """ 

163 Delete PDF binary but keep document metadata and text content. 

164 

165 Tooltip: "Remove the PDF file to save space. Text content will be 

166 preserved for searching." 

167 

168 Returns: 

169 JSON with bytes freed 

170 """ 

171 try: 

172 service = DocumentDeletionService(username) 

173 result = service.delete_blob_only(document_id) 

174 

175 if result.get("deleted"): 

176 return {"success": True, **result} 

177 # Note refusal: mirror the sibling DELETE /document/<id> route 

178 # which returns 403 when delete_document declines on a note. 

179 if result.get("is_note"): 

180 return JSONResponse({"success": False, **result}, status_code=403) 

181 error_code = ( 

182 404 if "not found" in result.get("error", "").lower() else 400 

183 ) 

184 # FastAPI does not honor Flask's ``(body, status)`` tuple return — it 

185 # would serialize the 2-tuple as an HTTP 200 with a ``[{...}, 404]`` 

186 # array body. Use JSONResponse explicitly, matching the sibling 

187 # delete routes. 

188 return JSONResponse( 

189 {"success": False, **result}, status_code=error_code 

190 ) 

191 

192 except Exception as e: 

193 return handle_api_error("deleting document blob", e) 

194 

195 

196@router.get("/document/{document_id}/preview") 

197def get_document_deletion_preview( 

198 request: Request, 

199 document_id, 

200 username: Annotated[str, Depends(require_auth)], 

201): 

202 """ 

203 Get a preview of what will be deleted. 

204 

205 Returns information about the document to help user confirm deletion. 

206 """ 

207 try: 

208 service = DocumentDeletionService(username) 

209 result = service.get_deletion_preview(document_id) 

210 

211 if result.get("found"): 

212 return {"success": True, **result} 

213 return JSONResponse( 

214 {"success": False, "error": "Document not found"}, 

215 status_code=404, 

216 ) 

217 

218 except Exception as e: 

219 return handle_api_error("getting document preview", e) 

220 

221 

222# ============================================================================= 

223# Collection Document Endpoints 

224# ============================================================================= 

225 

226 

227@router.delete("/collection/{collection_id}/document/{document_id}") 

228def remove_document_from_collection( 

229 request: Request, 

230 collection_id, 

231 document_id, 

232 username: Annotated[str, Depends(require_auth)], 

233): 

234 """ 

235 Remove document from a collection. 

236 

237 If the document is not in any other collection, it will be deleted. 

238 

239 Tooltip: "Remove from this collection. If not in any other collection, 

240 the document will be deleted." 

241 

242 Returns: 

243 JSON with unlink status and whether document was deleted 

244 """ 

245 try: 

246 service = DocumentDeletionService(username) 

247 result = service.remove_from_collection(document_id, collection_id) 

248 

249 if result.get("unlinked"): 

250 return {"success": True, **result} 

251 # Protected-home refusal (note in its Notes collection): 403 so 

252 # the frontend can distinguish it from 404 (not found / not in 

253 # this collection), mirroring the is_note guard on delete_document. 

254 if result.get("protected"): 

255 return JSONResponse({"success": False, **result}, status_code=403) 

256 return JSONResponse({"success": False, **result}, status_code=404) 

257 

258 except Exception as e: 

259 return handle_api_error("removing document from collection", e) 

260 

261 

262# ============================================================================= 

263# Collection Delete Endpoints 

264# ============================================================================= 

265 

266 

267@router.delete("/collections/{collection_id}") 

268def delete_collection( 

269 request: Request, 

270 collection_id, 

271 username: Annotated[str, Depends(require_auth)], 

272): 

273 """ 

274 Delete a collection and clean up all related data. 

275 

276 Deletes the collection, its RAG index, chunks, and any orphaned 

277 documents (documents not in any other collection). 

278 

279 Returns: 

280 JSON with deletion details including orphaned documents deleted 

281 """ 

282 try: 

283 service = CollectionDeletionService(username) 

284 result = service.delete_collection( 

285 collection_id, delete_orphaned_documents=True 

286 ) 

287 

288 if result.get("deleted"): 

289 return {"success": True, **result} 

290 error = result.get("error", "Unknown error") 

291 # 404 not found, 409 for a protected/system collection the service 

292 # refuses to delete, else 400. FastAPI doesn't honor Flask's 

293 # `(body, status)` tuple return, so use JSONResponse explicitly. 

294 if "not found" in error.lower(): 

295 status_code = 404 

296 elif result.get("collection_type") in PROTECTED_COLLECTION_TYPES: 

297 status_code = 409 

298 else: 

299 status_code = 400 

300 return JSONResponse( 

301 {"success": False, **result}, status_code=status_code 

302 ) 

303 

304 except Exception as e: 

305 return handle_api_error("deleting collection", e) 

306 

307 

308@router.delete("/collections/{collection_id}/index") 

309def delete_collection_index( 

310 request: Request, 

311 collection_id, 

312 username: Annotated[str, Depends(require_auth)], 

313): 

314 """ 

315 Delete only the RAG index for a collection, keeping the collection itself. 

316 

317 Useful for rebuilding an index from scratch. 

318 

319 Returns: 

320 JSON with deletion details 

321 """ 

322 try: 

323 service = CollectionDeletionService(username) 

324 result = service.delete_collection_index_only(collection_id) 

325 

326 if result.get("deleted"): 

327 return {"success": True, **result} 

328 return JSONResponse({"success": False, **result}, status_code=404) 

329 

330 except Exception as e: 

331 return handle_api_error("deleting collection index", e) 

332 

333 

334@router.get("/collections/{collection_id}/preview") 

335def get_collection_deletion_preview( 

336 request: Request, 

337 collection_id, 

338 username: Annotated[str, Depends(require_auth)], 

339): 

340 """ 

341 Get a preview of what will be deleted. 

342 

343 Returns information about the collection to help user confirm deletion. 

344 """ 

345 try: 

346 service = CollectionDeletionService(username) 

347 result = service.get_deletion_preview(collection_id) 

348 

349 if result.get("found"): 

350 return {"success": True, **result} 

351 return JSONResponse( 

352 {"success": False, "error": "Collection not found"}, 

353 status_code=404, 

354 ) 

355 

356 except Exception as e: 

357 return handle_api_error("getting collection preview", e) 

358 

359 

360# ============================================================================= 

361# Bulk Delete Endpoints 

362# ============================================================================= 

363 

364 

365@router.delete("/documents/bulk") 

366async def delete_documents_bulk( 

367 request: Request, username: Annotated[str, Depends(require_auth)] 

368): 

369 """ 

370 Delete multiple documents at once. 

371 

372 Tooltip: "Permanently delete all selected documents and their associated data." 

373 

374 Request body: 

375 {"document_ids": ["id1", "id2", ...]} 

376 

377 Returns: 

378 JSON with bulk deletion results 

379 """ 

380 try: 

381 document_ids, error = await _validate_document_ids(request) 

382 if error: 

383 return error 

384 

385 # Per-document DB deletion loop — run on the threadpool so a 

386 # large selection cannot stall the event loop. 

387 result = await run_db_sync( 

388 lambda: BulkDeletionService(username).delete_documents(document_ids) 

389 ) 

390 

391 return {"success": True, **result} 

392 

393 except Exception as e: 

394 return handle_api_error("bulk deleting documents", e) 

395 

396 

397@router.delete("/documents/blobs") 

398async def delete_documents_blobs_bulk( 

399 request: Request, username: Annotated[str, Depends(require_auth)] 

400): 

401 """ 

402 Delete PDF binaries for multiple documents. 

403 

404 Tooltip: "Remove PDF files from selected documents to free up database space. 

405 Text content is preserved." 

406 

407 Request body: 

408 {"document_ids": ["id1", "id2", ...]} 

409 

410 Returns: 

411 JSON with bulk blob deletion results 

412 """ 

413 try: 

414 document_ids, error = await _validate_document_ids(request) 

415 if error: 

416 return error 

417 

418 result = await run_db_sync( 

419 lambda: BulkDeletionService(username).delete_blobs(document_ids) 

420 ) 

421 

422 return {"success": True, **result} 

423 

424 except Exception as e: 

425 return handle_api_error("bulk deleting blobs", e) 

426 

427 

428@router.delete("/collection/{collection_id}/documents/bulk") 

429async def remove_documents_from_collection_bulk( 

430 request: Request, 

431 collection_id, 

432 username: Annotated[str, Depends(require_auth)], 

433): 

434 """ 

435 Remove multiple documents from a collection. 

436 

437 Documents that are not in any other collection will be deleted. 

438 

439 Request body: 

440 {"document_ids": ["id1", "id2", ...]} 

441 

442 Returns: 

443 JSON with bulk removal results 

444 """ 

445 try: 

446 document_ids, error = await _validate_document_ids(request) 

447 if error: 

448 return error 

449 

450 result = await run_db_sync( 

451 lambda: BulkDeletionService( 

452 username 

453 ).remove_documents_from_collection(document_ids, collection_id) 

454 ) 

455 

456 return {"success": True, **result} 

457 

458 except Exception as e: 

459 return handle_api_error("bulk removing documents from collection", e) 

460 

461 

462@router.post("/documents/preview") 

463async def get_bulk_deletion_preview( 

464 request: Request, username: Annotated[str, Depends(require_auth)] 

465): 

466 """ 

467 Get a preview of what will be affected by a bulk operation. 

468 

469 Request body: 

470 { 

471 "document_ids": ["id1", "id2", ...], 

472 "operation": "delete" or "delete_blobs" 

473 } 

474 

475 Returns: 

476 JSON with preview information 

477 """ 

478 try: 

479 data, error = await _parse_json_body(request) 

480 if error: 

481 return error 

482 document_ids, error = _validate_document_ids_from_data(data) 

483 if error: 

484 return error 

485 

486 operation = data.get("operation", "delete") 

487 

488 result = await run_db_sync( 

489 lambda: BulkDeletionService(username).get_bulk_preview( 

490 document_ids, operation 

491 ) 

492 ) 

493 

494 return {"success": True, **result} 

495 

496 except Exception as e: 

497 return handle_api_error("getting bulk preview", e)