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:
+194
-21
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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> </label>
|
||||
<button type="submit">Apply Filters</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-item">
|
||||
<label> </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 %}
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user