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
|
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.orm import Session
|
||||||
|
from sqlalchemy import desc, asc, or_, func
|
||||||
|
from typing import Optional, List
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
|
||||||
from app.auth import require_login
|
from app.auth import require_login
|
||||||
from app.models import FileRecord
|
from app.models import FileRecord, ProcessingLog
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.api.common import get_db
|
from app.api.common import get_db
|
||||||
from app.tasks.process_document import process_document
|
from app.tasks.process_document import process_document
|
||||||
from app.tasks.convert_to_pdf import convert_to_pdf
|
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
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -22,27 +25,113 @@ router = APIRouter()
|
|||||||
|
|
||||||
@router.get("/files")
|
@router.get("/files")
|
||||||
@require_login
|
@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.
|
Returns a paginated JSON list of FileRecord entries with processing status.
|
||||||
Protected by `@require_login`, so only logged-in sessions can access.
|
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:
|
Example response:
|
||||||
[
|
{
|
||||||
{
|
"files": [...],
|
||||||
"id": 123,
|
"pagination": {
|
||||||
"filehash": "abc123...",
|
"page": 1,
|
||||||
"original_filename": "example.pdf",
|
"per_page": 50,
|
||||||
"local_filename": "/workdir/tmp/<uuid>.pdf",
|
"total_items": 150,
|
||||||
"file_size": 1048576,
|
"total_pages": 3
|
||||||
"mime_type": "application/pdf",
|
}
|
||||||
"created_at": "2025-05-01T12:34:56.789000"
|
}
|
||||||
},
|
|
||||||
...
|
|
||||||
]
|
|
||||||
"""
|
"""
|
||||||
files = db.query(FileRecord).order_by(FileRecord.created_at.desc()).all()
|
# Start with base query
|
||||||
# Return a simple list of dicts
|
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 = []
|
result = []
|
||||||
for f in files:
|
for f in files:
|
||||||
result.append({
|
result.append({
|
||||||
@@ -52,9 +141,93 @@ def list_files_api(request: Request, db: Session = Depends(get_db)):
|
|||||||
"local_filename": f.local_filename,
|
"local_filename": f.local_filename,
|
||||||
"file_size": f.file_size,
|
"file_size": f.file_size,
|
||||||
"mime_type": f.mime_type,
|
"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}")
|
@router.delete("/files/{file_id}")
|
||||||
@require_login
|
@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.
|
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 sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
from app.views.base import APIRouter, templates, require_login, get_db, logger
|
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 = APIRouter()
|
||||||
|
|
||||||
@router.get("/files")
|
@router.get("/files")
|
||||||
@require_login
|
@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:
|
try:
|
||||||
# Import the model here to avoid circular imports
|
# 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
|
# Start with base query
|
||||||
files = db.query(FileRecord).order_by(FileRecord.created_at.desc()).all()
|
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
|
# 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", {
|
return templates.TemplateResponse("files.html", {
|
||||||
"request": request,
|
"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:
|
except Exception as e:
|
||||||
# Log any errors
|
# Log any errors
|
||||||
@@ -35,5 +108,52 @@ def files_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
return templates.TemplateResponse("files.html", {
|
return templates.TemplateResponse("files.html", {
|
||||||
"request": request,
|
"request": request,
|
||||||
"files": [],
|
"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)
|
"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 %}
|
{% block head_extra %}
|
||||||
<script src="/static/js/common.js"></script>
|
<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>
|
<style>
|
||||||
.file-table {
|
.file-table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -22,55 +18,161 @@
|
|||||||
.file-table th {
|
.file-table th {
|
||||||
background-color: #f7fafc;
|
background-color: #f7fafc;
|
||||||
font-weight: 600;
|
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;
|
background-color: #edf2f7;
|
||||||
}
|
}
|
||||||
.file-table tbody tr:hover {
|
.file-table tbody tr:hover {
|
||||||
background-color: #f7fafc;
|
background-color: #f7fafc;
|
||||||
}
|
|
||||||
.delete-btn {
|
|
||||||
color: #e53e3e;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
.delete-btn:hover {
|
.sort-indicator {
|
||||||
background-color: #fed7d7;
|
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;
|
color: #3182ce;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
}
|
}
|
||||||
.view-logs-btn:hover {
|
.action-btn:hover {
|
||||||
background-color: #bee3f8;
|
background-color: #bee3f8;
|
||||||
}
|
}
|
||||||
.error-message {
|
.action-btn.delete {
|
||||||
background-color: #FEE2E2;
|
color: #e53e3e;
|
||||||
border: 1px solid #F87171;
|
}
|
||||||
color: #B91C1C;
|
.action-btn.delete:hover {
|
||||||
padding: 1rem;
|
background-color: #fed7d7;
|
||||||
border-radius: 0.25rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TableSorter specific styles */
|
/* Filters section */
|
||||||
.tablesorter-header-inner {
|
.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;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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 */
|
/* Modal styles */
|
||||||
@@ -127,46 +229,13 @@
|
|||||||
background-color: #c53030;
|
background-color: #c53030;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Logs styles */
|
.error-message {
|
||||||
.logs-container {
|
background-color: #FEE2E2;
|
||||||
margin-top: 1rem;
|
border: 1px solid #F87171;
|
||||||
}
|
color: #B91C1C;
|
||||||
.log-entry {
|
padding: 1rem;
|
||||||
padding: 0.75rem;
|
|
||||||
border-left: 3px solid #e2e8f0;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
background-color: #f7fafc;
|
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
}
|
margin-bottom: 1rem;
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -182,34 +251,130 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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 -->
|
<!-- File table -->
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="file-table" id="fileTable">
|
<table class="file-table" id="fileTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>ID</th>
|
<th class="sortable" onclick="sortTable('id')">
|
||||||
<th>Original Filename</th>
|
ID
|
||||||
<th>File Size</th>
|
<span class="sort-indicator {% if sort_by == 'id' %}active{% endif %}">
|
||||||
<th>Mime Type</th>
|
{% if sort_by == 'id' %}
|
||||||
<th>Created At</th>
|
{% if sort_order == 'asc' %}▲{% else %}▼{% endif %}
|
||||||
<th class="sorter-false">Actions</th>
|
{% 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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for file in files %}
|
{% for file in files %}
|
||||||
<tr>
|
<tr onclick="viewFileDetail({{ file.id }}, event)">
|
||||||
<td>{{ file.id }}</td>
|
<td>{{ file.id }}</td>
|
||||||
<td>{{ file.original_filename }}</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.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>
|
<td>
|
||||||
<div style="display: flex; align-items: center;">
|
<div style="display: flex; align-items: center;">
|
||||||
<button onclick="showLogs('{{ file.id }}')" class="view-logs-btn" title="View processing logs">
|
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="View details">
|
||||||
<i class="fas fa-list"></i>
|
<i class="fas fa-info-circle"></i>
|
||||||
</button>
|
</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>
|
<i class="fas fa-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,13 +382,41 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<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>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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 -->
|
<!-- Delete confirmation modal -->
|
||||||
<div id="deleteModal" class="modal">
|
<div id="deleteModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -236,82 +429,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
// Modal functionality
|
// Modal functionality
|
||||||
const deleteModal = document.getElementById('deleteModal');
|
const deleteModal = document.getElementById('deleteModal');
|
||||||
const logsModal = document.getElementById('logsModal');
|
|
||||||
const cancelDelete = document.getElementById('cancelDelete');
|
const cancelDelete = document.getElementById('cancelDelete');
|
||||||
const confirmDelete = document.getElementById('confirmDelete');
|
const confirmDelete = document.getElementById('confirmDelete');
|
||||||
const closeLogsModal = document.getElementById('closeLogsModal');
|
|
||||||
let currentFileId = null;
|
let currentFileId = null;
|
||||||
|
|
||||||
function showDeleteModal(fileId) {
|
function showDeleteModal(fileId, event) {
|
||||||
|
if (event) event.stopPropagation();
|
||||||
currentFileId = fileId;
|
currentFileId = fileId;
|
||||||
deleteModal.style.display = 'flex';
|
deleteModal.style.display = 'flex';
|
||||||
}
|
}
|
||||||
|
|
||||||
function showLogs(fileId) {
|
function viewFileDetail(fileId, event) {
|
||||||
logsModal.style.display = 'flex';
|
if (event && event.target.closest('.action-btn')) {
|
||||||
document.getElementById('logsContent').innerHTML = '<p>Loading logs...</p>';
|
return; // Don't navigate if clicking action button
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
if (event) event.stopPropagation();
|
||||||
let html = '<div class="logs-container">';
|
window.location.href = `/files/${fileId}/detail`;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelDelete.addEventListener('click', () => {
|
cancelDelete.addEventListener('click', () => {
|
||||||
@@ -323,18 +459,11 @@
|
|||||||
deleteModal.style.display = 'none';
|
deleteModal.style.display = 'none';
|
||||||
});
|
});
|
||||||
|
|
||||||
closeLogsModal.addEventListener('click', () => {
|
|
||||||
logsModal.style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal if clicking outside of it
|
// Close modal if clicking outside of it
|
||||||
window.addEventListener('click', (event) => {
|
window.addEventListener('click', (event) => {
|
||||||
if (event.target === deleteModal) {
|
if (event.target === deleteModal) {
|
||||||
deleteModal.style.display = 'none';
|
deleteModal.style.display = 'none';
|
||||||
}
|
}
|
||||||
if (event.target === logsModal) {
|
|
||||||
logsModal.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function deleteFile(fileId) {
|
function deleteFile(fileId) {
|
||||||
@@ -357,19 +486,33 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize TableSorter
|
function sortTable(column) {
|
||||||
$(document).ready(function() {
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
$("#fileTable").tablesorter({
|
const currentSortBy = urlParams.get('sort_by') || 'created_at';
|
||||||
theme: 'bootstrap',
|
const currentSortOrder = urlParams.get('sort_order') || 'desc';
|
||||||
widthFixed: true,
|
|
||||||
headerTemplate: '{content} {icon}',
|
// Toggle sort order if clicking the same column
|
||||||
widgets: ['zebra', 'stickyHeaders'],
|
let newSortOrder = 'asc';
|
||||||
sortList: [[0, 0]], // Default sort on the first column ascending
|
if (column === currentSortBy) {
|
||||||
headers: {
|
newSortOrder = currentSortOrder === 'asc' ? 'desc' : 'asc';
|
||||||
5: { sorter: false } // Disable sorting on the Actions column
|
}
|
||||||
}
|
|
||||||
});
|
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>
|
</script>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% 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