Files
LT_QR_Codes_Management/routes/attendance_export.py
T
2026-09-11 22:42:45 -04:00

962 lines
52 KiB
Python

"""
routes/attendance_export.py
===========================
Export configuration and Excel export generation routes.
Routes: /export-configuration, /generate-excel-export
Helper functions: create_excel_export, create_excel_export_ordered,
format_employee_id_for_excel
"""
from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, url_for
from datetime import datetime, date, timedelta, time
import io, os, json, re, traceback
from extensions import db, logger_handler
from models.attendance import AttendanceData
from models.employee import Employee
from models.permissions import UserLocationPermission, UserProjectPermission
from models.project import Project
from models.qrcode import QRCode
from models.user import User
from sqlalchemy import text, or_, and_
from logger_handler import log_user_activity, log_database_operations
from utils.helpers import (
admin_required,
employee_id_regex_condition,
expand_employee_id_filter,
get_client_ip,
has_admin_privileges,
has_staff_level_access,
login_required,
staff_or_admin_required)
from utils.geocoding import (calculate_location_accuracy_enhanced, process_location_data_enhanced,
check_location_accuracy_column_exists)
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
from routes.attendance import bp # shared blueprint — do not redefine
@bp.route('/export-configuration', endpoint='export_configuration')
@login_required
def export_configuration():
"""Display export configuration page for customizing Excel exports"""
try:
user_role = session.get('role')
if user_role not in ['admin', 'payroll', 'accounting']:
logger_handler.logger.warning(f"User {session.get('username', 'unknown')} (role: {user_role}) attempted unauthorized access to export configuration")
flash('Access denied. Only administrators and payroll staff can access export configuration.', 'error')
return redirect(url_for('attendance.attendance_report'))
# Log export configuration access using your existing logger
try:
logger_handler.logger.info(f"User {session.get('username', 'unknown')} (role: {user_role}) accessed export configuration")
logger_handler.logger.info(f"User {session.get('username', 'unknown')} accessed export configuration page")
except Exception:
pass
# Get current filters from session or request args
filters = {
'date_from': request.args.get('date_from', ''),
'date_to': request.args.get('date_to', ''),
'location_filter': request.args.get('location', ''),
'employee_filter': request.args.get('employee', ''),
'project_filter': request.args.get('project', '')
}
logger_handler.logger.debug(f"Export config filters: {filters}")
# Get project name if project filter is applied
project_name = None
if filters.get('project_filter'):
try:
project = db.session.get(Project, int(filters['project_filter']))
if project:
project_name = project.name
logger_handler.logger.debug(f"Project filter: ID={filters['project_filter']}, Name={project_name}")
except Exception as e:
logger_handler.logger.warning(f"Error fetching project name for filter: {e}")
# Check if location accuracy feature exists
try:
has_location_accuracy = check_location_accuracy_column_exists()
except Exception as e:
logger_handler.logger.warning(f"Error checking location accuracy column: {e}")
has_location_accuracy = False
# Define all available columns with their default settings
available_columns = [
{'key': 'employee_id', 'label': 'Employee ID', 'default_name': 'ID', 'enabled': True},
{'key': 'employee_name', 'label': 'Employee Name', 'default_name': 'Employee Name', 'enabled': False},
{'key': 'location_name', 'label': 'Location', 'default_name': 'Location Name', 'enabled': True},
{'key': 'status', 'label': 'Event', 'default_name': 'Action Description', 'enabled': True},
{'key': 'check_in_date', 'label': 'Date', 'default_name': 'Date', 'enabled': True},
{'key': 'check_in_time', 'label': 'Time', 'default_name': 'Time', 'enabled': True},
{'key': 'qr_address', 'label': 'QR Address', 'default_name': 'Event Description', 'enabled': True},
{'key': 'address', 'label': 'Check-in Address', 'default_name': 'Recorded Address', 'enabled': True},
{'key': 'device_info', 'label': 'Device', 'default_name': 'Platform', 'enabled': True},
{'key': 'ip_address', 'label': 'IP Address', 'default_name': 'IP Address', 'enabled': False},
{'key': 'user_agent', 'label': 'User Agent', 'default_name': 'Browser/User Agent', 'enabled': False},
{'key': 'latitude', 'label': 'Latitude', 'default_name': 'GPS Latitude', 'enabled': False},
{'key': 'longitude', 'label': 'Longitude', 'default_name': 'GPS Longitude', 'enabled': False},
{'key': 'accuracy', 'label': 'GPS Accuracy', 'default_name': 'GPS Accuracy (meters)', 'enabled': False},
]
# Add location accuracy column if feature exists
if has_location_accuracy:
available_columns.append({
'key': 'location_accuracy',
'label': 'Location Accuracy',
'default_name': 'Distance',
'enabled': True # Changed from False to True
})
logger_handler.logger.debug(f"Rendering export configuration with {len(available_columns)} columns")
return render_template('export_configuration.html',
available_columns=available_columns,
filters=filters,
project_name=project_name,
has_location_accuracy_feature=has_location_accuracy)
except Exception as e:
logger_handler.logger.error(f"Error in export_configuration route: {e}", exc_info=True)
# Use your existing logger error method with correct parameters
try:
logger_handler.log_flask_error(
'export_configuration_error',
str(e),
stack_trace=traceback.format_exc()
)
except Exception as log_error:
logger_handler.logger.warning(f"Could not log error: {log_error}")
flash('Error loading export configuration page.', 'error')
return redirect(url_for('attendance.attendance_report'))
@bp.route('/generate-excel-export', methods=['POST'], endpoint='generate_excel_export')
@login_required
def generate_excel_export():
"""Generate and download Excel file with selected columns in specified order"""
try:
user_role = session.get('role')
if user_role not in ['admin', 'payroll', 'accounting']:
logger_handler.logger.warning(f"User {session.get('username', 'unknown')} (role: {user_role}) attempted unauthorized Excel export")
flash('Access denied. Only administrators and payroll staff can export data.', 'error')
return redirect(url_for('attendance.attendance_report'))
logger_handler.logger.info(f"Excel export started by user {session.get('username', 'unknown')}")
# Log export action using your existing logger
try:
logger_handler.logger.info(f"User {session.get('username', 'unknown')} generated Excel export")
except Exception:
pass
# Get selected columns and custom names from form
selected_columns_raw = request.form.getlist('selected_columns')
logger_handler.logger.debug(f"Selected columns (raw): {selected_columns_raw}")
# Get column order from form
column_order_json = request.form.get('column_order', '[]')
try:
column_order = json.loads(column_order_json) if column_order_json else []
except (json.JSONDecodeError, TypeError):
column_order = []
logger_handler.logger.debug(f"Column order from form: {column_order}")
# Determine final column order
if column_order:
# Use the specified order, but only include actually selected columns
selected_columns = [col for col in column_order if col in selected_columns_raw]
# Add any selected columns that weren't in the order (shouldn't happen, but safety check)
for col in selected_columns_raw:
if col not in selected_columns:
selected_columns.append(col)
else:
# Fallback to raw selection order
selected_columns = selected_columns_raw
logger_handler.logger.debug(f"Final column order: {selected_columns}")
if not selected_columns:
flash('Please select at least one column to export.', 'error')
return redirect(url_for('attendance.export_configuration'))
column_names = {}
for column in selected_columns:
column_names[column] = request.form.get(f'name_{column}', column)
# Get filters
filters = {
'date_from': request.form.get('date_from'),
'date_to': request.form.get('date_to'),
'location_filter': request.form.get('location_filter'),
'employee_filter': request.form.get('employee_filter'),
'project_filter': request.form.get('project_filter')
}
logger_handler.logger.debug(f"Export filters: {filters}")
# Save user preferences in session for next time
session['export_preferences'] = {
'selected_columns': selected_columns,
'column_names': column_names,
'column_order': selected_columns # This is now the ordered list
}
# Generate Excel file with ordered columns
excel_file = create_excel_export_ordered(selected_columns, column_names, filters)
if excel_file:
# Get project name if project filter exists
project_name_for_filename = ''
if filters.get('project_filter'):
try:
project = db.session.get(Project, int(filters['project_filter']))
if project:
# Replace spaces and special characters with underscores
project_name_safe = project.name.replace(' ', '_').replace('/', '_').replace('\\', '_')
project_name_for_filename = f"{project_name_safe}_"
except Exception as e:
logger_handler.logger.warning(f"Error getting project name for filename: {e}")
# Format dates for filename (MMDDYYYY format)
date_from_formatted = ''
date_to_formatted = ''
if filters.get('date_from'):
try:
date_obj = datetime.strptime(filters['date_from'], '%Y-%m-%d')
date_from_formatted = date_obj.strftime('%m%d%Y')
except ValueError:
pass
if filters.get('date_to'):
try:
date_obj = datetime.strptime(filters['date_to'], '%Y-%m-%d')
date_to_formatted = date_obj.strftime('%m%d%Y')
except ValueError:
pass
# Build filename components
# Format: [project_name_]attendance_report_[fromdate_todate].xlsx
date_range_str = ''
if date_from_formatted and date_to_formatted:
date_range_str = f"{date_from_formatted}_{date_to_formatted}"
elif date_from_formatted:
date_range_str = f"{date_from_formatted}"
elif date_to_formatted:
date_range_str = f"{date_to_formatted}"
filename = f'{project_name_for_filename}attendance_report_{date_range_str}.xlsx'
logger_handler.logger.info(f"Excel export generated successfully: {filename}")
# Log successful export using your existing logger
try:
logger_handler.logger.info(f"Excel export generated successfully with {len(selected_columns)} columns in custom order by user {session.get('username', 'unknown')}: {filename}")
except Exception:
pass
return send_file(
excel_file,
as_attachment=True,
download_name=filename,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
else:
flash('Error generating Excel file.', 'error')
return redirect(url_for('attendance.export_configuration'))
except Exception as e:
logger_handler.logger.error(f"Error in generate_excel_export route: {e}", exc_info=True)
# Use your existing logger error method with correct parameters
try:
logger_handler.log_flask_error(
'excel_export_error',
str(e),
stack_trace=traceback.format_exc()
)
except Exception as log_error:
logger_handler.logger.warning(f"Could not log error: {log_error}")
flash('Error generating Excel export.', 'error')
return redirect(url_for('attendance.export_configuration'))
def create_excel_export(selected_columns, column_names, filters):
"""Create Excel file with selected attendance data - Updated to include employee names"""
try:
logger_handler.logger.info(f"Creating Excel export with {len(selected_columns)} columns")
# Import openpyxl modules
try:
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
from openpyxl.utils import get_column_letter
except ImportError as e:
logger_handler.logger.error(f"openpyxl import error: {e}. Run: pip install openpyxl")
return None
# Build query based on filters - JOIN with QRCode to get location_event and location_address
# Now also JOIN with Employee table to get employee names
query = db.session.query(AttendanceData, QRCode, Employee).join(
QRCode, AttendanceData.qr_code_id == QRCode.id
).outerjoin(
Employee, text("CAST(attendance_data.employee_id AS UNSIGNED) = employee.id")
)
# Apply date filters
if filters.get('date_from'):
try:
date_from = datetime.strptime(filters['date_from'], '%Y-%m-%d').date()
query = query.filter(AttendanceData.check_in_date >= date_from)
logger_handler.logger.debug(f"Applied date_from filter: {date_from}")
except ValueError as e:
logger_handler.logger.warning(f"Invalid date_from format: {e}")
if filters.get('date_to'):
try:
date_to = datetime.strptime(filters['date_to'], '%Y-%m-%d').date()
query = query.filter(AttendanceData.check_in_date <= date_to)
logger_handler.logger.debug(f"Applied date_to filter: {date_to}")
except ValueError as e:
logger_handler.logger.warning(f"Invalid date_to format: {e}")
# Apply location filter
if filters.get('location_filter'):
query = query.filter(AttendanceData.location_name.like(f"%{filters['location_filter']}%"))
logger_handler.logger.debug(f"Applied location filter: {filters['location_filter']}")
# Apply employee filter — supports comma-separated multi-employee values.
# Each ID is expanded into its SP/PW/PT spellings so extra-work check-ins
# are exported alongside regular ones (same rule as the attendance report).
if filters.get('employee_filter'):
emp_ids = [e.strip() for e in filters['employee_filter'].split(',') if e.strip()]
if emp_ids:
exact_variants, regex_patterns = expand_employee_id_filter(emp_ids)
if regex_patterns:
query = query.filter(or_(
AttendanceData.employee_id.in_(exact_variants),
employee_id_regex_condition(AttendanceData.employee_id, regex_patterns)
))
else:
query = query.filter(AttendanceData.employee_id.in_(exact_variants))
logger_handler.logger.debug(f"Applied employee filter: {emp_ids}")
# Apply project filter
if filters.get('project_filter'):
try:
project_id = int(filters['project_filter'])
# For standard QR records: match by the QR code's own project_id.
# For dynamic QR records: the dynamic QR may not belong to any project,
# but the employee-selected location corresponds to a standard QR in that
# project. Include them by matching location_name against standard QRs
# in the selected project.
query = query.filter(
or_(
QRCode.project_id == project_id,
and_(
AttendanceData.is_dynamic_qr == True,
AttendanceData.location_name.in_(
db.session.query(QRCode.location)
.filter(
QRCode.project_id == project_id,
QRCode.qr_type == 'standard',
QRCode.location.isnot(None),
QRCode.location != ''
)
.subquery()
)
)
)
)
logger_handler.logger.debug(f"Applied project filter: {project_id}")
except (ValueError, TypeError) as e:
logger_handler.logger.warning(f"Invalid project filter: {e}")
# Order by date and time
query = query.order_by(AttendanceData.check_in_date.desc(), AttendanceData.check_in_time.desc())
# Execute query
results = query.all()
logger_handler.logger.debug(f"Query returned {len(results)} records for export")
if not results:
logger_handler.logger.warning("No records found for export")
return None
# Create workbook
wb = Workbook()
ws = wb.active
ws.title = "Attendance Report"
# Header styling
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
# Set headers based on selected columns
headers = []
for column_key in selected_columns:
header_name = column_names.get(column_key, column_key)
headers.append(header_name)
# Write headers
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# Write data rows
for row_idx, (attendance_record, qr_record, employee_record) in enumerate(results, 2):
for col_idx, column_key in enumerate(selected_columns, 1):
cell = ws.cell(row=row_idx, column=col_idx)
try:
# Handle each column type
if column_key == 'employee_id':
cell.value = format_employee_id_for_excel(attendance_record.employee_id)
elif column_key == 'employee_name':
# NEW: Handle employee name from joined Employee table
if employee_record:
cell.value = f"{employee_record.lastName}, {employee_record.firstName}"
else:
cell.value = f"Unknown (ID: {attendance_record.employee_id})"
elif column_key == 'location_name':
cell.value = attendance_record.location_name or ''
elif column_key == 'status':
cell.value = qr_record.location_event if qr_record.location_event else 'Check In'
elif column_key == 'check_in_date':
cell.value = attendance_record.check_in_date.strftime('%Y-%m-%d') if attendance_record.check_in_date else ''
elif column_key == 'check_in_time':
cell.value = attendance_record.check_in_time.strftime('%H:%M:%S') if attendance_record.check_in_time else ''
elif column_key == 'qr_address':
# Use attendance-level qr_address first (set for dynamic QR check-ins),
# fall back to the QR code's location_address for standard QR.
cell.value = (
getattr(attendance_record, 'qr_address', None)
or (qr_record.location_address if qr_record else '')
or ''
)
elif column_key == 'address':
# Check-in address logic based on location accuracy WITH HYPERLINKS
# If location accuracy < 0.3 miles, use QR address; otherwise use actual check-in address
if hasattr(attendance_record, 'location_accuracy') and attendance_record.location_accuracy is not None:
try:
accuracy_value = float(attendance_record.location_accuracy)
if accuracy_value < 0.3:
# High accuracy - use QR code ADDRESS (not location) with hyperlink
address_text = (
getattr(attendance_record, 'qr_address', None)
or (qr_record.location_address if qr_record and qr_record.location_address else '')
or ''
)
if address_text and hasattr(qr_record, 'address_latitude') and hasattr(qr_record, 'address_longitude') and qr_record.address_latitude and qr_record.address_longitude:
# Format coordinates with 10 decimal places
lat_formatted = f"{float(qr_record.address_latitude):.10f}"
lng_formatted = f"{float(qr_record.address_longitude):.10f}"
hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")'
cell.value = hyperlink_formula
logger_handler.logger.debug(f"Added QR address hyperlink for employee {attendance_record.employee_id}")
else:
cell.value = address_text
logger_handler.logger.debug(f"Using QR address for employee {attendance_record.employee_id} (accuracy: {accuracy_value:.3f} miles)")
else:
# Lower accuracy - use actual check-in address with hyperlink
address_text = attendance_record.address or ''
if address_text and attendance_record.latitude and attendance_record.longitude:
# Format coordinates with 10 decimal places
lat_formatted = f"{float(attendance_record.latitude):.10f}"
lng_formatted = f"{float(attendance_record.longitude):.10f}"
hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")'
cell.value = hyperlink_formula
logger_handler.logger.debug(f"Added check-in address hyperlink for employee {attendance_record.employee_id}")
else:
cell.value = address_text
logger_handler.logger.debug(f"Using check-in address for employee {attendance_record.employee_id} (accuracy: {accuracy_value:.3f} miles)")
except (ValueError, TypeError):
# If accuracy can't be converted to float, use check-in address with hyperlink
address_text = attendance_record.address or ''
if address_text and attendance_record.latitude and attendance_record.longitude:
# Format coordinates with 10 decimal places
lat_formatted = f"{float(attendance_record.latitude):.10f}"
lng_formatted = f"{float(attendance_record.longitude):.10f}"
hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")'
cell.value = hyperlink_formula
logger_handler.logger.debug(f"Added check-in address hyperlink (fallback) for employee {attendance_record.employee_id}")
else:
cell.value = address_text
else:
# No location accuracy data - use actual check-in address with hyperlink
address_text = attendance_record.address or ''
if address_text and attendance_record.latitude and attendance_record.longitude:
# Format coordinates with 10 decimal places
lat_formatted = f"{float(attendance_record.latitude):.10f}"
lng_formatted = f"{float(attendance_record.longitude):.10f}"
hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")'
cell.value = hyperlink_formula
logger_handler.logger.debug(f"Added check-in address hyperlink (no accuracy data) for employee {attendance_record.employee_id}")
else:
cell.value = address_text
elif column_key == 'device_info':
cell.value = attendance_record.device_info or ''
elif column_key == 'ip_address':
cell.value = attendance_record.ip_address or ''
elif column_key == 'user_agent':
cell.value = attendance_record.user_agent or ''
elif column_key == 'latitude':
cell.value = attendance_record.latitude or ''
elif column_key == 'longitude':
cell.value = attendance_record.longitude or ''
elif column_key == 'accuracy':
cell.value = attendance_record.accuracy or ''
elif column_key == 'location_accuracy':
cell.value = attendance_record.location_accuracy or ''
else:
cell.value = ''
except Exception as cell_error:
logger_handler.logger.warning(f"Error setting cell value for {column_key}: {cell_error}")
cell.value = ''
# Auto-adjust column widths based on content and header
for col_idx, column_key in enumerate(selected_columns, 1):
column_letter = get_column_letter(col_idx)
max_length = 0
# Get header name length
header_name = column_names.get(column_key, column_key)
max_length = len(str(header_name))
# Check content in all rows (sample first 100 rows for performance)
for row_idx in range(2, min(102, ws.max_row + 1)):
cell = ws.cell(row=row_idx, column=col_idx)
try:
cell_value = str(cell.value) if cell.value else ''
# For HYPERLINK formulas, extract the display text
if cell_value.startswith('=HYPERLINK'):
# Extract text between last quotes: HYPERLINK("url","display_text")
import re
match = re.search(r',"([^"]+)"\)$', cell_value)
if match:
cell_value = match.group(1)
if len(cell_value) > max_length:
max_length = len(cell_value)
except Exception:
pass # Non-string cell value — skip width measurement
# Set width based on column type with reasonable limits
# Define optimal widths for specific column types
column_width_rules = {
'employee_id': {'min': 8, 'max': 15},
'employee_name': {'min': 20, 'max': 30},
'location_name': {'min': 15, 'max': 35},
'status': {'min': 12, 'max': 20},
'check_in_date': {'min': 12, 'max': 15},
'check_in_time': {'min': 10, 'max': 12},
'qr_address': {'min': 20, 'max': 40},
'address': {'min': 20, 'max': 45},
'device_info': {'min': 12, 'max': 20},
'ip_address': {'min': 14, 'max': 18},
'user_agent': {'min': 15, 'max': 30},
'latitude': {'min': 12, 'max': 15},
'longitude': {'min': 12, 'max': 15},
'accuracy': {'min': 10, 'max': 15},
'location_accuracy': {'min': 10, 'max': 15}
}
# Get rules for this column or use defaults
rules = column_width_rules.get(column_key, {'min': 10, 'max': 40})
# Calculate adjusted width: add 2 for padding, respect min/max
adjusted_width = max_length + 2
adjusted_width = max(rules['min'], min(adjusted_width, rules['max']))
ws.column_dimensions[column_letter].width = adjusted_width
logger_handler.logger.debug(f"Column {column_letter} ({column_key}): width={adjusted_width} (max_content={max_length})")
# Save to BytesIO
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
logger_handler.logger.info("Excel file created successfully with employee names")
# Log export action with employee name column
try:
logger_handler.logger.info(f"Excel export with employee names generated by user {session.get('username', 'unknown')}")
except Exception:
pass
return excel_buffer
except Exception as e:
logger_handler.logger.error(f"Error creating Excel export: {e}", exc_info=True)
# Log error
try:
logger_handler.log_flask_error(
'excel_export_error',
str(e),
stack_trace=traceback.format_exc()
)
except Exception as log_error:
logger_handler.logger.warning(f"Could not log error: {log_error}")
return None
def format_employee_id_for_excel(employee_id):
if not employee_id:
return ''
emp_id_str = str(employee_id).strip()
if emp_id_str.isdigit():
return int(emp_id_str)
else:
return emp_id_str
def create_excel_export_ordered(selected_columns, column_names, filters):
"""Create Excel file with selected attendance data in specified column order"""
try:
logger_handler.logger.info(f"Creating Excel export with {len(selected_columns)} columns")
# Import openpyxl modules
try:
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
from openpyxl.utils import get_column_letter
except ImportError as e:
logger_handler.logger.error(f"openpyxl import error: {e}. Run: pip install openpyxl")
return None
# Build query based on filters - JOIN with QRCode to get location_event and location_address
# Now also JOIN with Employee table to get employee names
query = db.session.query(AttendanceData, QRCode, Employee).join(
QRCode, AttendanceData.qr_code_id == QRCode.id
).outerjoin(
Employee, text("CAST(attendance_data.employee_id AS UNSIGNED) = employee.id")
)
# Apply date filters
if filters.get('date_from'):
try:
date_from = datetime.strptime(filters['date_from'], '%Y-%m-%d').date()
query = query.filter(AttendanceData.check_in_date >= date_from)
logger_handler.logger.debug(f"Applied date_from filter: {date_from}")
except ValueError as e:
logger_handler.logger.warning(f"Invalid date_from format: {e}")
if filters.get('date_to'):
try:
date_to = datetime.strptime(filters['date_to'], '%Y-%m-%d').date()
query = query.filter(AttendanceData.check_in_date <= date_to)
logger_handler.logger.debug(f"Applied date_to filter: {date_to}")
except ValueError as e:
logger_handler.logger.warning(f"Invalid date_to format: {e}")
# Apply location filter
if filters.get('location_filter'):
query = query.filter(AttendanceData.location_name.like(f"%{filters['location_filter']}%"))
logger_handler.logger.debug(f"Applied location filter: {filters['location_filter']}")
# Apply employee filter — supports comma-separated multi-employee values.
# Each ID is expanded into its SP/PW/PT spellings so extra-work check-ins
# are exported alongside regular ones (same rule as the attendance report).
if filters.get('employee_filter'):
emp_ids = [e.strip() for e in filters['employee_filter'].split(',') if e.strip()]
if emp_ids:
exact_variants, regex_patterns = expand_employee_id_filter(emp_ids)
if regex_patterns:
query = query.filter(or_(
AttendanceData.employee_id.in_(exact_variants),
employee_id_regex_condition(AttendanceData.employee_id, regex_patterns)
))
else:
query = query.filter(AttendanceData.employee_id.in_(exact_variants))
logger_handler.logger.debug(f"Applied employee filter: {emp_ids}")
# Apply project filter
if filters.get('project_filter'):
try:
project_id = int(filters['project_filter'])
# For standard QR records: match by the QR code's own project_id.
# For dynamic QR records: the dynamic QR may not belong to any project,
# but the employee-selected location corresponds to a standard QR in that
# project. Include them by matching location_name against standard QRs
# in the selected project.
query = query.filter(
or_(
QRCode.project_id == project_id,
and_(
AttendanceData.is_dynamic_qr == True,
AttendanceData.location_name.in_(
db.session.query(QRCode.location)
.filter(
QRCode.project_id == project_id,
QRCode.qr_type == 'standard',
QRCode.location.isnot(None),
QRCode.location != ''
)
.subquery()
)
)
)
)
logger_handler.logger.debug(f"Applied project filter: {project_id}")
except (ValueError, TypeError) as e:
logger_handler.logger.warning(f"Invalid project filter: {e}")
# Order by date and time
query = query.order_by(AttendanceData.check_in_date.desc(), AttendanceData.check_in_time.desc())
# Execute query
results = query.all()
logger_handler.logger.debug(f"Query returned {len(results)} records for export")
if not results:
logger_handler.logger.warning("No records found for export")
return None
# Create workbook
wb = Workbook()
ws = wb.active
ws.title = "Attendance Report"
# Header styling
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="366092", end_color="366092", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
# Verification status color fills for location_accuracy column
# Yellow for pending, Green for approved, Red for rejected
verification_fill_pending = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid") # Yellow
verification_fill_approved = PatternFill(start_color="90EE90", end_color="90EE90", fill_type="solid") # Light Green
verification_fill_rejected = PatternFill(start_color="FF6B6B", end_color="FF6B6B", fill_type="solid") # Light Red
# Set headers based on selected columns in the specified order
headers = []
for column_key in selected_columns:
header_name = column_names.get(column_key, column_key)
headers.append(header_name)
# Write headers
for col, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# Write data rows
for row_idx, (attendance_record, qr_record, employee_record) in enumerate(results, 2):
for col_idx, column_key in enumerate(selected_columns, 1):
cell = ws.cell(row=row_idx, column=col_idx)
try:
# Handle each column type
if column_key == 'employee_id':
cell.value = format_employee_id_for_excel(attendance_record.employee_id)
elif column_key == 'employee_name':
# NEW: Handle employee name from joined Employee table
if employee_record:
cell.value = f"{employee_record.lastName}, {employee_record.firstName}"
else:
cell.value = f"Unknown (ID: {attendance_record.employee_id})"
elif column_key == 'location_name':
cell.value = attendance_record.location_name or ''
elif column_key == 'status':
cell.value = qr_record.location_event if qr_record.location_event else 'Check In'
elif column_key == 'check_in_date':
cell.value = attendance_record.check_in_date.strftime('%Y-%m-%d') if attendance_record.check_in_date else ''
elif column_key == 'check_in_time':
cell.value = attendance_record.check_in_time.strftime('%H:%M:%S') if attendance_record.check_in_time else ''
elif column_key == 'qr_address':
# Use attendance-level qr_address first (set for dynamic QR check-ins),
# fall back to the QR code's location_address for standard QR.
cell.value = (
getattr(attendance_record, 'qr_address', None)
or (qr_record.location_address if qr_record else '')
or ''
)
elif column_key == 'address':
# Check-in address logic based on location accuracy WITH HYPERLINKS
# If location accuracy < 0.3 miles, use QR address; otherwise use actual check-in address
if hasattr(attendance_record, 'location_accuracy') and attendance_record.location_accuracy is not None:
try:
accuracy_value = float(attendance_record.location_accuracy)
if accuracy_value < 0.3:
# High accuracy - use QR code ADDRESS (not location) with hyperlink
address_text = (
getattr(attendance_record, 'qr_address', None)
or (qr_record.location_address if qr_record and qr_record.location_address else '')
or ''
)
if address_text and hasattr(qr_record, 'address_latitude') and hasattr(qr_record, 'address_longitude') and qr_record.address_latitude and qr_record.address_longitude:
# Format coordinates with 10 decimal places
lat_formatted = f"{float(qr_record.address_latitude):.10f}"
lng_formatted = f"{float(qr_record.address_longitude):.10f}"
hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")'
cell.value = hyperlink_formula
logger_handler.logger.debug(f"Added QR address hyperlink for employee {attendance_record.employee_id}")
else:
cell.value = address_text
logger_handler.logger.debug(f"Using QR address for employee {attendance_record.employee_id} (accuracy: {accuracy_value:.3f} miles)")
else:
# Lower accuracy - use actual check-in address with hyperlink
address_text = attendance_record.address or ''
if address_text and attendance_record.latitude and attendance_record.longitude:
# Format coordinates with 10 decimal places
lat_formatted = f"{float(attendance_record.latitude):.10f}"
lng_formatted = f"{float(attendance_record.longitude):.10f}"
hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")'
cell.value = hyperlink_formula
logger_handler.logger.debug(f"Added check-in address hyperlink for employee {attendance_record.employee_id}")
else:
cell.value = address_text
logger_handler.logger.debug(f"Using check-in address for employee {attendance_record.employee_id} (accuracy: {accuracy_value:.3f} miles)")
except (ValueError, TypeError):
# If accuracy can't be converted to float, use check-in address with hyperlink
address_text = attendance_record.address or ''
if address_text and attendance_record.latitude and attendance_record.longitude:
# Format coordinates with 10 decimal places
lat_formatted = f"{float(attendance_record.latitude):.10f}"
lng_formatted = f"{float(attendance_record.longitude):.10f}"
hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")'
cell.value = hyperlink_formula
logger_handler.logger.debug(f"Added check-in address hyperlink (fallback) for employee {attendance_record.employee_id}")
else:
cell.value = address_text
else:
# No location accuracy data - use actual check-in address with hyperlink
address_text = attendance_record.address or ''
if address_text and attendance_record.latitude and attendance_record.longitude:
# Format coordinates with 10 decimal places
lat_formatted = f"{float(attendance_record.latitude):.10f}"
lng_formatted = f"{float(attendance_record.longitude):.10f}"
hyperlink_formula = f'=HYPERLINK("http://maps.google.com/maps?q={lat_formatted},{lng_formatted}","{address_text.strip()}")'
cell.value = hyperlink_formula
logger_handler.logger.debug(f"Added check-in address hyperlink (no accuracy data) for employee {attendance_record.employee_id}")
else:
cell.value = address_text
elif column_key == 'device_info':
cell.value = attendance_record.device_info or ''
elif column_key == 'ip_address':
cell.value = attendance_record.ip_address or ''
elif column_key == 'user_agent':
cell.value = attendance_record.user_agent or ''
elif column_key == 'latitude':
cell.value = attendance_record.latitude or ''
elif column_key == 'longitude':
cell.value = attendance_record.longitude or ''
elif column_key == 'accuracy':
cell.value = attendance_record.accuracy or ''
elif column_key == 'location_accuracy':
cell.value = attendance_record.location_accuracy or ''
# Apply color fill based on verification_status
# Only apply color if verification_status is not NULL
if hasattr(attendance_record, 'verification_status') and attendance_record.verification_status:
if attendance_record.verification_status == 'pending':
cell.fill = verification_fill_pending # Yellow
elif attendance_record.verification_status == 'approved':
cell.fill = verification_fill_approved # Green
elif attendance_record.verification_status == 'rejected':
cell.fill = verification_fill_rejected # Red
else:
cell.value = ''
except Exception as cell_error:
logger_handler.logger.warning(f"Error setting cell value for {column_key}: {cell_error}")
cell.value = ''
# Auto-adjust column widths based on content and header
for col_idx, column_key in enumerate(selected_columns, 1):
column_letter = get_column_letter(col_idx)
max_length = 0
# Get header name length
header_name = column_names.get(column_key, column_key)
max_length = len(str(header_name))
# Check content in all rows (sample first 100 rows for performance)
for row_idx in range(2, min(102, ws.max_row + 1)):
cell = ws.cell(row=row_idx, column=col_idx)
try:
cell_value = str(cell.value) if cell.value else ''
# For HYPERLINK formulas, extract the display text
if cell_value.startswith('=HYPERLINK'):
# Extract text between last quotes: HYPERLINK("url","display_text")
import re
match = re.search(r',"([^"]+)"\)$', cell_value)
if match:
cell_value = match.group(1)
if len(cell_value) > max_length:
max_length = len(cell_value)
except Exception:
pass # Non-string cell value — skip width measurement
# Set width based on column type with reasonable limits
# Define optimal widths for specific column types
column_width_rules = {
'employee_id': {'min': 8, 'max': 15},
'employee_name': {'min': 20, 'max': 30},
'location_name': {'min': 15, 'max': 35},
'status': {'min': 12, 'max': 20},
'check_in_date': {'min': 12, 'max': 15},
'check_in_time': {'min': 10, 'max': 12},
'qr_address': {'min': 20, 'max': 40},
'address': {'min': 20, 'max': 45},
'device_info': {'min': 12, 'max': 20},
'ip_address': {'min': 14, 'max': 18},
'user_agent': {'min': 15, 'max': 30},
'latitude': {'min': 12, 'max': 15},
'longitude': {'min': 12, 'max': 15},
'accuracy': {'min': 10, 'max': 15},
'location_accuracy': {'min': 10, 'max': 15}
}
# Get rules for this column or use defaults
rules = column_width_rules.get(column_key, {'min': 10, 'max': 40})
# Calculate adjusted width: add 2 for padding, respect min/max
adjusted_width = max_length + 2
adjusted_width = max(rules['min'], min(adjusted_width, rules['max']))
ws.column_dimensions[column_letter].width = adjusted_width
logger_handler.logger.debug(f"Column {column_letter} ({column_key}): width={adjusted_width} (max_content={max_length})")
# Save to BytesIO
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
logger_handler.logger.info("Excel file created successfully with employee names and verification status coloring")
# Log export action with employee name column and verification status coloring
try:
logger_handler.logger.info(f"Excel export with employee names and verification status coloring generated by user {session.get('username', 'unknown')}")
except Exception:
pass
return excel_buffer
except Exception as e:
logger_handler.logger.error(f"Error creating Excel export: {e}", exc_info=True)
# Log error
try:
logger_handler.log_flask_error(
'excel_export_ordered_error',
str(e),
stack_trace=traceback.format_exc()
)
except Exception as log_error:
logger_handler.logger.warning(f"Could not log error: {log_error}")
return None