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
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""
2Delete API Routes
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"""
14import json
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
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
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.
41async def _parse_json_body(request: Request):
42 """Parse the request body as JSON, returning a 400 on malformed bytes.
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).
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
65def _validate_document_ids_from_data(data):
66 """Validate document_ids from an already-parsed JSON body.
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 )
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 )
108 return document_ids, None
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)
119# =============================================================================
120# Document Delete Endpoints
121# =============================================================================
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.
133 Tooltip: "Permanently delete this document, including PDF and text content.
134 This cannot be undone."
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)
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)
152 except Exception as e:
153 return handle_api_error("deleting document", e)
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.
165 Tooltip: "Remove the PDF file to save space. Text content will be
166 preserved for searching."
168 Returns:
169 JSON with bytes freed
170 """
171 try:
172 service = DocumentDeletionService(username)
173 result = service.delete_blob_only(document_id)
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 )
192 except Exception as e:
193 return handle_api_error("deleting document blob", e)
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.
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)
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 )
218 except Exception as e:
219 return handle_api_error("getting document preview", e)
222# =============================================================================
223# Collection Document Endpoints
224# =============================================================================
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.
237 If the document is not in any other collection, it will be deleted.
239 Tooltip: "Remove from this collection. If not in any other collection,
240 the document will be deleted."
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)
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)
258 except Exception as e:
259 return handle_api_error("removing document from collection", e)
262# =============================================================================
263# Collection Delete Endpoints
264# =============================================================================
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.
276 Deletes the collection, its RAG index, chunks, and any orphaned
277 documents (documents not in any other collection).
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 )
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 )
304 except Exception as e:
305 return handle_api_error("deleting collection", e)
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.
317 Useful for rebuilding an index from scratch.
319 Returns:
320 JSON with deletion details
321 """
322 try:
323 service = CollectionDeletionService(username)
324 result = service.delete_collection_index_only(collection_id)
326 if result.get("deleted"):
327 return {"success": True, **result}
328 return JSONResponse({"success": False, **result}, status_code=404)
330 except Exception as e:
331 return handle_api_error("deleting collection index", e)
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.
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)
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 )
356 except Exception as e:
357 return handle_api_error("getting collection preview", e)
360# =============================================================================
361# Bulk Delete Endpoints
362# =============================================================================
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.
372 Tooltip: "Permanently delete all selected documents and their associated data."
374 Request body:
375 {"document_ids": ["id1", "id2", ...]}
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
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 )
391 return {"success": True, **result}
393 except Exception as e:
394 return handle_api_error("bulk deleting documents", e)
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.
404 Tooltip: "Remove PDF files from selected documents to free up database space.
405 Text content is preserved."
407 Request body:
408 {"document_ids": ["id1", "id2", ...]}
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
418 result = await run_db_sync(
419 lambda: BulkDeletionService(username).delete_blobs(document_ids)
420 )
422 return {"success": True, **result}
424 except Exception as e:
425 return handle_api_error("bulk deleting blobs", e)
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.
437 Documents that are not in any other collection will be deleted.
439 Request body:
440 {"document_ids": ["id1", "id2", ...]}
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
450 result = await run_db_sync(
451 lambda: BulkDeletionService(
452 username
453 ).remove_documents_from_collection(document_ids, collection_id)
454 )
456 return {"success": True, **result}
458 except Exception as e:
459 return handle_api_error("bulk removing documents from collection", e)
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.
469 Request body:
470 {
471 "document_ids": ["id1", "id2", ...],
472 "operation": "delete" or "delete_blobs"
473 }
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
486 operation = data.get("operation", "delete")
488 result = await run_db_sync(
489 lambda: BulkDeletionService(username).get_bulk_preview(
490 document_ids, operation
491 )
492 )
494 return {"success": True, **result}
496 except Exception as e:
497 return handle_api_error("getting bulk preview", e)