Enhance file detail page with original and processed file previews, GPT metadata display, and text extraction functionality

- Added endpoints for previewing original and processed PDF files.
- Implemented on-demand text extraction from original and processed PDFs.
- Updated file detail page to show original and processed file paths with existence status.
- Introduced GPT metadata display with a collapsible JSON view.
- Enhanced front-end with PDF.js for in-browser PDF rendering and improved user experience.
- Added integration tests for new features including metadata display and file previews.
This commit is contained in:
Christian Krakau-Louis
2026-02-11 23:32:23 +01:00
parent ce40cbcdd8
commit 02e1445e01
5 changed files with 1037 additions and 27 deletions
+160 -19
View File
@@ -2,6 +2,7 @@
File management views for displaying and managing files.
"""
import os
from typing import Optional
from fastapi import Depends, Query, Request
@@ -133,6 +134,7 @@ def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_d
Return the file detail page showing processing history and file information
"""
try:
import json
import os
from app.models import FileRecord, ProcessingLog
@@ -153,24 +155,28 @@ def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_d
.all()
)
# Check if file exists on disk
file_exists = os.path.exists(file_record.local_filename) if file_record.local_filename else False
# Check if original file exists (use persisted path from database)
original_file_exists = False
if file_record.original_file_path and os.path.exists(file_record.original_file_path):
original_file_exists = True
# Check if processed file exists
processed_exists = False
workdir = settings.workdir
processed_dir = os.path.join(workdir, "processed")
if os.path.exists(processed_dir):
base_filename = os.path.splitext(file_record.original_filename)[0]
potential_paths = [
os.path.join(processed_dir, f"{file_record.filehash}.pdf"),
os.path.join(processed_dir, f"{base_filename}_processed.pdf"),
os.path.join(processed_dir, file_record.original_filename),
]
for path in potential_paths:
if os.path.exists(path):
processed_exists = True
break
# Check if processed file exists (use persisted path from database)
processed_file_exists = False
if file_record.processed_file_path and os.path.exists(file_record.processed_file_path):
processed_file_exists = True
# Load metadata from JSON file if it exists
gpt_metadata = None
if file_record.processed_file_path:
# Metadata JSON file is stored alongside the processed PDF
metadata_path = os.path.splitext(file_record.processed_file_path)[0] + ".json"
if os.path.exists(metadata_path):
try:
with open(metadata_path, "r", encoding="utf-8") as f:
gpt_metadata = json.load(f)
logger.debug(f"Loaded GPT metadata from {metadata_path}")
except Exception as e:
logger.warning(f"Failed to load metadata from {metadata_path}: {e}")
# Compute processing flow for visualization
flow_data = _compute_processing_flow(logs)
@@ -190,8 +196,9 @@ def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_d
"request": request,
"file": file_record,
"logs": logs,
"file_exists": file_exists,
"processed_exists": processed_exists,
"original_file_exists": original_file_exists,
"processed_file_exists": processed_file_exists,
"gpt_metadata": gpt_metadata,
"flow_data": flow_data,
"step_summary": step_summary,
},
@@ -396,3 +403,137 @@ def _compute_step_summary(logs):
"total_main_steps": len(main_steps_seen),
"total_upload_tasks": len(upload_tasks_seen),
}
@router.get("/files/{file_id}/preview/original")
@require_login
def preview_original_file(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Serve the original (pre-processing) PDF file for preview
"""
import os
from fastapi import HTTPException, status
from fastapi.responses import FileResponse
from app.models import FileRecord
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not file_record.original_file_path or not os.path.exists(file_record.original_file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Original file not found on disk")
return FileResponse(
path=file_record.original_file_path,
media_type="application/pdf",
headers={"Content-Disposition": "inline"},
)
@router.get("/files/{file_id}/preview/processed")
@require_login
def preview_processed_file(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Serve the processed (with embedded metadata) PDF file for preview
"""
import os
from fastapi import HTTPException, status
from fastapi.responses import FileResponse
from app.models import FileRecord
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not file_record.processed_file_path or not os.path.exists(file_record.processed_file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Processed file not found on disk")
return FileResponse(
path=file_record.processed_file_path,
media_type="application/pdf",
headers={"Content-Disposition": "inline"},
)
@router.get("/files/{file_id}/text/original")
@require_login
def get_original_text(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Extract and return text from the original PDF file on-demand
"""
import os
from fastapi import HTTPException, status
from fastapi.responses import JSONResponse
from app.models import FileRecord
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not file_record.original_file_path or not os.path.exists(file_record.original_file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Original file not found on disk")
try:
# Extract text from PDF using PyPDF2
from PyPDF2 import PdfReader
reader = PdfReader(file_record.original_file_path)
text = ""
for page in reader.pages:
text += page.extract_text() + "\n\n"
if not text.strip():
text = "(No text could be extracted from this PDF - it may be a scanned image without OCR)"
return JSONResponse(content={"text": text.strip(), "page_count": len(reader.pages)})
except Exception as e:
logger.error(f"Error extracting text from original file {file_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to extract text: {str(e)}"
)
@router.get("/files/{file_id}/text/processed")
@require_login
def get_processed_text(request: Request, file_id: int, db: Session = Depends(get_db)):
"""
Extract and return text from the processed PDF file on-demand
"""
import os
from fastapi import HTTPException, status
from fastapi.responses import JSONResponse
from app.models import FileRecord
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
if not file_record.processed_file_path or not os.path.exists(file_record.processed_file_path):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Processed file not found on disk")
try:
# Extract text from PDF using PyPDF2
from PyPDF2 import PdfReader
reader = PdfReader(file_record.processed_file_path)
text = ""
for page in reader.pages:
text += page.extract_text() + "\n\n"
if not text.strip():
text = "(No text could be extracted from this PDF - it may be a scanned image without OCR)"
return JSONResponse(content={"text": text.strip(), "page_count": len(reader.pages)})
except Exception as e:
logger.error(f"Error extracting text from processed file {file_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to extract text: {str(e)}"
)
+111
View File
@@ -0,0 +1,111 @@
# File Detail Page - Technical Reference
## API Endpoints
```
GET /files/{id}/detail → Main detail page (template view)
GET /files/{id}/preview/original → Serve original PDF file
GET /files/{id}/preview/processed → Serve processed PDF file
GET /files/{id}/text/original → Extract text from original PDF (on-demand)
GET /files/{id}/text/processed → Extract text from processed PDF (on-demand)
POST /api/files/{id}/retry → Retry full processing
POST /api/files/{id}/retry-subtask → Retry specific task
```
## Text Extraction
Text extraction is performed **on-demand** when the user clicks "View Extracted Text":
- Uses PyPDF2 to extract text from PDF files in real-time
- Returns JSON: `{"text": "...", "page_count": 3}`
- Client-side caching prevents re-extraction on subsequent views
- Loading indicator shown during extraction
- Graceful error handling if extraction fails
## Template Context Variables
| Variable | Type | Description |
|----------|------|-------------|
| `file` | FileRecord | Database record with file metadata |
| `gpt_metadata` | dict | Extracted metadata from JSON file (or None) |
| `extracted_text` | str | Text content from OCR/extraction (or None) |
| `original_file_exists` | bool | True if original file exists on disk |
| `processed_file_exists` | bool | True if processed file exists on disk |
| `logs` | List[ProcessingLog] | Processing history records |
| `step_summary` | dict | Aggregated processing status counts |
| `flow_data` | dict | Processing flow visualization data |
## File System Structure
```
workdir/
├── tmp/
│ └── {uuid}.pdf ← Temporary ingestion file
├── original/
│ └── {uuid}.pdf ← Immutable original copy (original_file_path)
└── processed/
├── 2024-01-15_Invoice.pdf ← Processed with metadata (processed_file_path)
└── 2024-01-15_Invoice.json ← GPT metadata JSON
```
## Metadata JSON Schema
Stored as `{processed_file_path_without_extension}.json`:
```json
{
"document_type": "Invoice",
"filename": "2024-01-15_Company_Invoice",
"date": "2024-01-15",
"absender": "Test Company GmbH",
"empfaenger": "Customer Inc",
"betrag": "€ 1,234.56",
"kontonummer": "DE89370400440532013000",
"tags": ["invoice", "payment", "2024"],
"original_file_path": "/workdir/original/{uuid}.pdf",
"processed_file_path": "/workdir/processed/2024-01-15_Invoice.pdf"
}
```
## JavaScript Functions
```javascript
toggleMetadata() // Show/hide JSON metadata view
toggleTextModal(modalId) // Open/close text extraction modals
window.onclick // Close modal when clicking outside
```
## Database Schema
```sql
CREATE TABLE files (
id INTEGER PRIMARY KEY,
filehash TEXT NOT NULL,
original_filename TEXT,
local_filename TEXT NOT NULL,
original_file_path TEXT, -- Points to workdir/original/
processed_file_path TEXT, -- Points to workdir/processed/
file_size INTEGER,
mime_type TEXT,
created_at DATETIME
);
CREATE TABLE processing_logs (
id INTEGER PRIMARY KEY,
file_id INTEGER,
task_id TEXT,
step_name TEXT, -- e.g., "extract_text", "process_with_azure_document_intelligence"
status TEXT, -- "pending", "in_progress", "success", "failure"
message TEXT,
detail TEXT, -- May contain extracted text
timestamp DATETIME
);
```
## Processing Stages
Text extraction logs to check for `extracted_text`:
- `extract_text` - Local PDF text extraction
- `process_with_azure_document_intelligence` - Azure OCR processing
Both may contain extracted text in the `detail` field when `status = 'success'`.
+55 -4
View File
@@ -95,13 +95,52 @@ The **Files** page provides access to all processed documents:
When you click on a file, you'll see a comprehensive detail view with the following sections:
#### File Information
This section displays metadata about the file:
#### File Information
This section displays metadata about the file:
- File ID and original filename
- File hash (SHA-256)
- File size and MIME type
- Creation timestamp
- Local path and disk status
- Original file path and status (shows if the immutable original is available)
- Processed file path and status (shows if the final processed file is available)
#### Extracted Metadata (GPT)
If metadata has been extracted by GPT, this section displays structured information including:
- **Document Type**: Classification (Invoice, Receipt, Contract, etc.)
- **Suggested Filename**: AI-recommended filename based on content
- **Document Date**: Extracted date from document
- **Sender (Absender)**: Sender or issuing party information
- **Recipient (Empfänger)**: Recipient information
- **Amount (Betrag)**: Financial amounts (for invoices, receipts)
- **Account Number**: Bank account information
- **Tags**: Document categories and labels
**Show JSON**: Click this button to toggle the full metadata in JSON format. This is useful for:
- Viewing all extracted fields at once
- Debugging metadata extraction issues
- Copying metadata for external use
- Understanding the complete data structure
#### Document Previews
View your documents side-by-side in embedded PDF viewers:
- **Original Document**: The immutable original file as first ingested, before any processing
- **Processed Document**: The final file with embedded metadata
**Features**:
- In-browser PDF rendering for immediate viewing
- Side-by-side comparison of original vs processed versions
- Full 600px height previews for detailed review
- **View Extracted Text** buttons below each preview to see the full text content
**Text Extraction Modals**: Click "View Extracted Text" to open a fullscreen modal showing:
- Complete extracted text from OCR or PDF text layer
- Dark-themed, scrollable display for easy reading
- Copy-friendly pre-formatted text
- Close by clicking the close button or clicking outside the modal
**Note**: The original file is stored in an immutable archive (`workdir/original/`) and is never modified. The processed file is stored in `workdir/processed/` with the suggested filename and embedded metadata.
#### Processing History
View the complete processing history with a timeline showing:
@@ -138,11 +177,23 @@ If your file is still available on disk, you can preview it directly in the brow
- **Processed File**: The file after metadata has been embedded (if processing completed)
Both previews support:
- In-browser PDF viewing
- Opening in a new tab for full-screen viewing
- In-browser PDF viewing with embedded viewer
- Side-by-side comparison of original and processed versions
- Full text extraction viewing via modal overlays
**Note**: The original file is stored in an immutable archive and is never modified. This ensures you always have access to the file exactly as it was uploaded, which is valuable for:
**View Extracted Text**: Each preview includes a button to view the complete extracted text in a fullscreen modal. When you click this button:
- The system extracts text from the PDF file on-demand using PyPDF2
- A loading indicator shows while extraction is in progress
- The extracted text is displayed in a scrollable, copy-friendly format
- The text is cached so subsequent views load instantly
This is useful for:
- Verifying OCR accuracy
- Searching within document content
- Copying text for external use
- Reading documents without downloading them
**Note**: The original file is stored in an immutable archive (`workdir/original/`) and is never modified. This ensures you always have access to the file exactly as it was uploaded, which is valuable for:
- Auditing and compliance
- Debugging processing issues
- Reprocessing with improved algorithms
+482 -4
View File
@@ -2,6 +2,12 @@
{% block title %}File Details{% endblock %}
{% block head_extra %}
<!-- PDF.js library (Apache 2.0 License - compatible) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js"></script>
<script>
// Configure PDF.js worker
pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js';
</script>
<style>
.detail-container {
max-width: 1200px;
@@ -497,6 +503,80 @@
background-color: #cbd5e0;
cursor: not-allowed;
}
/* Text Modal Styles */
.text-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 1000;
overflow-y: auto;
padding: 2rem;
}
.text-modal-content {
background-color: white;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
border-radius: 0.5rem;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* PDF Canvas Viewer Styles */
.pdf-viewer-container {
border: 2px solid #e2e8f0;
border-radius: 0.5rem;
overflow: hidden;
background-color: #f7fafc;
position: relative;
}
.pdf-canvas-wrapper {
overflow-y: auto;
max-height: 600px;
background-color: #525252;
display: flex;
flex-direction: column;
align-items: center;
padding: 1rem;
}
.pdf-canvas {
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
margin-bottom: 0.5rem;
background-color: white;
}
.pdf-controls {
background-color: #2d3748;
color: white;
padding: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.875rem;
}
.pdf-nav-btn {
background-color: #4299e1;
color: white;
border: none;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.875rem;
}
.pdf-nav-btn:hover {
background-color: #3182ce;
}
.pdf-nav-btn:disabled {
background-color: #718096;
cursor: not-allowed;
}
.pdf-loading {
text-align: center;
padding: 3rem;
color: #718096;
}
</style>
{% if file %}
<script>
@@ -637,6 +717,191 @@
icon.classList.add('fa-chevron-up');
}
}
// JavaScript for metadata JSON toggle
function toggleMetadata() {
const jsonView = document.getElementById('metadata-json-view');
const icon = document.getElementById('metadata-toggle-icon');
const btn = document.getElementById('metadata-toggle-btn');
if (jsonView.style.display === 'none') {
jsonView.style.display = 'block';
icon.classList.remove('fa-chevron-down');
icon.classList.add('fa-chevron-up');
btn.innerHTML = '<i id="metadata-toggle-icon" class="fas fa-chevron-up"></i> Hide JSON';
} else {
jsonView.style.display = 'none';
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
btn.innerHTML = '<i id="metadata-toggle-icon" class="fas fa-chevron-down"></i> Show JSON';
}
}
// JavaScript for text modal toggle with on-demand loading
let textCache = { original: null, processed: null };
async function loadAndShowText(type, fileId) {
const modalId = type + '-text-modal';
const loadingId = type + '-text-loading';
const contentId = type + '-text-content';
// Show modal immediately
toggleTextModal(modalId);
// If already loaded, just show it
if (textCache[type]) {
document.getElementById(loadingId).style.display = 'none';
document.getElementById(contentId).style.display = 'block';
document.getElementById(contentId).textContent = textCache[type];
return;
}
// Show loading state
document.getElementById(loadingId).style.display = 'block';
document.getElementById(contentId).style.display = 'none';
try {
const response = await fetch(`/files/${fileId}/text/${type}`);
if (!response.ok) {
throw new Error('Failed to extract text');
}
const data = await response.json();
textCache[type] = data.text;
// Show the text
document.getElementById(loadingId).style.display = 'none';
document.getElementById(contentId).style.display = 'block';
document.getElementById(contentId).textContent = data.text;
} catch (error) {
console.error('Error loading text:', error);
document.getElementById(loadingId).innerHTML = `
<div style="color: #f56565;">
<i class="fas fa-exclamation-triangle" style="font-size: 2rem; margin-bottom: 1rem;"></i>
<p>Failed to extract text from PDF</p>
<p style="font-size: 0.875rem;">${error.message}</p>
</div>
`;
}
}
function toggleTextModal(modalId) {
const modal = document.getElementById(modalId);
if (modal.style.display === 'none' || modal.style.display === '') {
modal.style.display = 'block';
document.body.style.overflow = 'hidden'; // Prevent background scrolling
} else {
modal.style.display = 'none';
document.body.style.overflow = 'auto'; // Re-enable scrolling
}
}
// Close modal when clicking outside the content
window.onclick = function(event) {
const modals = document.querySelectorAll('.text-modal');
modals.forEach(modal => {
if (event.target === modal) {
modal.style.display = 'none';
document.body.style.overflow = 'auto';
}
});
}
// PDF.js viewer functionality
const pdfViewers = {
original: { currentPage: 1, totalPages: 0, pdfDoc: null },
processed: { currentPage: 1, totalPages: 0, pdfDoc: null }
};
async function loadPDF(type, fileId) {
const url = `/files/${fileId}/preview/${type}`;
const canvasWrapper = document.getElementById(`${type}-canvas-wrapper`);
try {
// Load PDF document
const loadingTask = pdfjsLib.getDocument(url);
const pdf = await loadingTask.promise;
pdfViewers[type].pdfDoc = pdf;
pdfViewers[type].totalPages = pdf.numPages;
pdfViewers[type].currentPage = 1;
// Clear loading message and render first page
canvasWrapper.innerHTML = '';
await renderPage(type);
updatePageInfo(type);
} catch (error) {
console.error(`Error loading ${type} PDF:`, error);
canvasWrapper.innerHTML = `
<div class="pdf-loading">
<i class="fas fa-exclamation-triangle" style="font-size: 2rem; color: #f56565; margin-bottom: 1rem;"></i>
<p style="color: #f56565;">Failed to load PDF</p>
</div>
`;
}
}
async function renderPage(type) {
const viewer = pdfViewers[type];
if (!viewer.pdfDoc) return;
const canvasWrapper = document.getElementById(`${type}-canvas-wrapper`);
const page = await viewer.pdfDoc.getPage(viewer.currentPage);
// Calculate scale to fit container width (max 600px width)
const viewport = page.getViewport({ scale: 1.0 });
const scale = Math.min(600 / viewport.width, 2.0);
const scaledViewport = page.getViewport({ scale });
// Create canvas for this page
const canvas = document.createElement('canvas');
canvas.className = 'pdf-canvas';
canvas.height = scaledViewport.height;
canvas.width = scaledViewport.width;
const context = canvas.getContext('2d');
const renderContext = {
canvasContext: context,
viewport: scaledViewport
};
// Clear previous canvas and render new one
canvasWrapper.innerHTML = '';
canvasWrapper.appendChild(canvas);
await page.render(renderContext).promise;
}
function changePage(type, delta) {
const viewer = pdfViewers[type];
const newPage = viewer.currentPage + delta;
if (newPage >= 1 && newPage <= viewer.totalPages) {
viewer.currentPage = newPage;
renderPage(type);
updatePageInfo(type);
}
}
function updatePageInfo(type) {
const viewer = pdfViewers[type];
document.getElementById(`${type}-page-info`).textContent =
`Page ${viewer.currentPage} of ${viewer.totalPages}`;
document.getElementById(`${type}-prev-btn`).disabled = viewer.currentPage === 1;
document.getElementById(`${type}-next-btn`).disabled = viewer.currentPage === viewer.totalPages;
}
// Load PDFs when page loads
document.addEventListener('DOMContentLoaded', function() {
const fileId = {{ file.id | tojson }};
{% if original_file_exists %}
loadPDF('original', fileId);
{% endif %}
{% if processed_file_exists %}
loadPDF('processed', fileId);
{% endif %}
});
</script>
{% endif %}
{% endblock %}
@@ -683,13 +948,15 @@
<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>
<span class="detail-label">Original File Path</span>
<span class="detail-value" style="font-family: monospace; font-size: 0.875rem;">
{{ file.original_file_path if file.original_file_path else 'N/A' }}
</span>
</div>
<div class="detail-item">
<span class="detail-label">File on Disk</span>
<span class="detail-label">Original File Status</span>
<span class="detail-value">
{% if file_exists %}
{% if original_file_exists %}
<span class="file-status-indicator exists">
<i class="fas fa-check-circle"></i> File exists
</span>
@@ -700,6 +967,217 @@
{% endif %}
</span>
</div>
<div class="detail-item">
<span class="detail-label">Processed File Path</span>
<span class="detail-value" style="font-family: monospace; font-size: 0.875rem;">
{{ file.processed_file_path if file.processed_file_path else 'N/A' }}
</span>
</div>
<div class="detail-item">
<span class="detail-label">Processed File Status</span>
<span class="detail-value">
{% if processed_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>
<!-- GPT Metadata Card -->
{% if gpt_metadata %}
<div class="detail-card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 style="margin: 0;">Extracted Metadata (GPT)</h3>
<button id="metadata-toggle-btn" onclick="toggleMetadata()" style="background-color: #4299e1; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600;">
<i id="metadata-toggle-icon" class="fas fa-chevron-down"></i> Show JSON
</button>
</div>
<div class="detail-grid">
{% if gpt_metadata.document_type %}
<div class="detail-item">
<span class="detail-label">Document Type</span>
<span class="detail-value">{{ gpt_metadata.document_type }}</span>
</div>
{% endif %}
{% if gpt_metadata.filename %}
<div class="detail-item">
<span class="detail-label">Suggested Filename</span>
<span class="detail-value" style="font-family: monospace; font-size: 0.875rem;">{{ gpt_metadata.filename }}</span>
</div>
{% endif %}
{% if gpt_metadata.date %}
<div class="detail-item">
<span class="detail-label">Document Date</span>
<span class="detail-value">{{ gpt_metadata.date }}</span>
</div>
{% endif %}
{% if gpt_metadata.absender %}
<div class="detail-item">
<span class="detail-label">Sender (Absender)</span>
<span class="detail-value">{{ gpt_metadata.absender }}</span>
</div>
{% endif %}
{% if gpt_metadata.empfaenger %}
<div class="detail-item">
<span class="detail-label">Recipient (Empfänger)</span>
<span class="detail-value">{{ gpt_metadata.empfaenger }}</span>
</div>
{% endif %}
{% if gpt_metadata.betrag %}
<div class="detail-item">
<span class="detail-label">Amount (Betrag)</span>
<span class="detail-value">{{ gpt_metadata.betrag }}</span>
</div>
{% endif %}
{% if gpt_metadata.kontonummer %}
<div class="detail-item">
<span class="detail-label">Account Number</span>
<span class="detail-value" style="font-family: monospace;">{{ gpt_metadata.kontonummer }}</span>
</div>
{% endif %}
{% if gpt_metadata.tags %}
<div class="detail-item">
<span class="detail-label">Tags</span>
<span class="detail-value">
{% if gpt_metadata.tags is string %}
{{ gpt_metadata.tags }}
{% else %}
{{ ', '.join(gpt_metadata.tags) }}
{% endif %}
</span>
</div>
{% endif %}
</div>
<!-- Collapsible JSON view -->
<div id="metadata-json-view" style="display: none; margin-top: 1rem;">
<pre style="background-color: #1a202c; color: #e2e8f0; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; font-size: 0.875rem; line-height: 1.5;">{{ gpt_metadata | tojson(indent=2) }}</pre>
</div>
</div>
{% endif %}
<!-- PDF Preview Card -->
<div class="detail-card">
<h3>Document Previews</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; margin-top: 1rem;">
<!-- Original PDF Preview -->
<div>
<h4 style="font-weight: 600; color: #2d3748; margin-bottom: 0.5rem;">Original Document</h4>
{% if original_file_exists %}
<div class="pdf-viewer-container" id="original-pdf-container">
<div class="pdf-controls">
<button class="pdf-nav-btn" onclick="changePage('original', -1)" id="original-prev-btn">← Previous</button>
<span id="original-page-info">Loading...</span>
<button class="pdf-nav-btn" onclick="changePage('original', 1)" id="original-next-btn">Next →</button>
</div>
<div class="pdf-canvas-wrapper" id="original-canvas-wrapper">
<div class="pdf-loading">
<i class="fas fa-spinner fa-spin" style="font-size: 2rem; margin-bottom: 1rem;"></i>
<p>Loading PDF...</p>
</div>
</div>
</div>
<button
onclick="loadAndShowText('original', {{ file.id }})"
style="margin-top: 0.5rem; background-color: #4299e1; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; width: 100%;"
>
<i class="fas fa-file-alt"></i> View Extracted Text
</button>
{% else %}
<div style="text-align: center; padding: 3rem; background-color: #f7fafc; border-radius: 0.5rem; color: #718096;">
<i class="fas fa-file-pdf" style="font-size: 3rem; opacity: 0.5; margin-bottom: 1rem;"></i>
<p>Original file not available</p>
</div>
{% endif %}
</div>
<!-- Processed PDF Preview -->
<div>
<h4 style="font-weight: 600; color: #2d3748; margin-bottom: 0.5rem;">Processed Document</h4>
{% if processed_file_exists %}
<div class="pdf-viewer-container" id="processed-pdf-container">
<div class="pdf-controls">
<button class="pdf-nav-btn" onclick="changePage('processed', -1)" id="processed-prev-btn">← Previous</button>
<span id="processed-page-info">Loading...</span>
<button class="pdf-nav-btn" onclick="changePage('processed', 1)" id="processed-next-btn">Next →</button>
</div>
<div class="pdf-canvas-wrapper" id="processed-canvas-wrapper">
<div class="pdf-loading">
<i class="fas fa-spinner fa-spin" style="font-size: 2rem; margin-bottom: 1rem;"></i>
<p>Loading PDF...</p>
</div>
</div>
</div>
<button
onclick="loadAndShowText('processed', {{ file.id }})"
style="margin-top: 0.5rem; background-color: #48bb78; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600; width: 100%;"
>
<i class="fas fa-file-alt"></i> View Extracted Text
</button>
{% else %}
<div style="text-align: center; padding: 3rem; background-color: #f7fafc; border-radius: 0.5rem; color: #718096;">
<i class="fas fa-file-pdf" style="font-size: 3rem; opacity: 0.5; margin-bottom: 1rem;"></i>
<p>Processed file not available yet</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Text Modals (Hidden by default, loaded on-demand) -->
<!-- Original Text Modal -->
<div id="original-text-modal" class="text-modal" style="display: none;">
<div class="text-modal-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 2px solid #e2e8f0;">
<h3 style="margin: 0; color: #2d3748;">Extracted Text (Original)</h3>
<button
onclick="toggleTextModal('original-text-modal')"
style="background-color: #f56565; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600;"
>
<i class="fas fa-times"></i> Close
</button>
</div>
<div id="original-text-loading" style="text-align: center; padding: 3rem; color: #4299e1;">
<i class="fas fa-spinner fa-spin" style="font-size: 2rem; margin-bottom: 1rem;"></i>
<p>Extracting text from PDF...</p>
</div>
<pre id="original-text-content" style="display: none; background-color: #1a202c; color: #e2e8f0; padding: 1.5rem; border-radius: 0.5rem; max-height: 600px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-size: 0.875rem; line-height: 1.6;"></pre>
</div>
</div>
<!-- Processed Text Modal -->
<div id="processed-text-modal" class="text-modal" style="display: none;">
<div class="text-modal-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 2px solid #e2e8f0;">
<h3 style="margin: 0; color: #2d3748;">Extracted Text (Processed)</h3>
<button
onclick="toggleTextModal('processed-text-modal')"
style="background-color: #f56565; color: white; padding: 0.5rem 1rem; border: none; border-radius: 0.25rem; cursor: pointer; font-weight: 600;"
>
<i class="fas fa-times"></i> Close
</button>
</div>
<div id="processed-text-loading" style="text-align: center; padding: 3rem; color: #48bb78;">
<i class="fas fa-spinner fa-spin" style="font-size: 2rem; margin-bottom: 1rem;"></i>
<p>Extracting text from PDF...</p>
</div>
<pre id="processed-text-content" style="display: none; background-color: #1a202c; color: #e2e8f0; padding: 1.5rem; border-radius: 0.5rem; max-height: 600px; overflow-y: auto; white-space: pre-wrap; word-wrap: break-word; font-size: 0.875rem; line-height: 1.6;"></pre>
</div>
</div>
+229
View File
@@ -0,0 +1,229 @@
"""
Tests for file detail page enhancements.
Tests the new features:
- GPT metadata display
- PDF preview endpoints
- Text modal functionality
- Using persisted original_file_path and processed_file_path
"""
import json
import os
import tempfile
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.main import app
from app.models import FileRecord
@pytest.fixture
def sample_metadata():
"""Sample GPT metadata"""
return {
"document_type": "Invoice",
"filename": "2024-01-15_Company_Invoice",
"date": "2024-01-15",
"absender": "Test Company GmbH",
"empfaenger": "Customer Inc",
"betrag": "€ 1,234.56",
"kontonummer": "DE89370400440532013000",
"tags": ["invoice", "payment", "2024"],
}
@pytest.mark.integration
def test_file_detail_page_with_metadata(client: TestClient, db_session, sample_pdf_file):
"""Test file detail page displays GPT metadata correctly"""
from app.models import FileRecord
# Create a file record with paths
file_record = FileRecord(
filehash="test123abc",
original_filename="test_invoice.pdf",
local_filename=str(sample_pdf_file),
file_size=1024,
mime_type="application/pdf",
original_file_path=str(sample_pdf_file),
processed_file_path=None, # Not processed yet
)
db_session.add(file_record)
db_session.commit()
db_session.refresh(file_record)
# Get detail page
response = client.get(f"/files/{file_record.id}/detail")
assert response.status_code == 200
html = response.text
# Check for file info display
assert "File Information" in html
assert "test_invoice.pdf" in html
assert "Original File Path" in html
@pytest.mark.integration
def test_file_detail_with_gpt_metadata(client: TestClient, db_session, sample_pdf_file, sample_metadata, tmp_path):
"""Test file detail page with GPT metadata JSON"""
from app.models import FileRecord
# Create processed file path and metadata JSON
processed_file = tmp_path / "2024-01-15_Company_Invoice.pdf"
processed_file.write_bytes(sample_pdf_file.read_bytes())
metadata_file = tmp_path / "2024-01-15_Company_Invoice.json"
metadata_file.write_text(json.dumps(sample_metadata, indent=2))
file_record = FileRecord(
filehash="test456def",
original_filename="invoice_001.pdf",
local_filename=str(sample_pdf_file),
file_size=1024,
mime_type="application/pdf",
original_file_path=str(sample_pdf_file),
processed_file_path=str(processed_file),
)
db_session.add(file_record)
db_session.commit()
db_session.refresh(file_record)
# Get detail page
response = client.get(f"/files/{file_record.id}/detail")
assert response.status_code == 200
html = response.text
# Check for metadata display
assert "Extracted Metadata (GPT)" in html
assert "Invoice" in html # document_type
assert "2024-01-15_Company_Invoice" in html # filename
assert "Test Company GmbH" in html # absender
assert "€ 1,234.56" in html # betrag
@pytest.mark.integration
def test_preview_original_file_endpoint(client: TestClient, db_session, sample_pdf_file):
"""Test original file preview endpoint"""
from app.models import FileRecord
file_record = FileRecord(
filehash="test789ghi",
original_filename="original.pdf",
local_filename=str(sample_pdf_file),
file_size=1024,
mime_type="application/pdf",
original_file_path=str(sample_pdf_file),
processed_file_path=None,
)
db_session.add(file_record)
db_session.commit()
db_session.refresh(file_record)
# Request preview
response = client.get(f"/files/{file_record.id}/preview/original")
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
@pytest.mark.integration
def test_preview_processed_file_endpoint(client: TestClient, db_session, sample_pdf_file, tmp_path):
"""Test processed file preview endpoint"""
from app.models import FileRecord
# Create processed file
processed_file = tmp_path / "processed.pdf"
processed_file.write_bytes(sample_pdf_file.read_bytes())
file_record = FileRecord(
filehash="test101jkl",
original_filename="doc.pdf",
local_filename=str(sample_pdf_file),
file_size=1024,
mime_type="application/pdf",
original_file_path=str(sample_pdf_file),
processed_file_path=str(processed_file),
)
db_session.add(file_record)
db_session.commit()
db_session.refresh(file_record)
# Request preview
response = client.get(f"/files/{file_record.id}/preview/processed")
assert response.status_code == 200
assert response.headers["content-type"] == "application/pdf"
@pytest.mark.integration
def test_preview_missing_file_returns_404(client: TestClient, db_session, sample_pdf_file):
"""Test preview endpoint returns 404 when file doesn't exist"""
from app.models import FileRecord
file_record = FileRecord(
filehash="test202mno",
original_filename="missing.pdf",
local_filename=str(sample_pdf_file),
file_size=1024,
mime_type="application/pdf",
original_file_path="/nonexistent/path/original.pdf",
processed_file_path="/nonexistent/path/processed.pdf",
)
db_session.add(file_record)
db_session.commit()
db_session.refresh(file_record)
# Request original (missing)
response = client.get(f"/files/{file_record.id}/preview/original")
assert response.status_code == 404
# Request processed (missing)
response = client.get(f"/files/{file_record.id}/preview/processed")
assert response.status_code == 404
@pytest.mark.integration
def test_file_detail_shows_file_status_indicators(client: TestClient, db_session, sample_pdf_file):
"""Test file detail page shows correct status indicators for original and processed files"""
from app.models import FileRecord
file_record = FileRecord(
filehash="test303pqr",
original_filename="status_test.pdf",
local_filename=str(sample_pdf_file),
file_size=1024,
mime_type="application/pdf",
original_file_path=str(sample_pdf_file), # Exists
processed_file_path="/nonexistent/processed.pdf", # Doesn't exist
)
db_session.add(file_record)
db_session.commit()
db_session.refresh(file_record)
response = client.get(f"/files/{file_record.id}/detail")
assert response.status_code == 200
html = response.text
# Check for status indicators
assert "Original File Status" in html
assert "Processed File Status" in html
# Original should show as exists
assert html.count("File exists") >= 1
# Processed should show as not found
assert "File not found" in html
@pytest.mark.unit
def test_metadata_json_structure(sample_metadata):
"""Test metadata JSON structure is valid"""
# Verify all expected fields are present
assert "document_type" in sample_metadata
assert "filename" in sample_metadata
assert "date" in sample_metadata
assert "absender" in sample_metadata
assert "tags" in sample_metadata
# Verify it's JSON serializable
json_str = json.dumps(sample_metadata)
parsed = json.loads(json_str)
assert parsed == sample_metadata