diff --git a/app/api/files.py b/app/api/files.py index 865250a0..c2df2a41 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -275,6 +275,127 @@ def delete_file_record(request: Request, file_id: int, db: Session = Depends(get detail=f"Error deleting file record: {str(e)}" ) +@router.post("/files/bulk-delete") +@require_login +def bulk_delete_files(request: Request, file_ids: List[int], db: Session = Depends(get_db)): + """ + Delete multiple file records from the database. + This only removes the database entries, not the actual files. + """ + # Check if file deletion is allowed + if not settings.allow_file_delete: + raise HTTPException( + status_code=403, + detail="File deletion is disabled in the configuration" + ) + + try: + # Find all file records + file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all() + + if not file_records: + raise HTTPException( + status_code=404, + detail="No files found with the provided IDs" + ) + + deleted_count = len(file_records) + deleted_ids = [f.id for f in file_records] + + # Log the deletion + logger.info(f"Bulk deleting {deleted_count} file records: IDs={deleted_ids}") + + # Delete all records + for file_record in file_records: + db.delete(file_record) + + db.commit() + + return { + "status": "success", + "message": f"Successfully deleted {deleted_count} file records", + "deleted_ids": deleted_ids + } + + except HTTPException: + raise + except Exception as e: + db.rollback() + logger.exception(f"Error bulk deleting file records: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error bulk deleting file records: {str(e)}" + ) + + +@router.post("/files/bulk-reprocess") +@require_login +def bulk_reprocess_files(request: Request, file_ids: List[int], db: Session = Depends(get_db)): + """ + Reprocess multiple files by queuing them for processing. + """ + try: + # Find all file records + file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all() + + if not file_records: + raise HTTPException( + status_code=404, + detail="No files found with the provided IDs" + ) + + task_ids = [] + processed_files = [] + errors = [] + + for file_record in file_records: + try: + # Check if local file exists + if not file_record.local_filename or not os.path.exists(file_record.local_filename): + errors.append({ + "file_id": file_record.id, + "filename": file_record.original_filename, + "error": "Local file not found" + }) + continue + + # Queue the file for processing + task = process_document.delay(file_record.local_filename) + task_ids.append(task.id) + processed_files.append({ + "file_id": file_record.id, + "filename": file_record.original_filename, + "task_id": task.id + }) + + logger.info(f"Reprocessing file: ID={file_record.id}, Filename={file_record.original_filename}, TaskID={task.id}") + + except Exception as e: + logger.exception(f"Error reprocessing file {file_record.id}: {str(e)}") + errors.append({ + "file_id": file_record.id, + "filename": file_record.original_filename, + "error": str(e) + }) + + return { + "status": "success" if processed_files else "error", + "message": f"Successfully queued {len(processed_files)} files for reprocessing", + "processed_files": processed_files, + "errors": errors if errors else None, + "task_ids": task_ids + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"Error bulk reprocessing files: {str(e)}") + raise HTTPException( + status_code=500, + detail=f"Error bulk reprocessing files: {str(e)}" + ) + + @router.post("/ui-upload") @require_login async def ui_upload(request: Request, file: UploadFile = File(...)): diff --git a/app/views/files.py b/app/views/files.py index 8da5c545..ea793f33 100644 --- a/app/views/files.py +++ b/app/views/files.py @@ -29,7 +29,7 @@ def files_page( try: # Import the model here to avoid circular imports from app.models import FileRecord, ProcessingLog - from sqlalchemy import desc, asc + from sqlalchemy import desc, asc, or_ # Start with base query query = db.query(FileRecord) @@ -42,6 +42,41 @@ def files_page( if mime_type: query = query.filter(FileRecord.mime_type == mime_type) + # Apply status filter (before pagination for correct counts) + if status: + # Subquery to get file IDs matching the status + if status == "pending": + # Files with no logs + subq = db.query(ProcessingLog.file_id).distinct() + query = query.filter(~FileRecord.id.in_(subq)) + elif status == "processing": + # Files with in_progress logs + subq = db.query(ProcessingLog.file_id).filter( + ProcessingLog.status == "in_progress" + ).distinct() + query = query.filter(FileRecord.id.in_(subq)) + elif status == "failed": + # Files with failure logs + subq = db.query(ProcessingLog.file_id).filter( + ProcessingLog.status == "failure" + ).distinct() + query = query.filter(FileRecord.id.in_(subq)) + elif status == "completed": + # Files with success logs but no failures or in_progress + success_files = db.query(ProcessingLog.file_id).filter( + ProcessingLog.status == "success" + ).distinct().subquery() + + failed_files = db.query(ProcessingLog.file_id).filter( + or_(ProcessingLog.status == "failure", ProcessingLog.status == "in_progress") + ).distinct().subquery() + + query = query.filter( + FileRecord.id.in_(db.query(success_files.c.file_id)) + ).filter( + ~FileRecord.id.in_(db.query(failed_files.c.file_id)) + ) + # Get total count before pagination total_items = query.count() diff --git a/frontend/templates/files.html b/frontend/templates/files.html index 5fcfcc22..bd9acec7 100644 --- a/frontend/templates/files.html +++ b/frontend/templates/files.html @@ -297,11 +297,34 @@ + + +
+ {% for file in files %} + @@ -382,7 +408,7 @@ {% else %} - + {% endfor %} @@ -513,6 +539,124 @@ function clearFilters() { window.location.href = '/files'; } + + // Bulk selection functionality + function toggleSelectAll() { + const selectAll = document.getElementById('selectAll'); + const checkboxes = document.querySelectorAll('.file-checkbox'); + checkboxes.forEach(checkbox => { + checkbox.checked = selectAll.checked; + }); + updateBulkActionsBar(); + } + + function updateBulkActionsBar() { + const checkboxes = document.querySelectorAll('.file-checkbox:checked'); + const selectedCount = checkboxes.length; + const bulkActionsBar = document.getElementById('bulkActionsBar'); + const selectedCountEl = document.getElementById('selectedCount'); + + if (selectedCount > 0) { + bulkActionsBar.style.display = 'block'; + selectedCountEl.textContent = selectedCount; + } else { + bulkActionsBar.style.display = 'none'; + } + + // Update "select all" checkbox state + const allCheckboxes = document.querySelectorAll('.file-checkbox'); + const selectAll = document.getElementById('selectAll'); + selectAll.checked = allCheckboxes.length > 0 && selectedCount === allCheckboxes.length; + } + + function clearSelection() { + document.querySelectorAll('.file-checkbox').forEach(cb => cb.checked = false); + document.getElementById('selectAll').checked = false; + updateBulkActionsBar(); + } + + function getSelectedFileIds() { + const checkboxes = document.querySelectorAll('.file-checkbox:checked'); + return Array.from(checkboxes).map(cb => parseInt(cb.value)); + } + + function bulkDelete() { + const fileIds = getSelectedFileIds(); + if (fileIds.length === 0) { + alert('Please select files to delete'); + return; + } + + if (!confirm(`Are you sure you want to delete ${fileIds.length} file(s)?`)) { + return; + } + + fetch('/api/files/bulk-delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(fileIds) + }) + .then(response => { + if (!response.ok) { + return response.json().then(err => { + throw new Error(err.detail || 'Failed to delete files'); + }); + } + return response.json(); + }) + .then(data => { + alert(data.message); + window.location.reload(); + }) + .catch(error => { + console.error('Error:', error); + alert(`Error deleting files: ${error.message}`); + }); + } + + function bulkReprocess() { + const fileIds = getSelectedFileIds(); + if (fileIds.length === 0) { + alert('Please select files to reprocess'); + return; + } + + if (!confirm(`Are you sure you want to reprocess ${fileIds.length} file(s)?`)) { + return; + } + + fetch('/api/files/bulk-reprocess', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(fileIds) + }) + .then(response => { + if (!response.ok) { + return response.json().then(err => { + throw new Error(err.detail || 'Failed to reprocess files'); + }); + } + return response.json(); + }) + .then(data => { + if (data.errors && data.errors.length > 0) { + const errorMsg = data.errors.map(e => `${e.filename}: ${e.error}`).join('\n'); + alert(`${data.message}\n\nErrors:\n${errorMsg}`); + } else { + alert(data.message); + } + clearSelection(); + window.location.reload(); + }) + .catch(error => { + console.error('Error:', error); + alert(`Error reprocessing files: ${error.message}`); + }); + } {% endblock %} diff --git a/tests/test_bulk_operations.py b/tests/test_bulk_operations.py new file mode 100644 index 00000000..97224d4d --- /dev/null +++ b/tests/test_bulk_operations.py @@ -0,0 +1,274 @@ +""" +Tests for bulk file operations (delete and reprocess). +""" +import pytest +from fastapi.testclient import TestClient +from app.models import FileRecord, ProcessingLog +from unittest.mock import patch, MagicMock + + +@pytest.mark.integration +@pytest.mark.requires_db +class TestBulkOperations: + """Tests for bulk file operations.""" + + def test_bulk_delete_success(self, client: TestClient, db_session): + """Test bulk deletion of files.""" + # Create sample files + file_ids = [] + for i in range(3): + file_record = FileRecord( + filehash=f"hash{i}", + original_filename=f"test{i}.pdf", + local_filename=f"/tmp/test{i}.pdf", + file_size=1024, + mime_type="application/pdf" + ) + db_session.add(file_record) + db_session.flush() + file_ids.append(file_record.id) + db_session.commit() + + # Bulk delete + response = client.post( + "/api/files/bulk-delete", + json=file_ids + ) + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert len(data["deleted_ids"]) == 3 + + # Verify files are deleted + for file_id in file_ids: + file_record = db_session.query(FileRecord).filter(FileRecord.id == file_id).first() + assert file_record is None + + def test_bulk_delete_empty_list(self, client: TestClient, db_session): + """Test bulk deletion with empty list.""" + response = client.post( + "/api/files/bulk-delete", + json=[] + ) + assert response.status_code == 404 + data = response.json() + assert "No files found" in data["detail"] + + def test_bulk_delete_nonexistent_files(self, client: TestClient, db_session): + """Test bulk deletion of non-existent files.""" + response = client.post( + "/api/files/bulk-delete", + json=[9999, 9998] + ) + assert response.status_code == 404 + + @patch('app.api.files.process_document') + def test_bulk_reprocess_success(self, mock_process_document, client: TestClient, db_session): + """Test bulk reprocessing of files.""" + # Setup mock + mock_task = MagicMock() + mock_task.id = "test-task-id" + mock_process_document.delay.return_value = mock_task + + # Create sample files with local files that "exist" + file_ids = [] + for i in range(2): + file_record = FileRecord( + filehash=f"hash{i}", + original_filename=f"test{i}.pdf", + local_filename=f"/tmp/test{i}.pdf", + file_size=1024, + mime_type="application/pdf" + ) + db_session.add(file_record) + db_session.flush() + file_ids.append(file_record.id) + db_session.commit() + + # Mock os.path.exists to return True + with patch('os.path.exists', return_value=True): + response = client.post( + "/api/files/bulk-reprocess", + json=file_ids + ) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "success" + assert len(data["processed_files"]) == 2 + assert len(data["task_ids"]) == 2 + + @patch('app.api.files.process_document') + def test_bulk_reprocess_missing_files(self, mock_process_document, client: TestClient, db_session): + """Test bulk reprocessing when some local files are missing.""" + # Setup mock + mock_task = MagicMock() + mock_task.id = "test-task-id" + mock_process_document.delay.return_value = mock_task + + # Create sample files + file_ids = [] + for i in range(2): + file_record = FileRecord( + filehash=f"hash{i}", + original_filename=f"test{i}.pdf", + local_filename=f"/tmp/test{i}.pdf" if i == 0 else None, # Second file has no local file + file_size=1024, + mime_type="application/pdf" + ) + db_session.add(file_record) + db_session.flush() + file_ids.append(file_record.id) + db_session.commit() + + # Mock os.path.exists to return False for missing file + with patch('os.path.exists', return_value=False): + response = client.post( + "/api/files/bulk-reprocess", + json=file_ids + ) + + assert response.status_code == 200 + data = response.json() + # All files should have errors since we mocked exists to return False + assert data["errors"] is not None + assert len(data["errors"]) == 2 + + def test_bulk_reprocess_nonexistent_files(self, client: TestClient, db_session): + """Test bulk reprocessing of non-existent files.""" + response = client.post( + "/api/files/bulk-reprocess", + json=[9999, 9998] + ) + assert response.status_code == 404 + + +@pytest.mark.integration +@pytest.mark.requires_db +class TestStatusFilter: + """Tests for status filtering in files view.""" + + def test_status_filter_pending(self, client: TestClient, db_session): + """Test filtering files by pending status.""" + # Create files with different statuses + # File 1: Pending (no logs) + file1 = FileRecord( + filehash="hash1", + original_filename="pending.pdf", + local_filename="/tmp/pending.pdf", + file_size=1024, + mime_type="application/pdf" + ) + db_session.add(file1) + + # File 2: Processing (has in_progress log) + file2 = FileRecord( + filehash="hash2", + original_filename="processing.pdf", + local_filename="/tmp/processing.pdf", + file_size=1024, + mime_type="application/pdf" + ) + db_session.add(file2) + db_session.flush() + + log2 = ProcessingLog( + file_id=file2.id, + task_id="task2", + step_name="OCR", + status="in_progress", + message="Processing..." + ) + db_session.add(log2) + db_session.commit() + + # Test pending filter + response = client.get("/files?status=pending") + assert response.status_code == 200 + # Check that pending file is shown (HTML response) + assert "pending.pdf" in response.text + assert "processing.pdf" not in response.text + + def test_status_filter_processing(self, client: TestClient, db_session): + """Test filtering files by processing status.""" + # Create file with in_progress status + file_record = FileRecord( + filehash="hash1", + original_filename="processing.pdf", + local_filename="/tmp/processing.pdf", + file_size=1024, + mime_type="application/pdf" + ) + db_session.add(file_record) + db_session.flush() + + log = ProcessingLog( + file_id=file_record.id, + task_id="task1", + step_name="OCR", + status="in_progress", + message="Processing..." + ) + db_session.add(log) + db_session.commit() + + # Test processing filter + response = client.get("/files?status=processing") + assert response.status_code == 200 + assert "processing.pdf" in response.text + + def test_status_filter_completed(self, client: TestClient, db_session): + """Test filtering files by completed status.""" + # Create file with success status + file_record = FileRecord( + filehash="hash1", + original_filename="completed.pdf", + local_filename="/tmp/completed.pdf", + file_size=1024, + mime_type="application/pdf" + ) + db_session.add(file_record) + db_session.flush() + + log = ProcessingLog( + file_id=file_record.id, + task_id="task1", + step_name="OCR", + status="success", + message="Completed" + ) + db_session.add(log) + db_session.commit() + + # Test completed filter + response = client.get("/files?status=completed") + assert response.status_code == 200 + assert "completed.pdf" in response.text + + def test_status_filter_failed(self, client: TestClient, db_session): + """Test filtering files by failed status.""" + # Create file with failure status + file_record = FileRecord( + filehash="hash1", + original_filename="failed.pdf", + local_filename="/tmp/failed.pdf", + file_size=1024, + mime_type="application/pdf" + ) + db_session.add(file_record) + db_session.flush() + + log = ProcessingLog( + file_id=file_record.id, + task_id="task1", + step_name="OCR", + status="failure", + message="Failed" + ) + db_session.add(log) + db_session.commit() + + # Test failed filter + response = client.get("/files?status=failed") + assert response.status_code == 200 + assert "failed.pdf" in response.text
+ + ID @@ -359,6 +382,9 @@
+ + {{ file.id }} {{ file.original_filename }} {{ (file.file_size / 1024) | round(2) }} KB
No files foundNo files found