Files
nextgenpdf/pdf_renamer.py
T

1567 lines
73 KiB
Python

import os
import tempfile
import zipfile
import threading
import uuid
import json
from datetime import datetime
from functools import wraps
from flask import Flask, request, render_template_string, redirect, url_for, session, send_from_directory, flash, get_flashed_messages, jsonify
from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix
from PyPDF2 import PdfReader
from authlib.integrations.flask_client import OAuth
app = Flask(__name__)
# Trust proxy headers (since we're behind Nginx)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
app.config['UPLOAD_FOLDER'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'processed')
app.config['ARCHIVE_FOLDER'] = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'archives')
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs(app.config['ARCHIVE_FOLDER'], exist_ok=True)
app.config['MAX_CONTENT_LENGTH'] = 500 * 1024 * 1024 # 500MB limit
app.secret_key = os.getenv('SECRET_KEY', 'dev-secret-key-change-in-production')
# Secure session cookie settings
app.config['SESSION_COOKIE_SECURE'] = True # Only send cookie over HTTPS
app.config['SESSION_COOKIE_HTTPONLY'] = True # Prevent JavaScript access
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' # CSRF protection
app.config['PERMANENT_SESSION_LIFETIME'] = 3600 # 1 hour session timeout
# Entra ID Configuration
TENANT_ID = os.getenv('TENANT_ID', 'your-tenant-id')
CLIENT_ID = os.getenv('CLIENT_ID', 'your-client-id')
CLIENT_SECRET = os.getenv('CLIENT_SECRET', 'your-client-secret')
print(f"DEBUG: TENANT_ID = {TENANT_ID}")
print(f"DEBUG: CLIENT_ID = {CLIENT_ID}")
print(f"DEBUG: CLIENT_SECRET = {'*' * len(CLIENT_SECRET) if CLIENT_SECRET else 'None'}")
# Admin Configuration
ADMIN_USERNAME = os.getenv('ADMIN_USERNAME', 'trbcit')
ADMIN_PASSWORD = os.getenv('ADMIN_PASSWORD', 'atdrmbicn')
# Initialize OAuth
oauth = OAuth(app)
# Register Microsoft OAuth provider with proper configuration
try:
microsoft = oauth.register(
name='microsoft',
client_id=CLIENT_ID,
client_secret=CLIENT_SECRET,
server_metadata_url=f'https://login.microsoftonline.com/{TENANT_ID}/v2.0/.well-known/openid-configuration',
client_kwargs={
'scope': 'openid email profile',
'token_endpoint_auth_method': 'client_secret_post'
}
)
print("DEBUG: Microsoft OAuth provider registered successfully")
except Exception as e:
print(f"ERROR: Failed to register Microsoft OAuth provider: {e}")
microsoft = None
# Job persistence
JOBS_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'jobs.json')
job_queue = {}
job_lock = threading.Lock()
def save_jobs():
"""Save jobs to persistent storage"""
try:
with job_lock:
# Convert datetime objects to strings for JSON serialization
jobs_to_save = {}
for job_id, job in job_queue.items():
jobs_to_save[job_id] = job.copy()
# Convert any datetime objects if needed
if 'created_at' in jobs_to_save[job_id]:
jobs_to_save[job_id]['created_at'] = str(jobs_to_save[job_id]['created_at'])
with open(JOBS_FILE, 'w') as f:
json.dump(jobs_to_save, f, indent=2)
except Exception as e:
print(f"ERROR: Failed to save jobs: {e}")
def load_jobs():
"""Load jobs from persistent storage"""
global job_queue
try:
if os.path.exists(JOBS_FILE):
with open(JOBS_FILE, 'r') as f:
loaded_jobs = json.load(f)
# Only load jobs where archive files still exist
valid_jobs = {}
skipped_count = 0
for job_id, job_data in loaded_jobs.items():
# Only keep completed jobs with existing archive files
if job_data.get('status') == 'completed':
zip_path = job_data.get('zip_path', '')
if zip_path and os.path.exists(zip_path):
valid_jobs[job_id] = job_data
else:
skipped_count += 1
print(f"DEBUG: Skipping job {job_id} - archive file missing: {zip_path}")
# Skip jobs that were in progress (they failed during restart)
elif job_data.get('status') in ['pending', 'processing']:
skipped_count += 1
print(f"DEBUG: Skipping incomplete job {job_id} - status: {job_data.get('status')}")
# Keep error state jobs for visibility
elif job_data.get('status') == 'error':
valid_jobs[job_id] = job_data
job_queue = valid_jobs
print(f"DEBUG: Loaded {len(job_queue)} jobs from persistent storage ({skipped_count} skipped)")
# Save the cleaned up job list back to disk
if skipped_count > 0:
save_jobs()
else:
print("DEBUG: No existing jobs file found, starting fresh")
except Exception as e:
print(f"ERROR: Failed to load jobs: {e}")
job_queue = {}
# Load jobs on startup
load_jobs()
# Job cleanup settings
JOB_RETENTION_DAYS = 7
SAVE_BATCH_SIZE = 5 # Only save after this many updates
save_counter = 0
def update_job_status(job_id, status, **kwargs):
"""Update job status and persist changes"""
with job_lock:
if job_id in job_queue:
job_queue[job_id]['status'] = status
job_queue[job_id].update(kwargs)
job_queue[job_id]['updated_at'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# Only save for important status changes
if status in ['completed', 'error', 'processing']:
save_jobs()
def cleanup_old_jobs():
"""Remove jobs older than JOB_RETENTION_DAYS"""
from datetime import timedelta
cutoff = datetime.now() - timedelta(days=JOB_RETENTION_DAYS)
with job_lock:
jobs_to_remove = []
for job_id, job in list(job_queue.items()):
if job.get('status') == 'completed':
try:
created_at = datetime.strptime(job.get('created_at', ''), '%Y-%m-%d %H:%M:%S')
if created_at < cutoff:
# Clean up associated files
if job.get('zip_path') and os.path.exists(job['zip_path']):
try:
os.remove(job['zip_path'])
except Exception as e:
print(f"Error removing ZIP file: {e}")
# Clean up uploaded files
for file_info in job.get('files', []):
if 'path' in file_info and os.path.exists(file_info['path']):
try:
os.remove(file_info['path'])
except Exception as e:
print(f"Error removing file: {e}")
jobs_to_remove.append(job_id)
except (ValueError, TypeError) as e:
print(f"Error parsing date for job {job_id}: {e}")
for job_id in jobs_to_remove:
del job_queue[job_id]
print(f"Cleaned up old job: {job_id}")
if jobs_to_remove:
save_jobs()
print(f"Removed {len(jobs_to_remove)} old jobs")
def save_jobs_batched():
"""Save jobs to disk with batching to reduce I/O"""
global save_counter
save_counter += 1
# Save immediately for critical operations, otherwise batch
if save_counter >= SAVE_BATCH_SIZE:
save_jobs()
save_counter = 0
def analyze_failed_extractions(log_path):
"""
Analyze failed extraction log to identify new patterns.
Returns suggestions for new regex patterns.
"""
import re
from collections import defaultdict
if not os.path.exists(log_path):
return "No failed extraction log found."
patterns_found = defaultdict(int)
try:
with open(log_path, 'r', encoding='utf-8') as f:
content = f.read()
# Split into individual failed extractions
failed_entries = content.split('-' * 50)
for entry in failed_entries:
if 'Text content:' in entry:
text_part = entry.split('Text content:')[1].strip()
lines = text_part.split('\n')
# Analyze line patterns
for i, line in enumerate(lines):
line = line.strip()
if not line:
continue
# Look for potential patterns
# Gender + something
if re.match(r'^(Female|Male)', line):
patterns_found['gender_start'] += 1
# School + something
if re.search(r'(?:High|School|Academy|Middle|Elementary)', line):
patterns_found['school_in_line'] += 1
# Grade + something
if re.match(r'^\d{1,2}', line):
patterns_found['grade_start'] += 1
# Names (capitalized words)
name_words = re.findall(r'\b([A-Z][a-z]+)\b', line)
if len(name_words) >= 2:
patterns_found['multiple_names'] += 1
# Check for concatenated patterns
if re.match(r'^[A-Z][a-z]+[A-Z][a-z]+', line):
patterns_found['concatenated_names'] += 1
except Exception as e:
return f"Error analyzing log: {str(e)}"
# Generate suggestions
suggestions = []
if patterns_found.get('gender_start', 0) > 0:
suggestions.append("Found gender at start of lines - may need new gender-first patterns")
if patterns_found.get('school_in_line', 0) > 0:
suggestions.append("Found school names in lines - may need school-based patterns")
if patterns_found.get('grade_start', 0) > 0:
suggestions.append("Found grades at start of lines - may need grade-first patterns")
if patterns_found.get('multiple_names', 0) > 0:
suggestions.append("Found multiple capitalized words - may need better name parsing")
if patterns_found.get('concatenated_names', 0) > 0:
suggestions.append("Found concatenated names - may need better concatenation handling")
if not suggestions:
suggestions.append("No obvious patterns detected - may need manual inspection")
def map_pattern_1(match1, match2):
return {
'gender': match1.group(1),
'school': match1.group(2).strip(),
'last_name': match1.group(3),
'grade': match2.group(1),
'first_name': match2.group(2)
}
def map_pattern_2(match1, match2, match3):
return {
'first_name': match1.group(1),
'gender': match2.group(1),
'school': match2.group(2).strip(),
'last_name': match2.group(3),
'grade': match3.group(1)
}
def map_pattern_3(match1, match2, match3):
return {
'gender': match1.group(1),
'first_name': match1.group(2),
'school': match2.group(1).strip(),
'last_name': match2.group(2),
'grade': match3.group(1)
}
def map_pattern_4(match1, match2, match3):
return {
'school': match1.group(1).strip(),
'grade': match1.group(2),
'gender': match2.group(1),
'first_name': match2.group(2),
'last_name': match3.group(1)
}
def map_pattern_5(match1, match2, match3):
return {
'first_name': match1.group(1),
'gender': match2.group(1),
'grade': match2.group(2),
'school': match3.group(1).strip(),
'last_name': match3.group(2)
}
def map_pattern_6(match1, match2, match3):
return {
'first_name': match1.group(1),
'school': match2.group(1).strip(),
'grade': match2.group(2),
'last_name': match2.group(3),
'gender': match3.group(1)
}
def map_pattern_7(match1, match2):
return {
'gender': match1.group(1),
'first_name': match1.group(2),
'school': match2.group(1).strip(),
'grade': match2.group(2),
'last_name': match2.group(3)
}
def map_pattern_8(match1, match2, match3):
return {
'school': match1.group(1).strip(),
'first_name': match1.group(2),
'last_name': match2.group(1),
'gender': match3.group(1),
'grade': match3.group(2)
}
def map_pattern_9(match1, match2, match3, match4):
return {
'first_name': match1.group(2),
'last_name': match1.group(1),
'grade': match2.group(1),
'school': match3.group(1).strip(),
'gender': match4.group(1)
}
def map_pattern_10(match1, match2):
return {
'gender': match1.group(1),
'school': match1.group(2).strip(),
'last_name': match1.group(3),
'grade': match2.group(1),
'first_name': match2.group(2)
}
ALLOWED_EXTENSIONS = {'pdf', 'zip'}
ALLOWED_MIME_TYPES = {
'pdf': 'application/pdf',
'zip': 'application/zip'
}
# Global jobs storage (in production, use database)
job_queue = {}
def login_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
print(f"DEBUG login_required: session = {dict(session)}")
if 'user' not in session and 'admin' not in session:
print("DEBUG: No user or admin in session, redirecting to login")
return redirect(url_for('login'))
print(f"DEBUG: User authenticated: {session.get('user', session.get('admin'))}")
return f(*args, **kwargs)
return decorated_function
def allowed_file(filename, file_stream=None):
"""
Validate file based on extension and optionally MIME type.
Args:
filename: The name of the file
file_stream: Optional file stream to check MIME type
Returns:
bool: True if file is allowed, False otherwise
"""
# Check extension
if '.' not in filename:
return False
ext = filename.rsplit('.', 1)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
return False
# Additional MIME type validation if file stream provided
if file_stream:
try:
import magic
file_stream.seek(0)
mime_type = magic.from_buffer(file_stream.read(2048), mime=True)
file_stream.seek(0)
expected_mime = ALLOWED_MIME_TYPES.get(ext)
if expected_mime and mime_type != expected_mime:
print(f"MIME type mismatch: expected {expected_mime}, got {mime_type}")
return False
except ImportError:
# python-magic not installed, skip MIME check
pass
except Exception as e:
print(f"Error checking MIME type: {e}")
return False
return True
def extract_names_from_pdf(pdf_path):
reader = None
try:
reader = PdfReader(pdf_path)
# All PDFs are flattened, so only try text extraction from first page
if len(reader.pages) > 0:
first_page_text = reader.pages[0].extract_text()
extracted_data = extract_student_info_from_text(first_page_text)
if extracted_data and all(extracted_data.values()):
return {
'filename': f"{extracted_data['last_name']}{extracted_data['first_name']}_{extracted_data['grade']}.pdf",
'last_name': extracted_data['last_name'],
'first_name': extracted_data['first_name'],
'grade': extracted_data['grade'],
'school': extracted_data['school'],
'gender': extracted_data['gender']
}
# If we get here, we couldn't extract the data
# Log the failed extraction for debugging
import os
failed_log_path = os.path.join(os.path.dirname(pdf_path), 'failed_extractions.log')
try:
with open(failed_log_path, 'a', encoding='utf-8') as f:
f.write(f"Failed to extract from: {os.path.basename(pdf_path)}\n")
if len(reader.pages) > 0:
text = reader.pages[0].extract_text()
f.write(f"Text content:\n{text}\n")
f.write("-" * 50 + "\n")
except Exception as log_error:
pass # Don't let logging errors break the main flow
return {'error': 'Could not extract student information from PDF. Text extraction failed.'}
except Exception as e:
return {'error': f'PDF read error: {str(e)}'}
finally:
# Properly close PDF to prevent resource leaks
if reader is not None:
try:
reader.stream.close()
except:
pass
def extract_student_info_from_text(text):
"""
Extract student information from flattened PDF text.
This function handles multiple concatenation patterns and edge cases.
"""
import re
extracted = {
'first_name': '',
'last_name': '',
'grade': '',
'school': '',
'gender': ''
}
# Split text into lines for better parsing
lines = text.split('\n')
# First pass: Look for individual fields that appear on separate lines
for line in lines:
line = line.strip()
if not line:
continue
# Look for grade (numbers, including K-12)
grade_match = re.match(r'^(?:Grade\s*)?(\d{1,2}|K|k|\d+(?:st|nd|rd|th))$', line)
if grade_match:
grade = grade_match.group(1).upper()
if grade in ['K', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12']:
extracted['grade'] = grade
# Look for gender (case insensitive, handle common variations)
if re.match(r'^(Male|Female|M|F|Boy|Girl)$', line, re.IGNORECASE):
gender = line.capitalize()
if gender in ['Male', 'Female']:
extracted['gender'] = gender
# Look for school names (more flexible pattern)
school_match = re.match(r'^(.+?)(?:\s+(?:High|School|Academy|Middle|Elementary|Christian|Catholic|Public|Private))$', line, re.IGNORECASE)
if school_match and len(school_match.group(1)) > 3: # Avoid false positives
extracted['school'] = line
# Second pass: Try various concatenation patterns
patterns = [
# Pattern 1: Gender + School + LastName, Grade + FirstName
{
'regex1': r'^(Female|Male)(.+?)([A-Z][a-z]+)$',
'regex2': r'^(\d{1,2}|K)([A-Z][a-z]+)$',
'map': map_pattern_1
},
# Pattern 2: FirstName, Gender + School + LastName, Grade
{
'regex1': r'^([A-Z][a-z]+)$',
'regex2': r'^(Female|Male)(.+?)([A-Z][a-z]+)$',
'regex3': r'^(\d{1,2}|K)$',
'map': map_pattern_2
},
# Pattern 3: Gender + FirstName, School + LastName, Grade
{
'regex1': r'^(Female|Male)([A-Z][a-z]+)$',
'regex2': r'^(.+?)([A-Z][a-z]+)$',
'regex3': r'^(\d{1,2}|K)$',
'map': map_pattern_3
},
# Pattern 4: School + Grade, Gender + FirstName, LastName
{
'regex1': r'^(.+?)(\d{1,2}|K)$',
'regex2': r'^(Female|Male)([A-Z][a-z]+)$',
'regex3': r'^([A-Z][a-z]+)$',
'map': map_pattern_4
},
# Pattern 5: FirstName, Gender + Grade, School + LastName
{
'regex1': r'^([A-Z][a-z]+)$',
'regex2': r'^(Female|Male)(\d{1,2}|K)$',
'regex3': r'^(.+?)([A-Z][a-z]+)$',
'map': map_pattern_5
},
# Pattern 6: FirstName, School + Grade + LastName, Gender
{
'regex1': r'^([A-Z][a-z]+)$',
'regex2': r'^(.+?)(\d{1,2}|K)([A-Z][a-z]+)$',
'regex3': r'^(Female|Male)$',
'map': map_pattern_6
},
# Pattern 7: Gender + FirstName, School + Grade + LastName
{
'regex1': r'^(Female|Male)([A-Z][a-z]+)$',
'regex2': r'^(.+?)(\d{1,2}|K)([A-Z][a-z]+)$',
'map': map_pattern_7
},
# Pattern 8: School + FirstName, LastName, Gender + Grade
{
'regex1': r'^(.+?)([A-Z][a-z]+)$',
'regex2': r'^([A-Z][a-z]+)$',
'regex3': r'^(Female|Male)(\d{1,2}|K)$',
'map': map_pattern_8
},
# Pattern 9: FirstLast (concatenated), Grade, School, Gender
{
'regex1': r'^([A-Z][a-z]+)([A-Z][a-z]+)$',
'regex2': r'^(\d{1,2}|K)$',
'regex3': r'^(.+)$',
'regex4': r'^(Female|Male)$',
'map': map_pattern_9
},
# Pattern 10: Gender + School + LastName, Grade + FirstName
{
'regex1': r'^(Female|Male)(.+?)([A-Z][a-z]+)$',
'regex2': r'^(\d{1,2}|K)([A-Z][a-z]+)',
'map': map_pattern_10
}
]
# Try each pattern
for pattern in patterns:
if all(extracted.values()): # Already found all fields
break
for i, line in enumerate(lines):
line = line.strip()
if not line:
continue
match1 = re.match(pattern['regex1'], line)
if not match1:
continue
mapped = None
if 'regex4' in pattern and i + 3 < len(lines):
# Try 4-line pattern
match2 = re.match(pattern['regex2'], lines[i + 1].strip())
match3 = re.match(pattern['regex3'], lines[i + 2].strip())
match4 = re.match(pattern['regex4'], lines[i + 3].strip())
if match2 and match3 and match4:
mapped = pattern['map'](match1, match2, match3, match4)
elif 'regex3' in pattern and i + 2 < len(lines):
# Try 3-line pattern
match2 = re.match(pattern['regex2'], lines[i + 1].strip())
match3 = re.match(pattern['regex3'], lines[i + 2].strip())
if match2 and match3:
mapped = pattern['map'](match1, match2, match3)
elif 'regex2' in pattern and i + 1 < len(lines):
# Try 2-line pattern
match2 = re.match(pattern['regex2'], lines[i + 1].strip())
if match2:
if 'regex3' in pattern or 'regex4' in pattern:
# This pattern expects more matches but only has 2 lines available
continue
mapped = pattern['map'](match1, match2)
if mapped:
extracted.update(mapped)
# Third pass: Fallback logic for concatenated names and missing fields
if not extracted['first_name'] or not extracted['last_name']:
# Find all capitalized words that could be names
all_text = ' '.join(lines)
name_candidates = re.findall(r'\b([A-Z][a-z]+)\b', all_text)
# Filter out common non-name words
common_words = {'Student', 'First', 'Last', 'Name', 'Grade', 'School', 'Gender', 'Date', 'Id', 'Envelope', 'Docusign', 'Male', 'Female', 'Details', 'Registration', 'Episode', 'Page', 'Signature', 'Participant', 'Parent', 'Guardian', 'High', 'Academy', 'Christian', 'Liberty', 'Middle', 'Elementary', 'Catholic', 'Public', 'Private', 'Other', 'Not', 'Listed'}
name_words = [w for w in name_candidates if w not in common_words and len(w) > 1]
# Try to assign names based on context
if len(name_words) >= 2:
if not extracted['first_name']:
extracted['first_name'] = name_words[0]
if not extracted['last_name']:
extracted['last_name'] = name_words[-1] # Usually last name is last
# Final validation and cleanup
required_fields = ['first_name', 'last_name', 'grade', 'school', 'gender']
if all(extracted.get(field) for field in required_fields):
# Clean up school names (remove extra spaces, standardize)
extracted['school'] = re.sub(r'\s+', ' ', extracted['school']).strip()
return extracted
return None
def process_job(job_id, files):
temp_extract_dirs = []
try:
print("=== DEBUG: process_job STARTED ===")
with job_lock:
job_queue[job_id]['status'] = 'processing'
job_queue[job_id]['progress'] = 'Starting job processing...'
save_jobs() # Persist initial progress
processed_files = []
# Create base Episode directory for this job
episode_dir = os.path.join(app.config['UPLOAD_FOLDER'], 'Episode')
os.makedirs(episode_dir, exist_ok=True)
job_queue[job_id]['progress'] = 'Created output directory structure'
# Analyze ZIP contents before processing
zip_contents = {}
job_queue[job_id]['progress'] = 'Analyzing uploaded files...'
for file_info in files:
if file_info['name'].lower().endswith('.zip'):
try:
with zipfile.ZipFile(file_info['path'], 'r') as zip_ref:
pdf_files = [f for f in zip_ref.namelist() if f.lower().endswith('.pdf')]
zip_contents[file_info['name']] = pdf_files
except Exception as e:
zip_contents[file_info['name']] = [f"Error reading ZIP: {str(e)}"]
job_queue[job_id]['zip_contents'] = zip_contents
total_files = sum(1 for file_info in files if file_info['name'].lower().endswith('.pdf')) + \
sum(len(zip_contents.get(file_info['name'], []))
for file_info in files if file_info['name'].lower().endswith('.zip'))
job_queue[job_id]['progress'] = f'Found {total_files} files to process'
save_jobs() # Persist file count
processed_count = 0
for file_info in files:
filepath = file_info['path']
filename = file_info['name']
if filename.lower().endswith('.zip'):
try:
job_queue[job_id]['progress'] = f'Extracting ZIP archive: {filename}'
print(f"DEBUG: Starting ZIP extraction for {filename}")
# Extract ZIP
extract_dir = os.path.join(app.config['UPLOAD_FOLDER'], f'extract_{job_id}_{os.path.splitext(filename)[0]}')
os.makedirs(extract_dir, exist_ok=True)
temp_extract_dirs.append(extract_dir)
print(f"DEBUG: Created extract directory: {extract_dir}")
with zipfile.ZipFile(filepath, 'r') as zip_ref:
file_count = len(zip_ref.namelist())
print(f"DEBUG: ZIP contains {file_count} files: {zip_ref.namelist()}")
zip_ref.extractall(extract_dir)
job_queue[job_id]['progress'] = f'Extracted {file_count} files from {filename}'
save_jobs() # Persist ZIP extraction progress
print(f"DEBUG: ZIP extraction completed, progress updated: {job_queue[job_id]['progress']}")
# Process PDFs in extract_dir
pdf_files_found = []
for root, dirs, files_in_zip in os.walk(extract_dir):
for f in files_in_zip:
if f.lower().endswith('.pdf'):
pdf_files_found.append(f)
pdf_path = os.path.join(root, f)
job_queue[job_id]['progress'] = f'Processing PDF: {f}'
save_jobs() # Persist current file progress
print(f"DEBUG: Processing PDF: {f} at {pdf_path}")
print(f"DEBUG: File exists: {os.path.exists(pdf_path)}")
pdf_info = extract_names_from_pdf(pdf_path)
print(f"DEBUG: PDF extraction result: {pdf_info}")
if 'error' in pdf_info:
processed_files.append(('skipped', f, pdf_info['error']))
job_queue[job_id]['progress'] = f'Skipped {f} - {pdf_info["error"]}'
print(f"DEBUG: Skipped PDF {f}: {pdf_info['error']}")
else:
job_queue[job_id]['progress'] = f'Extracted data for {pdf_info["first_name"]} {pdf_info["last_name"]}'
print(f"DEBUG: Successfully extracted data for {pdf_info['first_name']} {pdf_info['last_name']}")
# Create folder structure: Episode/School/Grade/Gender/
school_dir = os.path.join(episode_dir, pdf_info['school'])
grade_dir = os.path.join(school_dir, pdf_info['grade'])
gender_dir = os.path.join(grade_dir, pdf_info['gender'])
print(f"DEBUG: Creating directories: {school_dir} -> {grade_dir} -> {gender_dir}")
os.makedirs(gender_dir, exist_ok=True)
print(f"DEBUG: Directories created successfully")
# Move file to appropriate folder with new name
new_path = os.path.join(gender_dir, pdf_info['filename'])
print(f"DEBUG: Attempting to move {pdf_path} to {new_path}")
counter = 1
while os.path.exists(new_path):
base, ext = os.path.splitext(pdf_info['filename'])
new_path = os.path.join(gender_dir, f"{base}_{counter}{ext}")
counter += 1
print(f"DEBUG: File exists, trying {new_path}")
print(f"DEBUG: Moving file to {new_path}")
os.rename(pdf_path, new_path)
processed_files.append(('success', os.path.relpath(new_path, episode_dir), new_path))
processed_count += 1
job_queue[job_id]['progress'] = f'Organized {processed_count}/{total_files} files'
# Batch saves - only every 5 files
if processed_count % 5 == 0:
save_jobs()
print(f"DEBUG: Organized file {processed_count}/{total_files}")
print(f"DEBUG: Found {len(pdf_files_found)} PDF files in ZIP: {pdf_files_found}")
except Exception as e:
processed_files.append(('skipped', filename, f'ZIP processing error: {str(e)}'))
job_queue[job_id]['progress'] = f'Failed to process ZIP {filename}: {str(e)}'
print(f"ZIP processing error for {filename}: {str(e)}")
continue
else:
# Single PDF
try:
job_queue[job_id]['progress'] = f'Processing PDF: {filename}'
save_jobs() # Persist current file progress
pdf_info = extract_names_from_pdf(filepath)
if 'error' in pdf_info:
processed_files.append(('skipped', filename, pdf_info['error']))
job_queue[job_id]['progress'] = f'Skipped {filename} - {pdf_info["error"]}'
else:
job_queue[job_id]['progress'] = f'Extracted data for {pdf_info["first_name"]} {pdf_info["last_name"]}'
# Create folder structure: Episode/School/Grade/Gender/
school_dir = os.path.join(episode_dir, pdf_info['school'])
grade_dir = os.path.join(school_dir, pdf_info['grade'])
gender_dir = os.path.join(grade_dir, pdf_info['gender'])
os.makedirs(gender_dir, exist_ok=True)
# Move file to appropriate folder with new name
new_filepath = os.path.join(gender_dir, pdf_info['filename'])
counter = 1
while os.path.exists(new_filepath):
base, ext = os.path.splitext(pdf_info['filename'])
new_filepath = os.path.join(gender_dir, f"{base}_{counter}{ext}")
counter += 1
os.rename(filepath, new_filepath)
processed_files.append(('success', os.path.relpath(new_filepath, episode_dir), new_filepath))
processed_count += 1
job_queue[job_id]['progress'] = f'Organized {processed_count}/{total_files} files'
# Batch saves - only every 5 files
if processed_count % 5 == 0:
save_jobs()
except Exception as e:
processed_files.append(('skipped', filename, f'PDF processing error: {str(e)}'))
job_queue[job_id]['progress'] = f'Failed to process PDF {filename}: {str(e)}'
print(f"PDF processing error for {filename}: {str(e)}")
continue
job_queue[job_id]['progress'] = 'Creating ZIP archive...'
# Create ZIP of the entire Episode directory structure
successful_files = [p for p in processed_files if p[0] == 'success']
print(f"DEBUG: Starting ZIP creation, successful_files: {len(successful_files)}")
if successful_files:
try:
# Create ZIP in archives folder
zip_output = os.path.join(app.config['ARCHIVE_FOLDER'], f'renamed_{job_id}.zip')
print(f"DEBUG: Creating ZIP at {zip_output}")
with zipfile.ZipFile(zip_output, 'w', zipfile.ZIP_DEFLATED) as zip_out:
for root, dirs, files_in_dir in os.walk(episode_dir):
for file in files_in_dir:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, episode_dir)
print(f"DEBUG: Adding to ZIP: {file_path} as {arcname}")
zip_out.write(file_path, arcname)
job_queue[job_id]['zip_path'] = zip_output
job_queue[job_id]['zip_name'] = f'renamed_{job_id}.zip'
job_queue[job_id]['progress'] = f'Created ZIP archive with {len(successful_files)} organized files'
save_jobs() # Persist completion progress
print(f"DEBUG: ZIP creation completed successfully")
# Clean up Episode directory after successful ZIP creation
try:
import shutil
shutil.rmtree(episode_dir)
print(f"DEBUG: Cleaned up Episode directory: {episode_dir}")
except Exception as cleanup_error:
print(f"WARNING: Failed to clean up Episode directory: {cleanup_error}")
# Clean up original uploaded files
for file_info in files:
try:
if os.path.exists(file_info['path']):
os.remove(file_info['path'])
print(f"DEBUG: Removed uploaded file: {file_info['path']}")
except Exception as file_cleanup_error:
print(f"WARNING: Failed to remove uploaded file: {file_cleanup_error}")
except Exception as e:
job_queue[job_id]['progress'] = f'Error creating ZIP: {str(e)}'
job_queue[job_id]['status'] = 'error'
save_jobs() # Persist error status
print(f"DEBUG: ZIP creation failed: {str(e)}")
else:
job_queue[job_id]['progress'] = 'No files to archive - all files were skipped'
print(f"DEBUG: No successful files to archive")
with job_lock:
job_queue[job_id]['status'] = 'completed'
job_queue[job_id]['processed_files'] = processed_files
job_queue[job_id]['skipped'] = [(p[1], p[2]) for p in processed_files if p[0] == 'skipped']
save_jobs() # Persist completed job
print(f"DEBUG: Job {job_id} completed successfully. Processed: {len(processed_files)}, Skipped: {len(job_queue[job_id]['skipped'])}")
except Exception as e:
with job_lock:
job_queue[job_id]['status'] = 'error'
job_queue[job_id]['progress'] = f'Job failed with error: {str(e)}'
save_jobs() # Persist error status
print(f"Job {job_id} failed: {str(e)}") # Debug logging
finally:
# Clean up temporary directories
for temp_dir in temp_extract_dirs:
if os.path.exists(temp_dir):
try:
import shutil
shutil.rmtree(temp_dir)
print(f"DEBUG: Cleaned up temporary directory: {temp_dir}")
except Exception as cleanup_error:
print(f"ERROR: Failed to clean up {temp_dir}: {cleanup_error}")
@app.route('/login')
def login():
return render_template_string('''
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
<style>
body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 20px; text-align: center; }
.login-container { background-color: #fff; padding: 30px; border-radius: 10px; box-shadow: 0 0 10px rgba(0,0,0,0.1); display: inline-block; margin: 20px; }
h1 { color: #333; }
.login-options { display: flex; justify-content: space-around; flex-wrap: wrap; }
.login-option { margin: 10px; }
a { color: #007bff; text-decoration: none; padding: 15px 30px; background-color: #007bff; color: white; border-radius: 5px; display: inline-block; }
a:hover { background-color: #0056b3; }
form { margin-top: 20px; }
input[type="text"], input[type="password"] { padding: 10px; margin: 5px; width: 200px; border: 1px solid #ccc; border-radius: 5px; }
input[type="submit"] { background-color: #28a745; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; }
input[type="submit"]:hover { background-color: #218838; }
</style>
</head>
<body>
<img src="/static/trbc-logo-full.png" alt="Logo" width="200">
<h1>PDF Renamer Login</h1>
<div class="login-options">
<div class="login-option">
<h2>Microsoft Entra ID</h2>
<a href="/entra-login">Login with Microsoft</a>
</div>
<div class="login-option">
<h2>Admin Login</h2>
<form method="post" action="/admin-login">
<input type="text" name="username" placeholder="Username" required><br>
<input type="password" name="password" placeholder="Password" required><br>
<input type="submit" value="Admin Login">
</form>
</div>
</div>
</body>
</html>
''')
@app.route('/entra-login')
def entra_login():
# Check if Microsoft OAuth provider is available
if microsoft is None:
flash('Microsoft Entra ID authentication is not configured.', 'error')
return redirect(url_for('login'))
# Check if Microsoft Entra ID is configured
if TENANT_ID in ['your-tenant-id', 'YOUR_MICROSOFT_ENTRA_TENANT_ID'] or \
CLIENT_ID in ['your-client-id', 'YOUR_MICROSOFT_ENTRA_CLIENT_ID'] or \
CLIENT_SECRET in ['your-client-secret', 'YOUR_MICROSOFT_ENTRA_CLIENT_SECRET']:
flash('Microsoft Entra ID is not properly configured. Please check your environment variables.', 'error')
return redirect(url_for('login'))
try:
redirect_uri = url_for('auth', _external=True)
print(f"DEBUG: Initiating OAuth with redirect_uri: {redirect_uri}")
return microsoft.authorize_redirect(redirect_uri)
except Exception as e:
print(f"ERROR: Failed to initiate OAuth: {e}")
import traceback
print(f"Traceback: {traceback.format_exc()}")
flash('Failed to initiate Microsoft authentication. Please try again or use admin login.', 'error')
return redirect(url_for('login'))
@app.route('/admin-login', methods=['POST'])
def admin_login():
username = request.form.get('username')
password = request.form.get('password')
if username == ADMIN_USERNAME and password == ADMIN_PASSWORD:
session['admin'] = True
return redirect(url_for('upload_files'))
else:
return render_template_string('''
<!DOCTYPE html>
<html>
<head>
<title>Login Failed</title>
<style>
body { font-family: Arial, sans-serif; background-color: #f4f4f4; text-align: center; padding: 50px; }
.error { color: #dc3545; background-color: #f8d7da; padding: 20px; border-radius: 5px; border: 1px solid #f5c6cb; }
a { color: #007bff; text-decoration: none; }
</style>
</head>
<body>
<div class="error">
<h1>Login Failed</h1>
<p>Invalid username or password.</p>
<a href="/login">Try Again</a>
</div>
</body>
</html>
''')
@app.route('/auth')
def auth():
try:
print("DEBUG: Starting OAuth callback processing")
token = microsoft.authorize_access_token()
print(f"DEBUG: Got token: {token}")
# Get user info from the token's userinfo (already parsed by Authlib)
user_info = token.get('userinfo')
if not user_info:
print("ERROR: No userinfo in token")
flash('Failed to get user information from Microsoft.', 'error')
return redirect(url_for('login'))
print(f"DEBUG: Parsed user info: {user_info}")
# Store user information in session
session['user'] = {
'name': user_info.get('name', 'Unknown'),
'email': user_info.get('email') or user_info.get('preferred_username', 'unknown@example.com'),
'oid': user_info.get('oid'),
}
session.permanent = True
print(f"DEBUG: User logged in successfully: {session['user']}")
return redirect(url_for('upload_files'))
except Exception as e:
print(f"OAuth error: {e}") # Log the error for debugging
import traceback
print(f"Full traceback: {traceback.format_exc()}")
flash('Authentication failed. Please try again.', 'error')
return redirect(url_for('login'))
@app.route('/logout')
@login_required
def logout():
session.pop('user', None)
session.pop('admin', None)
return redirect(url_for('login'))
@app.route('/', methods=['GET', 'POST'])
@login_required
def upload_files():
if request.method == 'POST':
files = request.files.getlist('files')
if not files or all(not f.filename for f in files):
flash('No files selected.', 'error')
return redirect(url_for('upload_files'))
# Create job ID using timestamp format: YYYY-MM-DD_HH-MM-SS
job_id = datetime.now().strftime('%Y-%m-%d_%H-%M-%S')
job_queue[job_id] = {
'status': 'queued',
'files': [],
'zip_path': None,
'zip_name': None,
'processed_files': [],
'skipped': [],
'created_at': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'job_id': job_id # Store the job ID for reference
}
file_list = []
for file in files:
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
filepath = os.path.join(app.config['UPLOAD_FOLDER'], f"{job_id}_{filename}")
file.save(filepath)
file_list.append({'name': filename, 'path': filepath})
print(f"Uploaded file: {filename} -> {filepath}") # Debug logging
if not file_list:
flash('No valid files uploaded. Only PDF and ZIP files are allowed.', 'error')
job_queue.pop(job_id, None) # Remove empty job
return redirect(url_for('upload_files'))
job_queue[job_id]['files'] = file_list
print(f"Created job {job_id} with {len(file_list)} files") # Debug logging
# Save job to persistent storage
save_jobs()
# Start processing in background
def process_with_context():
with app.app_context():
process_job(job_id, file_list)
thread = threading.Thread(target=process_with_context)
thread.daemon = True
thread.start()
flash(f'Job created successfully. Processing {len(file_list)} files...', 'success')
return redirect(url_for('jobs'))
return render_template_string('''
<!DOCTYPE html>
<html>
<head>
<title>PDF Renamer</title>
<style>
body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 20px; text-align: center; }
img { margin-bottom: 20px; }
h1 { color: #333; }
form { background-color: #fff; padding: 20px; border-radius: 10px; box-shadow: 0 0 10px rgba(0,0,0,0.1); display: inline-block; }
input[type="file"] { margin: 10px 0; }
input[type="submit"] { background-color: #28a745; color: white; border: none; padding: 10px 20px; border-radius: 5px; cursor: pointer; }
input[type="submit"]:hover { background-color: #218838; }
.logout { position: absolute; top: 20px; right: 20px; background-color: #dc3545; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; }
.logout:hover { background-color: #c82333; }
.view-jobs-btn { background-color: #007bff; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; display: inline-block; margin: 10px 0; }
.view-jobs-btn:hover { background-color: #0056b3; }
</style>
</head>
<body>
<a href="/logout" class="logout">Logout</a>
<img src="/static/trbc-logo-full.png" alt="Logo" width="200">
<h1>Upload PDF Files or ZIP to Rename</h1>
<a href="/jobs" class="view-jobs-btn">View Jobs</a>
<br><br>
<form method="post" enctype="multipart/form-data">
<input type="file" name="files" multiple accept=".pdf,.zip">
<br><br>
<input type="submit" value="Process PDFs">
</form>
</body>
</html>
''')
@app.route('/jobs')
@login_required
def jobs():
return render_template_string('''
<!DOCTYPE html>
<html>
<head>
<title>Jobs</title>
<style>
body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 20px; text-align: center; }
img { margin-bottom: 20px; }
h1 { color: #333; }
.job { background-color: #fff; margin: 10px auto; padding: 15px; border-radius: 10px; box-shadow: 0 0 10px rgba(0,0,0,0.1); max-width: 800px; text-align: left; }
.status { padding: 5px 10px; border-radius: 5px; color: white; font-weight: bold; }
.status.queued { background-color: #ffc107; }
.status.processing { background-color: #17a2b8; }
.status.completed { background-color: #28a745; }
.status.error { background-color: #dc3545; }
.progress-text { color: #17a2b8; font-style: italic; }
.download-btn { background-color: #007bff; color: white; text-decoration: none; padding: 8px 16px; border-radius: 5px; margin-left: 10px; }
.download-btn:hover { background-color: #0056b3; }
.delete-btn { background-color: #dc3545; color: white; border: none; padding: 8px 16px; border-radius: 5px; margin-left: 10px; cursor: pointer; }
.delete-btn:hover { background-color: #c82333; }
.flash { padding: 10px; margin: 10px 0; border-radius: 5px; text-align: center; }
.flash.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.flash.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.logout { position: absolute; top: 20px; right: 20px; background-color: #dc3545; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; }
.logout:hover { background-color: #c82333; }
.nav-links { margin-bottom: 20px; }
.nav-links a { color: #007bff; text-decoration: none; padding: 10px 20px; background-color: #f8f9fa; border-radius: 5px; margin: 0 10px; }
.nav-links a:hover { background-color: #e9ecef; }
.nav-btn { background-color: #007bff; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; display: inline-block; margin: 0 10px; }
.nav-btn:hover { background-color: #0056b3; }
.zip-content { background-color: #f8f9fa; padding: 10px; margin: 5px 0; border-radius: 5px; border-left: 4px solid #007bff; }
.zip-toggle { cursor: pointer; background-color: #e9ecef; padding: 8px; margin: 5px 0; border-radius: 5px; border: 1px solid #dee2e6; }
.zip-toggle:hover { background-color: #dee2e6; }
.zip-details { display: none; margin-top: 10px; }
.skipped-section { background-color: #fff3cd; border: 1px solid #ffeaa7; border-radius: 5px; padding: 10px; margin: 10px 0; }
.skipped-toggle { cursor: pointer; background-color: #ffeaa7; padding: 8px; margin: 5px 0; border-radius: 5px; border: 1px solid #ffc107; font-weight: bold; color: #856404; }
.skipped-toggle:hover { background-color: #ffc107; }
.skipped-details { display: none; margin-top: 10px; }
ul { list-style-type: none; padding: 0; }
li { background-color: #ffffff; margin: 2px 0; padding: 5px; border-radius: 3px; }
</style>
</head>
<body>
<a href="/logout" class="logout">Logout</a>
<img src="/static/trbc-logo-full.png" alt="Logo" width="200">
<h1>Processing Jobs</h1>
<div class="nav-links" style="margin-bottom: 20px;">
<a href="/" class="nav-btn">← Upload Files</a>
<a href="/completed-jobs" class="nav-btn">View Completed Jobs Archive →</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% for job_id, job in jobs.items() %}
<div class="job">
<h3>Job {{ job_id[:8] }}</h3>
<p><strong>Status:</strong> <span class="status {{ job.status }}">{{ job.status.title() }}</span></p>
{% if job.status == 'processing' and job.progress %}
<p><strong>Progress:</strong> <span class="progress-text">{{ job.progress }}</span></p>
{% endif %}
<p><strong>Files:</strong> {{ job.files|length }}</p>
{% if job.zip_contents %}
<p><strong>ZIP Contents:</strong></p>
{% for zip_name, pdf_files in job.zip_contents.items() %}
<div class="zip-content">
<div class="zip-toggle" onclick="toggleZipDetails(this)">
<strong>{{ zip_name }}</strong> ({{ pdf_files|length }} files) ▶
</div>
<div class="zip-details">
<ul>
{% for pdf_file in pdf_files %}
<li>{{ pdf_file }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endfor %}
{% endif %}
{% if job.status == 'completed' %}
{% if job.zip_name %}
<p><a href="/download/{{ job.zip_name }}" class="download-btn">Download ZIP</a>
<form method="post" action="/delete/{{ job_id }}" style="display: inline;">
<button type="submit" class="delete-btn" onclick="return confirm('Are you sure you want to delete this job?')">Delete Job</button>
</form></p>
{% else %}
<p><em>No files were successfully processed to create a ZIP archive.</em>
<form method="post" action="/delete/{{ job_id }}" style="display: inline;">
<button type="submit" class="delete-btn" onclick="return confirm('Are you sure you want to delete this job?')">Delete Job</button>
</form></p>
{% endif %}
{% if job.skipped %}
<div class="skipped-section">
<div class="skipped-toggle" onclick="toggleSkippedDetails(this)">
⚠️ Skipped Files ({{ job.skipped|length }}) ▶
</div>
<div class="skipped-details">
<ul>
{% for skip_info in job.skipped %}
<li><strong>{{ skip_info[0] }}</strong>: {{ skip_info[1] }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
{% endif %}
</div>
{% endfor %}
{% if not jobs %}
<p>No jobs yet. <a href="/">Upload some files</a> to get started.</p>
{% endif %}
<script>
// Track previous job statuses to detect completion
let previousJobStatuses = {};
// Collapsible section functions
function toggleZipDetails(element) {
const details = element.nextElementSibling;
const isVisible = details.style.display === 'block';
details.style.display = isVisible ? 'none' : 'block';
element.innerHTML = element.innerHTML.replace(isVisible ? ' ▼' : ' ▶', isVisible ? ' ▶' : ' ▼');
}
function toggleSkippedDetails(element) {
const details = element.nextElementSibling;
const isVisible = details.style.display === 'block';
details.style.display = isVisible ? 'none' : 'block';
element.innerHTML = element.innerHTML.replace(isVisible ? ' ▼' : ' ▶', isVisible ? ' ▶' : ' ▼');
}
function updateJobs() {
fetch('/api/jobs', { credentials: 'same-origin' })
.then(response => response.json())
.then(data => {
console.log('Job update received:', data);
const jobs = Object.entries(data);
// Check for job completions
let hasNewCompletion = false;
if (jobs.length > 0) {
jobs.forEach(([jobId, job]) => {
console.log(`Job ${jobId}: status=${job.status}, progress=${job.progress || 'none'}`);
// Check if job just completed
if (job.status === 'completed' && previousJobStatuses[jobId] === 'processing') {
hasNewCompletion = true;
console.log(`Job ${jobId} just completed!`);
}
// Update previous status
previousJobStatuses[jobId] = job.status;
});
}
// Redirect to completed jobs if a job just finished
if (hasNewCompletion) {
console.log('Redirecting to completed jobs page...');
setTimeout(() => {
window.location.href = '/completed-jobs';
}, 500); // Small delay to show completion briefly
return; // Stop processing to avoid UI updates during redirect
}
const jobsContainer = document.querySelector('h1').parentNode;
const existingJobs = jobsContainer.querySelectorAll('.job');
const existingNoJobsMsg = jobsContainer.querySelectorAll('p');
// Remove existing job elements
existingJobs.forEach(job => job.remove());
// Remove existing "No jobs yet" messages
existingNoJobsMsg.forEach(p => {
if (p.textContent.includes('No jobs yet')) {
p.remove();
}
});
// Add updated job elements
const jobEntries = Object.entries(data);
if (jobEntries.length > 0) {
jobEntries.forEach(([jobId, job]) => {
const jobDiv = document.createElement('div');
jobDiv.className = 'job';
jobDiv.innerHTML = `
<h3>Job ${jobId.substring(0, 8)}</h3>
<p><strong>Status:</strong> <span class="status ${job.status}">${job.status.charAt(0).toUpperCase() + job.status.slice(1)}</span></p>
${job.status === 'processing' && job.progress ? `<p><strong>Progress:</strong> <span class="progress-text">${job.progress}</span></p>` : ''}
<p><strong>Files:</strong> ${job.files ? job.files.length : 0}</p>
${job.zip_contents ? `
<p><strong>ZIP Contents:</strong></p>
${Object.entries(job.zip_contents).map(([zipName, pdfFiles]) => `
<div class="zip-content">
<div class="zip-toggle" onclick="toggleZipDetails(this)">
<strong>${zipName}</strong> (${pdfFiles.length} files) ▶
</div>
<div class="zip-details">
<ul>
${pdfFiles.map(pdfFile => `<li>${pdfFile}</li>`).join('')}
</ul>
</div>
</div>
`).join('')}
` : ''}
${job.status === 'completed' ? (job.zip_name ? `
<p><a href="/download/${job.zip_name}" class="download-btn">Download ZIP</a>
<form method="post" action="/delete/${jobId}" style="display: inline;">
<button type="submit" class="delete-btn" onclick="return confirm('Are you sure you want to delete this job?')">Delete Job</button>
</form></p>
` : `
<p><em>No files were successfully processed to create a ZIP archive.</em>
<form method="post" action="/delete/${jobId}" style="display: inline;">
<button type="submit" class="delete-btn" onclick="return confirm('Are you sure you want to delete this job?')">Delete Job</button>
</form></p>
` : ''}
${job.skipped ? `
<div class="skipped-section">
<div class="skipped-toggle" onclick="toggleSkippedDetails(this)">
⚠️ Skipped Files (${job.skipped.length}) ▶
</div>
<div class="skipped-details">
<ul>
${job.skipped.map(skipInfo => `<li><strong>${skipInfo[0]}</strong>: ${skipInfo[1]}</li>`).join('')}
</ul>
</div>
</div>
` : ''}
`;
jobsContainer.insertBefore(jobDiv, jobsContainer.querySelector('script') || jobsContainer.lastElementChild);
});
} else {
const noJobsMsg = document.createElement('p');
noJobsMsg.innerHTML = 'No jobs yet. <a href="/">Upload some files</a> to get started.';
jobsContainer.insertBefore(noJobsMsg, jobsContainer.querySelector('script') || jobsContainer.lastElementChild);
}
})
.catch(error => console.error('Error updating jobs:', error));
}
// Update jobs every 1 second
setInterval(updateJobs, 1000);
// Initialize previous job statuses
updateJobs(); // Run once immediately to populate initial statuses
</script>
</body>
</html>
''', jobs=job_queue)
@app.route('/api/jobs')
@login_required
def api_jobs():
return jsonify(job_queue)
@app.route('/download/<filename>')
@login_required
def download(filename):
"""Download a completed job's ZIP file with path traversal protection"""
# Prevent path traversal attacks
if '..' in filename or '/' in filename or '\\' in filename:
return "Invalid filename", 400
# Security check: only allow downloading files that belong to completed jobs
with job_lock:
for job_id, job in job_queue.items():
if job.get('status') == 'completed' and job.get('zip_name') == filename:
# Use ARCHIVE_FOLDER instead of UPLOAD_FOLDER
file_path = os.path.join(app.config['ARCHIVE_FOLDER'], filename)
# Verify the resolved path is within ARCHIVE_FOLDER
resolved_path = os.path.abspath(file_path)
archive_folder = os.path.abspath(app.config['ARCHIVE_FOLDER'])
if not resolved_path.startswith(archive_folder):
return "Access denied", 403
if os.path.exists(resolved_path):
return send_from_directory(app.config['ARCHIVE_FOLDER'], filename, as_attachment=True)
return "File not found or access denied", 404
@app.route('/delete/<job_id>', methods=['POST'])
@login_required
def delete_job(job_id):
try:
with job_lock:
if job_id in job_queue:
job = job_queue[job_id]
# Only allow deletion of completed jobs
if job.get('status') == 'completed':
# Clean up the ZIP file from archives folder
if job.get('zip_name'):
zip_path = os.path.join(app.config['ARCHIVE_FOLDER'], job['zip_name'])
if os.path.exists(zip_path):
try:
os.remove(zip_path)
print(f"DEBUG: Deleted archive file: {zip_path}")
except Exception as e:
print(f"ERROR: Failed to delete archive: {e}")
flash('Job deleted but archive file could not be removed.', 'warning')
# Clean up any remaining uploaded files
for file_info in job.get('files', []):
if 'path' in file_info and os.path.exists(file_info['path']):
try:
os.remove(file_info['path'])
print(f"DEBUG: Deleted uploaded file: {file_info['path']}")
except Exception as e:
print(f"ERROR: Failed to delete uploaded file: {e}")
# Remove the job from the queue
del job_queue[job_id]
save_jobs() # Persist job deletion
flash('Job deleted successfully.', 'success')
else:
flash('Only completed jobs can be deleted.', 'error')
else:
flash('Job not found.', 'error')
except Exception as e:
print(f"ERROR: Exception in delete_job: {e}")
import traceback
traceback.print_exc()
flash(f'Error deleting job: {str(e)}', 'error')
return redirect('/jobs')
@app.route('/completed-jobs')
@login_required
def completed_jobs():
# Show ALL jobs with their statuses for debugging
all_jobs = job_queue
return render_template_string('''
<!DOCTYPE html>
<html>
<head>
<title>All Jobs Status</title>
<style>
body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 20px; text-align: center; }
img { margin-bottom: 20px; }
h1 { color: #333; }
.job { background-color: #fff; margin: 10px auto; padding: 15px; border-radius: 10px; box-shadow: 0 0 10px rgba(0,0,0,0.1); max-width: 1000px; text-align: left; }
.status { padding: 5px 10px; border-radius: 5px; color: white; font-weight: bold; }
.status.completed { background-color: #28a745; }
.status.processing { background-color: #17a2b8; }
.status.error { background-color: #dc3545; }
.progress-text { color: #17a2b8; font-style: italic; margin-left: 10px; }
.download-btn { background-color: #007bff; color: white; text-decoration: none; padding: 8px 16px; border-radius: 5px; margin: 5px; display: inline-block; }
.download-btn:hover { background-color: #0056b3; }
.delete-btn { background-color: #dc3545; color: white; border: none; padding: 8px 16px; border-radius: 5px; margin: 5px; cursor: pointer; }
.delete-btn:hover { background-color: #c82333; }
.flash { padding: 10px; margin: 10px 0; border-radius: 5px; text-align: center; }
.flash.success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.flash.error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.logout { position: absolute; top: 20px; right: 20px; background-color: #dc3545; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; }
.logout:hover { background-color: #c82333; }
.nav-links { margin-bottom: 20px; }
.nav-links a { color: #007bff; text-decoration: none; padding: 10px 20px; background-color: #f8f9fa; border-radius: 5px; margin: 0 10px; }
.nav-links a:hover { background-color: #e9ecef; }
.nav-btn { background-color: #007bff; color: white; text-decoration: none; padding: 10px 20px; border-radius: 5px; display: inline-block; margin: 0 10px; }
.nav-btn:hover { background-color: #0056b3; }
.job-stats { background-color: #e9ecef; padding: 10px; border-radius: 5px; margin-bottom: 15px; }
.file-count { color: #17a2b8; font-weight: bold; }
.zip-content { background-color: #f8f9fa; padding: 10px; margin: 5px 0; border-radius: 5px; border-left: 4px solid #007bff; }
.zip-toggle { cursor: pointer; background-color: #e9ecef; padding: 8px; margin: 5px 0; border-radius: 5px; border: 1px solid #dee2e6; }
.zip-toggle:hover { background-color: #dee2e6; }
.zip-details { display: none; margin-top: 10px; }
.skipped-section { background-color: #fff3cd; border: 1px solid #ffeaa7; border-radius: 5px; padding: 10px; margin: 10px 0; }
.skipped-toggle { cursor: pointer; background-color: #ffeaa7; padding: 8px; margin: 5px 0; border-radius: 5px; border: 1px solid #ffc107; font-weight: bold; color: #856404; }
.skipped-toggle:hover { background-color: #ffc107; }
.skipped-details { display: none; margin-top: 10px; }
ul { list-style-type: none; padding: 0; }
li { background-color: #ffffff; margin: 2px 0; padding: 5px; border-radius: 3px; }
</style>
</head>
<body>
<a href="/logout" class="logout">Logout</a>
<img src="/static/trbc-logo-full.png" alt="Logo" width="200">
<h1>All Jobs Status</h1>
<div class="nav-links">
<a href="/" class="nav-btn">← Upload Files</a>
<a href="/jobs" class="nav-btn">View All Jobs →</a>
</div>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash {{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% for job_id, job in all_jobs.items() %}
<div class="job">
<h3>Job {{ job_id[:8] }} - {{ job.created_at if job.created_at else 'Unknown Date' }}</h3>
<div class="job-stats">
<span class="status {{ job.status }}">{{ job.status.title() }}</span>
<span class="file-count">{{ job.processed_files|selectattr('0', 'equalto', 'success')|list|length }} files processed</span>
{% if job.progress %}
<span class="progress-text">Progress: {{ job.progress }}</span>
{% endif %}
{% if job.skipped %}
<span style="color: #856404; font-weight: bold;">{{ job.skipped|length }} files skipped</span>
{% endif %}
</div>
{% if job.zip_contents %}
<p><strong>Original ZIP Contents:</strong></p>
{% for zip_name, pdf_files in job.zip_contents.items() %}
<div class="zip-content">
<div class="zip-toggle" onclick="toggleZipDetails(this)">
<strong>{{ zip_name }}</strong> ({{ pdf_files|length }} files) ▶
</div>
<div class="zip-details">
<ul>
{% for pdf_file in pdf_files %}
<li>{{ pdf_file }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endfor %}
{% endif %}
{% if job.zip_name %}
<p><strong>Download:</strong> <a href="/download/{{ job.zip_name }}" class="download-btn">📁 Download Organized ZIP ({{ job.zip_name }})</a></p>
{% endif %}
{% if job.skipped %}
<div class="skipped-section">
<div class="skipped-toggle" onclick="toggleSkippedDetails(this)">
⚠️ Skipped Files ({{ job.skipped|length }}) ▶
</div>
<div class="skipped-details">
<ul>
{% for skip_info in job.skipped %}
<li><strong>{{ skip_info[0] }}</strong>: {{ skip_info[1] }}</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<form method="post" action="/delete/{{ job_id }}" style="display: inline;">
<button type="submit" class="delete-btn" onclick="return confirm('Are you sure you want to permanently delete this completed job and its files?')">🗑️ Delete Job</button>
</form>
</div>
{% endfor %}
{% if not jobs %}
<p>No completed jobs yet. <a href="/">Upload some files</a> to get started.</p>
{% endif %}
<script>
// Collapsible section functions
function toggleZipDetails(element) {
const details = element.nextElementSibling;
const isVisible = details.style.display === 'block';
details.style.display = isVisible ? 'none' : 'block';
element.innerHTML = element.innerHTML.replace(isVisible ? ' ▼' : ' ▶', isVisible ? ' ▶' : ' ▼');
}
function toggleSkippedDetails(element) {
const details = element.nextElementSibling;
const isVisible = details.style.display === 'block';
details.style.display = isVisible ? 'none' : 'block';
element.innerHTML = element.innerHTML.replace(isVisible ? ' ▼' : ' ▶', isVisible ? ' ▶' : ' ▼');
}
</script>
</body>
</html>
''', all_jobs=all_jobs)
if __name__ == '__main__':
app.run(debug=True, port=3000)