Sep 11 - Reupload the code
This commit is contained in:
@@ -0,0 +1,962 @@
|
||||
"""
|
||||
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
|
||||
Reference in New Issue
Block a user