Files
GOV_QR_Codes_Management/routes/attendance.py
T
2026-03-20 15:04:10 -04:00

2371 lines
118 KiB
Python

"""
routes/attendance.py
====================
Attendance check-in records, manual entry, verification review,
export configuration, and Excel export routes.
Routes: /attendance, /attendance/<id>/edit, /attendance/add,
/attendance/save_manual, /api/attendance/*, /api/search_employees,
/api/get_project_locations, /verification-review/*,
/export-configuration, /generate-excel-export
"""
from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, send_file
from datetime import datetime, date, timedelta, time
import io, os, json, re, traceback
from extensions import db, logger_handler
from sqlalchemy import text
from logger_handler import log_user_activity, log_database_operations
from utils.helpers import (url_for,
admin_required,
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
bp = Blueprint('attendance', __name__)
def _get_models():
"""Return model classes from the current app context."""
from flask import current_app
return current_app.config['_models']
@bp.route('/attendance', endpoint='attendance_report')
@login_required
def attendance_report():
"""Safe attendance report with backward compatibility for location_accuracy and fixed datetime handling"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
print("📊 Loading attendance report...")
# Log attendance report access
try:
user_role = session.get('role', 'unknown')
logger_handler.logger.info(f"User {session.get('username', 'unknown')} accessed attendance report")
except Exception as log_error:
print(f"⚠️ Logging error (non-critical): {log_error}")
# Check if location_accuracy column exists
has_location_accuracy = check_location_accuracy_column_exists()
print(f"🔍 Location accuracy column exists: {has_location_accuracy}")
# Get filter parameters
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
location_filter = request.args.get('location', '')
# employee param is now a comma-separated list of IDs (multi-employee filter)
employee_filter = request.args.get('employee', '')
project_filter = request.args.get('project', '')
# Build the list of selected employee IDs (strip blanks)
employee_ids = [e.strip() for e in employee_filter.split(',') if e.strip()] if employee_filter else []
# Build display names for each selected employee
employee_display_names = []
for eid in employee_ids:
try:
emp = Employee.query.filter_by(id=int(eid)).first()
if emp:
employee_display_names.append({
'id': eid,
'name': f"{emp.lastName}, {emp.firstName}"
})
else:
employee_display_names.append({'id': eid, 'name': f"ID: {eid}"})
except (ValueError, TypeError):
employee_display_names.append({'id': eid, 'name': eid})
# Legacy single-value display name (kept for backward compat in template)
employee_display_name = ', '.join([e['name'] for e in employee_display_names])
# ============================================================
# PROJECT MANAGER ACCESS CONTROL
# ============================================================
user_role = session.get('role')
user_id = session.get('user_id')
# Initialize permission filters
allowed_project_ids = []
allowed_location_names = []
# Check if user is Project Manager and get their permissions
if user_role == 'project_manager':
print(f"🔒 Project Manager access control enabled for user {session.get('username')}")
try:
# Get assigned projects
assigned_projects = UserProjectPermission.query.filter_by(user_id=user_id).all()
allowed_project_ids = [p.project_id for p in assigned_projects]
# Get assigned locations
assigned_locations = UserLocationPermission.query.filter_by(user_id=user_id).all()
allowed_location_names = [l.location_name for l in assigned_locations]
# Log the permissions
logger_handler.logger.info(
f"🔒 Project Manager {session.get('username')} restricted to: "
f"Projects: {allowed_project_ids}, Locations: {allowed_location_names}"
)
print(f"🔒 Allowed projects: {allowed_project_ids}")
print(f"🔒 Allowed locations: {allowed_location_names}")
except Exception as perm_error:
print(f"⚠️ Error loading permissions: {perm_error}")
logger_handler.logger.error(f"Error loading Project Manager permissions: {perm_error}")
# If no permissions assigned, user cannot view anything
if not allowed_project_ids and not allowed_location_names:
logger_handler.logger.warning(
f"Project Manager {session.get('username')} has no assigned projects or locations"
)
flash('You do not have access to any projects or locations. Please contact an administrator.', 'warning')
# Create empty stats object using named tuple style
from collections import namedtuple
Stats = namedtuple('Stats', ['total_checkins', 'unique_employees', 'active_locations',
'today_checkins', 'records_with_gps', 'records_with_accuracy',
'avg_location_accuracy'])
empty_stats = Stats(0, 0, 0, 0, 0, 0, 0)
# Return empty template
return render_template('attendance_report.html',
attendance_records=[],
locations=[],
projects=[],
stats=empty_stats,
date_from=date_from,
date_to=date_to,
location_filter=location_filter,
employee_filter=employee_filter,
employee_ids=employee_ids,
employee_display_names=employee_display_names,
employee_display_name=employee_display_name,
project_filter=project_filter,
today_date=datetime.now().strftime('%Y-%m-%d'),
current_date_formatted=datetime.now().strftime('%B %d'),
has_location_accuracy_feature=has_location_accuracy,
user_role=user_role)
# ============================================================
# END: PROJECT MANAGER ACCESS CONTROL
# ============================================================
# Build base query - conditional based on column existence
if has_location_accuracy:
# New query with location accuracy
base_query = """
SELECT
ad.id,
ad.employee_id,
ad.check_in_date,
ad.check_in_time,
ad.location_name,
qc.location_event,
qc.location_address as qr_address,
ad.address as checked_in_address,
ad.latitude,
ad.longitude,
ad.location_accuracy,
ad.accuracy as gps_accuracy,
ad.device_info,
ad.created_timestamp,
ad.updated_timestamp,
CONCAT(e.firstName, ' ', e.lastName) as employee_name,
ad.verification_required,
ad.verification_status,
ad.verification_photo
FROM attendance_data ad
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id
WHERE 1=1
"""
else:
# Fallback query without location accuracy
base_query = """
SELECT
ad.id,
ad.employee_id,
ad.check_in_date,
ad.check_in_time,
ad.location_name,
qc.location_event,
qc.location_address as qr_address,
ad.address as checked_in_address,
ad.latitude,
ad.longitude,
NULL as location_accuracy,
ad.accuracy as gps_accuracy,
ad.device_info,
ad.created_timestamp,
ad.updated_timestamp,
CONCAT(e.firstName, ' ', e.lastName) as employee_name,
ad.verification_required,
ad.verification_status,
ad.verification_photo
FROM attendance_data ad
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id
WHERE 1=1
"""
# Prepare filter conditions and parameters
filter_conditions = []
query_params = {}
# ============================================================
# APPLY PROJECT MANAGER FILTERS TO SQL QUERY
# ============================================================
if user_role == 'project_manager':
# Filter by allowed projects
if allowed_project_ids:
project_placeholders = ','.join([f':project_{i}' for i in range(len(allowed_project_ids))])
filter_conditions.append(f"qc.project_id IN ({project_placeholders})")
for i, pid in enumerate(allowed_project_ids):
query_params[f'project_{i}'] = pid
# Filter by allowed locations
if allowed_location_names:
location_placeholders = ','.join([f':location_{i}' for i in range(len(allowed_location_names))])
filter_conditions.append(f"ad.location_name IN ({location_placeholders})")
for i, loc in enumerate(allowed_location_names):
query_params[f'location_{i}'] = loc
# ============================================================
# END: APPLY PROJECT MANAGER FILTERS
# ============================================================
# Apply user-selected filters
if date_from:
filter_conditions.append("ad.check_in_date >= :date_from")
query_params['date_from'] = date_from
if date_to:
filter_conditions.append("ad.check_in_date <= :date_to")
query_params['date_to'] = date_to
if location_filter:
filter_conditions.append("ad.location_name = :location")
query_params['location'] = location_filter
if employee_ids:
if len(employee_ids) == 1:
filter_conditions.append("ad.employee_id = :employee_0")
query_params['employee_0'] = employee_ids[0]
else:
placeholders = ', '.join([f':employee_{i}' for i in range(len(employee_ids))])
filter_conditions.append(f"ad.employee_id IN ({placeholders})")
for i, eid in enumerate(employee_ids):
query_params[f'employee_{i}'] = eid
logger_handler.logger.info(
f"Attendance report filtered by employee IDs: {employee_ids} "
f"by user {session.get('username', 'unknown')}"
)
if project_filter:
filter_conditions.append("qc.project_id = :project")
query_params['project'] = project_filter
# Combine query with filters
if filter_conditions:
base_query += " AND " + " AND ".join(filter_conditions)
base_query += " ORDER BY ad.check_in_date DESC, ad.check_in_time DESC LIMIT 1000"
print(f"🔍 Executing attendance query with filters: {list(query_params.keys())}")
# Execute query
result = db.session.execute(text(base_query), query_params)
records = result.fetchall()
print(f"✅ Loaded {len(records)} attendance records")
# Process records
processed_records = []
for record in records:
try:
record_dict = {
'id': record[0],
'employee_id': record[1],
'check_in_date': record[2],
'check_in_time': record[3],
'location_name': record[4],
'location_event': record[5],
'qr_address': record[6],
'checked_in_address': record[7],
'latitude': record[8],
'longitude': record[9],
'location_accuracy': record[10] if has_location_accuracy else None,
'gps_accuracy': record[11],
'device_info': record[12],
'created_timestamp': record[13],
'updated_timestamp': record[14],
'employee_name': record[15] or 'Unknown Employee',
'verification_required': record[16] if len(record) > 16 else False,
'verification_status': record[17] if len(record) > 17 else None,
'verification_photo': record[18] if len(record) > 18 else None
}
# Calculate accuracy_level for template display
if record_dict['location_accuracy'] is not None:
accuracy_value = float(record_dict['location_accuracy'])
if accuracy_value <= 0.3:
record_dict['accuracy_level'] = 'accurate'
else:
record_dict['accuracy_level'] = 'inaccurate'
else:
record_dict['accuracy_level'] = 'unknown'
processed_records.append(record_dict)
except Exception as rec_error:
print(f"⚠️ Error processing record: {rec_error}")
continue
# Get unique locations for filter dropdown
try:
# ============================================================
# FILTER LOCATIONS FOR PROJECT MANAGER
# ============================================================
if user_role == 'project_manager' and allowed_location_names:
# Only show locations the PM has access to
locations = sorted(allowed_location_names)
print(f"✅ Filtered to {len(locations)} locations for Project Manager")
else:
# Show all locations for Admin/Staff/Payroll
locations_query = db.session.execute(text("""
SELECT DISTINCT location_name
FROM attendance_data
WHERE location_name IS NOT NULL
ORDER BY location_name
"""))
locations = [row[0] for row in locations_query.fetchall()]
print(f"✅ Found {len(locations)} unique locations")
# ============================================================
# END: FILTER LOCATIONS FOR PROJECT MANAGER
# ============================================================
except Exception as e:
print(f"⚠️ Error loading locations: {e}")
locations = []
# Get projects for filter dropdown
try:
# ============================================================
# FILTER PROJECTS FOR PROJECT MANAGER
# ============================================================
if user_role == 'project_manager' and allowed_project_ids:
# Only show projects the PM has access to
project_placeholders = ','.join([str(pid) for pid in allowed_project_ids])
projects_query = db.session.execute(text(f"""
SELECT p.id, p.name, COUNT(DISTINCT ad.id) as attendance_count
FROM projects p
LEFT JOIN qr_codes qc ON qc.project_id = p.id
LEFT JOIN attendance_data ad ON ad.qr_code_id = qc.id
WHERE p.active_status = true AND p.id IN ({project_placeholders})
GROUP BY p.id, p.name
ORDER BY p.name
"""))
projects = projects_query.fetchall()
print(f"✅ Filtered to {len(projects)} projects for Project Manager")
else:
# Show all projects for Admin/Staff/Payroll
projects = db.session.execute(text("""
SELECT p.id, p.name, COUNT(DISTINCT ad.id) as attendance_count
FROM projects p
LEFT JOIN qr_codes qc ON qc.project_id = p.id
LEFT JOIN attendance_data ad ON ad.qr_code_id = qc.id
WHERE p.active_status = true
GROUP BY p.id, p.name
HAVING COUNT(DISTINCT ad.id) > 0
ORDER BY p.name
""")).fetchall()
print(f"✅ Loaded {len(projects)} projects with attendance data")
# ============================================================
# END: FILTER PROJECTS FOR PROJECT MANAGER
# ============================================================
except Exception as e:
print(f"⚠️ Error loading projects: {e}")
projects = []
# ============================================================
# STATISTICS - COMPLETELY REWRITTEN FOR SAFETY
# ============================================================
print("📊 Loading statistics...")
# Create simple dict for stats (most compatible approach)
stats_dict = {
'total_checkins': 0,
'unique_employees': 0,
'active_locations': 0,
'today_checkins': 0,
'records_with_gps': 0,
'records_with_accuracy': 0,
'avg_location_accuracy': 0.0
}
try:
# Build stats query
if has_location_accuracy:
stats_select = """
SELECT
COALESCE(COUNT(*), 0) as total_checkins,
COALESCE(COUNT(DISTINCT employee_id), 0) as unique_employees,
COALESCE(COUNT(DISTINCT qr_code_id), 0) as active_locations,
COALESCE(COUNT(CASE WHEN check_in_date = CURRENT_DATE THEN 1 END), 0) as today_checkins,
COALESCE(COUNT(CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL THEN 1 END), 0) as records_with_gps,
COALESCE(COUNT(CASE WHEN location_accuracy IS NOT NULL THEN 1 END), 0) as records_with_accuracy,
COALESCE(AVG(location_accuracy), 0) as avg_location_accuracy
"""
else:
stats_select = """
SELECT
COALESCE(COUNT(*), 0) as total_checkins,
COALESCE(COUNT(DISTINCT employee_id), 0) as unique_employees,
COALESCE(COUNT(DISTINCT qr_code_id), 0) as active_locations,
COALESCE(COUNT(CASE WHEN check_in_date = CURRENT_DATE THEN 1 END), 0) as today_checkins,
COALESCE(COUNT(CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL THEN 1 END), 0) as records_with_gps,
0 as records_with_accuracy,
0 as avg_location_accuracy
"""
stats_query_text = stats_select + " FROM attendance_data ad"
stats_params = {}
# Add filters for Project Manager
if user_role == 'project_manager':
stats_query_text += " LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id WHERE 1=1"
stats_conditions = []
if allowed_project_ids:
project_placeholders = ','.join([f':stat_project_{i}' for i in range(len(allowed_project_ids))])
stats_conditions.append(f"qc.project_id IN ({project_placeholders})")
for i, pid in enumerate(allowed_project_ids):
stats_params[f'stat_project_{i}'] = pid
if allowed_location_names:
location_placeholders = ','.join([f':stat_location_{i}' for i in range(len(allowed_location_names))])
stats_conditions.append(f"ad.location_name IN ({location_placeholders})")
for i, loc in enumerate(allowed_location_names):
stats_params[f'stat_location_{i}'] = loc
if stats_conditions:
stats_query_text += " AND " + " AND ".join(stats_conditions)
print(f"📊 Executing stats query...")
print(f"📊 Stats params: {list(stats_params.keys())}")
# Execute stats query
stats_result = db.session.execute(text(stats_query_text), stats_params)
stats_row = stats_result.fetchone()
print(f"📊 Stats row type: {type(stats_row)}")
print(f"📊 Stats row value: {stats_row}")
# Safely extract stats from row
if stats_row is not None and len(stats_row) >= 7:
try:
stats_dict['total_checkins'] = int(stats_row[0]) if stats_row[0] is not None else 0
stats_dict['unique_employees'] = int(stats_row[1]) if stats_row[1] is not None else 0
stats_dict['active_locations'] = int(stats_row[2]) if stats_row[2] is not None else 0
stats_dict['today_checkins'] = int(stats_row[3]) if stats_row[3] is not None else 0
stats_dict['records_with_gps'] = int(stats_row[4]) if stats_row[4] is not None else 0
stats_dict['records_with_accuracy'] = int(stats_row[5]) if stats_row[5] is not None else 0
stats_dict['avg_location_accuracy'] = float(stats_row[6]) if stats_row[6] is not None else 0.0
print(f"✅ Loaded statistics: {stats_dict['total_checkins']} total check-ins")
except (IndexError, TypeError, ValueError) as extract_error:
print(f"⚠️ Error extracting stats values: {extract_error}")
# stats_dict already has default values
else:
print("⚠️ Stats query returned None or insufficient columns, using default stats")
except Exception as stats_error:
print(f"❌ Error loading statistics: {stats_error}")
import traceback
print(f"❌ Stats error traceback: {traceback.format_exc()}")
# stats_dict already has default values
# Convert dict to object-like for template compatibility
class StatsObject:
def __init__(self, stats_dict):
for key, value in stats_dict.items():
setattr(self, key, value)
stats = StatsObject(stats_dict)
print(f"✅ Stats object created: total_checkins={stats.total_checkins}")
# ============================================================
# END: STATISTICS
# ============================================================
# Add today's date for template
today_date = datetime.now().strftime('%Y-%m-%d')
current_date_formatted = datetime.now().strftime('%B %d')
print("✅ Rendering attendance report template")
print(f"✅ Stats object: {stats}")
return render_template('attendance_report.html',
attendance_records=processed_records,
locations=locations,
projects=projects,
stats=stats,
date_from=date_from,
date_to=date_to,
location_filter=location_filter,
employee_filter=employee_filter,
employee_ids=employee_ids,
employee_display_names=employee_display_names,
employee_display_name=employee_display_name,
project_filter=project_filter,
today_date=datetime.now().strftime('%Y-%m-%d'),
current_date_formatted=datetime.now().strftime('%B %d'),
has_location_accuracy_feature=has_location_accuracy,
user_role=user_role)
except Exception as e:
print(f"❌ Error loading attendance report: {e}")
print(f"❌ Exception type: {type(e)}")
import traceback
error_traceback = traceback.format_exc()
print(f"❌ Traceback: {error_traceback}")
# Log the error
try:
logger_handler.log_database_error('attendance_report', e)
except Exception as log_error:
print(f"⚠️ Additional logging error: {log_error}")
flash('Error loading attendance report. Please check the server logs for details.', 'error')
return redirect(url_for('dashboard'))
@bp.route('/attendance/<int:record_id>/edit', methods=['GET', 'POST'], endpoint='edit_attendance')
@login_required
@log_database_operations('attendance_update')
def edit_attendance(record_id):
"""Edit attendance record (Admin and Payroll only)"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
# Check if user has permission to edit attendance records
if session.get('role') not in ['admin', 'payroll', 'accounting']:
flash('Access denied. Only administrators and accounting staff can edit attendance records.', 'error')
return redirect(url_for('attendance_report'))
try:
attendance_record = AttendanceData.query.get_or_404(record_id)
if request.method == 'POST':
# Get the audit note from form - REQUIRED
edit_note = request.form.get('edit_note', '').strip()
if not edit_note:
flash('Edit reason is required for audit purposes.', 'error')
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
return render_template('edit_attendance.html',
attendance_record=attendance_record,
projects=projects,
qr_codes=QRCode.query.filter_by(active_status=True).all())
# Track changes for logging
changes = {}
old_values = {
'employee_id': attendance_record.employee_id,
'check_in_date': attendance_record.check_in_date,
'check_in_time': attendance_record.check_in_time,
'location_name': attendance_record.location_name,
'qr_code_id': attendance_record.qr_code_id,
'location_event': attendance_record.qr_code.location_event if attendance_record.qr_code else None
}
# Update attendance record fields
new_employee_id = request.form['employee_id'].strip().upper()
new_check_in_date = datetime.strptime(request.form['check_in_date'], '%Y-%m-%d').date()
new_check_in_time = datetime.strptime(request.form['check_in_time'], '%H:%M').time()
new_location_name = request.form['location_name'].strip()
# Get the new QR code ID from the form (this determines the location event)
new_qr_code_id = request.form.get('qr_code_id', '').strip()
if not new_qr_code_id:
flash('Location event selection is required.', 'error')
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
return render_template('edit_attendance.html',
attendance_record=attendance_record,
projects=projects,
qr_codes=QRCode.query.filter_by(active_status=True).all())
# Validate the QR code exists
new_qr_code = QRCode.query.get(int(new_qr_code_id))
if not new_qr_code:
flash('Selected location event not found.', 'error')
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
return render_template('edit_attendance.html',
attendance_record=attendance_record,
projects=projects,
qr_codes=QRCode.query.filter_by(active_status=True).all())
# Track what changed
if attendance_record.employee_id != new_employee_id:
changes['employee_id'] = f"{attendance_record.employee_id}{new_employee_id}"
if attendance_record.check_in_date != new_check_in_date:
changes['check_in_date'] = f"{attendance_record.check_in_date}{new_check_in_date}"
if attendance_record.check_in_time != new_check_in_time:
changes['check_in_time'] = f"{attendance_record.check_in_time}{new_check_in_time}"
if attendance_record.location_name != new_location_name:
changes['location_name'] = f"{attendance_record.location_name}{new_location_name}"
if attendance_record.qr_code_id != int(new_qr_code_id):
old_event = attendance_record.qr_code.location_event if attendance_record.qr_code else 'Unknown'
new_event = new_qr_code.location_event
changes['location_event'] = f"{old_event}{new_event}"
changes['qr_code_id'] = f"{attendance_record.qr_code_id}{new_qr_code_id}"
# Apply changes
attendance_record.employee_id = new_employee_id
attendance_record.check_in_date = new_check_in_date
attendance_record.check_in_time = new_check_in_time
attendance_record.location_name = new_location_name
attendance_record.qr_code_id = int(new_qr_code_id)
attendance_record.updated_timestamp = datetime.utcnow()
# Store the audit note with timestamp and user info
timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
username = session.get('username', 'Unknown')
role = session.get('role', 'unknown')
new_note_entry = f"[{timestamp}] {role.title()} '{username}': {edit_note}"
if attendance_record.edit_note:
# Append to existing notes
attendance_record.edit_note = f"{attendance_record.edit_note}\n\n{new_note_entry}"
else:
# First edit note
attendance_record.edit_note = new_note_entry
db.session.commit()
# Enhanced logging with audit note
if changes:
logger_handler.log_security_event(
event_type="attendance_record_update",
description=f"{session.get('role', 'unknown').title()} {session.get('username')} updated attendance record {record_id}",
severity="MEDIUM",
additional_data={
'record_id': record_id,
'changes': changes,
'user_role': session.get('role'),
'edit_reason': edit_note,
'editor_username': session.get('username')
}
)
print(f"[LOG] {session.get('role', 'unknown').title()} {session.get('username')} updated attendance record {record_id}: {changes}")
print(f"[LOG] Edit reason: {edit_note}")
else:
# Log even if no changes were made (for audit purposes)
logger_handler.log_security_event(
event_type="attendance_record_edit_no_changes",
description=f"{session.get('role', 'unknown').title()} {session.get('username')} accessed edit form for record {record_id} but made no changes",
severity="LOW",
additional_data={
'record_id': record_id,
'user_role': session.get('role'),
'edit_reason': edit_note,
'editor_username': session.get('username')
}
)
print(f"[LOG] {session.get('role', 'unknown').title()} {session.get('username')} edited record {record_id} with no changes")
print(f"[LOG] Edit reason: {edit_note}")
flash(f'Attendance record for {new_employee_id} updated successfully! Edit reason logged for audit.', 'success')
return redirect(url_for('attendance_report'))
# GET request - show edit form
# Get available projects for the dropdown
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
# Get available QR codes for location dropdown (for backward compatibility)
qr_codes = QRCode.query.filter_by(active_status=True).all()
return render_template('edit_attendance.html',
attendance_record=attendance_record,
projects=projects,
qr_codes=qr_codes)
except Exception as e:
db.session.rollback()
logger_handler.log_database_error('attendance_update', e)
print(f"[LOG] Error updating attendance record {record_id}: {e}")
flash('Error updating attendance record. Please try again.', 'error')
return redirect(url_for('attendance_report'))
@bp.route('/attendance/add', methods=['GET'], endpoint='add_manual_attendance')
@login_required
@log_user_activity('manual_attendance_access')
def add_manual_attendance():
"""
Display form to manually add attendance record
Only accessible by admin and accounting roles
"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
user_role = session.get('role')
# Check authorization
if user_role not in ['admin', 'accounting']:
flash('You do not have permission to manually add attendance records.', 'error')
return redirect(url_for('attendance_report'))
# Get all active projects
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
# Get today's date for form
today_date = datetime.now().strftime('%Y-%m-%d')
logger_handler.logger.info(
f"User {session.get('username')} ({user_role}) accessed manual attendance entry form"
)
return render_template('add_manual_attendance.html',
projects=projects,
today_date=today_date)
except Exception as e:
logger_handler.logger.error(f"Error loading manual attendance form: {e}")
flash('Error loading form. Please try again.', 'error')
return redirect(url_for('attendance_report'))
@bp.route('/attendance/save_manual', methods=['POST'], endpoint='save_manual_attendance')
@login_required
@log_user_activity('manual_attendance_creation')
@log_database_operations('manual_attendance_insert')
def save_manual_attendance():
"""
Save manually created attendance record
Only accessible by admin and accounting roles
"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
user_role = session.get('role')
# Check authorization
if user_role not in ['admin', 'accounting']:
return jsonify({
'success': False,
'message': 'You do not have permission to manually add attendance records.'
}), 403
# Get form data
employee_id = request.form.get('employee_id', '').strip()
location_id = request.form.get('location_id', '').strip()
check_date = request.form.get('check_date', '').strip()
check_time = request.form.get('check_time', '').strip()
# Validate required fields
if not all([employee_id, location_id, check_date, check_time]):
flash('All fields are required.', 'error')
return redirect(url_for('add_manual_attendance'))
# Validate employee exists
employee = Employee.query.filter_by(id=int(employee_id)).first()
if not employee:
flash(f'Employee with ID {employee_id} not found.', 'error')
return redirect(url_for('add_manual_attendance'))
# Get QR code (location)
qr_code = QRCode.query.get(int(location_id))
if not qr_code:
flash('Selected location not found.', 'error')
return redirect(url_for('add_manual_attendance'))
# Parse date and time
try:
check_date_obj = datetime.strptime(check_date, '%Y-%m-%d').date()
check_time_obj = datetime.strptime(check_time, '%H:%M').time()
except ValueError as e:
flash('Invalid date or time format.', 'error')
logger_handler.logger.error(f"Date/time parsing error: {e}")
return redirect(url_for('add_manual_attendance'))
# Check if record already exists for this employee, location, date, and time
existing_record = AttendanceData.query.filter_by(
employee_id=str(employee_id),
qr_code_id=qr_code.id,
check_in_date=check_date_obj,
check_in_time=check_time_obj
).first()
if existing_record:
flash('An attendance record already exists for this employee at this location, date, and time.', 'warning')
return redirect(url_for('add_manual_attendance'))
# Create new attendance record
# Use QR code's location address for both QR address and check-in address
# Set fixed distance of 0.010 miles
new_attendance = AttendanceData(
qr_code_id=qr_code.id,
employee_id=str(employee_id),
check_in_date=check_date_obj,
check_in_time=check_time_obj,
location_name=qr_code.location,
# Use QR code's coordinates
latitude=qr_code.address_latitude,
longitude=qr_code.address_longitude,
# Use QR code's address for both
address=qr_code.location_address,
# Set fixed distance
location_accuracy=0.010,
accuracy=0.010,
# Mark as manual entry
location_source='manual_entry',
device_info='Manual Entry by Admin/Accounting',
user_agent=f'Manual Entry - User: {session.get("username")}',
ip_address=get_client_ip(),
status='present',
verification_required=False,
verification_status='approved',
created_timestamp=datetime.utcnow(),
updated_timestamp=datetime.utcnow()
)
db.session.add(new_attendance)
db.session.commit()
# Log the manual entry
logger_handler.logger.info(
f"Manual attendance record created by {session.get('username')} ({user_role}): "
f"Employee {employee.firstName} {employee.lastName} (ID: {employee_id}), "
f"Location: {qr_code.location}, Event: {qr_code.location_event}, "
f"Date: {check_date}, Time: {check_time}"
)
flash(f'Attendance record successfully created for {employee.firstName} {employee.lastName}.', 'success')
return redirect(url_for('attendance_report'))
except Exception as e:
db.session.rollback()
logger_handler.logger.error(f"Error saving manual attendance record: {e}")
logger_handler.logger.error(f"Traceback: {traceback.format_exc()}")
flash('Error saving attendance record. Please try again.', 'error')
return redirect(url_for('add_manual_attendance'))
@bp.route('/api/time-attendance/locations', endpoint='time_attendance_locations_api')
@login_required
def time_attendance_locations_api():
"""Return distinct location_name values from time_attendance, optionally filtered by project_id.
Used by the time attendance records page to dynamically scope the location dropdown."""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
project_id = request.args.get('project_id', '').strip()
if project_id:
try:
project_id_int = int(project_id)
except (ValueError, TypeError):
return jsonify({'success': False, 'error': 'Invalid project_id'}), 400
result = db.session.execute(text("""
SELECT DISTINCT location_name
FROM time_attendance
WHERE project_id = :project_id
AND location_name IS NOT NULL
ORDER BY location_name
"""), {'project_id': project_id_int})
else:
result = db.session.execute(text("""
SELECT DISTINCT location_name
FROM time_attendance
WHERE location_name IS NOT NULL
ORDER BY location_name
"""))
locations = [row[0] for row in result.fetchall()]
logger_handler.logger.info(
f"User {session.get('username', 'unknown')} fetched time attendance locations"
+ (f" for project_id={project_id}" if project_id else " (all projects)")
)
return jsonify({'success': True, 'locations': locations})
except Exception as e:
logger_handler.logger.error(f"Error in time_attendance_locations_api: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/attendance/locations', endpoint='attendance_locations_api')
@login_required
def attendance_locations_api():
"""Return distinct location_name values from attendance_data, optionally filtered by project_id.
Used by the attendance report page to dynamically scope the location dropdown when a project is selected."""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
project_id = request.args.get('project_id', '').strip()
if project_id:
try:
project_id_int = int(project_id)
except (ValueError, TypeError):
return jsonify({'success': False, 'error': 'Invalid project_id'}), 400
result = db.session.execute(text("""
SELECT DISTINCT ad.location_name
FROM attendance_data ad
INNER JOIN qr_codes qc ON ad.qr_code_id = qc.id
WHERE qc.project_id = :project_id
AND ad.location_name IS NOT NULL
ORDER BY ad.location_name
"""), {'project_id': project_id_int})
else:
result = db.session.execute(text("""
SELECT DISTINCT location_name
FROM attendance_data
WHERE location_name IS NOT NULL
ORDER BY location_name
"""))
locations = [row[0] for row in result.fetchall()]
logger_handler.logger.info(
f"User {session.get('username', 'unknown')} fetched attendance locations"
+ (f" for project_id={project_id}" if project_id else " (all projects)")
)
return jsonify({'success': True, 'locations': locations})
except Exception as e:
logger_handler.logger.error(f"Error in attendance_locations_api: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@bp.route('/api/search_employees', endpoint='search_employees_api')
@login_required
def search_employees_api():
"""
API endpoint to search employees by name or ID.
Returns matches from the Employee table first, then appends any IDs found
in attendance_data that have no Employee record — so unregistered IDs
that have attendance records can still be filtered on the attendance page.
"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
search_query = request.args.get('q', '').strip()
if not search_query or len(search_query) < 2:
return jsonify({'employees': []})
search_pattern = f"%{search_query}%"
# 1. Registered employees — search by ID or name
employees = Employee.query.filter(
db.or_(
Employee.id.like(search_pattern),
Employee.firstName.like(search_pattern),
Employee.lastName.like(search_pattern),
db.func.concat(Employee.firstName, ' ', Employee.lastName).like(search_pattern)
)
).limit(10).all()
employee_list = [{
'id': emp.id,
'firstName': emp.firstName,
'lastName': emp.lastName,
'full_name': f"{emp.firstName} {emp.lastName}"
} for emp in employees]
registered_ids = {str(emp.id) for emp in employees}
# 2. Unregistered IDs — present in attendance_data but not in Employee table.
# Only add when the search term looks like (part of) a numeric ID and we
# still have room in the result list.
if len(employee_list) < 10:
remaining_slots = 10 - len(employee_list)
try:
unregistered_rows = db.session.execute(
text("""
SELECT DISTINCT ad.employee_id
FROM attendance_data ad
LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id
WHERE e.id IS NULL
AND ad.employee_id LIKE :pattern
ORDER BY ad.employee_id
LIMIT :lim
"""),
{'pattern': search_pattern, 'lim': remaining_slots}
).fetchall()
for row in unregistered_rows:
emp_id = str(row[0])
if emp_id not in registered_ids:
employee_list.append({
'id': emp_id,
'firstName': f'ID: {emp_id}',
'lastName': '(no record)',
'full_name': f'ID: {emp_id} (no record)'
})
except Exception as unreg_err:
logger_handler.logger.warning(f"Could not search unregistered employee IDs: {unreg_err}")
return jsonify({'employees': employee_list})
except Exception as e:
logger_handler.logger.error(f"Error searching employees: {e}")
return jsonify({'employees': [], 'error': str(e)}), 500
@bp.route('/api/get_project_locations', endpoint='get_project_locations_api')
@login_required
def get_project_locations_api():
"""
API endpoint to get locations for a specific project
Returns JSON with location list
"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
project_id = request.args.get('project_id', '').strip()
if not project_id:
return jsonify({'success': False, 'locations': [], 'error': 'Project ID required'})
# Get active QR codes for this project
qr_codes = QRCode.query.filter_by(
project_id=int(project_id),
active_status=True
).order_by(QRCode.location).all()
# Group QR codes by location to get unique locations
locations_dict = {}
for qr in qr_codes:
location_key = f"{qr.location}||{qr.location_address}"
if location_key not in locations_dict:
locations_dict[location_key] = {
'location': qr.location,
'location_address': qr.location_address,
'qr_codes': {}
}
# Store QR code ID for each event type
locations_dict[location_key]['qr_codes'][qr.location_event] = qr.id
# Convert to list format
location_list = [{
'location': loc_data['location'],
'location_address': loc_data['location_address'],
'qr_codes': loc_data['qr_codes']
} for loc_data in locations_dict.values()]
return jsonify({'success': True, 'locations': location_list})
except Exception as e:
logger_handler.logger.error(f"Error getting project locations: {e}")
return jsonify({'success': False, 'locations': [], 'error': str(e)}), 500
@bp.route('/attendance/<int:record_id>/delete', methods=['POST'], endpoint='delete_attendance')
@login_required
@log_database_operations('attendance_delete')
def delete_attendance(record_id):
"""Delete attendance record (Admin and Payroll only)"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
# Check if user has permission to delete attendance records
if session.get('role') not in ['admin', 'payroll', 'accounting']:
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'success': False,
'message': 'Access denied. Only administrators and payroll staff can delete attendance records.'
}), 403
else:
flash('Access denied. Only administrators and payroll staff can delete attendance records.', 'error')
return redirect(url_for('attendance_report'))
try:
attendance_record = AttendanceData.query.get_or_404(record_id)
# Store record info for logging before deletion
employee_id = attendance_record.employee_id
location_name = attendance_record.location_name
check_in_date = attendance_record.check_in_date
# Log the deletion
logger_handler.log_security_event(
event_type="attendance_record_deletion",
description=f"{session.get('role', 'unknown').title()} {session.get('username')} deleted attendance record {record_id}",
severity="HIGH",
additional_data={
'record_id': record_id,
'employee_id': employee_id,
'location_name': location_name,
'check_in_date': str(check_in_date),
'user_role': session.get('role')
}
)
# Delete the record
db.session.delete(attendance_record)
db.session.commit()
print(f"[LOG] {session.get('role', 'unknown').title()} {session.get('username')} deleted attendance record {record_id} for employee {employee_id}")
# Return JSON response for AJAX requests
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'success': True,
'message': f'Attendance record for {employee_id} deleted successfully!'
})
else:
flash(f'Attendance record for {employee_id} deleted successfully!', 'success')
return redirect(url_for('attendance_report'))
except Exception as e:
db.session.rollback()
logger_handler.log_database_error('attendance_delete', e)
print(f"[LOG] Error deleting attendance record {record_id}: {e}")
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
return jsonify({
'success': False,
'message': 'Error deleting attendance record. Please try again.'
}), 500
else:
flash('Error deleting attendance record. Please try again.', 'error')
return redirect(url_for('attendance_report'))
@bp.route('/verification-review', endpoint='verification_review')
@login_required
def verification_review():
"""Admin page to review pending photo verifications"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
# Only admins can access
if session.get('role') not in ['admin', 'payroll', 'accounting']:
flash('Unauthorized access.', 'error')
return redirect(url_for('dashboard'))
# Get filter parameters
status_filter = request.args.get('status', 'pending')
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
project_filter = request.args.get('project', '')
location_filter = request.args.get('location', '')
employee_filter = request.args.get('employee', '')
# Build query - join with QRCode to access project_id
query = AttendanceData.query.join(QRCode).filter(
AttendanceData.verification_required == True
)
if status_filter and status_filter != 'all':
query = query.filter(AttendanceData.verification_status == status_filter)
if date_from:
query = query.filter(AttendanceData.check_in_date >= date_from)
if date_to:
query = query.filter(AttendanceData.check_in_date <= date_to)
# Apply project filter
if project_filter:
try:
query = query.filter(QRCode.project_id == int(project_filter))
except (ValueError, TypeError):
pass
# Apply location filter
if location_filter:
query = query.filter(AttendanceData.location_name.ilike(f'%{location_filter}%'))
# Apply employee ID filter
if employee_filter:
query = query.filter(AttendanceData.employee_id.ilike(f'%{employee_filter}%'))
# Get records with QR code information
verifications = query.order_by(
AttendanceData.verification_timestamp.desc()
).all()
# Build a dictionary for employee names lookup
employee_names = {}
for record in verifications:
if record.employee_id and record.employee_id not in employee_names:
try:
employee = Employee.query.filter_by(id=int(record.employee_id)).first()
if employee:
employee_names[record.employee_id] = f"{employee.lastName}, {employee.firstName}"
else:
employee_names[record.employee_id] = None
except (ValueError, TypeError):
employee_names[record.employee_id] = None
# Build a dictionary for project names lookup
project_names = {}
for record in verifications:
if record.qr_code and record.qr_code.project_id:
project_id = record.qr_code.project_id
if project_id not in project_names:
try:
project = Project.query.get(project_id)
if project:
project_names[project_id] = project.name
else:
project_names[project_id] = None
except Exception:
project_names[project_id] = None
# Get counts for status badges
pending_count = AttendanceData.query.filter(
AttendanceData.verification_status == 'pending'
).count()
approved_count = AttendanceData.query.filter(
AttendanceData.verification_status == 'approved'
).count()
rejected_count = AttendanceData.query.filter(
AttendanceData.verification_status == 'rejected'
).count()
# Get all projects for filter dropdown
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
# Get unique locations for filter dropdown
locations = db.session.query(AttendanceData.location_name).filter(
AttendanceData.verification_required == True
).distinct().order_by(AttendanceData.location_name).all()
location_list = [loc[0] for loc in locations if loc[0]]
# Log access
logger_handler.logger.info(
f"User {session.get('username')} ({session.get('role')}) accessed verification review page"
)
return render_template('verification_review.html',
verifications=verifications,
pending_count=pending_count,
approved_count=approved_count,
rejected_count=rejected_count,
status_filter=status_filter,
date_from=date_from,
date_to=date_to,
project_filter=project_filter,
location_filter=location_filter,
employee_filter=employee_filter,
projects=projects,
locations=location_list,
employee_names=employee_names,
project_names=project_names)
except Exception as e:
logger_handler.logger.error(f"Error in verification review: {e}")
flash('Error loading verification review.', 'error')
return redirect(url_for('dashboard'))
@bp.route('/verification-review/<int:record_id>/update', methods=['POST'], endpoint='update_verification_status')
@login_required
@log_database_operations('verification_update')
def update_verification_status(record_id):
"""Update verification status (approve/reject)"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
# Only admins can update
if session.get('role') not in ['admin', 'payroll', 'accounting']:
return jsonify({
'success': False,
'message': 'Unauthorized access'
}), 403
record = AttendanceData.query.get_or_404(record_id)
new_status = request.json.get('status')
admin_note = request.json.get('note', '')
if new_status not in ['approved', 'rejected']:
return jsonify({
'success': False,
'message': 'Invalid status'
}), 400
# Update record
record.verification_status = new_status
record.edit_note = f"Verification {new_status} by {session.get('username')}. {admin_note}"
db.session.commit()
# Log the action
logger_handler.log_photo_verification(
employee_id=record.employee_id,
qr_code_id=record.qr_code_id,
distance=record.location_accuracy or 0,
status=new_status
)
return jsonify({
'success': True,
'message': f'Verification {new_status} successfully'
})
except Exception as e:
db.session.rollback()
logger_handler.logger.error(f"Error updating verification: {e}")
return jsonify({
'success': False,
'message': 'Error updating verification status'
}), 500
@bp.route('/api/attendance/<int:record_id>/verification-details', endpoint='get_verification_details')
@login_required
def get_verification_details(record_id):
"""API endpoint to get verification details for a specific record"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
# Get the attendance record with verification data
record = AttendanceData.query.get_or_404(record_id)
# DEBUG: Log record details
print(f"=== VERIFICATION DETAILS DEBUG ===")
print(f"Record ID: {record.id}")
print(f"Employee: {record.employee_id}")
print(f"check_in_date type: {type(record.check_in_date)}")
print(f"check_in_date value: {record.check_in_date}")
print(f"check_in_time type: {type(record.check_in_time)}")
print(f"check_in_time value: {record.check_in_time}")
print(f"verification_photo exists: {record.verification_photo is not None}")
print(f"verification_status: {record.verification_status}")
print(f"==================================")
# Check if user has permission to view
# Allow admin and payroll staff to view verification details
if session.get('role') not in ['admin', 'payroll', 'accounting']:
return jsonify({
'success': False,
'message': 'Unauthorized access'
}), 403
# Log the access for security audit
logger_handler.logger.info(f"User {session.get('username')} ({session.get('role')}) accessed verification details for record {record_id}")
# Safely format dates/times with error handling
try:
check_in_date_str = record.check_in_date.strftime('%Y-%m-%d') if record.check_in_date else 'N/A'
except Exception as e:
print(f"Error formatting check_in_date: {e}")
check_in_date_str = str(record.check_in_date) if record.check_in_date else 'N/A'
try:
check_in_time_str = record.check_in_time.strftime('%I:%M %p') if record.check_in_time else 'N/A'
except Exception as e:
print(f"Error formatting check_in_time: {e}")
check_in_time_str = str(record.check_in_time) if record.check_in_time else 'N/A'
# Prepare record data with safe formatting
try:
check_in_date_str = record.check_in_date.strftime('%Y-%m-%d') if record.check_in_date else 'N/A'
except:
check_in_date_str = str(record.check_in_date) if record.check_in_date else 'N/A'
try:
check_in_time_str = record.check_in_time.strftime('%I:%M %p') if record.check_in_time else 'N/A'
except:
check_in_time_str = str(record.check_in_time) if record.check_in_time else 'N/A'
record_data = {
'id': record.id,
'employee_id': record.employee_id,
'location_name': record.location_name or 'Unknown',
'check_in_date': check_in_date_str,
'check_in_time': check_in_time_str,
'location_accuracy': float(record.location_accuracy) if record.location_accuracy else None,
'checked_in_address': record.address or 'No address',
'verification_photo': record.verification_photo,
'verification_status': record.verification_status,
'verification_required': record.verification_required,
'device_info': record.device_info or 'Unknown'
}
return jsonify({
'success': True,
'record': record_data
})
except Exception as e:
logger_handler.logger.error(f"Error getting verification details for record {record_id}: {e}")
print(f"❌ Error in get_verification_details for record {record_id}: {e}")
import traceback
print(f"❌ Traceback: {traceback.format_exc()}")
return jsonify({
'success': False,
'message': 'Error loading verification details'
}), 500
@bp.route('/verification-review/<int:record_id>', endpoint='verification_review_detail')
@login_required
def verification_review_detail(record_id):
"""Review a single verification photo on a dedicated page"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
# Check permissions
if session.get('role') not in ['admin', 'payroll', 'accounting']:
flash('Access denied. Only administrators, payroll, and accounting staff can review verification photos.', 'error')
return redirect(url_for('attendance_report'))
# Get the attendance record
record = AttendanceData.query.get_or_404(record_id)
# Check if this record has verification
if not record.verification_required:
flash('This record does not require verification.', 'warning')
return redirect(url_for('attendance_report'))
# Get the QR code information for additional context
qr_code = QRCode.query.get(record.qr_code_id) if record.qr_code_id else None
# Get employee name from Employee table
employee_name = None
try:
if record.employee_id:
employee = Employee.query.filter_by(id=int(record.employee_id)).first()
if employee:
employee_name = f"{employee.lastName}, {employee.firstName}"
else:
employee_name = f"Unknown (ID: {record.employee_id})"
except (ValueError, TypeError) as e:
logger_handler.logger.warning(f"Could not lookup employee name for ID {record.employee_id}: {e}")
employee_name = f"Unknown (ID: {record.employee_id})"
# Get event type from QR code (Check In/Check Out)
location_event = qr_code.location_event if qr_code and qr_code.location_event else 'N/A'
# Log the access for audit trail
logger_handler.logger.info(
f"User {session.get('username')} ({session.get('role')}) "
f"accessed verification review for record {record_id}"
)
# Format date and time for display
try:
check_in_date = record.check_in_date.strftime('%m/%d/%Y') if record.check_in_date else 'N/A'
except:
check_in_date = str(record.check_in_date) if record.check_in_date else 'N/A'
try:
check_in_time = record.check_in_time.strftime('%I:%M %p') if record.check_in_time else 'N/A'
except:
check_in_time = str(record.check_in_time) if record.check_in_time else 'N/A'
return render_template('verification_review_detail.html',
record=record,
qr_code=qr_code,
check_in_date=check_in_date,
check_in_time=check_in_time,
employee_name=employee_name,
location_event=location_event)
except Exception as e:
logger_handler.logger.error(f"Error loading verification review detail: {e}")
flash('Error loading verification details.', 'error')
return redirect(url_for('attendance_report'))
@bp.route('/api/attendance/stats', endpoint='attendance_stats_api')
@admin_required
def attendance_stats_api():
"""API endpoint for attendance statistics"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
try:
# Daily stats for the last 7 days
daily_stats = db.session.execute(text("""
SELECT
check_in_date,
COUNT(*) as checkins,
COUNT(DISTINCT employee_id) as unique_employees
FROM attendance_data
WHERE check_in_date >= CURRENT_DATE - INTERVAL '7 days'
GROUP BY check_in_date
ORDER BY check_in_date DESC
""")).fetchall()
# Location stats
location_stats = db.session.execute(text("""
SELECT
location_name,
COUNT(*) as total_checkins,
COUNT(DISTINCT employee_id) as unique_employees
FROM attendance_data
GROUP BY location_name
ORDER BY total_checkins DESC
LIMIT 10
""")).fetchall()
# Peak hours
hourly_stats = db.session.execute(text("""
SELECT
EXTRACT(hour FROM check_in_time) as hour,
COUNT(*) as checkins
FROM attendance_data
WHERE check_in_date >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY EXTRACT(hour FROM check_in_time)
ORDER BY hour
""")).fetchall()
return jsonify({
'daily_stats': [{'date': str(row[0]), 'checkins': row[1], 'employees': row[2]} for row in daily_stats],
'location_stats': [{'location': row[0], 'checkins': row[1], 'employees': row[2]} for row in location_stats],
'hourly_stats': [{'hour': int(row[0]), 'checkins': row[1]} for row in hourly_stats]
})
except Exception as e:
print(f"Error fetching attendance stats: {e}")
return jsonify({'error': 'Failed to fetch attendance statistics'}), 500
@bp.route('/export-configuration', endpoint='export_configuration')
@login_required
def export_configuration():
"""Display export configuration page for customizing Excel exports"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
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_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 as log_error:
print(f"⚠️ Logging error (non-critical): {log_error}")
# 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', '')
}
print(f"📊 Filters: {filters}")
# Get project name if project filter is applied
project_name = None
if filters.get('project_filter'):
try:
from models.project import Project
project = Project.query.get(int(filters['project_filter']))
if project:
project_name = project.name
print(f"📊 Project filter: ID={filters['project_filter']}, Name={project_name}")
except Exception as e:
print(f"⚠️ Error fetching project name: {e}")
# Check if location accuracy feature exists
try:
has_location_accuracy = check_location_accuracy_column_exists()
except Exception as e:
print(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
})
print(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:
print(f"❌ Error in export_configuration route: {e}")
print(f"❌ Traceback: {traceback.format_exc()}")
# 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:
print(f"⚠️ Could not log error: {log_error}")
flash('Error loading export configuration page.', 'error')
return redirect(url_for('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"""
AttendanceData, QRCode, Employee, Project, User, UserProjectPermission, UserLocationPermission = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Employee"], _get_models()["Project"], _get_models()["User"], _get_models()["UserProjectPermission"], _get_models()["UserLocationPermission"]
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_report'))
print("📊 Excel export generation started")
# Log export action using your existing logger
try:
logger_handler.logger.info(f"User {session.get('username', 'unknown')} generated Excel export")
except Exception as log_error:
print(f"⚠️ Logging error (non-critical): {log_error}")
# Get selected columns and custom names from form
selected_columns_raw = request.form.getlist('selected_columns')
print(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 = []
print(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
print(f"📊 Final column order: {selected_columns}")
if not selected_columns:
flash('Please select at least one column to export.', 'error')
return redirect(url_for('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')
}
print(f"📊 Export filters: {filters}")
print(f"📊 Column names: {column_names}")
# 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:
from models.project import Project
project = Project.query.get(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:
print(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'
print(f"📊 Excel file generated successfully: {filename}")
print(f"📊 Column order in export: {selected_columns}")
# 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 as log_error:
print(f"⚠️ Logging error (non-critical): {log_error}")
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('export_configuration'))
except Exception as e:
print(f"❌ Error in generate_excel_export route: {e}")
print(f"❌ Traceback: {traceback.format_exc()}")
# 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:
print(f"⚠️ Could not log error: {log_error}")
flash('Error generating Excel export.', 'error')
return redirect(url_for('export_configuration'))
def create_excel_export(selected_columns, column_names, filters):
"""Create Excel file with selected attendance data - Updated to include employee names"""
AttendanceData, Employee, QRCode = _get_models()["AttendanceData"], _get_models()["Employee"], _get_models()["QRCode"]
try:
print(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:
print(f"❌ openpyxl import error: {e}")
print("💡 Install openpyxl: 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)
print(f"📊 Applied date_from filter: {date_from}")
except ValueError as e:
print(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)
print(f"📊 Applied date_to filter: {date_to}")
except ValueError as e:
print(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']}%"))
print(f"📊 Applied location filter: {filters['location_filter']}")
# Apply employee filter — supports comma-separated multi-employee values
if filters.get('employee_filter'):
emp_ids = [e.strip() for e in filters['employee_filter'].split(',') if e.strip()]
if len(emp_ids) == 1:
query = query.filter(AttendanceData.employee_id == emp_ids[0])
elif len(emp_ids) > 1:
query = query.filter(AttendanceData.employee_id.in_(emp_ids))
print(f"📊 Applied employee filter: {emp_ids}")
# Apply project filter
if filters.get('project_filter'):
try:
project_id = int(filters['project_filter'])
query = query.filter(QRCode.project_id == project_id)
print(f"📊 Applied project filter: {project_id}")
except (ValueError, TypeError) as e:
print(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()
print(f"📊 Query returned {len(results)} records")
if not results:
print("⚠️ 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':
cell.value = qr_record.location_address if qr_record else ''
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 = qr_record.location_address if qr_record and qr_record.location_address else ''
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
print(f"📍 Added QR address hyperlink for employee {attendance_record.employee_id}")
else:
cell.value = address_text
print(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
print(f"📍 Added check-in address hyperlink for employee {attendance_record.employee_id}")
else:
cell.value = address_text
print(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
print(f"📍 Added check-in address hyperlink for employee {attendance_record.employee_id} (fallback)")
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
print(f"📍 Added check-in address hyperlink for employee {attendance_record.employee_id} (no accuracy data)")
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:
print(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:
pass
# 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
print(f"📏 Column {column_letter} ({column_key}): set width to {adjusted_width} (content: {max_length} chars)")
# Save to BytesIO
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
print("📊 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 as log_error:
print(f"⚠️ Logging error (non-critical): {log_error}")
return excel_buffer
except Exception as e:
print(f"❌ Error creating Excel export: {e}")
print(f"❌ Traceback: {traceback.format_exc()}")
# Log error
try:
logger_handler.log_flask_error(
'excel_export_error',
str(e),
stack_trace=traceback.format_exc()
)
except Exception as log_error:
print(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"""
AttendanceData, Employee, QRCode = _get_models()["AttendanceData"], _get_models()["Employee"], _get_models()["QRCode"]
try:
print(f"📊 Creating Excel export with {len(selected_columns)} columns in order: {selected_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:
print(f"❌ openpyxl import error: {e}")
print("💡 Install openpyxl: 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)
print(f"📊 Applied date_from filter: {date_from}")
except ValueError as e:
print(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)
print(f"📊 Applied date_to filter: {date_to}")
except ValueError as e:
print(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']}%"))
print(f"📊 Applied location filter: {filters['location_filter']}")
# Apply employee filter — supports comma-separated multi-employee values
if filters.get('employee_filter'):
emp_ids = [e.strip() for e in filters['employee_filter'].split(',') if e.strip()]
if len(emp_ids) == 1:
query = query.filter(AttendanceData.employee_id == emp_ids[0])
elif len(emp_ids) > 1:
query = query.filter(AttendanceData.employee_id.in_(emp_ids))
print(f"📊 Applied employee filter: {emp_ids}")
# Apply project filter
if filters.get('project_filter'):
try:
project_id = int(filters['project_filter'])
query = query.filter(QRCode.project_id == project_id)
print(f"📊 Applied project filter: {project_id}")
except (ValueError, TypeError) as e:
print(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()
print(f"📊 Query returned {len(results)} records")
if not results:
print("⚠️ 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':
cell.value = qr_record.location_address if qr_record else ''
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 = qr_record.location_address if qr_record and qr_record.location_address else ''
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
print(f"📍 Added QR address hyperlink for employee {attendance_record.employee_id}")
else:
cell.value = address_text
print(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
print(f"📍 Added check-in address hyperlink for employee {attendance_record.employee_id}")
else:
cell.value = address_text
print(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
print(f"📍 Added check-in address hyperlink for employee {attendance_record.employee_id} (fallback)")
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
print(f"📍 Added check-in address hyperlink for employee {attendance_record.employee_id} (no accuracy data)")
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:
print(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:
pass
# 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
print(f"📏 Column {column_letter} ({column_key}): set width to {adjusted_width} (content: {max_length} chars)")
# Save to BytesIO
excel_buffer = io.BytesIO()
wb.save(excel_buffer)
excel_buffer.seek(0)
print("📊 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 as log_error:
print(f"⚠️ Logging error (non-critical): {log_error}")
return excel_buffer
except Exception as e:
print(f"❌ Error creating Excel export: {e}")
print(f"❌ Traceback: {traceback.format_exc()}")
# Log error
try:
logger_handler.log_flask_error(
'excel_export_ordered_error',
str(e),
stack_trace=traceback.format_exc()
)
except Exception as log_error:
print(f"⚠️ Could not log error: {log_error}")
return None