Merge pull request #83 from christianlouis/copilot/fix-document-list-ordering

Add server-side pagination, filtering, and status tracking to /files view
This commit is contained in:
Christian Krakau-Louis
2026-02-07 16:24:53 +01:00
committed by GitHub
6 changed files with 1346 additions and 194 deletions
+194 -21
View File
@@ -1,19 +1,22 @@
"""
File-related API endpoints
"""
from fastapi import APIRouter, Request, HTTPException, Depends, UploadFile, File
from fastapi import APIRouter, Request, HTTPException, Depends, UploadFile, File, Query
from sqlalchemy.orm import Session
from sqlalchemy import desc, asc, or_, func
from typing import Optional, List
import logging
import os
import uuid
import mimetypes
from app.auth import require_login
from app.models import FileRecord
from app.models import FileRecord, ProcessingLog
from app.config import settings
from app.api.common import get_db
from app.tasks.process_document import process_document
from app.tasks.convert_to_pdf import convert_to_pdf
from app.utils.file_status import get_file_processing_status, get_files_processing_status
# Set up logging
logger = logging.getLogger(__name__)
@@ -22,27 +25,113 @@ router = APIRouter()
@router.get("/files")
@require_login
def list_files_api(request: Request, db: Session = Depends(get_db)):
def list_files_api(
request: Request,
db: Session = Depends(get_db),
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(50, ge=1, le=200, description="Items per page"),
sort_by: str = Query("created_at", description="Sort field: id, original_filename, file_size, mime_type, created_at, status"),
sort_order: str = Query("desc", description="Sort order: asc or desc"),
search: Optional[str] = Query(None, description="Search in filename"),
mime_type: Optional[str] = Query(None, description="Filter by MIME type"),
status: Optional[str] = Query(None, description="Filter by processing status")
):
"""
Returns a JSON list of all FileRecord entries.
Protected by `@require_login`, so only logged-in sessions can access.
Returns a paginated JSON list of FileRecord entries with processing status.
Supports server-side sorting, filtering, and searching.
Query Parameters:
- page: Page number (default: 1)
- per_page: Items per page (default: 50, max: 200)
- sort_by: Field to sort by (default: created_at)
- sort_order: asc or desc (default: desc)
- search: Search in filename
- mime_type: Filter by MIME type
- status: Filter by processing status (pending, processing, completed, failed)
Example response:
[
{
"id": 123,
"filehash": "abc123...",
"original_filename": "example.pdf",
"local_filename": "/workdir/tmp/<uuid>.pdf",
"file_size": 1048576,
"mime_type": "application/pdf",
"created_at": "2025-05-01T12:34:56.789000"
},
...
]
{
"files": [...],
"pagination": {
"page": 1,
"per_page": 50,
"total_items": 150,
"total_pages": 3
}
}
"""
files = db.query(FileRecord).order_by(FileRecord.created_at.desc()).all()
# Return a simple list of dicts
# Start with base query
query = db.query(FileRecord)
# Apply search filter
if search:
query = query.filter(FileRecord.original_filename.ilike(f"%{search}%"))
# Apply MIME type filter
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 (after all filters)
total_items = query.count()
# Apply sorting
sort_column = {
"id": FileRecord.id,
"original_filename": FileRecord.original_filename,
"file_size": FileRecord.file_size,
"mime_type": FileRecord.mime_type,
"created_at": FileRecord.created_at
}.get(sort_by, FileRecord.created_at)
if sort_order == "asc":
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
# Apply pagination
offset = (page - 1) * per_page
files = query.offset(offset).limit(per_page).all()
# Get processing status for all files efficiently
file_ids = [f.id for f in files]
statuses = get_files_processing_status(db, file_ids)
# Build result with processing status
result = []
for f in files:
result.append({
@@ -52,9 +141,93 @@ def list_files_api(request: Request, db: Session = Depends(get_db)):
"local_filename": f.local_filename,
"file_size": f.file_size,
"mime_type": f.mime_type,
"created_at": f.created_at.isoformat() if f.created_at else None
"created_at": f.created_at.isoformat() if f.created_at else None,
"processing_status": statuses.get(f.id, {
"status": "pending",
"last_step": None,
"has_errors": False,
"total_steps": 0
})
})
return result
# Calculate pagination info
total_pages = (total_items + per_page - 1) // per_page
return {
"files": result,
"pagination": {
"page": page,
"per_page": per_page,
"total_items": total_items,
"total_pages": total_pages
}
}
def _get_file_processing_status(db: Session, file_id: int) -> dict:
"""
Deprecated: Use app.utils.file_status.get_file_processing_status instead.
Kept for backward compatibility with file detail endpoint.
"""
from app.utils.file_status import get_file_processing_status
return get_file_processing_status(db, file_id)
@router.get("/files/{file_id}")
@require_login
def get_file_details(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Get detailed information about a specific file including processing history.
"""
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(
status_code=404,
detail=f"File record with ID {file_id} not found"
)
# Get processing logs
logs = db.query(ProcessingLog).filter(
ProcessingLog.file_id == file_id
).order_by(ProcessingLog.timestamp.desc()).all()
# Build log list
log_list = []
for log in logs:
log_list.append({
"id": log.id,
"task_id": log.task_id,
"step_name": log.step_name,
"status": log.status,
"message": log.message,
"timestamp": log.timestamp.isoformat() if log.timestamp else None
})
# Get processing status
processing_status = _get_file_processing_status(db, file_id)
# Check if files exist on disk
files_on_disk = {
"original": os.path.exists(file_record.local_filename) if file_record.local_filename else False
}
return {
"file": {
"id": file_record.id,
"filehash": file_record.filehash,
"original_filename": file_record.original_filename,
"local_filename": file_record.local_filename,
"file_size": file_record.file_size,
"mime_type": file_record.mime_type,
"created_at": file_record.created_at.isoformat() if file_record.created_at else None
},
"processing_status": processing_status,
"logs": log_list,
"files_on_disk": files_on_disk
}
@router.delete("/files/{file_id}")
@require_login
+102
View File
@@ -0,0 +1,102 @@
"""
Utility functions for file processing status determination.
"""
from typing import Dict, List
from sqlalchemy.orm import Session
from app.models import ProcessingLog
def get_file_processing_status(db: Session, file_id: int) -> Dict:
"""
Get the processing status for a file by checking its processing logs.
Args:
db: Database session
file_id: ID of the file
Returns:
dict with status, last_step, and has_errors
"""
# Get all logs for this file
logs = db.query(ProcessingLog).filter(
ProcessingLog.file_id == file_id
).order_by(ProcessingLog.timestamp.desc()).all()
return _compute_status_from_logs(logs)
def get_files_processing_status(db: Session, file_ids: List[int]) -> Dict[int, Dict]:
"""
Get processing status for multiple files efficiently.
Args:
db: Database session
file_ids: List of file IDs
Returns:
dict mapping file_id to status dict
"""
# Get all logs for these files in one query
logs = db.query(ProcessingLog).filter(
ProcessingLog.file_id.in_(file_ids)
).order_by(ProcessingLog.file_id, ProcessingLog.timestamp.desc()).all()
# Group logs by file_id
logs_by_file = {}
for log in logs:
if log.file_id not in logs_by_file:
logs_by_file[log.file_id] = []
logs_by_file[log.file_id].append(log)
# Compute status for each file
result = {}
for file_id in file_ids:
file_logs = logs_by_file.get(file_id, [])
result[file_id] = _compute_status_from_logs(file_logs)
return result
def _compute_status_from_logs(logs: List[ProcessingLog]) -> Dict:
"""
Compute processing status from a list of processing logs.
Args:
logs: List of ProcessingLog objects (should be ordered by timestamp desc)
Returns:
dict with status, last_step, has_errors, and total_steps
"""
if not logs:
return {
"status": "pending",
"last_step": None,
"has_errors": False,
"total_steps": 0
}
# Check for failures
has_errors = any(log.status == "failure" for log in logs)
# Check if any in progress
in_progress = any(log.status == "in_progress" for log in logs)
# Get the latest log
latest_log = logs[0]
# Determine overall status
if has_errors:
status = "failed"
elif in_progress:
status = "processing"
elif latest_log.status == "success":
status = "completed"
else:
status = "pending"
return {
"status": status,
"last_step": latest_log.step_name,
"has_errors": has_errors,
"total_steps": len(logs)
}
+128 -8
View File
@@ -1,32 +1,105 @@
"""
File management views for displaying and managing files.
"""
from fastapi import Request, Depends
from fastapi import Request, Depends, Query
from sqlalchemy.orm import Session
from typing import Optional
from app.views.base import APIRouter, templates, require_login, get_db, logger
from app.utils.file_status import get_files_processing_status
router = APIRouter()
@router.get("/files")
@require_login
def files_page(request: Request, db: Session = Depends(get_db)):
def files_page(
request: Request,
db: Session = Depends(get_db),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
sort_by: str = Query("created_at"),
sort_order: str = Query("desc"),
search: Optional[str] = Query(None),
mime_type: Optional[str] = Query(None),
status: Optional[str] = Query(None)
):
"""
Return the 'files.html' template with files from the database
Return the 'files.html' template with server-side pagination, sorting, and filtering
"""
try:
# Import the model here to avoid circular imports
from app.models import FileRecord
from app.models import FileRecord, ProcessingLog
from sqlalchemy import desc, asc
# Fetch all files from the database
files = db.query(FileRecord).order_by(FileRecord.created_at.desc()).all()
# Start with base query
query = db.query(FileRecord)
# Apply search filter
if search:
query = query.filter(FileRecord.original_filename.ilike(f"%{search}%"))
# Apply MIME type filter
if mime_type:
query = query.filter(FileRecord.mime_type == mime_type)
# Get total count before pagination
total_items = query.count()
# Apply sorting
sort_column = {
"id": FileRecord.id,
"original_filename": FileRecord.original_filename,
"file_size": FileRecord.file_size,
"mime_type": FileRecord.mime_type,
"created_at": FileRecord.created_at
}.get(sort_by, FileRecord.created_at)
if sort_order == "asc":
query = query.order_by(asc(sort_column))
else:
query = query.order_by(desc(sort_column))
# Apply pagination
offset = (page - 1) * per_page
files = query.offset(offset).limit(per_page).all()
# Get processing status for all files efficiently (avoids N+1)
file_ids = [f.id for f in files]
statuses = get_files_processing_status(db, file_ids)
# Add status to each file
files_with_status = []
for file in files:
file.processing_status = statuses.get(file.id, {}).get("status", "pending")
files_with_status.append(file)
# Calculate pagination info
total_pages = (total_items + per_page - 1) // per_page
# Get unique MIME types for filter dropdown
mime_types = db.query(FileRecord.mime_type).distinct().filter(
FileRecord.mime_type.isnot(None)
).all()
mime_types = [mt[0] for mt in mime_types if mt[0]]
# Debug output
logger.info(f"Retrieved {len(files)} files from database")
logger.info(f"Retrieved {len(files_with_status)} files from database (page {page}/{total_pages})")
return templates.TemplateResponse("files.html", {
"request": request,
"files": files
"files": files_with_status,
"pagination": {
"page": page,
"per_page": per_page,
"total_items": total_items,
"total_pages": total_pages
},
"sort_by": sort_by,
"sort_order": sort_order,
"search": search or "",
"mime_type": mime_type or "",
"status": status or "",
"mime_types": mime_types
})
except Exception as e:
# Log any errors
@@ -35,5 +108,52 @@ def files_page(request: Request, db: Session = Depends(get_db)):
return templates.TemplateResponse("files.html", {
"request": request,
"files": [],
"pagination": {
"page": 1,
"per_page": per_page,
"total_items": 0,
"total_pages": 0
},
"error": str(e)
})
@router.get("/files/{file_id}/detail")
@require_login
def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Return the file detail page showing processing history and file information
"""
try:
from app.models import FileRecord, ProcessingLog
import os
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
return templates.TemplateResponse("file_detail.html", {
"request": request,
"error": f"File with ID {file_id} not found"
})
# Get processing logs
logs = db.query(ProcessingLog).filter(
ProcessingLog.file_id == file_id
).order_by(ProcessingLog.timestamp.asc()).all()
# Check if file exists on disk
file_exists = os.path.exists(file_record.local_filename) if file_record.local_filename else False
return templates.TemplateResponse("file_detail.html", {
"request": request,
"file": file_record,
"logs": logs,
"file_exists": file_exists
})
except Exception as e:
logger.error(f"Error retrieving file details: {str(e)}")
return templates.TemplateResponse("file_detail.html", {
"request": request,
"error": str(e)
})
+310
View File
@@ -0,0 +1,310 @@
{% extends "base.html" %}
{% block title %}File Details{% endblock %}
{% block head_extra %}
<style>
.detail-container {
max-width: 1200px;
margin: 0 auto;
}
.back-button {
display: inline-flex;
align-items: center;
color: #3182ce;
text-decoration: none;
margin-bottom: 1.5rem;
font-weight: 600;
}
.back-button:hover {
color: #2c5aa0;
}
.back-button i {
margin-right: 0.5rem;
}
.detail-card {
background-color: white;
border-radius: 0.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
padding: 2rem;
margin-bottom: 1.5rem;
}
.detail-card h3 {
font-size: 1.5rem;
font-weight: bold;
margin-bottom: 1rem;
color: #2d3748;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
.detail-item {
display: flex;
flex-direction: column;
}
.detail-label {
font-weight: 600;
color: #4a5568;
margin-bottom: 0.25rem;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.detail-value {
color: #2d3748;
font-size: 1rem;
word-break: break-word;
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 0.5rem 1rem;
border-radius: 9999px;
font-size: 1rem;
font-weight: 600;
text-align: center;
}
.status-pending {
background-color: #FEF3C7;
color: #92400E;
}
.status-processing {
background-color: #DBEAFE;
color: #1E3A8A;
}
.status-completed {
background-color: #D1FAE5;
color: #065F46;
}
.status-failed {
background-color: #FEE2E2;
color: #991B1B;
}
/* Processing logs */
.timeline {
position: relative;
padding-left: 2rem;
}
.timeline::before {
content: '';
position: absolute;
left: 0.5rem;
top: 0;
bottom: 0;
width: 2px;
background-color: #e2e8f0;
}
.timeline-item {
position: relative;
padding-bottom: 2rem;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-marker {
position: absolute;
left: -1.5rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
background-color: #e2e8f0;
border: 2px solid white;
}
.timeline-marker.success {
background-color: #48bb78;
}
.timeline-marker.failure {
background-color: #f56565;
}
.timeline-marker.in_progress {
background-color: #4299e1;
}
.timeline-marker.pending {
background-color: #ecc94b;
}
.timeline-content {
background-color: #f7fafc;
border-radius: 0.5rem;
padding: 1rem;
border-left: 3px solid #e2e8f0;
}
.timeline-content.success {
border-left-color: #48bb78;
background-color: #f0fff4;
}
.timeline-content.failure {
border-left-color: #f56565;
background-color: #fff5f5;
}
.timeline-content.in_progress {
border-left-color: #4299e1;
background-color: #ebf8ff;
}
.timeline-content.pending {
border-left-color: #ecc94b;
background-color: #fffff0;
}
.timeline-title {
font-weight: 600;
color: #2d3748;
margin-bottom: 0.5rem;
}
.timeline-message {
color: #4a5568;
margin-bottom: 0.5rem;
}
.timeline-meta {
display: flex;
justify-content: space-between;
font-size: 0.875rem;
color: #718096;
}
.timeline-task-id {
font-family: monospace;
font-size: 0.75rem;
color: #a0aec0;
}
.no-logs {
text-align: center;
padding: 3rem;
color: #718096;
}
.no-logs i {
font-size: 3rem;
margin-bottom: 1rem;
opacity: 0.5;
}
.error-message {
background-color: #FEE2E2;
border: 1px solid #F87171;
color: #B91C1C;
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
}
.file-status-indicator {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
font-size: 0.875rem;
}
.file-status-indicator.exists {
background-color: #D1FAE5;
color: #065F46;
}
.file-status-indicator.missing {
background-color: #FEE2E2;
color: #991B1B;
}
</style>
{% endblock %}
{% block content %}
<div class="detail-container">
<a href="/files" class="back-button">
<i class="fas fa-arrow-left"></i>
Back to File List
</a>
{% if error %}
<div class="error-message">
<p><strong>Error:</strong> {{ error }}</p>
</div>
{% else %}
<!-- File Information Card -->
<div class="detail-card">
<h3>File Information</h3>
<div class="detail-grid">
<div class="detail-item">
<span class="detail-label">File ID</span>
<span class="detail-value">{{ file.id }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Original Filename</span>
<span class="detail-value">{{ file.original_filename }}</span>
</div>
<div class="detail-item">
<span class="detail-label">File Hash</span>
<span class="detail-value" style="font-family: monospace; font-size: 0.875rem;">{{ file.filehash }}</span>
</div>
<div class="detail-item">
<span class="detail-label">File Size</span>
<span class="detail-value">{{ (file.file_size / 1024) | round(2) }} KB ({{ file.file_size }} bytes)</span>
</div>
<div class="detail-item">
<span class="detail-label">MIME Type</span>
<span class="detail-value">{{ file.mime_type or 'N/A' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Created At</span>
<span class="detail-value">{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else 'N/A' }}</span>
</div>
<div class="detail-item">
<span class="detail-label">Local Path</span>
<span class="detail-value" style="font-family: monospace; font-size: 0.875rem;">{{ file.local_filename }}</span>
</div>
<div class="detail-item">
<span class="detail-label">File on Disk</span>
<span class="detail-value">
{% if file_exists %}
<span class="file-status-indicator exists">
<i class="fas fa-check-circle"></i> File exists
</span>
{% else %}
<span class="file-status-indicator missing">
<i class="fas fa-times-circle"></i> File not found
</span>
{% endif %}
</span>
</div>
</div>
</div>
<!-- Processing History Card -->
<div class="detail-card">
<h3>Processing History</h3>
{% if logs %}
<div class="timeline">
{% for log in logs %}
<div class="timeline-item">
<div class="timeline-marker {{ log.status.lower().replace(' ', '_') }}"></div>
<div class="timeline-content {{ log.status.lower().replace(' ', '_') }}">
<div class="timeline-title">
{{ log.step_name }} - <span style="text-transform: capitalize;">{{ log.status }}</span>
</div>
{% if log.message %}
<div class="timeline-message">{{ log.message }}</div>
{% endif %}
<div class="timeline-meta">
<span>{{ log.timestamp.strftime('%Y-%m-%d %H:%M:%S') if log.timestamp else 'N/A' }}</span>
{% if log.task_id %}
<span class="timeline-task-id">Task: {{ log.task_id[:16] }}...</span>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="no-logs">
<i class="fas fa-clipboard-list"></i>
<p>No processing logs found for this file.</p>
<p style="margin-top: 0.5rem; font-size: 0.875rem;">The file may not have been processed yet.</p>
</div>
{% endif %}
</div>
{% endif %}
</div>
{% endblock %}
+308 -165
View File
@@ -3,10 +3,6 @@
{% block head_extra %}
<script src="/static/js/common.js"></script>
<!-- Add TableSorter library -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/js/jquery.tablesorter.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jquery.tablesorter/2.31.3/css/theme.bootstrap_4.min.css">
<style>
.file-table {
width: 100%;
@@ -22,55 +18,161 @@
.file-table th {
background-color: #f7fafc;
font-weight: 600;
cursor: pointer;
position: relative;
}
.file-table th:hover {
.file-table th.sortable {
cursor: pointer;
user-select: none;
}
.file-table th.sortable:hover {
background-color: #edf2f7;
}
.file-table tbody tr:hover {
background-color: #f7fafc;
}
.delete-btn {
color: #e53e3e;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: none;
border: none;
display: flex;
align-items: center;
}
.delete-btn:hover {
background-color: #fed7d7;
.sort-indicator {
display: inline-block;
margin-left: 0.5rem;
opacity: 0.5;
}
.view-logs-btn {
.sort-indicator.active {
opacity: 1;
font-weight: bold;
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
text-align: center;
}
.status-pending {
background-color: #FEF3C7;
color: #92400E;
}
.status-processing {
background-color: #DBEAFE;
color: #1E3A8A;
}
.status-completed {
background-color: #D1FAE5;
color: #065F46;
}
.status-failed {
background-color: #FEE2E2;
color: #991B1B;
}
/* Action buttons */
.action-btn {
color: #3182ce;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
background: none;
border: none;
display: flex;
display: inline-flex;
align-items: center;
margin-right: 0.5rem;
}
.view-logs-btn:hover {
.action-btn:hover {
background-color: #bee3f8;
}
.error-message {
background-color: #FEE2E2;
border: 1px solid #F87171;
color: #B91C1C;
padding: 1rem;
border-radius: 0.25rem;
margin-bottom: 1rem;
.action-btn.delete {
color: #e53e3e;
}
.action-btn.delete:hover {
background-color: #fed7d7;
}
/* TableSorter specific styles */
.tablesorter-header-inner {
/* Filters section */
.filters-section {
background-color: #f7fafc;
padding: 1.5rem;
border-radius: 0.5rem;
margin-bottom: 1.5rem;
}
.filter-group {
display: flex;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-end;
}
.filter-item {
flex: 1;
min-width: 200px;
}
.filter-item label {
display: block;
font-weight: 600;
margin-bottom: 0.5rem;
color: #2d3748;
}
.filter-item input,
.filter-item select {
width: 100%;
padding: 0.5rem;
border: 1px solid #cbd5e0;
border-radius: 0.25rem;
}
.filter-item button {
padding: 0.5rem 1rem;
background-color: #3182ce;
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-weight: 600;
}
.filter-item button:hover {
background-color: #2c5aa0;
}
.filter-item button.clear {
background-color: #718096;
}
.filter-item button.clear:hover {
background-color: #4a5568;
}
/* Pagination */
.pagination {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e2e8f0;
}
.pagination-info {
color: #4a5568;
}
.pagination-buttons {
display: flex;
gap: 0.5rem;
}
.pagination-button {
padding: 0.5rem 1rem;
border: 1px solid #cbd5e0;
background-color: white;
border-radius: 0.25rem;
cursor: pointer;
color: #2d3748;
}
.pagination-button:hover:not(:disabled) {
background-color: #edf2f7;
}
.pagination-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination-button.active {
background-color: #3182ce;
color: white;
border-color: #3182ce;
}
/* Modal styles */
@@ -127,46 +229,13 @@
background-color: #c53030;
}
/* Logs styles */
.logs-container {
margin-top: 1rem;
}
.log-entry {
padding: 0.75rem;
border-left: 3px solid #e2e8f0;
margin-bottom: 0.5rem;
background-color: #f7fafc;
.error-message {
background-color: #FEE2E2;
border: 1px solid #F87171;
color: #B91C1C;
padding: 1rem;
border-radius: 0.25rem;
}
.log-entry.success {
border-left-color: #48bb78;
background-color: #f0fff4;
}
.log-entry.failure {
border-left-color: #f56565;
background-color: #fff5f5;
}
.log-entry.in_progress {
border-left-color: #4299e1;
background-color: #ebf8ff;
}
.log-step {
font-weight: 600;
color: #2d3748;
}
.log-message {
color: #4a5568;
margin-top: 0.25rem;
}
.log-timestamp {
font-size: 0.875rem;
color: #718096;
margin-top: 0.25rem;
}
.no-logs {
text-align: center;
padding: 2rem;
color: #718096;
margin-bottom: 1rem;
}
</style>
{% endblock %}
@@ -182,34 +251,130 @@
</div>
{% endif %}
<!-- Filters Section -->
<div class="filters-section">
<form method="get" action="/files" class="filter-group">
<div class="filter-item">
<label for="search">Search Filename</label>
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Enter filename...">
</div>
<div class="filter-item">
<label for="mime_type">MIME Type</label>
<select id="mime_type" name="mime_type">
<option value="">All Types</option>
{% for mt in mime_types %}
<option value="{{ mt }}" {% if mt == mime_type %}selected{% endif %}>{{ mt }}</option>
{% endfor %}
</select>
</div>
<div class="filter-item">
<label for="status">Status</label>
<select id="status" name="status">
<option value="">All Statuses</option>
<option value="pending" {% if status == "pending" %}selected{% endif %}>Pending</option>
<option value="processing" {% if status == "processing" %}selected{% endif %}>Processing</option>
<option value="completed" {% if status == "completed" %}selected{% endif %}>Completed</option>
<option value="failed" {% if status == "failed" %}selected{% endif %}>Failed</option>
</select>
</div>
<div class="filter-item">
<label>&nbsp;</label>
<button type="submit">Apply Filters</button>
</div>
<div class="filter-item">
<label>&nbsp;</label>
<button type="button" class="clear" onclick="clearFilters()">Clear</button>
</div>
<!-- Hidden fields to preserve sort order -->
<input type="hidden" name="sort_by" value="{{ sort_by }}">
<input type="hidden" name="sort_order" value="{{ sort_order }}">
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
</form>
</div>
<!-- File table -->
<div class="overflow-x-auto">
<table class="file-table" id="fileTable">
<thead>
<tr>
<th>ID</th>
<th>Original Filename</th>
<th>File Size</th>
<th>Mime Type</th>
<th>Created At</th>
<th class="sorter-false">Actions</th>
<th class="sortable" onclick="sortTable('id')">
ID
<span class="sort-indicator {% if sort_by == 'id' %}active{% endif %}">
{% if sort_by == 'id' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
{% else %}
{% endif %}
</span>
</th>
<th class="sortable" onclick="sortTable('original_filename')">
Original Filename
<span class="sort-indicator {% if sort_by == 'original_filename' %}active{% endif %}">
{% if sort_by == 'original_filename' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
{% else %}
{% endif %}
</span>
</th>
<th class="sortable" onclick="sortTable('file_size')">
File Size
<span class="sort-indicator {% if sort_by == 'file_size' %}active{% endif %}">
{% if sort_by == 'file_size' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
{% else %}
{% endif %}
</span>
</th>
<th class="sortable" onclick="sortTable('mime_type')">
MIME Type
<span class="sort-indicator {% if sort_by == 'mime_type' %}active{% endif %}">
{% if sort_by == 'mime_type' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
{% else %}
{% endif %}
</span>
</th>
<th>Status</th>
<th class="sortable" onclick="sortTable('created_at')">
Created At
<span class="sort-indicator {% if sort_by == 'created_at' %}active{% endif %}">
{% if sort_by == 'created_at' %}
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
{% else %}
{% endif %}
</span>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for file in files %}
<tr>
<tr onclick="viewFileDetail({{ file.id }}, event)">
<td>{{ file.id }}</td>
<td>{{ file.original_filename }}</td>
<td data-sort-value="{{ file.file_size }}">{{ (file.file_size / 1024) | round(2) }} KB</td>
<td>{{ (file.file_size / 1024) | round(2) }} KB</td>
<td>{{ file.mime_type }}</td>
<td>{{ file.created_at }}</td>
<td>
<span class="status-badge status-{{ file.processing_status }}">
{{ file.processing_status | title }}
</span>
</td>
<td>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else 'N/A' }}</td>
<td>
<div style="display: flex; align-items: center;">
<button onclick="showLogs('{{ file.id }}')" class="view-logs-btn" title="View processing logs">
<i class="fas fa-list"></i>
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="View details">
<i class="fas fa-info-circle"></i>
</button>
<button onclick="showDeleteModal('{{ file.id }}')" class="delete-btn" title="Delete file">
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="Delete file">
<i class="fas fa-trash"></i>
</button>
</div>
@@ -217,13 +382,41 @@
</tr>
{% else %}
<tr>
<td colspan="6" class="text-center py-4">No files found</td>
<td colspan="7" class="text-center py-4">No files found</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination.total_pages > 1 %}
<div class="pagination">
<div class="pagination-info">
Showing {{ ((pagination.page - 1) * pagination.per_page + 1) }} -
{{ min(pagination.page * pagination.per_page, pagination.total_items) }}
of {{ pagination.total_items }} files
</div>
<div class="pagination-buttons">
{% if pagination.page > 1 %}
<button class="pagination-button" onclick="goToPage(1)">First</button>
<button class="pagination-button" onclick="goToPage({{ pagination.page - 1 }})">Previous</button>
{% endif %}
{% for p in range(max(1, pagination.page - 2), min(pagination.total_pages + 1, pagination.page + 3)) %}
<button class="pagination-button {% if p == pagination.page %}active{% endif %}" onclick="goToPage({{ p }})">
{{ p }}
</button>
{% endfor %}
{% if pagination.page < pagination.total_pages %}
<button class="pagination-button" onclick="goToPage({{ pagination.page + 1 }})">Next</button>
<button class="pagination-button" onclick="goToPage({{ pagination.total_pages }})">Last</button>
{% endif %}
</div>
</div>
{% endif %}
<!-- Delete confirmation modal -->
<div id="deleteModal" class="modal">
<div class="modal-content">
@@ -236,82 +429,25 @@
</div>
</div>
<!-- Processing logs modal -->
<div id="logsModal" class="modal">
<div class="modal-content">
<div class="modal-title">Processing Logs</div>
<div id="logsContent">
<p>Loading logs...</p>
</div>
<div class="modal-buttons">
<button id="closeLogsModal" class="modal-btn modal-btn-cancel">Close</button>
</div>
</div>
</div>
<!-- Add JavaScript for handling DELETE requests -->
<script>
// Modal functionality
const deleteModal = document.getElementById('deleteModal');
const logsModal = document.getElementById('logsModal');
const cancelDelete = document.getElementById('cancelDelete');
const confirmDelete = document.getElementById('confirmDelete');
const closeLogsModal = document.getElementById('closeLogsModal');
let currentFileId = null;
function showDeleteModal(fileId) {
function showDeleteModal(fileId, event) {
if (event) event.stopPropagation();
currentFileId = fileId;
deleteModal.style.display = 'flex';
}
function showLogs(fileId) {
logsModal.style.display = 'flex';
document.getElementById('logsContent').innerHTML = '<p>Loading logs...</p>';
// Fetch logs from API
fetch(`/api/logs/file/${fileId}`)
.then(response => {
if (!response.ok) {
throw new Error('Failed to fetch logs');
}
return response.json();
})
.then(data => {
displayLogs(data);
})
.catch(error => {
console.error('Error:', error);
document.getElementById('logsContent').innerHTML =
`<div class="error-message">Error loading logs: ${error.message}</div>`;
});
}
function displayLogs(data) {
const logsContent = document.getElementById('logsContent');
if (!data.logs || data.logs.length === 0) {
logsContent.innerHTML = '<div class="no-logs">No processing logs found for this file.</div>';
return;
function viewFileDetail(fileId, event) {
if (event && event.target.closest('.action-btn')) {
return; // Don't navigate if clicking action button
}
let html = '<div class="logs-container">';
html += `<h3 style="margin-bottom: 1rem;">File: ${data.file.original_filename}</h3>`;
data.logs.forEach(log => {
const statusClass = log.status.toLowerCase().replace(' ', '_');
const timestamp = new Date(log.timestamp).toLocaleString();
html += `
<div class="log-entry ${statusClass}">
<div class="log-step">${log.step_name} - ${log.status}</div>
${log.message ? `<div class="log-message">${log.message}</div>` : ''}
<div class="log-timestamp">${timestamp}</div>
</div>
`;
});
html += '</div>';
logsContent.innerHTML = html;
if (event) event.stopPropagation();
window.location.href = `/files/${fileId}/detail`;
}
cancelDelete.addEventListener('click', () => {
@@ -323,18 +459,11 @@
deleteModal.style.display = 'none';
});
closeLogsModal.addEventListener('click', () => {
logsModal.style.display = 'none';
});
// Close modal if clicking outside of it
window.addEventListener('click', (event) => {
if (event.target === deleteModal) {
deleteModal.style.display = 'none';
}
if (event.target === logsModal) {
logsModal.style.display = 'none';
}
});
function deleteFile(fileId) {
@@ -357,19 +486,33 @@
});
}
// Initialize TableSorter
$(document).ready(function() {
$("#fileTable").tablesorter({
theme: 'bootstrap',
widthFixed: true,
headerTemplate: '{content} {icon}',
widgets: ['zebra', 'stickyHeaders'],
sortList: [[0, 0]], // Default sort on the first column ascending
headers: {
5: { sorter: false } // Disable sorting on the Actions column
}
});
});
function sortTable(column) {
const urlParams = new URLSearchParams(window.location.search);
const currentSortBy = urlParams.get('sort_by') || 'created_at';
const currentSortOrder = urlParams.get('sort_order') || 'desc';
// Toggle sort order if clicking the same column
let newSortOrder = 'asc';
if (column === currentSortBy) {
newSortOrder = currentSortOrder === 'asc' ? 'desc' : 'asc';
}
urlParams.set('sort_by', column);
urlParams.set('sort_order', newSortOrder);
urlParams.set('page', '1'); // Reset to first page on sort
window.location.search = urlParams.toString();
}
function goToPage(page) {
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('page', page);
window.location.search = urlParams.toString();
}
function clearFilters() {
window.location.href = '/files';
}
</script>
</div>
{% endblock %}
+304
View File
@@ -0,0 +1,304 @@
"""
Tests for file listing, pagination, filtering, and detail endpoints.
"""
import pytest
from fastapi.testclient import TestClient
from app.models import FileRecord, ProcessingLog
from datetime import datetime
@pytest.mark.integration
@pytest.mark.requires_db
class TestFileListingPagination:
"""Tests for file listing with pagination, sorting, and filtering."""
def test_list_files_empty_with_pagination(self, client: TestClient):
"""Test listing files when database is empty returns pagination structure."""
response = client.get("/api/files")
assert response.status_code == 200
data = response.json()
assert "files" in data
assert "pagination" in data
assert isinstance(data["files"], list)
assert len(data["files"]) == 0
assert data["pagination"]["total_items"] == 0
def test_list_files_with_data(self, client: TestClient, db_session):
"""Test listing files with sample data."""
# Create sample files
for i in range(5):
file_record = FileRecord(
filehash=f"hash{i}",
original_filename=f"test{i}.pdf",
local_filename=f"/tmp/test{i}.pdf",
file_size=1024 * (i + 1),
mime_type="application/pdf"
)
db_session.add(file_record)
db_session.commit()
response = client.get("/api/files")
assert response.status_code == 200
data = response.json()
assert len(data["files"]) == 5
assert data["pagination"]["total_items"] == 5
assert data["pagination"]["page"] == 1
def test_pagination_works(self, client: TestClient, db_session):
"""Test that pagination correctly limits results."""
# Create 10 sample files
for i in range(10):
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.commit()
# Get first page with 5 items per page
response = client.get("/api/files?page=1&per_page=5")
assert response.status_code == 200
data = response.json()
assert len(data["files"]) == 5
assert data["pagination"]["page"] == 1
assert data["pagination"]["total_pages"] == 2
# Get second page
response = client.get("/api/files?page=2&per_page=5")
assert response.status_code == 200
data = response.json()
assert len(data["files"]) == 5
assert data["pagination"]["page"] == 2
def test_sorting_by_filename(self, client: TestClient, db_session):
"""Test sorting files by filename."""
# Create files with different names
for name in ["zebra.pdf", "apple.pdf", "middle.pdf"]:
file_record = FileRecord(
filehash=name,
original_filename=name,
local_filename=f"/tmp/{name}",
file_size=1024,
mime_type="application/pdf"
)
db_session.add(file_record)
db_session.commit()
# Sort ascending
response = client.get("/api/files?sort_by=original_filename&sort_order=asc")
assert response.status_code == 200
data = response.json()
filenames = [f["original_filename"] for f in data["files"]]
assert filenames == ["apple.pdf", "middle.pdf", "zebra.pdf"]
# Sort descending
response = client.get("/api/files?sort_by=original_filename&sort_order=desc")
assert response.status_code == 200
data = response.json()
filenames = [f["original_filename"] for f in data["files"]]
assert filenames == ["zebra.pdf", "middle.pdf", "apple.pdf"]
def test_sorting_by_file_size(self, client: TestClient, db_session):
"""Test sorting files by size."""
# Create files with different sizes
for i, size in enumerate([5000, 1000, 3000]):
file_record = FileRecord(
filehash=f"hash{i}",
original_filename=f"file{i}.pdf",
local_filename=f"/tmp/file{i}.pdf",
file_size=size,
mime_type="application/pdf"
)
db_session.add(file_record)
db_session.commit()
# Sort by size ascending
response = client.get("/api/files?sort_by=file_size&sort_order=asc")
assert response.status_code == 200
data = response.json()
sizes = [f["file_size"] for f in data["files"]]
assert sizes == [1000, 3000, 5000]
def test_search_filter(self, client: TestClient, db_session):
"""Test searching files by filename."""
# Create files with different names
for name in ["invoice_2024.pdf", "receipt_2024.pdf", "report.pdf"]:
file_record = FileRecord(
filehash=name,
original_filename=name,
local_filename=f"/tmp/{name}",
file_size=1024,
mime_type="application/pdf"
)
db_session.add(file_record)
db_session.commit()
# Search for "2024"
response = client.get("/api/files?search=2024")
assert response.status_code == 200
data = response.json()
assert len(data["files"]) == 2
filenames = [f["original_filename"] for f in data["files"]]
assert "invoice_2024.pdf" in filenames
assert "receipt_2024.pdf" in filenames
assert "report.pdf" not in filenames
def test_mime_type_filter(self, client: TestClient, db_session):
"""Test filtering files by MIME type."""
# Create files with different MIME types
files_data = [
("file1.pdf", "application/pdf"),
("file2.txt", "text/plain"),
("file3.pdf", "application/pdf"),
]
for filename, mime_type in files_data:
file_record = FileRecord(
filehash=filename,
original_filename=filename,
local_filename=f"/tmp/{filename}",
file_size=1024,
mime_type=mime_type
)
db_session.add(file_record)
db_session.commit()
# Filter by PDF mime type
response = client.get("/api/files?mime_type=application/pdf")
assert response.status_code == 200
data = response.json()
assert len(data["files"]) == 2
for file in data["files"]:
assert file["mime_type"] == "application/pdf"
def test_processing_status_included(self, client: TestClient, db_session):
"""Test that processing status is included in file listing."""
# Create a file
file_record = FileRecord(
filehash="test_hash",
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
)
db_session.add(file_record)
db_session.commit()
# Add a processing log
log = ProcessingLog(
file_id=file_record.id,
task_id="test_task",
step_name="test_step",
status="success",
message="Test message"
)
db_session.add(log)
db_session.commit()
response = client.get("/api/files")
assert response.status_code == 200
data = response.json()
assert len(data["files"]) == 1
assert "processing_status" in data["files"][0]
assert data["files"][0]["processing_status"]["status"] == "completed"
@pytest.mark.integration
@pytest.mark.requires_db
class TestFileDetailEndpoint:
"""Tests for file detail endpoint."""
def test_get_file_detail_success(self, client: TestClient, db_session):
"""Test getting details for an existing file."""
# Create a file
file_record = FileRecord(
filehash="test_hash",
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
)
db_session.add(file_record)
db_session.commit()
# Add processing logs
for i, status in enumerate(["success", "in_progress", "success"]):
log = ProcessingLog(
file_id=file_record.id,
task_id=f"task_{i}",
step_name=f"step_{i}",
status=status,
message=f"Message {i}"
)
db_session.add(log)
db_session.commit()
response = client.get(f"/api/files/{file_record.id}")
assert response.status_code == 200
data = response.json()
# Check file details
assert "file" in data
assert data["file"]["id"] == file_record.id
assert data["file"]["original_filename"] == "test.pdf"
# Check processing status
assert "processing_status" in data
assert data["processing_status"]["status"] in ["completed", "processing"]
# Check logs
assert "logs" in data
assert len(data["logs"]) == 3
def test_get_nonexistent_file_detail(self, client: TestClient):
"""Test getting details for a non-existent file returns 404."""
response = client.get("/api/files/99999")
assert response.status_code == 404
def test_file_detail_status_determination(self, client: TestClient, db_session):
"""Test that processing status is correctly determined."""
# Create a file
file_record = FileRecord(
filehash="test_hash",
original_filename="test.pdf",
local_filename="/tmp/test.pdf",
file_size=1024,
mime_type="application/pdf"
)
db_session.add(file_record)
db_session.commit()
# Test 1: No logs = pending
response = client.get(f"/api/files/{file_record.id}")
assert response.status_code == 200
assert response.json()["processing_status"]["status"] == "pending"
# Test 2: Success log = completed
log = ProcessingLog(
file_id=file_record.id,
task_id="task_1",
step_name="step_1",
status="success"
)
db_session.add(log)
db_session.commit()
response = client.get(f"/api/files/{file_record.id}")
assert response.status_code == 200
assert response.json()["processing_status"]["status"] == "completed"
# Test 3: Failure log = failed
log2 = ProcessingLog(
file_id=file_record.id,
task_id="task_2",
step_name="step_2",
status="failure"
)
db_session.add(log2)
db_session.commit()
response = client.get(f"/api/files/{file_record.id}")
assert response.status_code == 200
assert response.json()["processing_status"]["status"] == "failed"