Implemented creating qr code in bulk (import excel file)
This commit is contained in:
@@ -19,6 +19,7 @@ from working_hours_calculator import WorkingHoursCalculator
|
||||
from payroll_excel_exporter import PayrollExcelExporter
|
||||
from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter
|
||||
from time_attendance_import_service import TimeAttendanceImportService
|
||||
from qr_code_import_service import QRCodeImportService
|
||||
|
||||
# Load environment variables in .env
|
||||
load_dotenv()
|
||||
@@ -3683,6 +3684,185 @@ def create_qr_code():
|
||||
|
||||
return render_template('create_qr_code.html', projects=projects, styles=styles)
|
||||
|
||||
@app.route('/qr-codes/bulk-import', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@log_database_operations('qr_code_bulk_import')
|
||||
def import_bulk_qr_codes():
|
||||
"""Bulk import QR codes from Excel file"""
|
||||
|
||||
if request.method == 'GET':
|
||||
return render_template('bulk_qr_import.html')
|
||||
|
||||
try:
|
||||
proceed_import = request.form.get('proceed_import') == 'true'
|
||||
|
||||
if proceed_import:
|
||||
if 'pending_qr_import_file' not in session or 'pending_qr_import_filename' not in session:
|
||||
flash('Import session expired. Please upload the file again.', 'error')
|
||||
return redirect(url_for('import_bulk_qr_codes'))
|
||||
|
||||
temp_path = session['pending_qr_import_file']
|
||||
filename = session['pending_qr_import_filename']
|
||||
|
||||
if not os.path.exists(temp_path):
|
||||
flash('Temporary file not found. Please upload the file again.', 'error')
|
||||
session.pop('pending_qr_import_file', None)
|
||||
session.pop('pending_qr_import_filename', None)
|
||||
return redirect(url_for('import_bulk_qr_codes'))
|
||||
else:
|
||||
if 'file' not in request.files:
|
||||
flash('No file uploaded.', 'error')
|
||||
return redirect(request.url)
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
flash('No file selected.', 'error')
|
||||
return redirect(request.url)
|
||||
|
||||
if not file.filename.lower().endswith(('.xlsx', '.xls')):
|
||||
flash('Please upload an Excel file (.xlsx or .xls).', 'error')
|
||||
return redirect(request.url)
|
||||
|
||||
filename = secure_filename(file.filename)
|
||||
temp_path = os.path.join(app.config.get('UPLOAD_FOLDER', '/tmp'),
|
||||
f"temp_qr_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}")
|
||||
|
||||
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
|
||||
file.save(temp_path)
|
||||
|
||||
session['pending_qr_import_file'] = temp_path
|
||||
session['pending_qr_import_filename'] = filename
|
||||
|
||||
validate_only = request.form.get('validate_only') == 'true' and not proceed_import
|
||||
|
||||
import_service = QRCodeImportService(db, logger_handler)
|
||||
|
||||
if validate_only:
|
||||
validation_result = import_service.validate_excel_file(temp_path)
|
||||
|
||||
if validation_result['success']:
|
||||
flash(f"Validation successful! Found {validation_result['valid_rows']} valid records.", 'success')
|
||||
else:
|
||||
flash(f"Validation found errors. Please fix them before importing.", 'error')
|
||||
|
||||
return render_template('bulk_qr_import.html', validation_result=validation_result)
|
||||
|
||||
projects = Project.query.filter_by(active_status=True).all()
|
||||
project_lookup = {p.name: p.id for p in projects}
|
||||
|
||||
import_result = import_service.import_from_excel(
|
||||
file_path=temp_path,
|
||||
created_by=session['user_id'],
|
||||
generate_qr_code_func=generate_qr_code,
|
||||
generate_qr_url_func=generate_qr_url,
|
||||
request_url_root=request.url_root,
|
||||
project_lookup=project_lookup,
|
||||
QRCode=QRCode,
|
||||
Project=Project,
|
||||
geocode_func=get_coordinates_from_address_enhanced
|
||||
)
|
||||
|
||||
if import_result['success']:
|
||||
logger_handler.logger.info(
|
||||
f"User {session['username']} successfully imported {import_result['imported_records']} QR codes via bulk import "
|
||||
f"({import_result.get('geocoded_records', 0)} addresses auto-geocoded)"
|
||||
)
|
||||
|
||||
flash(f"Import successful! Imported {import_result['imported_records']} QR codes "
|
||||
f"out of {import_result['total_rows']} total records.", 'success')
|
||||
|
||||
# Show geocoding info
|
||||
if import_result.get('geocoded_records', 0) > 0:
|
||||
flash(f"✓ {import_result['geocoded_records']} addresses were automatically geocoded using Google Maps.", 'info')
|
||||
|
||||
if import_result['failed_records'] > 0:
|
||||
flash(f"Note: {import_result['failed_records']} records failed to import. "
|
||||
f"Check the error details below.", 'warning')
|
||||
else:
|
||||
flash(f"Import failed: {import_result.get('error', 'Unknown error')}", 'error')
|
||||
|
||||
session.pop('pending_qr_import_file', None)
|
||||
session.pop('pending_qr_import_filename', None)
|
||||
|
||||
try:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
except Exception as cleanup_error:
|
||||
logger_handler.logger.warning(f"Failed to cleanup temp file: {cleanup_error}")
|
||||
|
||||
return render_template('bulk_qr_import.html', import_result=import_result)
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('qr_code_bulk_import', e)
|
||||
flash(f'Import failed: {str(e)}', 'error')
|
||||
return redirect(url_for('import_bulk_qr_codes'))
|
||||
|
||||
|
||||
@app.route('/qr-codes/bulk-import/template')
|
||||
@login_required
|
||||
def download_qr_import_template():
|
||||
"""Download Excel template for bulk QR code import"""
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "QR Code Import Template"
|
||||
|
||||
headers = [
|
||||
'QR Code Name',
|
||||
'QR Code Location',
|
||||
'Project',
|
||||
'Location Address',
|
||||
'Event',
|
||||
'Latitude',
|
||||
'Longitude'
|
||||
]
|
||||
|
||||
header_fill = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
|
||||
header_font = Font(bold=True, color='FFFFFF')
|
||||
header_alignment = Alignment(horizontal='center', vertical='center')
|
||||
|
||||
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 = header_alignment
|
||||
|
||||
example_data = [
|
||||
['HQ-Entrance', 'Main Building', 'Corporate HQ', '123 Main St, Springfield, IL 62701', 'Check IN', 39.781721, -89.650148],
|
||||
['HQ-Exit', 'Main Building', 'Corporate HQ', '123 Main St, Springfield, IL 62701', 'Check OUT', 39.781721, -89.650148],
|
||||
['Site-A-Gate1', 'Construction Site A', 'Construction Projects', '456 Oak Ave, Chicago, IL 60601', 'Check IN', '', '']
|
||||
]
|
||||
|
||||
for row_num, row_data in enumerate(example_data, 2):
|
||||
for col_num, value in enumerate(row_data, 1):
|
||||
ws.cell(row=row_num, column=col_num, value=value)
|
||||
|
||||
column_widths = [20, 20, 20, 40, 15, 15, 15]
|
||||
for col_num, width in enumerate(column_widths, 1):
|
||||
ws.column_dimensions[ws.cell(row=1, column=col_num).column_letter].width = width
|
||||
|
||||
excel_buffer = io.BytesIO()
|
||||
wb.save(excel_buffer)
|
||||
excel_buffer.seek(0)
|
||||
|
||||
logger_handler.logger.info(f"User {session.get('username', 'unknown')} downloaded QR import template")
|
||||
|
||||
return send_file(
|
||||
excel_buffer,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
as_attachment=True,
|
||||
download_name='QR_Code_Import_Template.xlsx'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_flask_error('qr_import_template_download', str(e))
|
||||
flash('Error generating template. Please try again.', 'error')
|
||||
return redirect(url_for('import_bulk_qr_codes'))
|
||||
|
||||
@app.route('/qr-codes/<int:qr_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@log_database_operations('qr_code_edit')
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
QR Code Import Service
|
||||
=====================
|
||||
|
||||
Service for handling bulk QR code imports from Excel files.
|
||||
Follows the same patterns as TimeAttendanceImportService.
|
||||
|
||||
IMPORTANT: This service does NOT import any models.
|
||||
All model classes must be passed as parameters from app.py.
|
||||
"""
|
||||
|
||||
import pandas as pd
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
import traceback
|
||||
|
||||
|
||||
class QRCodeImportService:
|
||||
"""Service for bulk QR code import from Excel files"""
|
||||
|
||||
def __init__(self, db, logger_handler=None):
|
||||
"""Initialize the import service with database and logger"""
|
||||
self.db = db
|
||||
self.logger = logger_handler
|
||||
|
||||
def validate_excel_file(self, file_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Validate Excel file structure and data
|
||||
|
||||
Args:
|
||||
file_path: Path to the Excel file
|
||||
|
||||
Returns:
|
||||
Dictionary with validation results
|
||||
"""
|
||||
try:
|
||||
# Read Excel file
|
||||
df = pd.read_excel(file_path)
|
||||
|
||||
# Required columns
|
||||
required_columns = [
|
||||
'QR Code Name',
|
||||
'QR Code Location',
|
||||
'Project',
|
||||
'Location Address',
|
||||
'Event'
|
||||
]
|
||||
|
||||
# Optional columns
|
||||
optional_columns = [
|
||||
'Latitude',
|
||||
'Longitude'
|
||||
]
|
||||
|
||||
# Check for required columns
|
||||
missing_columns = []
|
||||
for col in required_columns:
|
||||
if col not in df.columns:
|
||||
missing_columns.append(col)
|
||||
|
||||
if missing_columns:
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Missing required columns: {', '.join(missing_columns)}",
|
||||
'missing_columns': missing_columns
|
||||
}
|
||||
|
||||
# Validate data
|
||||
errors = []
|
||||
warnings = []
|
||||
valid_rows = []
|
||||
invalid_rows = []
|
||||
|
||||
for index, row in df.iterrows():
|
||||
row_num = index + 2 # Excel row number (header is row 1)
|
||||
row_errors = []
|
||||
|
||||
# Validate QR Code Name
|
||||
if pd.isna(row['QR Code Name']) or str(row['QR Code Name']).strip() == '':
|
||||
row_errors.append(f"Row {row_num}: QR Code Name is required")
|
||||
|
||||
# Validate Location
|
||||
if pd.isna(row['QR Code Location']) or str(row['QR Code Location']).strip() == '':
|
||||
row_errors.append(f"Row {row_num}: QR Code Location is required")
|
||||
|
||||
# Validate Location Address
|
||||
if pd.isna(row['Location Address']) or str(row['Location Address']).strip() == '':
|
||||
row_errors.append(f"Row {row_num}: Location Address is required")
|
||||
|
||||
# Validate Event
|
||||
if pd.isna(row['Event']) or str(row['Event']).strip() == '':
|
||||
row_errors.append(f"Row {row_num}: Event is required")
|
||||
|
||||
# Validate Project (must be a string)
|
||||
if pd.isna(row['Project']) or str(row['Project']).strip() == '':
|
||||
row_errors.append(f"Row {row_num}: Project is required")
|
||||
|
||||
# Validate GPS coordinates if provided
|
||||
has_latitude = 'Latitude' in df.columns and not pd.isna(row.get('Latitude'))
|
||||
has_longitude = 'Longitude' in df.columns and not pd.isna(row.get('Longitude'))
|
||||
|
||||
if has_latitude and has_longitude:
|
||||
try:
|
||||
lat = float(row['Latitude'])
|
||||
lon = float(row['Longitude'])
|
||||
|
||||
# Validate latitude range
|
||||
if not (-90 <= lat <= 90):
|
||||
row_errors.append(f"Row {row_num}: Latitude must be between -90 and 90")
|
||||
|
||||
# Validate longitude range
|
||||
if not (-180 <= lon <= 180):
|
||||
row_errors.append(f"Row {row_num}: Longitude must be between -180 and 180")
|
||||
except (ValueError, TypeError):
|
||||
row_errors.append(f"Row {row_num}: Invalid GPS coordinates format")
|
||||
elif has_latitude or has_longitude:
|
||||
warnings.append(f"Row {row_num}: Both Latitude and Longitude must be provided together")
|
||||
|
||||
if row_errors:
|
||||
errors.extend(row_errors)
|
||||
invalid_rows.append({
|
||||
'row_number': row_num,
|
||||
'data': row.to_dict(),
|
||||
'errors': row_errors
|
||||
})
|
||||
else:
|
||||
valid_rows.append({
|
||||
'row_number': row_num,
|
||||
'data': row.to_dict()
|
||||
})
|
||||
|
||||
return {
|
||||
'success': len(errors) == 0,
|
||||
'total_rows': len(df),
|
||||
'valid_rows': len(valid_rows),
|
||||
'invalid_rows': len(invalid_rows),
|
||||
'errors': errors,
|
||||
'warnings': warnings,
|
||||
'valid_data': valid_rows,
|
||||
'invalid_data': invalid_rows
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
if self.logger:
|
||||
self.logger.logger.error(f"Error validating Excel file: {e}")
|
||||
return {
|
||||
'success': False,
|
||||
'error': f"Error reading Excel file: {str(e)}",
|
||||
'total_rows': 0,
|
||||
'valid_rows': 0,
|
||||
'invalid_rows': 0,
|
||||
'errors': [str(e)],
|
||||
'warnings': []
|
||||
}
|
||||
|
||||
def import_from_excel(
|
||||
self,
|
||||
file_path: str,
|
||||
created_by: int,
|
||||
generate_qr_code_func,
|
||||
generate_qr_url_func,
|
||||
request_url_root: str,
|
||||
project_lookup: Dict[str, int] = None,
|
||||
QRCode=None,
|
||||
Project=None,
|
||||
geocode_func=None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Import QR codes from Excel file
|
||||
|
||||
Args:
|
||||
file_path: Path to the Excel file
|
||||
created_by: User ID who initiated the import
|
||||
generate_qr_code_func: Function to generate QR code image
|
||||
generate_qr_url_func: Function to generate QR URL
|
||||
request_url_root: Base URL for QR code destination
|
||||
project_lookup: Dictionary mapping project names to IDs
|
||||
QRCode: QRCode model class (passed from app.py)
|
||||
Project: Project model class (passed from app.py)
|
||||
geocode_func: Function to geocode addresses (optional, for auto-geocoding)
|
||||
|
||||
Returns:
|
||||
Dictionary with import results
|
||||
"""
|
||||
# Models are now passed as parameters to avoid import issues
|
||||
|
||||
# Validate that model classes were passed
|
||||
if QRCode is None or Project is None:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Model classes not provided. Please update your route to pass QRCode and Project models.',
|
||||
'imported_records': 0,
|
||||
'failed_records': 0,
|
||||
'errors': ['Model classes missing']
|
||||
}
|
||||
|
||||
try:
|
||||
# First validate the file
|
||||
validation_result = self.validate_excel_file(file_path)
|
||||
|
||||
if not validation_result['success']:
|
||||
return {
|
||||
'success': False,
|
||||
'error': validation_result.get('error', 'Validation failed'),
|
||||
'imported_records': 0,
|
||||
'failed_records': validation_result['total_rows'],
|
||||
'errors': validation_result['errors']
|
||||
}
|
||||
|
||||
# Read Excel file
|
||||
df = pd.read_excel(file_path)
|
||||
|
||||
# Track import statistics
|
||||
imported_count = 0
|
||||
failed_count = 0
|
||||
geocoded_count = 0 # Track how many addresses were auto-geocoded
|
||||
errors = []
|
||||
imported_qr_codes = []
|
||||
|
||||
# If no project lookup provided, create one
|
||||
if project_lookup is None:
|
||||
projects = Project.query.filter_by(active_status=True).all()
|
||||
project_lookup = {p.name: p.id for p in projects}
|
||||
|
||||
for index, row in df.iterrows():
|
||||
row_num = index + 2
|
||||
|
||||
try:
|
||||
# Extract data
|
||||
name = str(row['QR Code Name']).strip()
|
||||
location = str(row['QR Code Location']).strip()
|
||||
location_address = str(row['Location Address']).strip()
|
||||
location_event = str(row['Event']).strip()
|
||||
project_name = str(row['Project']).strip()
|
||||
|
||||
# Get project ID
|
||||
project_id = project_lookup.get(project_name)
|
||||
if not project_id:
|
||||
errors.append(f"Row {row_num}: Project '{project_name}' not found")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Extract GPS coordinates if provided
|
||||
address_latitude = None
|
||||
address_longitude = None
|
||||
has_coordinates = False
|
||||
coordinate_accuracy = None
|
||||
|
||||
if 'Latitude' in df.columns and 'Longitude' in df.columns:
|
||||
if not pd.isna(row.get('Latitude')) and not pd.isna(row.get('Longitude')):
|
||||
try:
|
||||
address_latitude = float(row['Latitude'])
|
||||
address_longitude = float(row['Longitude'])
|
||||
has_coordinates = True
|
||||
coordinate_accuracy = 'manual'
|
||||
|
||||
if self.logger:
|
||||
self.logger.logger.info(f"Row {row_num}: Using provided coordinates ({address_latitude}, {address_longitude})")
|
||||
except (ValueError, TypeError):
|
||||
if self.logger:
|
||||
self.logger.logger.warning(f"Row {row_num}: Invalid coordinate format, will attempt geocoding")
|
||||
|
||||
# Auto-geocode if coordinates not provided and geocode function available
|
||||
if not has_coordinates and geocode_func and location_address:
|
||||
try:
|
||||
if self.logger:
|
||||
self.logger.logger.info(f"Row {row_num}: Attempting to geocode address: {location_address[:50]}...")
|
||||
|
||||
# Call the geocoding function
|
||||
geocoded_lat, geocoded_lng, geocoded_accuracy = geocode_func(location_address)
|
||||
|
||||
if geocoded_lat and geocoded_lng:
|
||||
address_latitude = geocoded_lat
|
||||
address_longitude = geocoded_lng
|
||||
has_coordinates = True
|
||||
coordinate_accuracy = geocoded_accuracy if geocoded_accuracy else 'geocoded'
|
||||
geocoded_count += 1 # Increment geocoded counter
|
||||
|
||||
if self.logger:
|
||||
self.logger.logger.info(
|
||||
f"Row {row_num}: Successfully geocoded to ({address_latitude}, {address_longitude}) "
|
||||
f"with accuracy: {coordinate_accuracy}"
|
||||
)
|
||||
else:
|
||||
if self.logger:
|
||||
self.logger.logger.warning(f"Row {row_num}: Geocoding returned no results for address")
|
||||
except Exception as geocode_error:
|
||||
if self.logger:
|
||||
self.logger.logger.error(f"Row {row_num}: Geocoding error: {geocode_error}")
|
||||
# Continue without coordinates - they're optional
|
||||
|
||||
# Check for duplicate QR code name
|
||||
existing_qr = QRCode.query.filter_by(name=name, active_status=True).first()
|
||||
if existing_qr:
|
||||
errors.append(f"Row {row_num}: QR code with name '{name}' already exists")
|
||||
failed_count += 1
|
||||
continue
|
||||
|
||||
# Create new QR code record (without URL and image first)
|
||||
new_qr_code = QRCode(
|
||||
name=name,
|
||||
location=location,
|
||||
location_address=location_address,
|
||||
location_event=location_event,
|
||||
qr_code_image="",
|
||||
qr_url="",
|
||||
created_by=created_by,
|
||||
project_id=project_id,
|
||||
address_latitude=address_latitude,
|
||||
address_longitude=address_longitude,
|
||||
coordinate_accuracy=coordinate_accuracy,
|
||||
coordinates_updated_date=datetime.utcnow() if has_coordinates else None,
|
||||
fill_color='#000000',
|
||||
back_color='#FFFFFF',
|
||||
box_size=10,
|
||||
border=4,
|
||||
error_correction='H' # Highest error correction level (30% recovery)
|
||||
)
|
||||
|
||||
# Add to session and flush to get ID
|
||||
self.db.session.add(new_qr_code)
|
||||
self.db.session.flush()
|
||||
|
||||
# Generate URL and QR code image
|
||||
qr_url = generate_qr_url_func(name, new_qr_code.id)
|
||||
qr_data = f"{request_url_root}qr/{qr_url}"
|
||||
qr_image = generate_qr_code_func(
|
||||
data=qr_data,
|
||||
fill_color='#000000',
|
||||
back_color='#FFFFFF',
|
||||
box_size=10,
|
||||
border=4,
|
||||
error_correction='H' # Highest error correction level (30% recovery)
|
||||
)
|
||||
|
||||
# Update QR code with URL and image
|
||||
new_qr_code.qr_url = qr_url
|
||||
new_qr_code.qr_code_image = qr_image
|
||||
|
||||
imported_count += 1
|
||||
imported_qr_codes.append({
|
||||
'name': name,
|
||||
'location': location,
|
||||
'project': project_name,
|
||||
'id': new_qr_code.id
|
||||
})
|
||||
|
||||
except Exception as row_error:
|
||||
self.db.session.rollback()
|
||||
error_msg = f"Row {row_num}: {str(row_error)}"
|
||||
errors.append(error_msg)
|
||||
failed_count += 1
|
||||
|
||||
if self.logger:
|
||||
self.logger.logger.error(f"Error importing row {row_num}: {row_error}")
|
||||
|
||||
# Commit all successful imports
|
||||
if imported_count > 0:
|
||||
self.db.session.commit()
|
||||
|
||||
if self.logger:
|
||||
self.logger.logger.info(
|
||||
f"Bulk QR code import completed: {imported_count} imported, {failed_count} failed, "
|
||||
f"{geocoded_count} addresses auto-geocoded"
|
||||
)
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'imported_records': imported_count,
|
||||
'failed_records': failed_count,
|
||||
'geocoded_records': geocoded_count,
|
||||
'total_rows': len(df),
|
||||
'errors': errors,
|
||||
'imported_qr_codes': imported_qr_codes
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
self.db.session.rollback()
|
||||
|
||||
if self.logger:
|
||||
self.logger.logger.error(f"Error during QR code import: {e}")
|
||||
self.logger.logger.error(traceback.format_exc())
|
||||
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'imported_records': 0,
|
||||
'failed_records': 0,
|
||||
'errors': [str(e)]
|
||||
}
|
||||
@@ -0,0 +1,616 @@
|
||||
{% extends "base_authenticated.html" %}
|
||||
|
||||
{% block title %}Bulk QR Code Import{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<!-- Page Header -->
|
||||
<div class="page-header">
|
||||
<h1>
|
||||
<i class="fas fa-upload"></i>
|
||||
Bulk QR Code Import
|
||||
</h1>
|
||||
<p>Import multiple QR codes from an Excel file</p>
|
||||
</div>
|
||||
|
||||
<!-- Import Instructions -->
|
||||
<div class="import-section">
|
||||
<div class="import-header">
|
||||
<h2>
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Import Instructions
|
||||
</h2>
|
||||
</div>
|
||||
<div class="import-body">
|
||||
<div class="instructions-grid">
|
||||
<div class="instruction-card">
|
||||
<div class="instruction-icon">
|
||||
<i class="fas fa-table"></i>
|
||||
</div>
|
||||
<h3>Required Columns</h3>
|
||||
<ul>
|
||||
<li><strong>QR Code Name</strong> - Unique name for the QR code</li>
|
||||
<li><strong>QR Code Location</strong> - Location identifier</li>
|
||||
<li><strong>Project</strong> - Project name (must exist)</li>
|
||||
<li><strong>Location Address</strong> - Complete address</li>
|
||||
<li><strong>Event</strong> - Check IN or Check OUT</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="instruction-card">
|
||||
<div class="instruction-icon">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
</div>
|
||||
<h3>Optional Columns</h3>
|
||||
<ul>
|
||||
<li><strong>Latitude</strong> - GPS latitude coordinate</li>
|
||||
<li><strong>Longitude</strong> - GPS longitude coordinate</li>
|
||||
</ul>
|
||||
<p class="instruction-note">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
If providing coordinates, both Latitude and Longitude are required
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="instruction-card">
|
||||
<div class="instruction-icon">
|
||||
<i class="fas fa-download"></i>
|
||||
</div>
|
||||
<h3>Download Template</h3>
|
||||
<p>Use our pre-formatted template to ensure proper column structure:</p>
|
||||
<a href="{{ url_for('download_qr_import_template') }}" class="btn btn-primary">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
Download Excel Template
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Example Data Table -->
|
||||
<div class="example-section">
|
||||
<h3>
|
||||
<i class="fas fa-lightbulb"></i>
|
||||
Example Data
|
||||
</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="example-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>QR Code Name</th>
|
||||
<th>QR Code Location</th>
|
||||
<th>Project</th>
|
||||
<th>Location Address</th>
|
||||
<th>Event</th>
|
||||
<th>Latitude</th>
|
||||
<th>Longitude</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>HQ-Entrance</td>
|
||||
<td>Main Building</td>
|
||||
<td>Corporate HQ</td>
|
||||
<td>123 Main St, Springfield, IL 62701</td>
|
||||
<td>Check IN</td>
|
||||
<td>39.781721</td>
|
||||
<td>-89.650148</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>HQ-Exit</td>
|
||||
<td>Main Building</td>
|
||||
<td>Corporate HQ</td>
|
||||
<td>123 Main St, Springfield, IL 62701</td>
|
||||
<td>Check OUT</td>
|
||||
<td>39.781721</td>
|
||||
<td>-89.650148</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Site-A-Gate1</td>
|
||||
<td>Construction Site A</td>
|
||||
<td>Construction Projects</td>
|
||||
<td>456 Oak Ave, Chicago, IL 60601</td>
|
||||
<td>Check IN</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Upload Form -->
|
||||
<div class="import-section">
|
||||
<div class="import-header">
|
||||
<h2>
|
||||
<i class="fas fa-upload"></i>
|
||||
Upload Excel File
|
||||
</h2>
|
||||
</div>
|
||||
<div class="import-body">
|
||||
<form id="importForm" method="POST" enctype="multipart/form-data" action="{{ url_for('import_bulk_qr_codes') }}">
|
||||
<!-- File Upload Area -->
|
||||
<div class="file-upload-area" id="fileUploadArea">
|
||||
<div class="upload-icon">
|
||||
<i class="fas fa-cloud-upload-alt"></i>
|
||||
</div>
|
||||
<div class="upload-text">
|
||||
<h3>Drag & Drop Excel File</h3>
|
||||
<p>Or click to browse and select an Excel file (.xlsx, .xls)</p>
|
||||
<div class="file-info" id="fileInfo" style="display: none;">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
<span class="file-name"></span>
|
||||
<span class="file-size"></span>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file"
|
||||
id="fileInput"
|
||||
name="file"
|
||||
accept=".xlsx,.xls"
|
||||
class="file-input"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- 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="validate_only" name="validate_only" value="true">
|
||||
<label for="validate_only" class="checkbox-label">
|
||||
<strong>Validate Only (Don't Import)</strong>
|
||||
<span>Check for errors without importing data</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="form-actions">
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i>
|
||||
Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary" id="submitBtn" disabled>
|
||||
<i class="fas fa-upload"></i>
|
||||
<span id="submitBtnText">Upload & Import</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Progress Indicator -->
|
||||
<div id="progressIndicator" class="progress-indicator" style="display: none;">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="progressText">Processing...</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Validation Results -->
|
||||
{% if validation_result %}
|
||||
<div class="import-section">
|
||||
<div class="import-header">
|
||||
<h2>
|
||||
<i class="fas fa-check-circle"></i>
|
||||
Validation Results
|
||||
</h2>
|
||||
</div>
|
||||
<div class="import-body">
|
||||
<div class="validation-summary">
|
||||
<div class="validation-stat success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<div>
|
||||
<h3>{{ validation_result.valid_rows }}</h3>
|
||||
<p>Valid Records</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="validation-stat {% if validation_result.invalid_rows > 0 %}error{% else %}muted{% endif %}">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
<div>
|
||||
<h3>{{ validation_result.invalid_rows }}</h3>
|
||||
<p>Invalid Records</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="validation-stat info">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<div>
|
||||
<h3>{{ validation_result.total_rows }}</h3>
|
||||
<p>Total Records</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if validation_result.success and validation_result.valid_rows > 0 %}
|
||||
<div class="validation-message success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<div>
|
||||
<h3>Validation Successful!</h3>
|
||||
<p>All {{ validation_result.valid_rows }} records are valid and ready to import.</p>
|
||||
</div>
|
||||
<form method="POST" action="{{ url_for('import_bulk_qr_codes') }}">
|
||||
<input type="hidden" name="proceed_import" value="true">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-check"></i>
|
||||
Proceed with Import
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if validation_result.errors %}
|
||||
<div class="validation-errors">
|
||||
<h3>
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
Validation Errors ({{ validation_result.errors|length }})
|
||||
</h3>
|
||||
<ul class="error-list">
|
||||
{% for error in validation_result.errors[:20] %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
{% if validation_result.errors|length > 20 %}
|
||||
<li><em>...and {{ validation_result.errors|length - 20 }} more errors</em></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if validation_result.warnings %}
|
||||
<div class="validation-warnings">
|
||||
<h3>
|
||||
<i class="fas fa-info-circle"></i>
|
||||
Warnings ({{ validation_result.warnings|length }})
|
||||
</h3>
|
||||
<ul class="warning-list">
|
||||
{% for warning in validation_result.warnings[:10] %}
|
||||
<li>{{ warning }}</li>
|
||||
{% endfor %}
|
||||
{% if validation_result.warnings|length > 10 %}
|
||||
<li><em>...and {{ validation_result.warnings|length - 10 }} more warnings</em></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Import Results -->
|
||||
{% if import_result %}
|
||||
<div class="import-section">
|
||||
<div class="import-header">
|
||||
<h2>
|
||||
<i class="fas fa-check-circle"></i>
|
||||
Import Results
|
||||
</h2>
|
||||
</div>
|
||||
<div class="import-body">
|
||||
<div class="validation-summary">
|
||||
<div class="validation-stat success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<div>
|
||||
<h3>{{ import_result.imported_records }}</h3>
|
||||
<p>Successfully Imported</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="validation-stat {% if import_result.failed_records > 0 %}error{% else %}muted{% endif %}">
|
||||
<i class="fas fa-times-circle"></i>
|
||||
<div>
|
||||
<h3>{{ import_result.failed_records }}</h3>
|
||||
<p>Failed Records</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="validation-stat info">
|
||||
<i class="fas fa-file-alt"></i>
|
||||
<div>
|
||||
<h3>{{ import_result.total_rows }}</h3>
|
||||
<p>Total Records</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if import_result.success and import_result.imported_records > 0 %}
|
||||
<div class="validation-message success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
<div>
|
||||
<h3>Import Successful!</h3>
|
||||
<p>Successfully imported {{ import_result.imported_records }} QR codes.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">
|
||||
<i class="fas fa-eye"></i>
|
||||
View QR Codes
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{% if import_result.imported_qr_codes %}
|
||||
<div class="imported-qr-list">
|
||||
<h3>
|
||||
<i class="fas fa-qrcode"></i>
|
||||
Imported QR Codes
|
||||
</h3>
|
||||
<div class="table-responsive">
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Location</th>
|
||||
<th>Project</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for qr in import_result.imported_qr_codes[:10] %}
|
||||
<tr>
|
||||
<td>{{ qr.id }}</td>
|
||||
<td>{{ qr.name }}</td>
|
||||
<td>{{ qr.location }}</td>
|
||||
<td>{{ qr.project }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% if import_result.imported_qr_codes|length > 10 %}
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; font-style: italic;">
|
||||
...and {{ import_result.imported_qr_codes|length - 10 }} more QR codes
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if import_result.errors %}
|
||||
<div class="validation-errors">
|
||||
<h3>
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
Import Errors ({{ import_result.errors|length }})
|
||||
</h3>
|
||||
<ul class="error-list">
|
||||
{% for error in import_result.errors[:20] %}
|
||||
<li>{{ error }}</li>
|
||||
{% endfor %}
|
||||
{% if import_result.errors|length > 20 %}
|
||||
<li><em>...and {{ import_result.errors|length - 20 }} more errors</em></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Reuse time attendance import styles */
|
||||
@import url('/static/css/time_attendance.css');
|
||||
|
||||
.instructions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.instruction-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.instruction-icon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.instruction-card h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.instruction-card ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.instruction-card li {
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.instruction-card li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.instruction-note {
|
||||
background: #fef3c7;
|
||||
border-left: 4px solid #f59e0b;
|
||||
padding: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.example-section {
|
||||
margin-top: 2rem;
|
||||
padding: 1.5rem;
|
||||
background: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.example-section h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.example-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.example-table th,
|
||||
.example-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.example-table th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.example-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.imported-qr-list {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.imported-qr-list h3 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.data-table th,
|
||||
.data-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.data-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// File upload handling
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const fileUploadArea = document.getElementById('fileUploadArea');
|
||||
const fileInfo = document.getElementById('fileInfo');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const importForm = document.getElementById('importForm');
|
||||
const progressIndicator = document.getElementById('progressIndicator');
|
||||
const validateOnlyCheckbox = document.getElementById('validate_only');
|
||||
const submitBtnText = document.getElementById('submitBtnText');
|
||||
|
||||
// Update button text based on validate only checkbox
|
||||
if (validateOnlyCheckbox) {
|
||||
validateOnlyCheckbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
submitBtnText.textContent = 'Validate File';
|
||||
} else {
|
||||
submitBtnText.textContent = 'Upload & Import';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Drag and drop handlers
|
||||
fileUploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
fileUploadArea.addEventListener('dragleave', () => {
|
||||
fileUploadArea.classList.remove('dragover');
|
||||
});
|
||||
|
||||
fileUploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
fileUploadArea.classList.remove('dragover');
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
fileInput.files = files;
|
||||
handleFileSelect();
|
||||
}
|
||||
});
|
||||
|
||||
fileUploadArea.addEventListener('click', () => {
|
||||
fileInput.click();
|
||||
});
|
||||
|
||||
fileInput.addEventListener('change', handleFileSelect);
|
||||
|
||||
function handleFileSelect() {
|
||||
const file = fileInput.files[0];
|
||||
if (file) {
|
||||
const fileName = file.name;
|
||||
const fileSize = (file.size / 1024 / 1024).toFixed(2) + ' MB';
|
||||
|
||||
fileInfo.querySelector('.file-name').textContent = fileName;
|
||||
fileInfo.querySelector('.file-size').textContent = fileSize;
|
||||
fileInfo.style.display = 'flex';
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Form submission
|
||||
importForm.addEventListener('submit', (e) => {
|
||||
if (!fileInput.files[0]) {
|
||||
e.preventDefault();
|
||||
alert('Please select a file to import');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show progress indicator
|
||||
progressIndicator.style.display = 'block';
|
||||
submitBtn.disabled = true;
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
fileInput.value = '';
|
||||
fileInfo.style.display = 'none';
|
||||
submitBtn.disabled = true;
|
||||
progressIndicator.style.display = 'none';
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -556,6 +556,13 @@
|
||||
<p>Generate a QR code for attendance tracking at your location</p>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1rem; margin-bottom: 1rem;">
|
||||
<a href="{{ url_for('import_bulk_qr_codes') }}" class="btn btn-outline" style="display: inline-flex; align-items: center; gap: 0.5rem;">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
Bulk Import from Excel
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<form method="POST" class="form" id="createQRForm">
|
||||
<!-- Basic Information Section -->
|
||||
<div class="form-section">
|
||||
|
||||
Reference in New Issue
Block a user