Fix /files view issues and add bulk operations

- Fixed status filter in /files view endpoint
- Added bulk delete and reprocess API endpoints
- Added bulk selection UI with checkboxes
- Added bulk actions bar with reprocess and delete buttons
- Updated JavaScript to handle bulk operations

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-07 15:52:38 +00:00
parent 50adfc3694
commit 08775459aa
3 changed files with 302 additions and 2 deletions
+121
View File
@@ -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)}" 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") @router.post("/ui-upload")
@require_login @require_login
async def ui_upload(request: Request, file: UploadFile = File(...)): async def ui_upload(request: Request, file: UploadFile = File(...)):
+36 -1
View File
@@ -29,7 +29,7 @@ def files_page(
try: try:
# Import the model here to avoid circular imports # Import the model here to avoid circular imports
from app.models import FileRecord, ProcessingLog from app.models import FileRecord, ProcessingLog
from sqlalchemy import desc, asc from sqlalchemy import desc, asc, or_
# Start with base query # Start with base query
query = db.query(FileRecord) query = db.query(FileRecord)
@@ -42,6 +42,41 @@ def files_page(
if mime_type: if mime_type:
query = query.filter(FileRecord.mime_type == 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 # Get total count before pagination
total_items = query.count() total_items = query.count()
+145 -1
View File
@@ -297,11 +297,34 @@
</form> </form>
</div> </div>
<!-- Bulk Actions Section -->
<div id="bulkActionsBar" class="filters-section" style="display: none; background-color: #e6f3ff;">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<strong id="selectedCount">0</strong> files selected
</div>
<div style="display: flex; gap: 1rem;">
<button type="button" onclick="bulkReprocess()" class="filter-item" style="padding: 0.5rem 1rem; background-color: #3182ce; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600;">
<i class="fas fa-sync"></i> Reprocess Selected
</button>
<button type="button" onclick="bulkDelete()" class="filter-item" style="padding: 0.5rem 1rem; background-color: #e53e3e; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600;">
<i class="fas fa-trash"></i> Delete Selected
</button>
<button type="button" onclick="clearSelection()" class="filter-item" style="padding: 0.5rem 1rem; background-color: #718096; color: white; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600;">
Clear Selection
</button>
</div>
</div>
</div>
<!-- File table --> <!-- File table -->
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table class="file-table" id="fileTable"> <table class="file-table" id="fileTable">
<thead> <thead>
<tr> <tr>
<th style="width: 40px;">
<input type="checkbox" id="selectAll" onclick="toggleSelectAll()" title="Select all files on this page">
</th>
<th class="sortable" onclick="sortTable('id')"> <th class="sortable" onclick="sortTable('id')">
ID ID
<span class="sort-indicator {% if sort_by == 'id' %}active{% endif %}"> <span class="sort-indicator {% if sort_by == 'id' %}active{% endif %}">
@@ -359,6 +382,9 @@
<tbody> <tbody>
{% for file in files %} {% for file in files %}
<tr onclick="viewFileDetail({{ file.id }}, event)"> <tr onclick="viewFileDetail({{ file.id }}, event)">
<td onclick="event.stopPropagation();">
<input type="checkbox" class="file-checkbox" value="{{ file.id }}" onchange="updateBulkActionsBar()">
</td>
<td>{{ file.id }}</td> <td>{{ file.id }}</td>
<td>{{ file.original_filename }}</td> <td>{{ file.original_filename }}</td>
<td>{{ (file.file_size / 1024) | round(2) }} KB</td> <td>{{ (file.file_size / 1024) | round(2) }} KB</td>
@@ -382,7 +408,7 @@
</tr> </tr>
{% else %} {% else %}
<tr> <tr>
<td colspan="7" class="text-center py-4">No files found</td> <td colspan="8" class="text-center py-4">No files found</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -513,6 +539,124 @@
function clearFilters() { function clearFilters() {
window.location.href = '/files'; 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('No files selected');
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('No files selected');
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}`);
});
}
</script> </script>
</div> </div>
{% endblock %} {% endblock %}