Updated Time Attendance functionality

This commit is contained in:
2025-10-06 17:23:30 -04:00
parent 769b3d706e
commit 3648c5ddda
6 changed files with 1147 additions and 430 deletions
+239 -5
View File
@@ -1,6 +1,7 @@
from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, send_file from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify, send_file
from flask_sqlalchemy import SQLAlchemy from flask_sqlalchemy import SQLAlchemy
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
from werkzeug.utils import secure_filename
from functools import wraps from functools import wraps
from datetime import datetime, date, time, timedelta from datetime import datetime, date, time, timedelta
from sqlalchemy import text from sqlalchemy import text
@@ -15,6 +16,7 @@ from logger_handler import AppLogger, log_user_activity, log_database_operations
from single_checkin_calculator import SingleCheckInCalculator from single_checkin_calculator import SingleCheckInCalculator
from payroll_excel_exporter import PayrollExcelExporter from payroll_excel_exporter import PayrollExcelExporter
from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter
from time_attendance_import_service import TimeAttendanceImportService
# Load environment variables in .env # Load environment variables in .env
load_dotenv() load_dotenv()
@@ -6542,7 +6544,7 @@ def time_attendance_dashboard():
@admin_required @admin_required
@log_database_operations('time_attendance_import') @log_database_operations('time_attendance_import')
def import_time_attendance(): def import_time_attendance():
"""Import time attendance data from Excel file""" """Enhanced import time attendance data from Excel file"""
if request.method == 'POST': if request.method == 'POST':
try: try:
# Check if file is uploaded # Check if file is uploaded
@@ -6564,12 +6566,19 @@ def import_time_attendance():
filename = secure_filename(file.filename) filename = secure_filename(file.filename)
temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'), temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'),
f"temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}") f"temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}")
# Ensure upload directory exists
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
file.save(temp_path) file.save(temp_path)
try: try:
# Initialize import service # Initialize enhanced import service
import_service = TimeAttendanceImportService(db, logger_handler) import_service = TimeAttendanceImportService(db, logger_handler)
# Get import options
skip_duplicates = request.form.get('skip_duplicates', 'true').lower() == 'true'
validate_only = request.form.get('validate_only', 'false').lower() == 'true'
# Validate file first # Validate file first
validation_result = import_service.validate_excel_file(temp_path) validation_result = import_service.validate_excel_file(temp_path)
@@ -6578,12 +6587,24 @@ def import_time_attendance():
return render_template('time_attendance_import.html', return render_template('time_attendance_import.html',
validation_result=validation_result) validation_result=validation_result)
# Show warnings if any
if validation_result['warnings']:
for warning in validation_result['warnings']:
flash(warning, 'warning')
# If validate only, return validation results
if validate_only:
flash(f"File validation successful! Found {validation_result['valid_rows']} valid records.", 'success')
return render_template('time_attendance_import.html',
validation_result=validation_result)
# Proceed with import # Proceed with import
import_source = request.form.get('import_source', f"Manual Import - {filename}") import_source = request.form.get('import_source', f"Manual Import - {filename}")
import_result = import_service.import_from_excel( import_result = import_service.import_from_excel(
temp_path, temp_path,
created_by=session['user_id'], created_by=session['user_id'],
import_source=import_source import_source=import_source,
skip_duplicates=skip_duplicates
) )
if import_result['success']: if import_result['success']:
@@ -6591,12 +6612,17 @@ def import_time_attendance():
logger_handler.logger.info( logger_handler.logger.info(
f"User {session['username']} successfully imported time attendance data - " f"User {session['username']} successfully imported time attendance data - "
f"Batch: {import_result['batch_id']}, " f"Batch: {import_result['batch_id']}, "
f"Records: {import_result['imported_records']}/{import_result['total_records']}" f"Records: {import_result['imported_records']}/{import_result['total_records']}, "
f"Duplicates: {import_result['duplicate_records']}, "
f"Failed: {import_result['failed_records']}"
) )
flash(f"Import successful! Imported {import_result['imported_records']} records " flash(f"Import successful! Imported {import_result['imported_records']} records "
f"out of {import_result['total_records']} total records.", 'success') f"out of {import_result['total_records']} total records.", 'success')
if import_result['duplicate_records'] > 0:
flash(f"Skipped {import_result['duplicate_records']} duplicate records.", 'info')
if import_result['failed_records'] > 0: if import_result['failed_records'] > 0:
flash(f"Note: {import_result['failed_records']} records failed to import. " flash(f"Note: {import_result['failed_records']} records failed to import. "
f"Check the error details below.", 'warning') f"Check the error details below.", 'warning')
@@ -6604,14 +6630,19 @@ def import_time_attendance():
return render_template('time_attendance_import_result.html', return render_template('time_attendance_import_result.html',
import_result=import_result) import_result=import_result)
else: else:
flash(f"Import failed: {'; '.join(import_result['errors'])}", 'error') flash(f"Import failed: {'; '.join(import_result['errors'][:3])}", 'error')
if len(import_result['errors']) > 3:
flash(f"...and {len(import_result['errors']) - 3} more errors", 'warning')
return render_template('time_attendance_import.html', return render_template('time_attendance_import.html',
import_result=import_result) import_result=import_result)
finally: finally:
# Clean up temporary file # Clean up temporary file
if os.path.exists(temp_path): if os.path.exists(temp_path):
try:
os.remove(temp_path) os.remove(temp_path)
except Exception as cleanup_error:
logger_handler.logger.warning(f"Failed to cleanup temp file: {cleanup_error}")
except Exception as e: except Exception as e:
logger_handler.log_database_error('time_attendance_import', e) logger_handler.log_database_error('time_attendance_import', e)
@@ -6620,6 +6651,209 @@ def import_time_attendance():
return render_template('time_attendance_import.html') return render_template('time_attendance_import.html')
@app.route('/time-attendance/import/validate', methods=['POST'])
@admin_required
def validate_import_file():
"""AJAX endpoint to validate Excel file before import"""
try:
if 'file' not in request.files:
return jsonify({'success': False, 'message': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'success': False, 'message': 'No file selected'}), 400
# Validate file extension
if not file.filename.lower().endswith(('.xlsx', '.xls')):
return jsonify({'success': False, 'message': 'Invalid file format'}), 400
# Save temporarily
filename = secure_filename(file.filename)
temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'),
f"validate_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}")
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
file.save(temp_path)
try:
# Validate file
import_service = TimeAttendanceImportService(db, logger_handler)
validation_result = import_service.validate_excel_file(temp_path)
return jsonify({
'success': True,
'validation': validation_result
})
finally:
# Cleanup
if os.path.exists(temp_path):
os.remove(temp_path)
except Exception as e:
logger_handler.logger.error(f"Validation error: {e}")
return jsonify({
'success': False,
'message': f'Validation failed: {str(e)}'
}), 500
@app.route('/time-attendance/import/batch/<batch_id>')
@login_required
@log_user_activity('view_import_batch')
def view_import_batch(batch_id):
"""View details of a specific import batch"""
try:
import_service = TimeAttendanceImportService(db, logger_handler)
batch_summary = import_service.get_import_summary(batch_id)
if not batch_summary:
flash('Import batch not found.', 'error')
return redirect(url_for('time_attendance_dashboard'))
return render_template('time_attendance_batch_detail.html',
batch_summary=batch_summary)
except Exception as e:
logger_handler.logger.error(f"Error viewing batch {batch_id}: {e}")
flash('Error loading batch details.', 'error')
return redirect(url_for('time_attendance_dashboard'))
@app.route('/time-attendance/import/batch/<batch_id>/delete', methods=['POST'])
@admin_required
@log_database_operations('delete_import_batch')
def delete_import_batch(batch_id):
"""Delete an entire import batch"""
try:
import_service = TimeAttendanceImportService(db, logger_handler)
result = import_service.delete_import_batch(batch_id, deleted_by=session['user_id'])
if result['success']:
flash(result['message'], 'success')
logger_handler.logger.info(
f"User {session['username']} deleted import batch {batch_id} - "
f"{result['deleted_count']} records removed"
)
else:
flash(result['message'], 'error')
return redirect(url_for('time_attendance_dashboard'))
except Exception as e:
logger_handler.logger.error(f"Error deleting batch {batch_id}: {e}")
flash('Error deleting import batch.', 'error')
return redirect(url_for('time_attendance_dashboard'))
@app.route('/time-attendance/import/download-template')
@login_required
def download_import_template():
"""Download Excel template for time attendance import"""
try:
import io
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from flask import send_file
# Create workbook
wb = Workbook()
ws = wb.active
ws.title = "Time Attendance Template"
# Define headers
headers = ['ID', 'Name', 'Platform', 'Date', 'Time', 'Location Name',
'Action Description', 'Event Description', 'Recorded Address']
# Style headers
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_font = Font(bold=True, color="FFFFFF")
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num)
cell.value = header
cell.fill = header_fill
cell.font = header_font
cell.alignment = Alignment(horizontal='center')
# Add sample data rows
sample_data = [
['12345', 'John Doe', 'iPhone - iOS', '2025-10-06', '09:00:00',
'HQ Suite 210', 'Check In', 'Morning Entry', '123 Main Street'],
['12345', 'John Doe', 'iPhone - iOS', '2025-10-06', '17:30:00',
'HQ Suite 210', 'Check Out', 'Evening Exit', '123 Main Street'],
['67890', 'Jane Smith', 'Android', '2025-10-06', '08:45:00',
'Branch Office', 'Check In', 'Morning Entry', '456 Oak Avenue'],
]
for row_num, row_data in enumerate(sample_data, 2):
for col_num, value in enumerate(row_data, 1):
ws.cell(row=row_num, column=col_num, value=value)
# Adjust column widths
for col in ws.columns:
max_length = 0
col_letter = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[col_letter].width = adjusted_width
# Add instructions sheet
ws_instructions = wb.create_sheet("Instructions")
instructions = [
["Time Attendance Import Template - Instructions"],
[""],
["Required Columns:"],
["- ID: Employee ID (required)"],
["- Name: Employee full name (required)"],
["- Date: Attendance date in YYYY-MM-DD format (required)"],
["- Time: Attendance time in HH:MM:SS format (required)"],
["- Location Name: Location where attendance was recorded (required)"],
["- Action Description: Type of action (e.g., Check In, Check Out) (required)"],
[""],
["Optional Columns:"],
["- Platform: Device platform (e.g., iPhone - iOS, Android)"],
["- Event Description: Additional event details"],
["- Recorded Address: Physical address where attendance was recorded"],
[""],
["Important Notes:"],
["- Do not modify the header row"],
["- Ensure all required fields have values"],
["- Date format must be YYYY-MM-DD (e.g., 2025-10-06)"],
["- Time format must be HH:MM:SS (e.g., 09:00:00)"],
["- Remove the sample data rows before importing your actual data"],
["- Duplicate records will be automatically detected and skipped"],
]
for row_num, instruction in enumerate(instructions, 1):
ws_instructions.cell(row=row_num, column=1, value=instruction[0])
ws_instructions.column_dimensions['A'].width = 80
# Save to bytes
output = io.BytesIO()
wb.save(output)
output.seek(0)
# Log download
logger_handler.logger.info(f"User {session['username']} downloaded import template")
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=f'time_attendance_template_{datetime.now().strftime("%Y%m%d")}.xlsx'
)
except Exception as e:
logger_handler.logger.error(f"Error generating template: {e}")
flash('Error generating template file.', 'error')
return redirect(url_for('import_time_attendance'))
@app.route('/time-attendance/records') @app.route('/time-attendance/records')
@login_required @login_required
@log_user_activity('time_attendance_records_view') @log_user_activity('time_attendance_records_view')
+28
View File
@@ -554,6 +554,34 @@ body.has-sidebar .time-attendance-page {
background: #fecaca; background: #fecaca;
} }
/* Enhanced action buttons */
.action-btn.btn-info {
background: #4299e1;
color: white;
}
.action-btn.btn-info:hover {
background: #3182ce;
}
.action-btn.btn-delete {
background: #f56565;
color: white;
}
.action-btn.btn-delete:hover {
background: #e53e3e;
}
/* Progress indicator animation */
@keyframes spin {
to { transform: rotate(360deg); }
}
.spinner {
animation: spin 0.8s linear infinite;
}
/* Pagination */ /* Pagination */
.pagination-section { .pagination-section {
padding: 1.5rem; padding: 1.5rem;
+211
View File
@@ -0,0 +1,211 @@
{% extends "base_authenticated.html" %}
{% block title %}Import Batch Details - {{ COMPANY_NAME }}{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}">
<style>
.batch-detail-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin: 2rem 0;
}
.summary-card {
background: white;
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
text-align: center;
}
.summary-card .value {
font-size: 2rem;
font-weight: 700;
color: #4299e1;
margin-bottom: 0.5rem;
}
.summary-card .label {
color: #718096;
font-size: 0.875rem;
}
.detail-section {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 2rem;
}
.detail-section h3 {
color: #2d3748;
margin-bottom: 1.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.action-breakdown {
display: flex;
flex-direction: column;
gap: 1rem;
}
.action-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
background: #f7fafc;
border-radius: 8px;
}
.employee-list {
max-height: 400px;
overflow-y: auto;
}
.employee-item {
display: flex;
justify-content: space-between;
padding: 0.75rem;
border-bottom: 1px solid #e2e8f0;
}
.employee-item:last-child {
border-bottom: none;
}
</style>
{% endblock %}
{% block page_title %}Import Batch Details{% endblock %}
{% block content %}
<div class="batch-detail-page">
<!-- Header -->
<div class="time-attendance-header">
<div class="header-navigation">
<a href="{{ url_for('time_attendance_dashboard') }}" class="back-button">
<i class="fas fa-arrow-left"></i>
Back to Dashboard
</a>
</div>
<div class="header-content">
<h1>
<i class="fas fa-database"></i>
Import Batch Details
</h1>
<p class="header-description">
Batch ID: <code>{{ batch_summary.batch_id }}</code>
</p>
</div>
<div class="header-actions">
<a href="{{ url_for('time_attendance_records', import_batch=batch_summary.batch_id) }}"
class="btn btn-primary">
<i class="fas fa-eye"></i>
View All Records
</a>
{% if session.role == 'admin' %}
<button onclick="deleteBatch()" class="btn btn-danger">
<i class="fas fa-trash"></i>
Delete Batch
</button>
{% endif %}
</div>
</div>
<!-- Summary Cards -->
<div class="summary-grid">
<div class="summary-card">
<div class="value">{{ batch_summary.total_records }}</div>
<div class="label">Total Records</div>
</div>
<div class="summary-card">
<div class="value">{{ batch_summary.unique_employees }}</div>
<div class="label">Unique Employees</div>
</div>
<div class="summary-card">
<div class="value">{{ batch_summary.unique_locations }}</div>
<div class="label">Locations</div>
</div>
<div class="summary-card">
<div class="value">{{ batch_summary.date_range.start.strftime('%Y-%m-%d') }}</div>
<div class="label">Start Date</div>
</div>
<div class="summary-card">
<div class="value">{{ batch_summary.date_range.end.strftime('%Y-%m-%d') }}</div>
<div class="label">End Date</div>
</div>
</div>
<!-- Import Information -->
<div class="detail-section">
<h3>
<i class="fas fa-info-circle"></i>
Import Information
</h3>
<p><strong>Import Date:</strong> {{ batch_summary.import_date.strftime('%Y-%m-%d %H:%M:%S') if batch_summary.import_date else 'Unknown' }}</p>
<p><strong>Import Source:</strong> {{ batch_summary.import_source or 'Not specified' }}</p>
<p><strong>Batch ID:</strong> <code>{{ batch_summary.batch_id }}</code></p>
</div>
<!-- Action Breakdown -->
<div class="detail-section">
<h3>
<i class="fas fa-chart-bar"></i>
Actions Breakdown
</h3>
<div class="action-breakdown">
{% for action, count in batch_summary.actions.items() %}
<div class="action-item">
<span><strong>{{ action }}</strong></span>
<span>{{ count }} records</span>
</div>
{% endfor %}
</div>
</div>
<!-- Employee Summary -->
<div class="detail-section">
<h3>
<i class="fas fa-users"></i>
Employee Summary
</h3>
<div class="employee-list">
{% for emp_id, emp_data in batch_summary.employee_summary.items() %}
<div class="employee-item">
<span><strong>{{ emp_data.name }}</strong> (ID: {{ emp_id }})</span>
<span>{{ emp_data.count }} records</span>
</div>
{% endfor %}
</div>
</div>
</div>
<script>
function deleteBatch() {
if (confirm('Are you sure you want to delete this entire import batch? This will remove all {{ batch_summary.total_records }} records. This action cannot be undone.')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = '/time-attendance/import/batch/{{ batch_summary.batch_id }}/delete';
document.body.appendChild(form);
form.submit();
}
}
</script>
{% endblock %}
+30
View File
@@ -260,6 +260,16 @@
class="action-btn btn-view" title="View Records"> class="action-btn btn-view" title="View Records">
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
</a> </a>
<a href="{{ url_for('view_import_batch', batch_id=import_batch.import_batch_id) }}"
class="action-btn btn-info" title="Batch Details">
<i class="fas fa-info-circle"></i>
</a>
{% if session.role == 'admin' %}
<button onclick="deleteBatch('{{ import_batch.import_batch_id }}')"
class="action-btn btn-delete" title="Delete Batch">
<i class="fas fa-trash"></i>
</button>
{% endif %}
</div> </div>
</td> </td>
</tr> </tr>
@@ -430,5 +440,25 @@ function deleteRecord(recordId, employeeName, date) {
form.submit(); form.submit();
} }
} }
function deleteBatch(batchId) {
if (confirm('Are you sure you want to delete this entire import batch? This action cannot be undone.')) {
const form = document.createElement('form');
form.method = 'POST';
form.action = `/time-attendance/import/batch/${batchId}/delete`;
const csrfToken = document.querySelector('meta[name="csrf-token"]');
if (csrfToken) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'csrf_token';
input.value = csrfToken.content;
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
}
}
</script> </script>
{% endblock %} {% endblock %}
+244 -322
View File
@@ -3,6 +3,115 @@
{% block extra_head %} {% block extra_head %}
<link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}">
<style>
.validation-preview {
background: #f8fafc;
border: 2px solid #e2e8f0;
border-radius: 12px;
padding: 1.5rem;
margin-top: 1.5rem;
}
.validation-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
margin-bottom: 1rem;
}
.validation-stat {
background: white;
padding: 1rem;
border-radius: 8px;
text-align: center;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.validation-stat .number {
font-size: 1.75rem;
font-weight: 700;
color: #2d3748;
}
.validation-stat .label {
font-size: 0.875rem;
color: #718096;
margin-top: 0.25rem;
}
.validation-stat.success .number { color: #48bb78; }
.validation-stat.warning .number { color: #ed8936; }
.validation-stat.error .number { color: #f56565; }
.import-options {
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 1.5rem;
margin-top: 1.5rem;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 1rem;
}
.checkbox-item {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.checkbox-item input[type="checkbox"] {
margin-top: 0.25rem;
width: 18px;
height: 18px;
}
.checkbox-label {
flex: 1;
}
.checkbox-label strong {
display: block;
color: #2d3748;
margin-bottom: 0.25rem;
}
.checkbox-label span {
color: #718096;
font-size: 0.875rem;
}
.progress-indicator {
display: none;
margin-top: 1rem;
padding: 1rem;
background: #ebf8ff;
border: 1px solid #90cdf4;
border-radius: 8px;
text-align: center;
}
.progress-indicator.active {
display: block;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #e2e8f0;
border-top-color: #4299e1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
{% endblock %} {% endblock %}
{% block page_title %}Import Time Attendance{% endblock %} {% block page_title %}Import Time Attendance{% endblock %}
@@ -24,9 +133,16 @@
Import Time Attendance Data Import Time Attendance Data
</h1> </h1>
<p class="header-description"> <p class="header-description">
Upload Excel files containing employee time attendance records Upload Excel files containing employee time attendance records with enhanced validation
</p> </p>
</div> </div>
<div class="header-actions">
<a href="{{ url_for('download_import_template') }}" class="btn btn-secondary">
<i class="fas fa-download"></i>
Download Template
</a>
</div>
</div> </div>
<!-- Import Instructions --> <!-- Import Instructions -->
@@ -59,8 +175,16 @@
<div class="instruction-icon"> <div class="instruction-icon">
<i class="fas fa-shield-alt"></i> <i class="fas fa-shield-alt"></i>
</div> </div>
<h3>Data Validation</h3> <h3>Smart Validation</h3>
<p>Automatic validation and error reporting for invalid data</p> <p>Automatic duplicate detection and data validation</p>
</div>
<div class="instruction-item">
<div class="instruction-icon">
<i class="fas fa-copy"></i>
</div>
<h3>Duplicate Handling</h3>
<p>Automatically skip duplicate records during import</p>
</div> </div>
</div> </div>
@@ -89,7 +213,7 @@
<td>12345</td> <td>12345</td>
<td>John Doe</td> <td>John Doe</td>
<td>iPhone - iOS</td> <td>iPhone - iOS</td>
<td>2025-09-12</td> <td>2025-10-06</td>
<td>09:00:00</td> <td>09:00:00</td>
<td>HQ Suite 210</td> <td>HQ Suite 210</td>
<td>Check In</td> <td>Check In</td>
@@ -100,7 +224,7 @@
<td>67890</td> <td>67890</td>
<td>Jane Smith</td> <td>Jane Smith</td>
<td>Android</td> <td>Android</td>
<td>2025-09-12</td> <td>2025-10-06</td>
<td>17:30:00</td> <td>17:30:00</td>
<td>HQ Suite 210</td> <td>HQ Suite 210</td>
<td>Check Out</td> <td>Check Out</td>
@@ -147,6 +271,31 @@
</div> </div>
<!-- Import Options --> <!-- Import Options -->
<div class="import-options">
<h3>
<i class="fas fa-cog"></i>
Import Options
</h3>
<div class="checkbox-group">
<div class="checkbox-item">
<input type="checkbox" id="skip_duplicates" name="skip_duplicates" value="true" checked>
<label for="skip_duplicates" class="checkbox-label">
<strong>Skip Duplicate Records</strong>
<span>Automatically detect and skip records that already exist in the system</span>
</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="validate_only" name="validate_only" value="true">
<label for="validate_only" class="checkbox-label">
<strong>Validate Only (Don't Import)</strong>
<span>Check file for errors without actually importing the data</span>
</label>
</div>
</div>
</div>
<!-- Import Source -->
<div class="form-grid"> <div class="form-grid">
<div class="form-group"> <div class="form-group">
<label for="import_source" class="form-label"> <label for="import_source" class="form-label">
@@ -157,12 +306,18 @@
id="import_source" id="import_source"
name="import_source" name="import_source"
class="form-input" class="form-input"
placeholder="e.g., Monthly Attendance Report - September 2025" placeholder="e.g., Monthly Attendance Report - October 2025"
value="Excel Import - {{ current_date }}"> value="Excel Import - {{ current_date }}">
<small class="form-help">Optional description for this import batch</small> <small class="form-help">Optional description for this import batch</small>
</div> </div>
</div> </div>
<!-- Progress Indicator -->
<div id="progressIndicator" class="progress-indicator">
<div class="spinner"></div>
<p style="margin-top: 0.5rem;">Processing your file, please wait...</p>
</div>
<!-- Form Actions --> <!-- Form Actions -->
<div class="form-actions"> <div class="form-actions">
<button type="button" class="btn btn-outline" onclick="resetForm()"> <button type="button" class="btn btn-outline" onclick="resetForm()">
@@ -172,17 +327,13 @@
<button type="submit" class="btn btn-primary" id="submitBtn" disabled> <button type="submit" class="btn btn-primary" id="submitBtn" disabled>
<i class="fas fa-upload"></i> <i class="fas fa-upload"></i>
<span class="btn-text">Import Data</span> <span class="btn-text">Import Data</span>
<span class="btn-loading" style="display: none;">
<i class="fas fa-spinner fa-spin"></i>
Processing...
</span>
</button> </button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
<!-- Validation Results (if any) --> <!-- Validation Results -->
{% if validation_result %} {% if validation_result %}
<div class="import-results {% if validation_result.valid %}success{% else %}error{% endif %}"> <div class="import-results {% if validation_result.valid %}success{% else %}error{% endif %}">
<div class="results-header"> <div class="results-header">
@@ -197,28 +348,30 @@
</h2> </h2>
</div> </div>
<div class="results-body"> <div class="results-body">
<div class="results-summary"> <div class="validation-stats">
<div class="summary-item"> <div class="validation-stat">
<div class="number">{{ validation_result.total_rows }}</div> <div class="number">{{ validation_result.total_rows }}</div>
<div class="label">Total Rows</div> <div class="label">Total Rows</div>
</div> </div>
<div class="summary-item"> <div class="validation-stat success">
<div class="number">{{ validation_result.valid_rows }}</div>
<div class="label">Valid Rows</div>
</div>
{% if validation_result.invalid_rows > 0 %}
<div class="validation-stat warning">
<div class="number">{{ validation_result.invalid_rows }}</div>
<div class="label">Invalid Rows</div>
</div>
{% endif %}
<div class="validation-stat">
<div class="number">{{ validation_result.columns|length }}</div> <div class="number">{{ validation_result.columns|length }}</div>
<div class="label">Columns Found</div> <div class="label">Columns Found</div>
</div> </div>
{% if validation_result.errors %}
<div class="summary-item error">
<div class="number">{{ validation_result.errors|length }}</div>
<div class="label">Errors</div>
</div> </div>
{% if validation_result.file_info %}
<p><strong>File Size:</strong> {{ validation_result.file_info.size_mb }} MB</p>
{% endif %} {% endif %}
{% if validation_result.warnings %}
<div class="summary-item">
<div class="number">{{ validation_result.warnings|length }}</div>
<div class="label">Warnings</div>
</div>
{% endif %}
</div>
{% if validation_result.errors %} {% if validation_result.errors %}
<div class="error-list"> <div class="error-list">
@@ -252,7 +405,7 @@
<div class="sample-data"> <div class="sample-data">
<h4> <h4>
<i class="fas fa-eye"></i> <i class="fas fa-eye"></i>
Sample Data Preview Sample Data Preview (First 3 Rows)
</h4> </h4>
<div class="table-container"> <div class="table-container">
<table class="preview-table"> <table class="preview-table">
@@ -280,7 +433,7 @@
</div> </div>
{% endif %} {% endif %}
<!-- Import Results (if any) --> <!-- Import Results -->
{% if import_result %} {% if import_result %}
<div class="import-results {% if import_result.success %}success{% else %}error{% endif %}"> <div class="import-results {% if import_result.success %}success{% else %}error{% endif %}">
<div class="results-header"> <div class="results-header">
@@ -295,17 +448,23 @@
</h2> </h2>
</div> </div>
<div class="results-body"> <div class="results-body">
<div class="results-summary"> <div class="validation-stats">
<div class="summary-item"> <div class="validation-stat">
<div class="number">{{ import_result.total_records }}</div> <div class="number">{{ import_result.total_records }}</div>
<div class="label">Total Records</div> <div class="label">Total Records</div>
</div> </div>
<div class="summary-item success"> <div class="validation-stat success">
<div class="number">{{ import_result.imported_records }}</div> <div class="number">{{ import_result.imported_records }}</div>
<div class="label">Successfully Imported</div> <div class="label">Successfully Imported</div>
</div> </div>
{% if import_result.duplicate_records > 0 %}
<div class="validation-stat warning">
<div class="number">{{ import_result.duplicate_records }}</div>
<div class="label">Duplicates Skipped</div>
</div>
{% endif %}
{% if import_result.failed_records > 0 %} {% if import_result.failed_records > 0 %}
<div class="summary-item error"> <div class="validation-stat error">
<div class="number">{{ import_result.failed_records }}</div> <div class="number">{{ import_result.failed_records }}</div>
<div class="label">Failed Records</div> <div class="label">Failed Records</div>
</div> </div>
@@ -335,12 +494,32 @@
<div class="error-list"> <div class="error-list">
<h4> <h4>
<i class="fas fa-exclamation-triangle"></i> <i class="fas fa-exclamation-triangle"></i>
Import Errors Import Errors (Showing first 10)
</h4> </h4>
<ul> <ul>
{% for error in import_result.errors %} {% for error in import_result.errors[:10] %}
<li>{{ error }}</li> <li>{{ error }}</li>
{% endfor %} {% endfor %}
{% if import_result.errors|length > 10 %}
<li><em>...and {{ import_result.errors|length - 10 }} more errors</em></li>
{% endif %}
</ul>
</div>
{% endif %}
{% if import_result.warnings %}
<div class="warning-list">
<h4>
<i class="fas fa-info-circle"></i>
Import Warnings (Showing first 10)
</h4>
<ul>
{% for warning in import_result.warnings[:10] %}
<li>{{ warning }}</li>
{% endfor %}
{% if import_result.warnings|length > 10 %}
<li><em>...and {{ import_result.warnings|length - 10 }} more warnings</em></li>
{% endif %}
</ul> </ul>
</div> </div>
{% endif %} {% endif %}
@@ -349,275 +528,16 @@
{% endif %} {% endif %}
</div> </div>
<style>
.instructions-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.instruction-item {
text-align: center;
padding: 1.5rem;
background: #f8fafc;
border-radius: 0.75rem;
border: 1px solid #e2e8f0;
}
.instruction-icon {
width: 60px;
height: 60px;
margin: 0 auto 1rem;
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 1.5rem;
}
.instruction-item h3 {
font-size: 1.125rem;
font-weight: 600;
color: #0f172a;
margin-bottom: 0.5rem;
}
.instruction-item p {
color: #64748b;
font-size: 0.875rem;
}
.sample-format {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 0.75rem;
padding: 1.5rem;
margin-top: 2rem;
}
.sample-format h4 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: #0f172a;
margin-bottom: 1rem;
}
.format-table {
overflow-x: auto;
}
.format-table table {
width: 100%;
border-collapse: collapse;
background: #ffffff;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.format-table th,
.format-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e2e8f0;
font-size: 0.875rem;
}
.format-table th {
background: #f1f5f9;
font-weight: 600;
color: #374151;
}
.format-table tr:last-child td {
border-bottom: none;
}
.file-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
margin-top: 1rem;
color: #166534;
}
.file-info i {
color: #22c55e;
}
.file-name {
font-weight: 600;
}
.file-size {
font-size: 0.875rem;
color: #15803d;
}
.form-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
padding-top: 1.5rem;
border-top: 1px solid #e2e8f0;
margin-top: 2rem;
}
.btn-loading {
display: none;
}
.preview-table {
width: 100%;
border-collapse: collapse;
background: #ffffff;
border-radius: 0.5rem;
overflow: hidden;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1);
}
.preview-table th,
.preview-table td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid #e2e8f0;
font-size: 0.875rem;
}
.preview-table th {
background: #f8fafc;
font-weight: 600;
color: #374151;
}
.table-container {
overflow-x: auto;
margin-top: 1rem;
}
.text-success {
color: #10b981;
}
.text-danger {
color: #ef4444;
}
.warning-list {
background: #fffbeb;
border: 1px solid #fed7aa;
border-radius: 0.5rem;
padding: 1rem;
margin-top: 1rem;
}
.warning-list h4 {
color: #92400e;
font-size: 1rem;
font-weight: 600;
margin-bottom: 0.75rem;
}
.warning-list ul {
list-style: none;
padding: 0;
margin: 0;
}
.warning-list li {
color: #d97706;
font-size: 0.875rem;
padding: 0.25rem 0;
border-bottom: 1px solid #fed7aa;
}
.warning-list li:last-child {
border-bottom: none;
}
.sample-data {
margin-top: 1.5rem;
}
.sample-data h4 {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 1rem;
font-weight: 600;
color: #0f172a;
margin-bottom: 1rem;
}
.success-actions {
text-align: center;
padding: 1rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.success-actions p {
color: #166534;
margin-bottom: 1rem;
}
.success-actions code {
background: #dcfce7;
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-family: monospace;
color: #15803d;
}
.action-buttons {
display: flex;
gap: 1rem;
justify-content: center;
}
@media (max-width: 768px) {
.instructions-grid {
grid-template-columns: 1fr;
}
.form-actions {
flex-direction: column;
}
.action-buttons {
flex-direction: column;
}
.format-table,
.table-container {
font-size: 0.75rem;
}
}
</style>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { // File upload handling
const fileUploadArea = document.getElementById('fileUploadArea');
const fileInput = document.getElementById('fileInput'); const fileInput = document.getElementById('fileInput');
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');
// File upload handling // Drag and drop handlers
fileUploadArea.addEventListener('click', () => fileInput.click());
fileUploadArea.addEventListener('dragover', (e) => { fileUploadArea.addEventListener('dragover', (e) => {
e.preventDefault(); e.preventDefault();
fileUploadArea.classList.add('dragover'); fileUploadArea.classList.add('dragover');
@@ -630,6 +550,7 @@ document.addEventListener('DOMContentLoaded', function() {
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; fileInput.files = files;
@@ -637,13 +558,17 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
fileUploadArea.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', handleFileSelect); fileInput.addEventListener('change', handleFileSelect);
function handleFileSelect() { function handleFileSelect() {
const file = fileInput.files[0]; const file = fileInput.files[0];
if (file) { if (file) {
const fileName = file.name; const fileName = file.name;
const fileSize = formatFileSize(file.size); const fileSize = (file.size / 1024 / 1024).toFixed(2) + ' MB';
fileInfo.querySelector('.file-name').textContent = fileName; fileInfo.querySelector('.file-name').textContent = fileName;
fileInfo.querySelector('.file-size').textContent = fileSize; fileInfo.querySelector('.file-size').textContent = fileSize;
@@ -651,42 +576,39 @@ document.addEventListener('DOMContentLoaded', function() {
submitBtn.disabled = false; submitBtn.disabled = false;
// Validate file type // Validate file extension
const validExtensions = ['.xlsx', '.xls']; if (!fileName.toLowerCase().endsWith('.xlsx') && !fileName.toLowerCase().endsWith('.xls')) {
const fileExtension = fileName.toLowerCase().substring(fileName.lastIndexOf('.')); alert('Please select an Excel file (.xlsx or .xls)');
if (!validExtensions.includes(fileExtension)) {
alert('Please select a valid Excel file (.xlsx or .xls)');
resetForm(); resetForm();
} }
} }
} }
function formatFileSize(bytes) { // Form submission
if (bytes === 0) return '0 Bytes'; importForm.addEventListener('submit', (e) => {
const k = 1024; if (!fileInput.files[0]) {
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; e.preventDefault();
const i = Math.floor(Math.log(bytes) / Math.log(k)); alert('Please select a file to import');
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; return;
} }
// Form submission handling // Show progress indicator
importForm.addEventListener('submit', function(e) { progressIndicator.classList.add('active');
const btnText = submitBtn.querySelector('.btn-text');
const btnLoading = submitBtn.querySelector('.btn-loading');
btnText.style.display = 'none';
btnLoading.style.display = 'inline-flex';
submitBtn.disabled = true; submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...';
}); });
// Reset form function function resetForm() {
window.resetForm = function() { importForm.reset();
fileInput.value = '';
fileInfo.style.display = 'none'; fileInfo.style.display = 'none';
submitBtn.disabled = true; submitBtn.disabled = true;
fileUploadArea.classList.remove('dragover'); progressIndicator.classList.remove('active');
}; submitBtn.innerHTML = '<i class="fas fa-upload"></i> <span class="btn-text">Import Data</span>';
}); }
// Prevent form resubmission
if (window.history.replaceState) {
window.history.replaceState(null, null, window.location.href);
}
</script> </script>
{% endblock %} {% endblock %}
+339 -47
View File
@@ -1,20 +1,21 @@
""" """
Time Attendance Import Service Enhanced Time Attendance Import Service
============================= ========================================
Service to handle importing time attendance data from Excel files. Improved service with duplicate detection, advanced validation,
Provides functionality to parse Excel files and import data into the time_attendance table. and better error handling for Excel imports.
""" """
import pandas as pd import pandas as pd
import uuid import uuid
from datetime import datetime from datetime import datetime, time
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from typing import Dict, List, Any, Optional, Tuple from typing import Dict, List, Any, Optional, Tuple
import traceback import traceback
import hashlib
class TimeAttendanceImportService: class TimeAttendanceImportService:
"""Service to handle time attendance data import from Excel files""" """Enhanced service to handle time attendance data import from Excel files"""
def __init__(self, db, logger_handler=None): def __init__(self, db, logger_handler=None):
"""Initialize the import service with database and logger""" """Initialize the import service with database and logger"""
@@ -22,14 +23,15 @@ class TimeAttendanceImportService:
self.logger = logger_handler self.logger = logger_handler
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) -> Dict[str, Any]: import_source: str = None, skip_duplicates: bool = True) -> Dict[str, Any]:
""" """
Import time attendance data from Excel file Import time attendance data from Excel file with enhanced validation
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
Returns: Returns:
Dictionary containing import results Dictionary containing import results
@@ -40,7 +42,10 @@ class TimeAttendanceImportService:
'total_records': 0, 'total_records': 0,
'imported_records': 0, 'imported_records': 0,
'failed_records': 0, 'failed_records': 0,
'duplicate_records': 0,
'skipped_records': 0,
'errors': [], 'errors': [],
'warnings': [],
'success': False, 'success': False,
'import_date': datetime.utcnow() 'import_date': datetime.utcnow()
} }
@@ -48,10 +53,22 @@ class TimeAttendanceImportService:
try: try:
# Log import start # Log import start
if self.logger: if self.logger:
self.logger.logger.info(f"Starting time attendance import from {file_path} by user {created_by}") self.logger.logger.info(f"Starting enhanced time attendance import from {file_path} by user {created_by}")
# Read Excel file # Read Excel file with multiple sheet support
df = pd.read_excel(file_path, sheet_name=0) # Read first sheet try:
excel_file = pd.ExcelFile(file_path)
sheet_name = excel_file.sheet_names[0] # Use first sheet
df = pd.read_excel(file_path, sheet_name=sheet_name)
if self.logger:
self.logger.logger.info(f"Reading sheet: {sheet_name} with {len(df)} rows")
except Exception as e:
error_msg = f"Failed to read Excel file: {str(e)}"
import_results['errors'].append(error_msg)
if self.logger:
self.logger.logger.error(error_msg)
return import_results
# Validate required columns # Validate required columns
required_columns = ['ID', 'Name', 'Date', 'Time', 'Location Name', 'Action Description'] required_columns = ['ID', 'Name', 'Date', 'Time', 'Location Name', 'Action Description']
@@ -61,38 +78,78 @@ class TimeAttendanceImportService:
error_msg = f"Missing required columns: {', '.join(missing_columns)}" error_msg = f"Missing required columns: {', '.join(missing_columns)}"
import_results['errors'].append(error_msg) import_results['errors'].append(error_msg)
if self.logger: if self.logger:
self.logger.logger.error(f"Import failed - {error_msg}") self.logger.logger.error(error_msg)
return import_results return import_results
# Remove completely empty rows
df = df.dropna(how='all')
import_results['total_records'] = len(df) import_results['total_records'] = len(df)
# Process each row if import_results['total_records'] == 0:
error_msg = "No valid data rows found in Excel file"
import_results['errors'].append(error_msg)
return import_results
# Track duplicates using hash
duplicate_hashes = set()
if skip_duplicates:
duplicate_hashes = self._get_existing_record_hashes()
# Process each row with enhanced validation
for index, row in df.iterrows(): for index, row in df.iterrows():
try: try:
# Parse date and time # Skip empty rows
attendance_date = pd.to_datetime(row['Date']).date() if pd.isna(row['ID']) or pd.isna(row['Name']):
import_results['skipped_records'] += 1
import_results['warnings'].append(f"Row {index + 2}: Skipped due to missing ID or Name")
continue
# Handle time parsing - could be string or time object # Validate and parse date
time_str = str(row['Time']) try:
if ':' in time_str: attendance_date = pd.to_datetime(row['Date']).date()
attendance_time = datetime.strptime(time_str, '%H:%M:%S').time() except Exception as date_error:
else: import_results['failed_records'] += 1
# Handle Excel time format import_results['errors'].append(f"Row {index + 2}: Invalid date format - {str(date_error)}")
attendance_time = pd.to_datetime(row['Time']).time() continue
# Validate and parse time with multiple format support
try:
attendance_time = self._parse_time_field(row['Time'])
except Exception as time_error:
import_results['failed_records'] += 1
import_results['errors'].append(f"Row {index + 2}: Invalid time format - {str(time_error)}")
continue
# Prepare record data
record_data = {
'employee_id': str(row['ID']).strip(),
'employee_name': str(row['Name']).strip(),
'platform': str(row.get('Platform', '')).strip() if pd.notna(row.get('Platform')) else None,
'attendance_date': attendance_date,
'attendance_time': attendance_time,
'location_name': str(row['Location Name']).strip(),
'action_description': str(row['Action Description']).strip(),
'event_description': str(row.get('Event Description', '')).strip() if pd.notna(row.get('Event Description')) else None,
'recorded_address': str(row.get('Recorded Address', '')).strip() if pd.notna(row.get('Recorded Address')) else None,
}
# Check for duplicates
if skip_duplicates:
record_hash = self._generate_record_hash(record_data)
if record_hash in duplicate_hashes:
import_results['duplicate_records'] += 1
import_results['warnings'].append(
f"Row {index + 2}: Duplicate record for {record_data['employee_name']} "
f"on {attendance_date} at {attendance_time} - Skipped"
)
continue
duplicate_hashes.add(record_hash)
# Create TimeAttendance record # Create TimeAttendance record
from models.time_attendance import TimeAttendance from models.time_attendance import TimeAttendance
time_attendance_record = TimeAttendance( time_attendance_record = TimeAttendance(
employee_id=str(row['ID']).strip(), **record_data,
employee_name=str(row['Name']).strip(),
platform=str(row.get('Platform', '')).strip() if pd.notna(row.get('Platform')) else None,
attendance_date=attendance_date,
attendance_time=attendance_time,
location_name=str(row['Location Name']).strip(),
action_description=str(row['Action Description']).strip(),
event_description=str(row.get('Event Description', '')).strip() if pd.notna(row.get('Event Description')) else None,
recorded_address=str(row.get('Recorded Address', '')).strip() if pd.notna(row.get('Recorded Address')) else None,
import_batch_id=batch_id, import_batch_id=batch_id,
import_source=import_source or f"Excel Import - {file_path}", import_source=import_source or f"Excel Import - {file_path}",
created_by=created_by created_by=created_by
@@ -101,6 +158,10 @@ 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
if import_results['imported_records'] % 100 == 0:
self.db.session.flush()
except Exception as e: except Exception as e:
import_results['failed_records'] += 1 import_results['failed_records'] += 1
error_msg = f"Row {index + 2}: {str(e)}" error_msg = f"Row {index + 2}: {str(e)}"
@@ -111,9 +172,9 @@ class TimeAttendanceImportService:
continue continue
# Commit all records # Final commit
self.db.session.commit() self.db.session.commit()
import_results['success'] = True import_results['success'] = import_results['imported_records'] > 0
# Log successful import # Log successful import
if self.logger: if self.logger:
@@ -121,7 +182,9 @@ class TimeAttendanceImportService:
f"Time attendance import completed - Batch: {batch_id}, " f"Time attendance import completed - Batch: {batch_id}, "
f"Total: {import_results['total_records']}, " f"Total: {import_results['total_records']}, "
f"Imported: {import_results['imported_records']}, " f"Imported: {import_results['imported_records']}, "
f"Failed: {import_results['failed_records']}" f"Failed: {import_results['failed_records']}, "
f"Duplicates: {import_results['duplicate_records']}, "
f"Skipped: {import_results['skipped_records']}"
) )
except SQLAlchemyError as e: except SQLAlchemyError as e:
@@ -144,9 +207,99 @@ class TimeAttendanceImportService:
return import_results return import_results
def _parse_time_field(self, time_value) -> time:
"""
Parse time field with multiple format support
Args:
time_value: Time value from Excel (string, datetime, or time object)
Returns:
time object
"""
if pd.isna(time_value):
raise ValueError("Time value is empty")
# If already a time object
if isinstance(time_value, time):
return time_value
# Convert to string and try parsing
time_str = str(time_value).strip()
# Try common time formats
time_formats = [
'%H:%M:%S',
'%H:%M',
'%I:%M:%S %p',
'%I:%M %p',
]
for fmt in time_formats:
try:
return datetime.strptime(time_str, fmt).time()
except ValueError:
continue
# Try pandas datetime parsing as fallback
try:
return pd.to_datetime(time_value).time()
except:
pass
raise ValueError(f"Unable to parse time value: {time_value}")
def _generate_record_hash(self, record_data: Dict) -> str:
"""
Generate unique hash for a record to detect duplicates
Args:
record_data: Dictionary containing record information
Returns:
Hash string
"""
hash_string = (
f"{record_data['employee_id']}_"
f"{record_data['attendance_date']}_"
f"{record_data['attendance_time']}_"
f"{record_data['location_name']}_"
f"{record_data['action_description']}"
)
return hashlib.md5(hash_string.encode()).hexdigest()
def _get_existing_record_hashes(self) -> set:
"""
Get hashes of existing records to detect duplicates
Returns:
Set of record hashes
"""
try:
from models.time_attendance import TimeAttendance
existing_records = TimeAttendance.query.all()
hashes = set()
for record in existing_records:
record_data = {
'employee_id': record.employee_id,
'attendance_date': record.attendance_date,
'attendance_time': record.attendance_time,
'location_name': record.location_name,
'action_description': record.action_description
}
hashes.add(self._generate_record_hash(record_data))
return hashes
except Exception as e:
if self.logger:
self.logger.logger.warning(f"Failed to get existing record hashes: {e}")
return set()
def validate_excel_file(self, file_path: str) -> Dict[str, Any]: def validate_excel_file(self, file_path: str) -> Dict[str, Any]:
""" """
Validate Excel file structure before import Enhanced Excel file validation with detailed analysis
Args: Args:
file_path: Path to the Excel file file_path: Path to the Excel file
@@ -157,19 +310,41 @@ class TimeAttendanceImportService:
validation_results = { validation_results = {
'valid': False, 'valid': False,
'total_rows': 0, 'total_rows': 0,
'valid_rows': 0,
'invalid_rows': 0,
'columns': [], 'columns': [],
'sample_data': [], 'sample_data': [],
'errors': [], 'errors': [],
'warnings': [] 'warnings': [],
'file_info': {}
} }
try: try:
# Get file information
import os
file_stats = os.stat(file_path)
validation_results['file_info'] = {
'size': file_stats.st_size,
'size_mb': round(file_stats.st_size / (1024 * 1024), 2)
}
# Read Excel file # Read Excel file
df = pd.read_excel(file_path, sheet_name=0) excel_file = pd.ExcelFile(file_path)
sheet_name = excel_file.sheet_names[0]
df = pd.read_excel(file_path, sheet_name=sheet_name)
# Remove empty rows
original_row_count = len(df)
df = df.dropna(how='all')
validation_results['total_rows'] = len(df) validation_results['total_rows'] = len(df)
validation_results['columns'] = df.columns.tolist() validation_results['columns'] = df.columns.tolist()
if original_row_count > len(df):
validation_results['warnings'].append(
f"Removed {original_row_count - len(df)} completely empty rows"
)
# Get sample data (first 5 rows) # Get sample data (first 5 rows)
sample_rows = df.head(5).to_dict('records') sample_rows = df.head(5).to_dict('records')
validation_results['sample_data'] = sample_rows validation_results['sample_data'] = sample_rows
@@ -179,35 +354,86 @@ class TimeAttendanceImportService:
missing_columns = [col for col in required_columns if col not in df.columns] missing_columns = [col for col in required_columns if col not in df.columns]
if missing_columns: if missing_columns:
validation_results['errors'].append(f"Missing required columns: {', '.join(missing_columns)}") validation_results['errors'].append(
f"Missing required columns: {', '.join(missing_columns)}"
)
# Check for empty required fields # Check for empty required fields
valid_row_count = 0
for index, row in df.iterrows():
is_valid = True
for col in required_columns: for col in required_columns:
if col in df.columns: if col in df.columns and pd.isna(row[col]):
empty_count = df[col].isna().sum() is_valid = False
if empty_count > 0: break
if is_valid:
valid_row_count += 1
validation_results['valid_rows'] = valid_row_count
validation_results['invalid_rows'] = len(df) - valid_row_count
if validation_results['invalid_rows'] > 0:
validation_results['warnings'].append( validation_results['warnings'].append(
f"Column '{col}' has {empty_count} empty values" f"{validation_results['invalid_rows']} rows have missing required data"
) )
# Validate date format # Validate date format
if 'Date' in df.columns: if 'Date' in df.columns:
invalid_dates = 0
for idx, date_val in df['Date'].items():
if pd.notna(date_val):
try: try:
pd.to_datetime(df['Date'], errors='coerce') pd.to_datetime(date_val)
except: except:
validation_results['errors'].append("Invalid date format in 'Date' column") invalid_dates += 1
if invalid_dates > 0:
validation_results['warnings'].append(
f"{invalid_dates} rows have invalid date format"
)
# Validate time format
if 'Time' in df.columns:
invalid_times = 0
for idx, time_val in df['Time'].items():
if pd.notna(time_val):
try:
self._parse_time_field(time_val)
except:
invalid_times += 1
if invalid_times > 0:
validation_results['warnings'].append(
f"{invalid_times} rows have invalid time format"
)
# Check for potential duplicates
if all(col in df.columns for col in ['ID', 'Date', 'Time', 'Location Name']):
duplicate_check = df[['ID', 'Date', 'Time', 'Location Name']].duplicated()
duplicate_count = duplicate_check.sum()
if duplicate_count > 0:
validation_results['warnings'].append(
f"{duplicate_count} potential duplicate records detected"
)
# Set valid flag # Set valid flag
validation_results['valid'] = len(validation_results['errors']) == 0 validation_results['valid'] = (
len(validation_results['errors']) == 0 and
validation_results['valid_rows'] > 0
)
except Exception as e: except Exception as e:
validation_results['errors'].append(f"Failed to read Excel file: {str(e)}") validation_results['errors'].append(f"Failed to validate Excel file: {str(e)}")
if self.logger:
self.logger.logger.error(f"Validation error: {e}")
return validation_results return validation_results
def get_import_summary(self, batch_id: str) -> Optional[Dict[str, Any]]: def get_import_summary(self, batch_id: str) -> Optional[Dict[str, Any]]:
""" """
Get summary of imported data by batch ID Get detailed summary of imported data by batch ID
Args: Args:
batch_id: Import batch identifier batch_id: Import batch identifier
@@ -238,6 +464,17 @@ class TimeAttendanceImportService:
action = record.action_description action = record.action_description
actions[action] = actions.get(action, 0) + 1 actions[action] = actions.get(action, 0) + 1
# Group by employee
employee_summary = {}
for record in records:
emp_id = record.employee_id
if emp_id not in employee_summary:
employee_summary[emp_id] = {
'name': record.employee_name,
'count': 0
}
employee_summary[emp_id]['count'] += 1
return { return {
'batch_id': batch_id, 'batch_id': batch_id,
'total_records': total_records, 'total_records': total_records,
@@ -245,6 +482,7 @@ class TimeAttendanceImportService:
'unique_locations': unique_locations, 'unique_locations': unique_locations,
'date_range': date_range, 'date_range': date_range,
'actions': actions, 'actions': actions,
'employee_summary': employee_summary,
'import_date': records[0].import_date if records else None, 'import_date': records[0].import_date if records else None,
'import_source': records[0].import_source if records else None 'import_source': records[0].import_source if records else None
} }
@@ -253,3 +491,57 @@ class TimeAttendanceImportService:
if self.logger: if self.logger:
self.logger.logger.error(f"Failed to get import summary for batch {batch_id}: {e}") self.logger.logger.error(f"Failed to get import summary for batch {batch_id}: {e}")
return None return None
def delete_import_batch(self, batch_id: str, deleted_by: int = None) -> Dict[str, Any]:
"""
Delete all records from a specific import batch
Args:
batch_id: Import batch identifier
deleted_by: User ID who initiated the deletion
Returns:
Dictionary containing deletion results
"""
result = {
'success': False,
'deleted_count': 0,
'message': ''
}
try:
from models.time_attendance import TimeAttendance
records = TimeAttendance.query.filter_by(import_batch_id=batch_id).all()
deleted_count = len(records)
if deleted_count == 0:
result['message'] = 'No records found for this batch'
return result
# Delete records
for record in records:
self.db.session.delete(record)
self.db.session.commit()
# Log deletion
if self.logger:
self.logger.logger.info(
f"User {deleted_by} deleted import batch {batch_id} - "
f"Removed {deleted_count} records"
)
result['success'] = True
result['deleted_count'] = deleted_count
result['message'] = f'Successfully deleted {deleted_count} records'
except Exception as e:
self.db.session.rollback()
result['message'] = f'Error deleting batch: {str(e)}'
if self.logger:
self.logger.logger.error(f"Failed to delete batch {batch_id}: {e}")
return result