Add multipart file processing
This commit is contained in:
parent
9945a29a8e
commit
224e97ccc4
2 changed files with 723 additions and 1 deletions
505
backend/app.py
505
backend/app.py
|
|
@ -8,6 +8,7 @@ import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import uuid
|
import uuid
|
||||||
import pdf2image
|
import pdf2image
|
||||||
|
from multipart_handler import MultipartHandler, default_handler
|
||||||
|
|
||||||
# Add the parent directory to Python path to allow imports
|
# Add the parent directory to Python path to allow imports
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
@ -24,6 +25,8 @@ app = Flask(__name__,
|
||||||
static_folder=os.path.join(frontend_path, 'static'),
|
static_folder=os.path.join(frontend_path, 'static'),
|
||||||
static_url_path='/static')
|
static_url_path='/static')
|
||||||
|
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = 100 * 1024 * 1024 # 100MB
|
||||||
|
|
||||||
# Dictionary to store background task results
|
# Dictionary to store background task results
|
||||||
task_results = {}
|
task_results = {}
|
||||||
|
|
||||||
|
|
@ -876,6 +879,508 @@ def debug_results():
|
||||||
return jsonify({'error': str(e)}), 500
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
# Cleanup task for batch processors
|
# Cleanup task for batch processors
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/upload/analyze', methods=['POST'])
|
||||||
|
def api_upload_analyze():
|
||||||
|
"""
|
||||||
|
API endpoint for uploading and analyzing a PDF file.
|
||||||
|
Expects multipart/form-data with:
|
||||||
|
- file: PDF file
|
||||||
|
- page_num: (optional) Page number to analyze (default: 1)
|
||||||
|
- adjust: (optional) Auto-adjust coordinates (default: true)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if file is in request
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify(default_handler.create_multipart_response(
|
||||||
|
False, {'error': 'No file part in request'}
|
||||||
|
)), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
|
||||||
|
# Save uploaded file
|
||||||
|
success, result = default_handler.save_uploaded_file(file, permanent=True)
|
||||||
|
if not success:
|
||||||
|
return jsonify(default_handler.create_multipart_response(False, result)), 400
|
||||||
|
|
||||||
|
# Get processing parameters
|
||||||
|
page_num = request.form.get('page_num', '1')
|
||||||
|
adjust = request.form.get('adjust', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Run analyzer
|
||||||
|
filepath = result['filepath']
|
||||||
|
command = f"python backend/page_treatment/analyzer.py --image {filepath} --page {page_num} --start-page {page_num} --end-page {page_num}"
|
||||||
|
|
||||||
|
# Generate task ID
|
||||||
|
task_id = f"api_analyzer_{int(time.time() * 1000)}"
|
||||||
|
|
||||||
|
# Initialize task result
|
||||||
|
task_results[task_id] = {
|
||||||
|
'success': None,
|
||||||
|
'output': "Processing uploaded file...",
|
||||||
|
'done': False,
|
||||||
|
'file_info': result
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start background processing
|
||||||
|
thread = threading.Thread(target=run_command, args=(task_id, command))
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
# Return response
|
||||||
|
return jsonify(default_handler.create_multipart_response(True, {
|
||||||
|
'task_id': task_id,
|
||||||
|
'file_info': result,
|
||||||
|
'message': f"Processing started for {result['filename']}, page {page_num}"
|
||||||
|
}))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in api_upload_analyze: {str(e)}")
|
||||||
|
return jsonify(default_handler.create_multipart_response(
|
||||||
|
False, {'error': str(e)}
|
||||||
|
)), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/upload/process', methods=['POST'])
|
||||||
|
def api_upload_process():
|
||||||
|
"""
|
||||||
|
API endpoint for uploading and processing a PDF through all stages.
|
||||||
|
Expects multipart/form-data with:
|
||||||
|
- file: PDF file
|
||||||
|
- page_num: (optional) Page number (default: 1)
|
||||||
|
- adjust: (optional) Auto-adjust coordinates (default: true)
|
||||||
|
- stages: (optional) Comma-separated list of stages to run (default: analyzer,visualizer,extractor)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Check if file is in request
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify(default_handler.create_multipart_response(
|
||||||
|
False, {'error': 'No file part in request'}
|
||||||
|
)), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
|
||||||
|
# Save uploaded file
|
||||||
|
success, result = default_handler.save_uploaded_file(file, permanent=True)
|
||||||
|
if not success:
|
||||||
|
return jsonify(default_handler.create_multipart_response(False, result)), 400
|
||||||
|
|
||||||
|
# Get processing parameters
|
||||||
|
page_num = request.form.get('page_num', '1')
|
||||||
|
adjust = request.form.get('adjust', 'true').lower() == 'true'
|
||||||
|
stages = request.form.get('stages', 'analyzer,visualizer,extractor').split(',')
|
||||||
|
|
||||||
|
# Create a combined task ID
|
||||||
|
task_id = f"api_process_{int(time.time() * 1000)}"
|
||||||
|
|
||||||
|
# Process through all stages
|
||||||
|
filepath = result['filepath']
|
||||||
|
processing_info = {
|
||||||
|
'task_id': task_id,
|
||||||
|
'file_info': result,
|
||||||
|
'page_num': page_num,
|
||||||
|
'adjust': adjust,
|
||||||
|
'stages': stages,
|
||||||
|
'current_stage': None,
|
||||||
|
'completed_stages': [],
|
||||||
|
'results': {}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Start processing in background
|
||||||
|
def process_all_stages():
|
||||||
|
try:
|
||||||
|
# Run analyzer if requested
|
||||||
|
if 'analyzer' in stages:
|
||||||
|
processing_info['current_stage'] = 'analyzer'
|
||||||
|
command = f"python backend/page_treatment/analyzer.py --image {filepath} --page {page_num} --start-page {page_num} --end-page {page_num}"
|
||||||
|
|
||||||
|
process = subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
input="n\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
processing_info['completed_stages'].append('analyzer')
|
||||||
|
processing_info['results']['analyzer'] = {
|
||||||
|
'success': True,
|
||||||
|
'doctags_file': 'results/output.doctags.txt'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
processing_info['results']['analyzer'] = {
|
||||||
|
'success': False,
|
||||||
|
'error': process.stderr
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run visualizer if requested
|
||||||
|
if 'visualizer' in stages:
|
||||||
|
processing_info['current_stage'] = 'visualizer'
|
||||||
|
command = f"python backend/page_treatment/visualizer.py --doctags results/output.doctags.txt --pdf {filepath} --page {page_num}"
|
||||||
|
if adjust:
|
||||||
|
command += " --adjust"
|
||||||
|
|
||||||
|
process = subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
processing_info['completed_stages'].append('visualizer')
|
||||||
|
processing_info['results']['visualizer'] = {
|
||||||
|
'success': True,
|
||||||
|
'visualization_file': f'results/visualization_page_{page_num}.png'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
processing_info['results']['visualizer'] = {
|
||||||
|
'success': False,
|
||||||
|
'error': process.stderr
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
# Run extractor if requested
|
||||||
|
if 'extractor' in stages:
|
||||||
|
processing_info['current_stage'] = 'extractor'
|
||||||
|
command = f"python backend/page_treatment/picture_extractor.py --doctags results/output.doctags.txt --pdf {filepath} --page {page_num}"
|
||||||
|
if adjust:
|
||||||
|
command += " --adjust"
|
||||||
|
|
||||||
|
process = subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.returncode == 0:
|
||||||
|
processing_info['completed_stages'].append('extractor')
|
||||||
|
|
||||||
|
# Count extracted images
|
||||||
|
pics_dir = Path("results") / "pictures"
|
||||||
|
image_count = len(list(pics_dir.glob("*.png"))) if pics_dir.exists() else 0
|
||||||
|
|
||||||
|
processing_info['results']['extractor'] = {
|
||||||
|
'success': True,
|
||||||
|
'images_extracted': image_count,
|
||||||
|
'pictures_folder': 'results/pictures'
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
processing_info['results']['extractor'] = {
|
||||||
|
'success': False,
|
||||||
|
'error': process.stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
processing_info['current_stage'] = 'completed'
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
processing_info['current_stage'] = 'error'
|
||||||
|
processing_info['error'] = str(e)
|
||||||
|
|
||||||
|
# Store processing info
|
||||||
|
task_results[task_id] = processing_info
|
||||||
|
|
||||||
|
# Start background thread
|
||||||
|
thread = threading.Thread(target=process_all_stages)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
# Return response
|
||||||
|
return jsonify(default_handler.create_multipart_response(True, {
|
||||||
|
'task_id': task_id,
|
||||||
|
'file_info': result,
|
||||||
|
'stages': stages,
|
||||||
|
'message': f"Processing started for {result['filename']}, page {page_num}"
|
||||||
|
}))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in api_upload_process: {str(e)}")
|
||||||
|
return jsonify(default_handler.create_multipart_response(
|
||||||
|
False, {'error': str(e)}
|
||||||
|
)), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/upload/batch', methods=['POST'])
|
||||||
|
def api_upload_batch():
|
||||||
|
"""
|
||||||
|
API endpoint for uploading and batch processing a PDF.
|
||||||
|
Expects multipart/form-data with:
|
||||||
|
- file: PDF file
|
||||||
|
- start_page: (optional) Start page (default: 1)
|
||||||
|
- end_page: (optional) End page (default: all)
|
||||||
|
- parallel: (optional) Enable parallel processing (default: true)
|
||||||
|
- adjust: (optional) Auto-adjust coordinates (default: true)
|
||||||
|
"""
|
||||||
|
if not batch_processing_available:
|
||||||
|
return jsonify(default_handler.create_multipart_response(
|
||||||
|
False, {'error': 'Batch processing not available'}
|
||||||
|
)), 503
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if file is in request
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify(default_handler.create_multipart_response(
|
||||||
|
False, {'error': 'No file part in request'}
|
||||||
|
)), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
|
||||||
|
# Save uploaded file
|
||||||
|
success, result = default_handler.save_uploaded_file(file, permanent=True)
|
||||||
|
if not success:
|
||||||
|
return jsonify(default_handler.create_multipart_response(False, result)), 400
|
||||||
|
|
||||||
|
# Get PDF info
|
||||||
|
filepath = result['filepath']
|
||||||
|
try:
|
||||||
|
from pdf2image.pdf2image import pdfinfo_from_path
|
||||||
|
info = pdfinfo_from_path(filepath)
|
||||||
|
total_pages = info["Pages"]
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify(default_handler.create_multipart_response(
|
||||||
|
False, {'error': f'Failed to read PDF info: {str(e)}'}
|
||||||
|
)), 400
|
||||||
|
|
||||||
|
# Get processing parameters
|
||||||
|
start_page = int(request.form.get('start_page', '1'))
|
||||||
|
end_page = int(request.form.get('end_page', str(total_pages)))
|
||||||
|
parallel = request.form.get('parallel', 'true').lower() == 'true'
|
||||||
|
adjust = request.form.get('adjust', 'true').lower() == 'true'
|
||||||
|
|
||||||
|
# Validate page range
|
||||||
|
start_page = max(1, min(start_page, total_pages))
|
||||||
|
end_page = max(start_page, min(end_page, total_pages))
|
||||||
|
|
||||||
|
# Start batch processing
|
||||||
|
batch_id = f"api_{uuid.uuid4().hex[:8]}"
|
||||||
|
options = {
|
||||||
|
'adjust': adjust,
|
||||||
|
'parallel': parallel,
|
||||||
|
'generate_report': True
|
||||||
|
}
|
||||||
|
|
||||||
|
if start_batch_processing(batch_id, filepath, start_page, end_page, options):
|
||||||
|
return jsonify(default_handler.create_multipart_response(True, {
|
||||||
|
'batch_id': batch_id,
|
||||||
|
'file_info': result,
|
||||||
|
'total_pages': total_pages,
|
||||||
|
'processing_pages': f"{start_page}-{end_page}",
|
||||||
|
'page_count': end_page - start_page + 1,
|
||||||
|
'message': f'Batch processing started for {result["filename"]}'
|
||||||
|
}))
|
||||||
|
else:
|
||||||
|
return jsonify(default_handler.create_multipart_response(
|
||||||
|
False, {'error': 'Failed to start batch processing'}
|
||||||
|
)), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in api_upload_batch: {str(e)}")
|
||||||
|
return jsonify(default_handler.create_multipart_response(
|
||||||
|
False, {'error': str(e)}
|
||||||
|
)), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/task/<task_id>', methods=['GET'])
|
||||||
|
def api_task_status(task_id):
|
||||||
|
"""Get status of a processing task"""
|
||||||
|
if task_id not in task_results:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Task not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'task': task_results[task_id]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/batch/<batch_id>', methods=['GET'])
|
||||||
|
def api_batch_status(batch_id):
|
||||||
|
"""Get status of a batch processing job"""
|
||||||
|
if not batch_processing_available:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Batch processing not available'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
processor = get_batch_processor(batch_id)
|
||||||
|
if not processor:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Batch not found'
|
||||||
|
}), 404
|
||||||
|
|
||||||
|
state = processor.get_state()
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'batch': state
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/cleanup', methods=['POST'])
|
||||||
|
def api_cleanup():
|
||||||
|
"""Cleanup old uploaded files"""
|
||||||
|
try:
|
||||||
|
max_age_hours = int(request.json.get('max_age_hours', 24))
|
||||||
|
folder = request.json.get('folder', 'both')
|
||||||
|
|
||||||
|
removed_count = default_handler.cleanup_old_files(max_age_hours, folder)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'removed_files': removed_count,
|
||||||
|
'message': f'Removed {removed_count} old files'
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
# Add this streamlined endpoint to your app.py
|
||||||
|
|
||||||
|
@app.route('/api/upload/doctags', methods=['POST'])
|
||||||
|
def api_upload_doctags():
|
||||||
|
"""
|
||||||
|
Simple API endpoint for uploading a PDF and getting DocTags output.
|
||||||
|
Automatically cleans up the uploaded file after processing.
|
||||||
|
|
||||||
|
Expects multipart/form-data with:
|
||||||
|
- file: PDF file
|
||||||
|
- page_num: (optional) Page number to analyze (default: 1)
|
||||||
|
|
||||||
|
Returns JSON with:
|
||||||
|
- success: boolean
|
||||||
|
- filename: original filename
|
||||||
|
- page: page number processed
|
||||||
|
- doctags: the DocTags content
|
||||||
|
"""
|
||||||
|
uploaded_file_path = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if file is in request
|
||||||
|
if 'file' not in request.files:
|
||||||
|
return jsonify({'success': False, 'error': 'No file part in request'}), 400
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
|
||||||
|
# Save uploaded file temporarily
|
||||||
|
success, result = default_handler.save_uploaded_file(file, permanent=True)
|
||||||
|
if not success:
|
||||||
|
return jsonify({'success': False, 'error': result.get('error', 'Failed to save file')}), 400
|
||||||
|
|
||||||
|
# Store the uploaded file path for cleanup
|
||||||
|
uploaded_file_path = result['filepath']
|
||||||
|
|
||||||
|
# Get page number
|
||||||
|
page_num = request.form.get('page_num', '1')
|
||||||
|
|
||||||
|
# Run analyzer directly (synchronously)
|
||||||
|
command = f"python backend/page_treatment/analyzer.py --image {uploaded_file_path} --page {page_num} --start-page {page_num} --end-page {page_num}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run the command with timeout
|
||||||
|
process = subprocess.run(
|
||||||
|
command,
|
||||||
|
shell=True,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
input="n\n",
|
||||||
|
timeout=60 # 60 second timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if process.returncode != 0:
|
||||||
|
error_msg = process.stderr or 'Analyzer failed'
|
||||||
|
logger.error(f"Analyzer error: {error_msg}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Analyzer failed',
|
||||||
|
'details': error_msg
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
# Check if doctags file was created
|
||||||
|
doctags_path = Path("results") / "output.doctags.txt"
|
||||||
|
if not doctags_path.exists():
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'DocTags file not generated'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
# Read the doctags content
|
||||||
|
with open(doctags_path, 'r', encoding='utf-8') as f:
|
||||||
|
doctags_content = f.read()
|
||||||
|
|
||||||
|
# Prepare successful response
|
||||||
|
response = {
|
||||||
|
'success': True,
|
||||||
|
'filename': result['filename'],
|
||||||
|
'page': int(page_num),
|
||||||
|
'doctags': doctags_content
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up the uploaded file
|
||||||
|
try:
|
||||||
|
if uploaded_file_path and os.path.exists(uploaded_file_path):
|
||||||
|
os.remove(uploaded_file_path)
|
||||||
|
logger.info(f"Cleaned up uploaded file: {uploaded_file_path}")
|
||||||
|
except Exception as cleanup_error:
|
||||||
|
logger.error(f"Error cleaning up file: {cleanup_error}")
|
||||||
|
# Don't fail the request due to cleanup error
|
||||||
|
|
||||||
|
return jsonify(response)
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': 'Processing timeout - page took too long to analyze'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Processing error: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': f'Processing error: {str(e)}'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in api_upload_doctags: {str(e)}")
|
||||||
|
return jsonify({
|
||||||
|
'success': False,
|
||||||
|
'error': str(e)
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Ensure cleanup happens even if there's an error
|
||||||
|
try:
|
||||||
|
if uploaded_file_path and os.path.exists(uploaded_file_path):
|
||||||
|
os.remove(uploaded_file_path)
|
||||||
|
logger.info(f"Cleaned up uploaded file in finally block: {uploaded_file_path}")
|
||||||
|
except Exception as cleanup_error:
|
||||||
|
logger.error(f"Error in finally cleanup: {cleanup_error}")
|
||||||
|
|
||||||
|
# Add periodic cleanup task
|
||||||
|
def periodic_cleanup():
|
||||||
|
"""Run cleanup every hour"""
|
||||||
|
while True:
|
||||||
|
time.sleep(3600) # Wait 1 hour
|
||||||
|
try:
|
||||||
|
removed = default_handler.cleanup_old_files(24, 'both')
|
||||||
|
if removed > 0:
|
||||||
|
logger.info(f"Periodic cleanup removed {removed} old files")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in periodic cleanup: {str(e)}")
|
||||||
|
|
||||||
|
# Start cleanup thread
|
||||||
|
cleanup_thread = threading.Thread(target=periodic_cleanup)
|
||||||
|
cleanup_thread.daemon = True
|
||||||
|
cleanup_thread.start()
|
||||||
|
|
||||||
|
|
||||||
def cleanup_task():
|
def cleanup_task():
|
||||||
"""Periodic cleanup of old batch processors"""
|
"""Periodic cleanup of old batch processors"""
|
||||||
if not batch_processing_available:
|
if not batch_processing_available:
|
||||||
|
|
|
||||||
217
backend/multipart_handler.py
Normal file
217
backend/multipart_handler.py
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Multipart File Upload Handler for DocTags
|
||||||
|
Handles file upload processing and validation
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
from werkzeug.datastructures import FileStorage
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
from typing import Optional, Dict, Tuple, List
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class MultipartHandler:
|
||||||
|
"""Handle multipart file uploads with validation and storage"""
|
||||||
|
|
||||||
|
ALLOWED_EXTENSIONS = {'pdf'}
|
||||||
|
MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
|
||||||
|
UPLOAD_FOLDER = 'uploads'
|
||||||
|
TEMP_FOLDER = 'temp_uploads'
|
||||||
|
|
||||||
|
def __init__(self, upload_folder: str = None, temp_folder: str = None):
|
||||||
|
self.upload_folder = Path(upload_folder or self.UPLOAD_FOLDER)
|
||||||
|
self.temp_folder = Path(temp_folder or self.TEMP_FOLDER)
|
||||||
|
self._ensure_folders()
|
||||||
|
|
||||||
|
def _ensure_folders(self):
|
||||||
|
"""Ensure upload and temp folders exist"""
|
||||||
|
for folder in [self.upload_folder, self.temp_folder]:
|
||||||
|
if not folder.exists():
|
||||||
|
folder.mkdir(parents=True)
|
||||||
|
logger.info(f"Created folder: {folder}")
|
||||||
|
|
||||||
|
def allowed_file(self, filename: str) -> bool:
|
||||||
|
"""Check if file extension is allowed"""
|
||||||
|
return '.' in filename and \
|
||||||
|
filename.rsplit('.', 1)[1].lower() in self.ALLOWED_EXTENSIONS
|
||||||
|
|
||||||
|
def validate_file(self, file: FileStorage) -> Tuple[bool, Optional[str]]:
|
||||||
|
"""
|
||||||
|
Validate uploaded file
|
||||||
|
Returns: (is_valid, error_message)
|
||||||
|
"""
|
||||||
|
if not file:
|
||||||
|
return False, "No file provided"
|
||||||
|
|
||||||
|
if file.filename == '':
|
||||||
|
return False, "No file selected"
|
||||||
|
|
||||||
|
if not self.allowed_file(file.filename):
|
||||||
|
return False, f"Invalid file type. Allowed types: {', '.join(self.ALLOWED_EXTENSIONS)}"
|
||||||
|
|
||||||
|
# Check file size (if possible)
|
||||||
|
file.seek(0, os.SEEK_END)
|
||||||
|
file_size = file.tell()
|
||||||
|
file.seek(0) # Reset file pointer
|
||||||
|
|
||||||
|
if file_size > self.MAX_FILE_SIZE:
|
||||||
|
return False, f"File too large. Maximum size: {self.MAX_FILE_SIZE / (1024*1024):.1f}MB"
|
||||||
|
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
def save_uploaded_file(self, file: FileStorage, permanent: bool = True) -> Tuple[bool, Dict]:
|
||||||
|
"""
|
||||||
|
Save uploaded file
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: The uploaded file
|
||||||
|
permanent: If True, save to uploads folder; if False, save to temp folder
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(success, result_dict) where result_dict contains:
|
||||||
|
- filepath: Path to saved file
|
||||||
|
- filename: Original filename
|
||||||
|
- unique_filename: Saved filename
|
||||||
|
- size: File size in bytes
|
||||||
|
- error: Error message if failed
|
||||||
|
"""
|
||||||
|
# Validate file
|
||||||
|
is_valid, error_msg = self.validate_file(file)
|
||||||
|
if not is_valid:
|
||||||
|
return False, {'error': error_msg}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Secure the filename
|
||||||
|
original_filename = secure_filename(file.filename)
|
||||||
|
timestamp = int(time.time() * 1000) # Millisecond timestamp
|
||||||
|
|
||||||
|
# Create unique filename
|
||||||
|
name_parts = original_filename.rsplit('.', 1)
|
||||||
|
if len(name_parts) == 2:
|
||||||
|
unique_filename = f"{name_parts[0]}_{timestamp}.{name_parts[1]}"
|
||||||
|
else:
|
||||||
|
unique_filename = f"{original_filename}_{timestamp}"
|
||||||
|
|
||||||
|
# Determine save location
|
||||||
|
save_folder = self.upload_folder if permanent else self.temp_folder
|
||||||
|
filepath = save_folder / unique_filename
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
file.save(str(filepath))
|
||||||
|
|
||||||
|
# Get file size
|
||||||
|
file_size = os.path.getsize(filepath)
|
||||||
|
|
||||||
|
logger.info(f"Saved file: {filepath} (size: {file_size} bytes)")
|
||||||
|
|
||||||
|
return True, {
|
||||||
|
'filepath': str(filepath),
|
||||||
|
'filename': original_filename,
|
||||||
|
'unique_filename': unique_filename,
|
||||||
|
'size': file_size,
|
||||||
|
'permanent': permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving file: {str(e)}")
|
||||||
|
return False, {'error': f"Failed to save file: {str(e)}"}
|
||||||
|
|
||||||
|
def save_to_temp(self, file: FileStorage) -> Tuple[bool, Dict]:
|
||||||
|
"""Save file to temporary folder"""
|
||||||
|
return self.save_uploaded_file(file, permanent=False)
|
||||||
|
|
||||||
|
def move_to_permanent(self, temp_filepath: str) -> Tuple[bool, Dict]:
|
||||||
|
"""Move file from temp to permanent storage"""
|
||||||
|
try:
|
||||||
|
temp_path = Path(temp_filepath)
|
||||||
|
if not temp_path.exists():
|
||||||
|
return False, {'error': 'Temporary file not found'}
|
||||||
|
|
||||||
|
permanent_path = self.upload_folder / temp_path.name
|
||||||
|
shutil.move(str(temp_path), str(permanent_path))
|
||||||
|
|
||||||
|
return True, {
|
||||||
|
'filepath': str(permanent_path),
|
||||||
|
'moved_from': str(temp_path)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error moving file: {str(e)}")
|
||||||
|
return False, {'error': f"Failed to move file: {str(e)}"}
|
||||||
|
|
||||||
|
def cleanup_old_files(self, max_age_hours: int = 24, folder: str = 'both'):
|
||||||
|
"""
|
||||||
|
Remove old files from upload/temp folders
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_age_hours: Maximum age of files in hours
|
||||||
|
folder: 'uploads', 'temp', or 'both'
|
||||||
|
"""
|
||||||
|
folders_to_clean = []
|
||||||
|
if folder in ['uploads', 'both']:
|
||||||
|
folders_to_clean.append(self.upload_folder)
|
||||||
|
if folder in ['temp', 'both']:
|
||||||
|
folders_to_clean.append(self.temp_folder)
|
||||||
|
|
||||||
|
current_time = time.time()
|
||||||
|
max_age_seconds = max_age_hours * 3600
|
||||||
|
removed_count = 0
|
||||||
|
|
||||||
|
for folder_path in folders_to_clean:
|
||||||
|
if not folder_path.exists():
|
||||||
|
continue
|
||||||
|
|
||||||
|
for file_path in folder_path.glob('*.pdf'):
|
||||||
|
try:
|
||||||
|
file_age = current_time - file_path.stat().st_mtime
|
||||||
|
if file_age > max_age_seconds:
|
||||||
|
file_path.unlink()
|
||||||
|
removed_count += 1
|
||||||
|
logger.info(f"Deleted old file: {file_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error deleting file {file_path}: {str(e)}")
|
||||||
|
|
||||||
|
return removed_count
|
||||||
|
|
||||||
|
def get_file_info(self, filepath: str) -> Optional[Dict]:
|
||||||
|
"""Get information about an uploaded file"""
|
||||||
|
try:
|
||||||
|
path = Path(filepath)
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
stats = path.stat()
|
||||||
|
return {
|
||||||
|
'filepath': str(path),
|
||||||
|
'filename': path.name,
|
||||||
|
'size': stats.st_size,
|
||||||
|
'created': stats.st_ctime,
|
||||||
|
'modified': stats.st_mtime,
|
||||||
|
'exists': True
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting file info: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_multipart_response(self, success: bool, data: Dict) -> Dict:
|
||||||
|
"""Create standardized response for multipart operations"""
|
||||||
|
response = {
|
||||||
|
'success': success,
|
||||||
|
'timestamp': time.time()
|
||||||
|
}
|
||||||
|
|
||||||
|
if success:
|
||||||
|
response['data'] = data
|
||||||
|
else:
|
||||||
|
response['error'] = data.get('error', 'Unknown error')
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
# Create a default instance
|
||||||
|
default_handler = MultipartHandler()
|
||||||
Loading…
Reference in a new issue