Feb25 2026: updated import progress bar and fixed big file import fail

This commit is contained in:
2026-02-25 12:50:34 -05:00
parent 56630658c6
commit b96a305033
3 changed files with 543 additions and 100 deletions
+238
View File
@@ -8716,6 +8716,244 @@ def analyze_import_invalid():
'message': f'Analysis failed: {str(e)}' 'message': f'Analysis failed: {str(e)}'
}), 500 }), 500
# ---------------------------------------------------------------------------
# Time Attendance Import — SSE progress streaming (disk-based, multi-worker safe)
#
# Design: progress state is written to a small JSON file on disk so that any
# gunicorn worker process can read it. No shared in-memory state is required.
# The /stream endpoint runs the import itself (synchronously inside the SSE
# generator) while writing progress to the file and yielding events to the
# browser — compatible with gunicorn gevent workers.
# ---------------------------------------------------------------------------
def _progress_file_path(job_id: str) -> str:
"""Return the path for the on-disk progress file for a given job_id."""
upload_dir = app.config.get('UPLOAD_FOLDER', '/tmp')
os.makedirs(upload_dir, exist_ok=True)
return os.path.join(upload_dir, f"import_progress_{job_id}.json")
def _write_progress(job_id: str, event: dict) -> None:
"""Atomically write the latest progress event to disk."""
path = _progress_file_path(job_id)
try:
tmp = path + '.tmp'
with open(tmp, 'w') as f:
json.dump(event, f)
os.replace(tmp, path)
except Exception:
pass # Best-effort; import will continue regardless
@app.route('/time-attendance/import/start', methods=['POST'])
@login_required
def start_import_job():
"""
Validates the uploaded file, saves it to disk, stores import options in a
progress file, then returns a job_id. The actual import runs inside the
SSE stream endpoint so no background thread or shared memory is needed.
"""
try:
if 'files' not in request.files:
return jsonify({'success': False, 'error': 'No file uploaded.'}), 400
files = request.files.getlist('files')
if not files or files[0].filename == '':
return jsonify({'success': False, 'error': 'No file selected.'}), 400
file = files[0]
if not file.filename.lower().endswith(('.xlsx', '.xls')):
return jsonify({'success': False, 'error': 'Invalid file format.'}), 400
filename = secure_filename(file.filename)
upload_dir = app.config.get('UPLOAD_FOLDER', '/tmp')
os.makedirs(upload_dir, exist_ok=True)
job_id = str(uuid.uuid4())
temp_path = os.path.join(upload_dir,
f"stream_{job_id}_{filename}")
file.save(temp_path)
# Store import options alongside the file so the stream endpoint can
# read them without depending on session or shared memory.
job_meta = {
'type': 'pending',
'temp_path': temp_path,
'filename': filename,
'skip_duplicates': request.form.get('skip_duplicates', 'true').lower() == 'true',
'project_id': int(request.form.get('project_id')) if request.form.get('project_id') else None,
'import_source': request.form.get('import_source', f"Manual Import - {filename}"),
'created_by': session['user_id'],
'username': session.get('username', 'unknown'),
}
_write_progress(job_id, job_meta)
logger_handler.logger.info(
f"User {job_meta['username']} queued time attendance import job {job_id} for file {filename}"
)
return jsonify({'success': True, 'job_id': job_id})
except Exception as e:
logger_handler.logger.error(f"Error queuing import job: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/time-attendance/import/stream/<job_id>')
@login_required
def stream_import_progress(job_id):
"""
SSE endpoint runs the import synchronously while streaming progress to
the browser. Works across multiple gunicorn workers because all state is
stored on disk (no in-memory job store).
"""
progress_path = _progress_file_path(job_id)
def generate():
import time as _time
# ── Read the job metadata written by /start ────────────────────────
deadline = _time.time() + 15 # Wait up to 15 s for the file to appear
meta = None
while _time.time() < deadline:
if os.path.exists(progress_path):
try:
with open(progress_path) as f:
meta = json.load(f)
break
except Exception:
pass
yield "data: " + json.dumps({'type': 'heartbeat'}) + "\n\n"
_time.sleep(0.3)
if not meta or meta.get('type') != 'pending':
yield "data: " + json.dumps({
'type': 'error',
'message': 'Job metadata not found. Please try importing again.'
}) + "\n\n"
return
temp_path = meta['temp_path']
skip_dupes = meta['skip_duplicates']
project_id = meta['project_id']
import_source= meta['import_source']
created_by = meta['created_by']
username = meta['username']
if not os.path.exists(temp_path):
yield "data: " + json.dumps({
'type': 'error',
'message': 'Uploaded file not found. Please try importing again.'
}) + "\n\n"
return
yield "data: " + json.dumps({'type': 'status', 'message': 'Reading and validating file...'}) + "\n\n"
# ── Run the import with a progress callback ────────────────────────
try:
svc = TimeAttendanceImportService(db, logger_handler)
# progress_callback writes to disk AND yields an SSE event.
# We collect events in a list so the generator can yield them.
_pending_events = []
def on_progress(current, total, message):
pct = int(current / total * 100) if total else 0
event = {
'type': 'progress',
'current': current,
'total': total,
'percent': pct,
'message': message,
}
_write_progress(job_id, event)
_pending_events.append(event)
# We need to interleave yielding with the synchronous import loop.
# Strategy: run import_from_excel; the callback appends to
# _pending_events; after every DB commit batch (50 records) we
# flush pending events to the SSE stream.
import threading as _threading
result_holder = [None]
error_holder = [None]
done_event = _threading.Event()
def _run():
# Push an application context so the thread can access
# Flask-SQLAlchemy, Employee.query, etc.
with app.app_context():
try:
result_holder[0] = svc.import_from_excel(
temp_path,
created_by=created_by,
import_source=import_source,
skip_duplicates=skip_dupes,
force_import_hashes=[],
project_id=project_id,
progress_callback=on_progress,
)
except Exception as exc:
error_holder[0] = exc
finally:
done_event.set()
t = _threading.Thread(target=_run, daemon=True)
t.start()
# Yield progress events as they arrive while the import thread runs
while not done_event.is_set():
while _pending_events:
yield "data: " + json.dumps(_pending_events.pop(0)) + "\n\n"
yield "data: " + json.dumps({'type': 'heartbeat'}) + "\n\n"
_time.sleep(0.4)
# Drain any remaining events after the thread finishes
while _pending_events:
yield "data: " + json.dumps(_pending_events.pop(0)) + "\n\n"
if error_holder[0]:
raise error_holder[0]
result = result_holder[0]
if result and result['success']:
logger_handler.logger.info(
f"User {username} imported {result['imported_records']} time attendance records "
f"via stream (batch: {result['batch_id']})"
)
# Sanitize result dict for JSON serialization — convert any
# datetime objects (e.g. import_date) to ISO-format strings.
if result and isinstance(result.get('import_date'), datetime):
result['import_date'] = result['import_date'].isoformat()
done_event_data = {'type': 'done', 'result': result}
_write_progress(job_id, done_event_data)
yield "data: " + json.dumps(done_event_data) + "\n\n"
except Exception as e:
logger_handler.logger.error(f"Import stream error for job {job_id}: {e}")
error_event = {'type': 'error', 'message': str(e)}
_write_progress(job_id, error_event)
yield "data: " + json.dumps(error_event) + "\n\n"
finally:
# Clean up temp files
for path in (temp_path, progress_path):
try:
if os.path.exists(path):
os.remove(path)
except Exception:
pass
return Response(
generate(),
mimetype='text/event-stream',
headers={
'Cache-Control': 'no-cache',
'X-Accel-Buffering': 'no', # Disable nginx buffering for SSE
}
)
@app.route('/time-attendance/import/cancel-pending') @app.route('/time-attendance/import/cancel-pending')
@login_required @login_required
def cancel_pending_import(): def cancel_pending_import():
+282 -91
View File
@@ -96,26 +96,107 @@
.progress-indicator { .progress-indicator {
display: none; display: none;
margin-top: 1rem; margin-top: 1.25rem;
padding: 1rem; padding: 1.25rem 1.5rem;
background: #ebf8ff; background: #ebf8ff;
border: 1px solid #90cdf4; border: 1px solid #90cdf4;
border-radius: 8px; border-radius: 10px;
text-align: center;
} }
.progress-indicator.active { .progress-indicator.active {
display: block; display: block;
} }
.progress-header {
display: flex;
align-items: center;
gap: 0.6rem;
margin-bottom: 0.75rem;
font-weight: 600;
color: #2b6cb0;
}
.progress-bar-track {
background: #bee3f8;
border-radius: 999px;
height: 18px;
overflow: hidden;
margin-bottom: 0.5rem;
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
}
.progress-bar-fill {
height: 100%;
width: 0%;
background: linear-gradient(90deg, #4299e1, #3182ce);
border-radius: 999px;
transition: width 0.3s ease;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 6px;
}
.progress-bar-fill .pct-label {
font-size: 0.7rem;
font-weight: 700;
color: white;
white-space: nowrap;
opacity: 0;
transition: opacity 0.2s;
}
.progress-bar-fill.has-width .pct-label {
opacity: 1;
}
.progress-counters {
display: flex;
justify-content: space-between;
font-size: 0.8rem;
color: #4a5568;
}
.progress-message {
margin-top: 0.4rem;
font-size: 0.8rem;
color: #718096;
font-style: italic;
min-height: 1.1em;
}
.progress-done-message {
display: none;
margin-top: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.9rem;
}
.progress-done-message.success {
background: #f0fff4;
border: 1px solid #68d391;
color: #276749;
display: block;
}
.progress-done-message.error {
background: #fff5f5;
border: 1px solid #fc8181;
color: #c53030;
display: block;
}
.spinner { .spinner {
display: inline-block; display: inline-block;
width: 20px; width: 16px;
height: 20px; height: 16px;
border: 3px solid #e2e8f0; border: 2.5px solid #bee3f8;
border-top-color: #4299e1; border-top-color: #4299e1;
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
flex-shrink: 0;
} }
@keyframes spin { @keyframes spin {
@@ -472,8 +553,21 @@
<!-- Progress Indicator --> <!-- Progress Indicator -->
<div id="progressIndicator" class="progress-indicator"> <div id="progressIndicator" class="progress-indicator">
<div class="spinner"></div> <div class="progress-header">
<p style="margin-top: 0.5rem;">Processing your file, please wait...</p> <div class="spinner" id="progressSpinner"></div>
<span id="progressTitle">Uploading &amp; preparing import...</span>
</div>
<div class="progress-bar-track">
<div class="progress-bar-fill" id="progressBarFill">
<span class="pct-label" id="progressPctLabel">0%</span>
</div>
</div>
<div class="progress-counters">
<span id="progressCounter">0 of 0 records processed</span>
<span id="progressPct">0%</span>
</div>
<div class="progress-message" id="progressMessage">Starting...</div>
<div class="progress-done-message" id="progressDoneMsg"></div>
</div> </div>
<!-- Form Actions --> <!-- Form Actions -->
@@ -687,118 +781,210 @@
</div> </div>
<script> <script>
// File upload handling // ─── DOM references ────────────────────────────────────────────────────────
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
const fileUploadArea = document.getElementById('fileUploadArea'); const fileUploadArea = document.getElementById('fileUploadArea');
const fileInfo = document.getElementById('fileInfo'); const fileInfo = document.getElementById('fileInfo');
const submitBtn = document.getElementById('submitBtn'); const submitBtn = document.getElementById('submitBtn');
const importForm = document.getElementById('importForm'); const importForm = document.getElementById('importForm');
const progressIndicator = document.getElementById('progressIndicator'); const progressIndicator= document.getElementById('progressIndicator');
const progressBarFill = document.getElementById('progressBarFill');
const progressPctLabel = document.getElementById('progressPctLabel');
const progressCounter = document.getElementById('progressCounter');
const progressPct = document.getElementById('progressPct');
const progressMessage = document.getElementById('progressMessage');
const progressTitle = document.getElementById('progressTitle');
const progressDoneMsg = document.getElementById('progressDoneMsg');
// Drag and drop handlers // ─── Drag & drop ───────────────────────────────────────────────────────────
fileUploadArea.addEventListener('dragover', (e) => { fileUploadArea.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
fileUploadArea.classList.add('dragover'); fileUploadArea.classList.add('dragover');
}); });
fileUploadArea.addEventListener('dragleave', () => { fileUploadArea.addEventListener('dragleave', () => {
fileUploadArea.classList.remove('dragover'); fileUploadArea.classList.remove('dragover');
}); });
fileUploadArea.addEventListener('drop', (e) => { fileUploadArea.addEventListener('drop', (e) => {
e.preventDefault(); e.preventDefault();
fileUploadArea.classList.remove('dragover'); fileUploadArea.classList.remove('dragover');
const files = e.dataTransfer.files; const files = e.dataTransfer.files;
if (files.length > 0) { if (files.length > 0) { fileInput.files = files; handleFileSelect(); }
fileInput.files = files;
handleFileSelect();
}
}); });
fileUploadArea.addEventListener('click', () => fileInput.click());
fileUploadArea.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', handleFileSelect); fileInput.addEventListener('change', handleFileSelect);
function handleFileSelect() { function handleFileSelect() {
const files = fileInput.files; const files = fileInput.files;
if (files.length > 0) { if (!files.length) return;
// Validate all files
let invalidFiles = [];
for (let i = 0; i < files.length; i++) {
const fileName = files[i].name;
if (!fileName.toLowerCase().endsWith('.xlsx') && !fileName.toLowerCase().endsWith('.xls')) {
invalidFiles.push(fileName);
}
}
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 const invalid = Array.from(files).filter(
importForm.addEventListener('submit', (e) => { f => !f.name.toLowerCase().endsWith('.xlsx') && !f.name.toLowerCase().endsWith('.xls')
if (fileInput.files.length === 0) { );
e.preventDefault(); if (invalid.length) {
alert('Please select at least one file to import'); alert('Invalid file format:\n' + invalid.map(f => f.name).join('\n') +
'\n\nPlease select only Excel files (.xlsx or .xls)');
resetForm();
return; return;
} }
// Validate project selection const totalSize = Array.from(files).reduce((s, f) => s + f.size, 0);
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 names = Array.from(files).map(f => f.name).join(', ');
const countEl = fileInfo.querySelector('.file-count');
countEl.textContent = '(' + names + ')';
Object.assign(countEl.style, { fontSize: '0.75rem', color: '#718096', display: 'block', marginTop: '0.5rem' });
}
fileInfo.style.display = 'flex';
fileInfo.style.flexDirection = 'column';
submitBtn.disabled = false;
}
// ─── Progress bar helpers ──────────────────────────────────────────────────
function setProgress(current, total, message) {
const pct = total > 0 ? Math.min(100, Math.round(current / total * 100)) : 0;
progressBarFill.style.width = pct + '%';
if (pct > 8) {
progressBarFill.classList.add('has-width');
progressPctLabel.textContent = pct + '%';
}
progressPct.textContent = pct + '%';
progressCounter.textContent = current + ' of ' + total + ' records processed';
if (message) progressMessage.textContent = message;
}
function showDone(success, message) {
document.getElementById('progressSpinner').style.display = 'none';
progressDoneMsg.className = 'progress-done-message ' + (success ? 'success' : 'error');
progressDoneMsg.textContent = message;
progressTitle.textContent = success ? 'Import complete!' : 'Import failed';
}
// ─── Form submit → SSE import flow ────────────────────────────────────────
importForm.addEventListener('submit', async (e) => {
e.preventDefault(); // Always intercept; we use fetch + SSE instead
if (!fileInput.files.length) {
alert('Please select at least one file to import.');
return;
}
const projectSelect = document.getElementById('project_id'); const projectSelect = document.getElementById('project_id');
if (!projectSelect.value || projectSelect.value === '') { if (!projectSelect.value) {
e.preventDefault(); alert('Please select a project. Project selection is required.');
alert('Please select a project. Project selection is required for attendance imports.');
projectSelect.focus(); projectSelect.focus();
projectSelect.style.borderColor = '#ef4444'; projectSelect.style.borderColor = '#ef4444';
return; return;
} }
// Show progress indicator // Show progress UI
progressIndicator.classList.add('active'); progressIndicator.classList.add('active');
progressDoneMsg.className = 'progress-done-message'; // hide
progressDoneMsg.textContent = '';
setProgress(0, 0, 'Uploading file...');
progressTitle.textContent = 'Uploading & preparing import...';
document.getElementById('progressSpinner').style.display = '';
submitBtn.disabled = true; submitBtn.disabled = true;
const fileCount = fileInput.files.length; submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Importing...';
const fileText = fileCount === 1 ? 'file' : 'files';
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing ' + fileCount + ' ' + fileText + '...'; // Build FormData from the existing form (includes all checkboxes, selects, etc.)
const formData = new FormData(importForm);
// ── Step 1: POST the file to /start → get job_id ──────────────────────
let jobId;
try {
const resp = await fetch('{{ url_for("start_import_job") }}', {
method: 'POST',
body: formData
});
const data = await resp.json();
if (!data.success) {
showDone(false, 'Upload failed: ' + (data.error || 'Unknown error'));
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
return;
}
jobId = data.job_id;
} catch (err) {
showDone(false, 'Network error during upload: ' + err.message);
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
return;
}
progressMessage.textContent = 'File received. Starting import...';
// ── Step 2: Open SSE stream for this job ──────────────────────────────
const streamUrl = '{{ url_for("stream_import_progress", job_id="__JOB_ID__") }}'.replace('__JOB_ID__', jobId);
const evtSource = new EventSource(streamUrl);
evtSource.onmessage = (event) => {
let msg;
try { msg = JSON.parse(event.data); } catch { return; }
if (msg.type === 'status') {
progressMessage.textContent = msg.message;
progressTitle.textContent = 'Importing records...';
} else if (msg.type === 'progress') {
setProgress(msg.current, msg.total, msg.message);
if (msg.percent >= 5) {
progressTitle.textContent = 'Importing records — ' + msg.percent + '% complete';
}
} else if (msg.type === 'done') {
evtSource.close();
const result = msg.result;
setProgress(result.total_records, result.total_records, 'Import finished.');
let summary = '✅ Imported ' + result.imported_records + ' of ' + result.total_records + ' records.';
if (result.duplicate_records > 0)
summary += ' Skipped ' + result.duplicate_records + ' duplicates.';
if (result.failed_records > 0)
summary += ' ⚠️ ' + result.failed_records + ' records failed.';
showDone(result.success, summary);
submitBtn.innerHTML = '<i class="fas fa-check"></i> Done';
// Redirect to records view after a short delay
if (result.success && result.batch_id) {
setTimeout(() => {
window.location.href = '{{ url_for("time_attendance_records") }}?import_batch=' + result.batch_id;
}, 2500);
} else {
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
}
} else if (msg.type === 'error') {
evtSource.close();
showDone(false, '❌ Import error: ' + msg.message);
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
} else if (msg.type === 'heartbeat') {
// Keep-alive — no action needed
}
};
evtSource.onerror = () => {
evtSource.close();
// Only show error if import hasn't completed yet
if (!progressDoneMsg.classList.contains('success')) {
showDone(false, 'Connection lost. Please check the records page to verify import status.');
}
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
};
}); });
// Reset border color when project is selected // ─── Misc ─────────────────────────────────────────────────────────────────
document.getElementById('project_id').addEventListener('change', function() { document.getElementById('project_id').addEventListener('change', function() {
if (this.value) { if (this.value) this.style.borderColor = '#e2e8f0';
this.style.borderColor = '#e2e8f0';
}
}); });
function resetForm() { function resetForm() {
@@ -807,9 +993,14 @@ function resetForm() {
submitBtn.disabled = true; submitBtn.disabled = true;
progressIndicator.classList.remove('active'); progressIndicator.classList.remove('active');
submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>'; submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
progressBarFill.style.width = '0%';
progressBarFill.classList.remove('has-width');
progressDoneMsg.className = 'progress-done-message';
progressMessage.textContent = '';
progressCounter.textContent = '0 of 0 records processed';
progressPct.textContent = '0%';
} }
// Prevent form resubmission
if (window.history.replaceState) { if (window.history.replaceState) {
window.history.replaceState(null, null, window.location.href); window.history.replaceState(null, null, window.location.href);
} }
+23 -9
View File
@@ -571,19 +571,21 @@ class TimeAttendanceImportService:
return analysis_result return analysis_result
def import_from_excel(self, file_path: str, created_by: int = None, def import_from_excel(self, file_path: str, created_by: int = None,
import_source: str = None, skip_duplicates: bool = True, import_source: str = None, skip_duplicates: bool = True,
force_import_hashes: List[str] = None, project_id: int = None) -> Dict[str, Any]: force_import_hashes: List[str] = None, project_id: int = None,
progress_callback=None) -> Dict[str, Any]:
""" """
Import time attendance data from Excel file with enhanced duplicate handling Import time attendance data from Excel file with enhanced duplicate handling.
Args: Args:
file_path: Path to the Excel file file_path: Path to the Excel file
created_by: User ID who initiated the import created_by: User ID who initiated the import
import_source: Description of import source import_source: Description of import source
skip_duplicates: Whether to skip duplicate records skip_duplicates: Whether to skip duplicate records
force_import_hashes: List of hashes to force import (user confirmed duplicates) force_import_hashes: List of hashes to force import (user confirmed duplicates)
progress_callback: Optional callable(current, total, message) for real-time progress
Returns: Returns:
Dictionary containing import results Dictionary containing import results
""" """
@@ -732,10 +734,22 @@ class TimeAttendanceImportService:
self.db.session.add(time_attendance_record) self.db.session.add(time_attendance_record)
import_results['imported_records'] += 1 import_results['imported_records'] += 1
# Commit in batches for better performance # Commit in batches of 50 to prevent memory buildup on large files
if import_results['imported_records'] % 100 == 0: if import_results['imported_records'] % 50 == 0:
self.db.session.flush() self.db.session.commit()
# Report progress via callback if provided
if progress_callback:
processed = (import_results['imported_records'] +
import_results['failed_records'] +
import_results['duplicate_records'] +
import_results['skipped_records'])
progress_callback(
processed,
import_results['total_records'],
f"Importing record {processed} of {import_results['total_records']}..."
)
except Exception as e: except Exception as e:
import_results['failed_records'] += 1 import_results['failed_records'] += 1