Implement Time Attendance import multiple files
This commit is contained in:
@@ -8177,35 +8177,76 @@ def import_time_attendance():
|
|||||||
print(f"✅ Temp path exists: {os.path.exists(temp_path)}")
|
print(f"✅ Temp path exists: {os.path.exists(temp_path)}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# Normal file upload flow
|
# Normal file upload flow - now supports multiple files
|
||||||
if 'file' not in request.files:
|
if 'files' not in request.files:
|
||||||
flash('No file uploaded.', 'error')
|
flash('No files uploaded.', 'error')
|
||||||
return redirect(request.url)
|
return redirect(request.url)
|
||||||
|
|
||||||
file = request.files['file']
|
files = request.files.getlist('files')
|
||||||
if file.filename == '':
|
if not files or len(files) == 0:
|
||||||
flash('No file selected.', 'error')
|
flash('No files selected.', 'error')
|
||||||
return redirect(request.url)
|
return redirect(request.url)
|
||||||
|
|
||||||
# Validate file extension
|
# Validate all files and save them temporarily
|
||||||
if not file.filename.lower().endswith(('.xlsx', '.xls')):
|
temp_paths = []
|
||||||
flash('Please upload an Excel file (.xlsx or .xls).', 'error')
|
filenames = []
|
||||||
|
|
||||||
|
for file in files:
|
||||||
|
if file.filename == '':
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Validate file extension
|
||||||
|
if not file.filename.lower().endswith(('.xlsx', '.xls')):
|
||||||
|
flash(f'Invalid file format: {file.filename}. Please upload only Excel files (.xlsx or .xls).', 'error')
|
||||||
|
# Clean up already saved files
|
||||||
|
for saved_path in temp_paths:
|
||||||
|
if os.path.exists(saved_path):
|
||||||
|
os.remove(saved_path)
|
||||||
|
return redirect(request.url)
|
||||||
|
|
||||||
|
# Save uploaded file temporarily
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'),
|
||||||
|
f"temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}")
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
|
||||||
|
file.save(temp_path)
|
||||||
|
|
||||||
|
temp_paths.append(temp_path)
|
||||||
|
filenames.append(filename)
|
||||||
|
|
||||||
|
print(f"✅ Uploaded file {len(temp_paths)}: {filename}")
|
||||||
|
print(f"✅ Saved to: {temp_path}")
|
||||||
|
|
||||||
|
if len(temp_paths) == 0:
|
||||||
|
flash('No valid files selected.', 'error')
|
||||||
return redirect(request.url)
|
return redirect(request.url)
|
||||||
|
|
||||||
# Save uploaded file temporarily
|
# Store file paths in session for duplicate/invalid review
|
||||||
filename = secure_filename(file.filename)
|
session['pending_import_file'] = temp_paths[0] if len(temp_paths) == 1 else temp_paths
|
||||||
temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'),
|
session['pending_import_filename'] = filenames[0] if len(filenames) == 1 else filenames
|
||||||
f"temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}")
|
session['pending_import_files_multiple'] = len(temp_paths) > 1
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
|
temp_path = temp_paths[0] if len(temp_paths) == 1 else temp_paths
|
||||||
file.save(temp_path)
|
filename = filenames[0] if len(filenames) == 1 else ', '.join(filenames)
|
||||||
|
|
||||||
# Store file path in session for duplicate/invalid review
|
print(f"✅ Total files uploaded: {len(temp_paths)}")
|
||||||
session['pending_import_file'] = temp_path
|
|
||||||
session['pending_import_filename'] = filename
|
# Determine if we're processing multiple files
|
||||||
|
is_multiple_files = session.get('pending_import_files_multiple', False)
|
||||||
print(f"✅ Uploaded new file: {filename}")
|
files_to_process = []
|
||||||
print(f"✅ Saved to: {temp_path}")
|
|
||||||
|
if is_multiple_files:
|
||||||
|
# Multiple files mode
|
||||||
|
if isinstance(temp_path, list):
|
||||||
|
files_to_process = list(zip(temp_path, filename.split(', ') if isinstance(filename, str) else filename))
|
||||||
|
else:
|
||||||
|
files_to_process = [(temp_path, filename)]
|
||||||
|
else:
|
||||||
|
# Single file mode (existing behavior)
|
||||||
|
files_to_process = [(temp_path, filename)]
|
||||||
|
|
||||||
|
print(f"📁 Processing {len(files_to_process)} file(s)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import_service = TimeAttendanceImportService(db, logger_handler)
|
import_service = TimeAttendanceImportService(db, logger_handler)
|
||||||
@@ -8223,6 +8264,141 @@ def import_time_attendance():
|
|||||||
print(f" Analyze invalid: {analyze_invalid}")
|
print(f" Analyze invalid: {analyze_invalid}")
|
||||||
print(f" Coming from invalid review: {coming_from_invalid_review}")
|
print(f" Coming from invalid review: {coming_from_invalid_review}")
|
||||||
|
|
||||||
|
# Store combined results for multiple files
|
||||||
|
all_results = {
|
||||||
|
'total_files': len(files_to_process),
|
||||||
|
'successful_files': 0,
|
||||||
|
'failed_files': 0,
|
||||||
|
'total_imported': 0,
|
||||||
|
'total_duplicates': 0,
|
||||||
|
'total_failed': 0,
|
||||||
|
'file_results': [],
|
||||||
|
'errors': [],
|
||||||
|
'warnings': []
|
||||||
|
}
|
||||||
|
|
||||||
|
# Process each file
|
||||||
|
for file_index, (current_temp_path, current_filename) in enumerate(files_to_process, 1):
|
||||||
|
print(f"\n📄 Processing file {file_index}/{len(files_to_process)}: {current_filename}")
|
||||||
|
|
||||||
|
import_result = None # Initialize to prevent reference errors
|
||||||
|
|
||||||
|
try:
|
||||||
|
# For multiple files, skip review screens and import directly
|
||||||
|
if is_multiple_files:
|
||||||
|
print(f" 📦 Batch mode: processing directly without review screens")
|
||||||
|
|
||||||
|
# Validate the file first
|
||||||
|
validation_result = import_service.validate_excel_file(current_temp_path)
|
||||||
|
|
||||||
|
if not validation_result['valid']:
|
||||||
|
raise Exception(f"Validation failed: {'; '.join(validation_result['errors'])}")
|
||||||
|
|
||||||
|
# Get import settings
|
||||||
|
project_id = request.form.get('project_id')
|
||||||
|
project_id = int(project_id) if project_id and project_id != '' else None
|
||||||
|
import_source = request.form.get('import_source', f"Batch Import - {current_filename}")
|
||||||
|
|
||||||
|
# Import the file (always skip duplicates in batch mode)
|
||||||
|
import_result = import_service.import_from_excel(
|
||||||
|
current_temp_path,
|
||||||
|
created_by=session['user_id'],
|
||||||
|
import_source=import_source,
|
||||||
|
skip_duplicates=True, # Always skip duplicates in batch mode
|
||||||
|
force_import_hashes=set(),
|
||||||
|
project_id=project_id
|
||||||
|
)
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Single file - use existing review workflow logic below
|
||||||
|
# This continues to the existing code after the loop
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Accumulate results if import was performed
|
||||||
|
if import_result and import_result.get('success'):
|
||||||
|
all_results['successful_files'] += 1
|
||||||
|
all_results['total_imported'] += import_result.get('imported_records', 0)
|
||||||
|
all_results['total_duplicates'] += import_result.get('duplicate_records', 0)
|
||||||
|
all_results['file_results'].append({
|
||||||
|
'filename': current_filename,
|
||||||
|
'status': 'success',
|
||||||
|
'imported': import_result.get('imported_records', 0),
|
||||||
|
'batch_id': import_result.get('batch_id', '')
|
||||||
|
})
|
||||||
|
print(f" ✅ Imported {import_result.get('imported_records', 0)} records")
|
||||||
|
elif import_result:
|
||||||
|
# Import ran but failed
|
||||||
|
all_results['failed_files'] += 1
|
||||||
|
all_results['total_failed'] += import_result.get('failed_records', 0)
|
||||||
|
all_results['errors'].append(f"{current_filename}: Import failed")
|
||||||
|
all_results['file_results'].append({
|
||||||
|
'filename': current_filename,
|
||||||
|
'status': 'failed',
|
||||||
|
'error': 'Import returned unsuccessful status'
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as file_error:
|
||||||
|
print(f"❌ Error processing file {current_filename}: {file_error}")
|
||||||
|
logger_handler.logger.error(f"Error processing file {current_filename}: {file_error}")
|
||||||
|
all_results['failed_files'] += 1
|
||||||
|
all_results['errors'].append(f"{current_filename}: {str(file_error)}")
|
||||||
|
all_results['file_results'].append({
|
||||||
|
'filename': current_filename,
|
||||||
|
'status': 'failed',
|
||||||
|
'error': str(file_error)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Cleanup individual file (only for multiple file mode, single file cleanup happens later)
|
||||||
|
if is_multiple_files and os.path.exists(current_temp_path):
|
||||||
|
try:
|
||||||
|
os.remove(current_temp_path)
|
||||||
|
print(f" 🗑️ Cleaned up temp file")
|
||||||
|
except Exception as cleanup_error:
|
||||||
|
print(f" ⚠️ Failed to cleanup temp file: {cleanup_error}")
|
||||||
|
|
||||||
|
# After processing all files
|
||||||
|
if is_multiple_files:
|
||||||
|
# Log the batch import activity
|
||||||
|
logger_handler.logger.info(
|
||||||
|
f"Batch Import: User {session.get('username', 'unknown')} imported time attendance data from {len(files_to_process)} files - "
|
||||||
|
f"Successful: {all_results['successful_files']}/{all_results['total_files']}, "
|
||||||
|
f"Total imported: {all_results['total_imported']}, "
|
||||||
|
f"Duplicates: {all_results['total_duplicates']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Show combined results
|
||||||
|
if all_results['successful_files'] > 0:
|
||||||
|
flash(f"✅ Successfully imported {all_results['total_imported']} records from {all_results['successful_files']}/{all_results['total_files']} files.", 'success')
|
||||||
|
|
||||||
|
if all_results['total_duplicates'] > 0:
|
||||||
|
flash(f"ℹ️ Skipped {all_results['total_duplicates']} duplicate records across all files.", 'info')
|
||||||
|
|
||||||
|
if all_results['failed_files'] > 0:
|
||||||
|
flash(f"❌ {all_results['failed_files']} file(s) failed to import.", 'error')
|
||||||
|
|
||||||
|
# Show first few error details
|
||||||
|
for error in all_results['errors'][:3]:
|
||||||
|
flash(f"Error: {error}", 'error')
|
||||||
|
|
||||||
|
if len(all_results['errors']) > 3:
|
||||||
|
flash(f"...and {len(all_results['errors']) - 3} more errors", 'error')
|
||||||
|
|
||||||
|
# Clear session
|
||||||
|
session.pop('pending_import_file', None)
|
||||||
|
session.pop('pending_import_filename', None)
|
||||||
|
session.pop('pending_import_files_multiple', None)
|
||||||
|
|
||||||
|
print(f"\n📊 Batch Import Summary:")
|
||||||
|
print(f" Total files: {all_results['total_files']}")
|
||||||
|
print(f" Successful: {all_results['successful_files']}")
|
||||||
|
print(f" Failed: {all_results['failed_files']}")
|
||||||
|
print(f" Total imported: {all_results['total_imported']}")
|
||||||
|
print(f" Total duplicates: {all_results['total_duplicates']}")
|
||||||
|
|
||||||
|
return redirect(url_for('time_attendance_dashboard'))
|
||||||
|
|
||||||
# Check if this is coming from duplicate review
|
# Check if this is coming from duplicate review
|
||||||
force_import_hashes = request.form.getlist('force_import_hashes[]')
|
force_import_hashes = request.form.getlist('force_import_hashes[]')
|
||||||
|
|
||||||
|
|||||||
@@ -364,19 +364,21 @@
|
|||||||
<i class="fas fa-cloud-upload-alt"></i>
|
<i class="fas fa-cloud-upload-alt"></i>
|
||||||
</div>
|
</div>
|
||||||
<div class="upload-text">
|
<div class="upload-text">
|
||||||
<h3>Drag & Drop Excel File</h3>
|
<h3>Drag & Drop Excel Files</h3>
|
||||||
<p>Or click to browse and select an Excel file (.xlsx, .xls)</p>
|
<p>Or click to browse and select Excel files (.xlsx, .xls). Multiple files supported.</p>
|
||||||
<div class="file-info" id="fileInfo" style="display: none;">
|
<div class="file-info" id="fileInfo" style="display: none;">
|
||||||
<i class="fas fa-file-excel"></i>
|
<i class="fas fa-file-excel"></i>
|
||||||
<span class="file-name"></span>
|
<span class="file-name"></span>
|
||||||
<span class="file-size"></span>
|
<span class="file-size"></span>
|
||||||
|
<span class="file-count"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<input type="file"
|
<input type="file"
|
||||||
id="fileInput"
|
id="fileInput"
|
||||||
name="file"
|
name="files"
|
||||||
accept=".xlsx,.xls"
|
accept=".xlsx,.xls"
|
||||||
class="file-input"
|
class="file-input"
|
||||||
|
multiple
|
||||||
required>
|
required>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -721,30 +723,56 @@ fileUploadArea.addEventListener('click', () => {
|
|||||||
fileInput.addEventListener('change', handleFileSelect);
|
fileInput.addEventListener('change', handleFileSelect);
|
||||||
|
|
||||||
function handleFileSelect() {
|
function handleFileSelect() {
|
||||||
const file = fileInput.files[0];
|
const files = fileInput.files;
|
||||||
if (file) {
|
if (files.length > 0) {
|
||||||
const fileName = file.name;
|
// Validate all files
|
||||||
const fileSize = (file.size / 1024 / 1024).toFixed(2) + ' MB';
|
let invalidFiles = [];
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
fileInfo.querySelector('.file-name').textContent = fileName;
|
const fileName = files[i].name;
|
||||||
fileInfo.querySelector('.file-size').textContent = fileSize;
|
if (!fileName.toLowerCase().endsWith('.xlsx') && !fileName.toLowerCase().endsWith('.xls')) {
|
||||||
fileInfo.style.display = 'flex';
|
invalidFiles.push(fileName);
|
||||||
|
}
|
||||||
submitBtn.disabled = false;
|
|
||||||
|
|
||||||
// Validate file extension
|
|
||||||
if (!fileName.toLowerCase().endsWith('.xlsx') && !fileName.toLowerCase().endsWith('.xls')) {
|
|
||||||
alert('Please select an Excel file (.xlsx or .xls)');
|
|
||||||
resetForm();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (invalidFiles.length > 0) {
|
||||||
|
alert('Invalid file format detected:\n' + invalidFiles.join('\n') + '\n\nPlease select only Excel files (.xlsx or .xls)');
|
||||||
|
resetForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate total size
|
||||||
|
let totalSize = 0;
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
totalSize += files[i].size;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display file info
|
||||||
|
if (files.length === 1) {
|
||||||
|
fileInfo.querySelector('.file-name').textContent = files[0].name;
|
||||||
|
fileInfo.querySelector('.file-size').textContent = (totalSize / 1024 / 1024).toFixed(2) + ' MB';
|
||||||
|
fileInfo.querySelector('.file-count').textContent = '';
|
||||||
|
} else {
|
||||||
|
fileInfo.querySelector('.file-name').textContent = files.length + ' files selected';
|
||||||
|
fileInfo.querySelector('.file-size').textContent = (totalSize / 1024 / 1024).toFixed(2) + ' MB total';
|
||||||
|
const fileNames = Array.from(files).map(f => f.name).join(', ');
|
||||||
|
fileInfo.querySelector('.file-count').textContent = '(' + fileNames + ')';
|
||||||
|
fileInfo.querySelector('.file-count').style.fontSize = '0.75rem';
|
||||||
|
fileInfo.querySelector('.file-count').style.color = '#718096';
|
||||||
|
fileInfo.querySelector('.file-count').style.display = 'block';
|
||||||
|
fileInfo.querySelector('.file-count').style.marginTop = '0.5rem';
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInfo.style.display = 'flex';
|
||||||
|
fileInfo.style.flexDirection = 'column';
|
||||||
|
submitBtn.disabled = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form submission
|
// Form submission
|
||||||
importForm.addEventListener('submit', (e) => {
|
importForm.addEventListener('submit', (e) => {
|
||||||
if (!fileInput.files[0]) {
|
if (fileInput.files.length === 0) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
alert('Please select a file to import');
|
alert('Please select at least one file to import');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,7 +789,9 @@ importForm.addEventListener('submit', (e) => {
|
|||||||
// Show progress indicator
|
// Show progress indicator
|
||||||
progressIndicator.classList.add('active');
|
progressIndicator.classList.add('active');
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...';
|
const fileCount = fileInput.files.length;
|
||||||
|
const fileText = fileCount === 1 ? 'file' : 'files';
|
||||||
|
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing ' + fileCount + ' ' + fileText + '...';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset border color when project is selected
|
// Reset border color when project is selected
|
||||||
|
|||||||
@@ -139,7 +139,12 @@
|
|||||||
<i class="fas fa-cloud-upload-alt"></i>
|
<i class="fas fa-cloud-upload-alt"></i>
|
||||||
Importing Data
|
Importing Data
|
||||||
</h1>
|
</h1>
|
||||||
|
{% if filename is string and ',' in filename %}
|
||||||
|
<p>Files: <strong>{{ filename.split(', ')|length }} files</strong></p>
|
||||||
|
<p style="font-size: 0.875rem; color: #718096; margin-top: 0.25rem;">{{ filename }}</p>
|
||||||
|
{% else %}
|
||||||
<p>File: <strong>{{ filename }}</strong></p>
|
<p>File: <strong>{{ filename }}</strong></p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Progress Circle -->
|
<!-- Progress Circle -->
|
||||||
|
|||||||
Reference in New Issue
Block a user