Sep 11 - Reupload the code
This commit is contained in:
+580
@@ -0,0 +1,580 @@
|
||||
"""
|
||||
routes/admin.py
|
||||
===============
|
||||
Admin panel and log management routes.
|
||||
|
||||
Routes: /admin/logs, /admin/health/google-maps, /api/logs/*
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, url_for
|
||||
from datetime import datetime, timedelta
|
||||
import json, math
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from sqlalchemy import text
|
||||
from utils.geocoding import gmaps_client
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import admin_required, login_required
|
||||
|
||||
bp = Blueprint('admin', __name__)
|
||||
|
||||
|
||||
|
||||
@bp.route('/admin/logs', endpoint='admin_logs')
|
||||
@admin_required
|
||||
def admin_logs():
|
||||
"""Admin logging dashboard"""
|
||||
try:
|
||||
# Get log statistics for the last 7 days
|
||||
stats = logger_handler.get_log_statistics(days=7)
|
||||
return render_template('admin_logs.html', log_stats=stats)
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('admin_logs_load', e)
|
||||
flash('Error loading log statistics.', 'error')
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
|
||||
def check_google_maps_health():
|
||||
"""Check if Google Maps services are working properly"""
|
||||
try:
|
||||
if not gmaps_client:
|
||||
return False, "Google Maps client not initialized"
|
||||
|
||||
# Test with a known address
|
||||
test_result = gmaps_client.geocode("1600 Amphitheatre Parkway, Mountain View, CA")
|
||||
|
||||
if test_result:
|
||||
return True, "Google Maps services are operational"
|
||||
else:
|
||||
return False, "Google Maps API not returning results"
|
||||
|
||||
except Exception as e:
|
||||
return False, f"Google Maps health check failed: {str(e)}"
|
||||
|
||||
# Optional: Add health check route
|
||||
@bp.route('/admin/health/google-maps', endpoint='google_maps_health')
|
||||
@admin_required
|
||||
def google_maps_health():
|
||||
"""Admin route to check Google Maps service health"""
|
||||
is_healthy, message = check_google_maps_health()
|
||||
|
||||
return jsonify({
|
||||
'healthy': is_healthy,
|
||||
'message': message,
|
||||
'service': 'Google Maps',
|
||||
'fallback_available': True,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
# API endpoints for logging data (admin only)
|
||||
@bp.route('/api/logs/recent', endpoint='api_recent_logs')
|
||||
@admin_required
|
||||
def api_recent_logs():
|
||||
"""API endpoint to get recent log entries with full details and pagination support"""
|
||||
try:
|
||||
days = request.args.get('days', 1, type=int)
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
page = request.args.get('page', 1, type=int)
|
||||
category = request.args.get('category', '')
|
||||
severity = request.args.get('severity', '')
|
||||
search = request.args.get('search', '')
|
||||
|
||||
logger_handler.logger.debug(
|
||||
f"api_recent_logs: days={days}, limit={limit}, page={page}, "
|
||||
f"category={category!r}, severity={severity!r}, search={search!r}"
|
||||
)
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
# Calculate offset for pagination
|
||||
offset = (page - 1) * limit
|
||||
|
||||
# Build the base SQL query with filters
|
||||
base_sql = """
|
||||
SELECT
|
||||
event_id,
|
||||
event_type,
|
||||
event_category,
|
||||
event_description,
|
||||
event_data,
|
||||
severity_level,
|
||||
created_timestamp,
|
||||
username,
|
||||
user_id,
|
||||
ip_address
|
||||
FROM log_events
|
||||
WHERE created_timestamp >= :cutoff_date
|
||||
"""
|
||||
|
||||
count_sql = """
|
||||
SELECT COUNT(*) as total_count
|
||||
FROM log_events
|
||||
WHERE created_timestamp >= :cutoff_date
|
||||
"""
|
||||
|
||||
params = {'cutoff_date': cutoff_date}
|
||||
|
||||
# Add category filter
|
||||
if category:
|
||||
base_sql += " AND event_category = :category"
|
||||
count_sql += " AND event_category = :category"
|
||||
params['category'] = category
|
||||
|
||||
# Add severity filter
|
||||
if severity:
|
||||
base_sql += " AND severity_level = :severity"
|
||||
count_sql += " AND severity_level = :severity"
|
||||
params['severity'] = severity
|
||||
|
||||
# Add search filter
|
||||
if search:
|
||||
search_condition = " AND (event_type LIKE :search OR event_description LIKE :search OR username LIKE :search)"
|
||||
base_sql += search_condition
|
||||
count_sql += search_condition
|
||||
params['search'] = f'%{search}%'
|
||||
|
||||
# Get total count first
|
||||
count_result = db.session.execute(text(count_sql), params).fetchone()
|
||||
total_count = count_result.total_count if count_result else 0
|
||||
|
||||
# Add ordering, limit and offset to main query
|
||||
base_sql += " ORDER BY created_timestamp DESC LIMIT :limit OFFSET :offset"
|
||||
params['limit'] = limit
|
||||
params['offset'] = offset
|
||||
|
||||
# Execute main query
|
||||
result = db.session.execute(text(base_sql), params).fetchall()
|
||||
|
||||
logs = []
|
||||
for row in result:
|
||||
# Parse event_data if it's JSON
|
||||
event_data = None
|
||||
if row.event_data:
|
||||
try:
|
||||
event_data = json.loads(row.event_data) if isinstance(row.event_data, str) else row.event_data
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
event_data = row.event_data
|
||||
|
||||
logs.append({
|
||||
'event_id': row.event_id,
|
||||
'event_type': row.event_type,
|
||||
'event_category': row.event_category,
|
||||
'description': row.event_description,
|
||||
'event_data': event_data,
|
||||
'severity': row.severity_level,
|
||||
'timestamp': row.created_timestamp.isoformat(),
|
||||
'username': row.username or 'System',
|
||||
'user_id': row.user_id,
|
||||
'ip_address': row.ip_address or '-'
|
||||
})
|
||||
|
||||
logger_handler.logger.debug(f"api_recent_logs: returning {len(logs)} of {total_count} total records")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'logs': logs,
|
||||
'total': total_count,
|
||||
'page': page,
|
||||
'limit': limit,
|
||||
'total_pages': math.ceil(total_count / limit) if total_count > 0 else 0,
|
||||
'has_next': offset + limit < total_count,
|
||||
'has_prev': page > 1
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('api_recent_logs', e)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Failed to fetch recent logs: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@bp.route('/api/logs/stats', endpoint='api_log_stats')
|
||||
@admin_required
|
||||
def api_log_stats():
|
||||
"""API endpoint to get logging statistics"""
|
||||
try:
|
||||
days = request.args.get('days', 7, type=int)
|
||||
logger_handler.logger.debug(f"api_log_stats: fetching statistics for last {days} days")
|
||||
|
||||
# Get statistics from logger handler
|
||||
stats = logger_handler.get_log_statistics(days=days)
|
||||
|
||||
|
||||
# Ensure all expected keys exist with updated categories
|
||||
expected_stats = {
|
||||
'total_events': stats.get('total_events', 0),
|
||||
'security_events': stats.get('security_events', 0),
|
||||
'authentication_events': stats.get('authentication_events', 0),
|
||||
'qr_management_events': stats.get('qr_management_events', 0),
|
||||
'database_errors': stats.get('database_errors', 0),
|
||||
'application_events': stats.get('application_events', 0),
|
||||
'system_events': stats.get('system_events', 0)
|
||||
}
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'stats': expected_stats,
|
||||
'days': days,
|
||||
'timestamp': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('api_log_stats', e)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Failed to fetch log statistics: {str(e)}',
|
||||
'stats': {
|
||||
'total_events': 0,
|
||||
'security_events': 0,
|
||||
'authentication_events': 0,
|
||||
'qr_management_events': 0,
|
||||
'database_errors': 0,
|
||||
'application_events': 0,
|
||||
'system_events': 0
|
||||
}
|
||||
}), 500
|
||||
|
||||
@bp.route('/api/logs/cleanup', methods=['POST'], endpoint='api_cleanup_logs')
|
||||
@admin_required
|
||||
def api_cleanup_logs():
|
||||
"""API endpoint to cleanup old log entries"""
|
||||
try:
|
||||
# Get JSON data
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No JSON data provided'
|
||||
}), 400
|
||||
|
||||
days_to_keep = data.get('days_to_keep', 90)
|
||||
|
||||
# Validate input
|
||||
if not isinstance(days_to_keep, int) or days_to_keep < 7:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'days_to_keep must be an integer >= 7'
|
||||
}), 400
|
||||
|
||||
if days_to_keep > 365:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'days_to_keep cannot exceed 365 days'
|
||||
}), 400
|
||||
|
||||
# Perform cleanup using logger handler
|
||||
deleted_count = logger_handler.cleanup_old_logs(days_to_keep=days_to_keep)
|
||||
|
||||
admin_username = session.get('username', 'unknown')
|
||||
logger_handler.logger.info(
|
||||
f"Admin {admin_username} performed log cleanup: {deleted_count} records deleted "
|
||||
f"(keeping last {days_to_keep} days)"
|
||||
)
|
||||
|
||||
# Log the admin action
|
||||
logger_handler.log_security_event(
|
||||
event_type="admin_log_cleanup",
|
||||
description=f"Admin {admin_username} performed log cleanup: {deleted_count} entries removed (keeping last {days_to_keep} days)",
|
||||
severity="HIGH",
|
||||
additional_data={
|
||||
'admin_user': admin_username,
|
||||
'days_to_keep': days_to_keep,
|
||||
'deleted_count': deleted_count,
|
||||
'ip_address': request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr)
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_count': deleted_count,
|
||||
'days_to_keep': days_to_keep,
|
||||
'message': f'Successfully cleaned up {deleted_count} old log entries (keeping last {days_to_keep} days)',
|
||||
'performed_by': admin_username,
|
||||
'performed_at': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('api_cleanup_logs', e)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Failed to cleanup old logs: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@bp.route('/api/logs/clear', methods=['POST'], endpoint='api_clear_logs')
|
||||
@admin_required
|
||||
def api_clear_logs():
|
||||
"""API endpoint to clear ALL log entries"""
|
||||
try:
|
||||
admin_username = session.get('username', 'unknown')
|
||||
logger_handler.logger.info(f"Admin {admin_username} initiated full log clear")
|
||||
|
||||
# Count existing logs before deletion
|
||||
try:
|
||||
count_sql = "SELECT COUNT(*) as total_logs FROM log_events"
|
||||
count_result = db.session.execute(text(count_sql)).fetchone()
|
||||
total_logs = count_result.total_logs if count_result else 0
|
||||
|
||||
if total_logs == 0:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_count': 0,
|
||||
'message': 'No logs found to clear'
|
||||
})
|
||||
|
||||
except Exception as count_error:
|
||||
logger_handler.logger.warning(f"Error counting logs before clear: {count_error}")
|
||||
total_logs = 0
|
||||
|
||||
# Perform the clear operation
|
||||
try:
|
||||
clear_sql = "DELETE FROM log_events"
|
||||
result = db.session.execute(text(clear_sql))
|
||||
deleted_count = result.rowcount
|
||||
db.session.commit()
|
||||
|
||||
logger_handler.logger.info(
|
||||
f"Admin {admin_username} cleared all log entries: {deleted_count} records deleted"
|
||||
)
|
||||
|
||||
# Log the clear operation (this will be the first entry in the new log)
|
||||
logger_handler.log_security_event(
|
||||
event_type="admin_log_clear",
|
||||
description=f"Admin {admin_username} cleared all log entries: {deleted_count} records deleted",
|
||||
severity="HIGH",
|
||||
additional_data={
|
||||
'admin_user': admin_username,
|
||||
'deleted_count': deleted_count,
|
||||
'ip_address': request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr)
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_count': deleted_count,
|
||||
'message': f'Successfully cleared {deleted_count} log entries',
|
||||
'performed_by': admin_username,
|
||||
'performed_at': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
except Exception as delete_error:
|
||||
logger_handler.log_database_error('api_clear_logs_delete', delete_error)
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Failed to clear logs: {str(delete_error)}'
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('api_clear_logs', e)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Failed to clear logs: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@bp.route('/api/logs/clear-old', methods=['POST'], endpoint='api_clear_old_logs')
|
||||
@admin_required
|
||||
def api_clear_old_logs():
|
||||
"""API endpoint to clear log entries older than specified days"""
|
||||
try:
|
||||
# Get JSON data
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No JSON data provided'
|
||||
}), 400
|
||||
|
||||
days_threshold = data.get('days_threshold', 90)
|
||||
admin_username = session.get('username', 'unknown')
|
||||
|
||||
# Validate input
|
||||
if not isinstance(days_threshold, int) or days_threshold not in [30, 60, 90]:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'days_threshold must be 30, 60, or 90'
|
||||
}), 400
|
||||
|
||||
# Calculate cutoff date
|
||||
cutoff_date = datetime.now() - timedelta(days=days_threshold)
|
||||
|
||||
# Count existing logs before deletion
|
||||
try:
|
||||
count_sql = "SELECT COUNT(*) as total_logs FROM log_events WHERE created_timestamp < :cutoff_date"
|
||||
count_result = db.session.execute(text(count_sql), {'cutoff_date': cutoff_date}).fetchone()
|
||||
total_logs = count_result.total_logs if count_result else 0
|
||||
|
||||
if total_logs == 0:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_count': 0,
|
||||
'message': f'No logs older than {days_threshold} days found to clear'
|
||||
})
|
||||
|
||||
except Exception as count_error:
|
||||
logger_handler.logger.warning(f"Error counting old logs before clear: {count_error}")
|
||||
total_logs = 0
|
||||
|
||||
# Perform the clear operation
|
||||
try:
|
||||
clear_sql = "DELETE FROM log_events WHERE created_timestamp < :cutoff_date"
|
||||
result = db.session.execute(text(clear_sql), {'cutoff_date': cutoff_date})
|
||||
deleted_count = result.rowcount
|
||||
db.session.commit()
|
||||
|
||||
logger_handler.logger.info(
|
||||
f"Admin {admin_username} cleared {deleted_count} log entries older than {days_threshold} days"
|
||||
)
|
||||
|
||||
# Log the clear operation
|
||||
logger_handler.log_security_event(
|
||||
event_type="admin_clear_old_logs",
|
||||
description=f"Admin {admin_username} cleared {deleted_count} log entries older than {days_threshold} days",
|
||||
severity="HIGH",
|
||||
additional_data={
|
||||
'admin_user': admin_username,
|
||||
'days_threshold': days_threshold,
|
||||
'deleted_count': deleted_count,
|
||||
'cutoff_date': cutoff_date.isoformat(),
|
||||
'ip_address': request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr)
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_count': deleted_count,
|
||||
'days_threshold': days_threshold,
|
||||
'message': f'Successfully cleared {deleted_count} log entries older than {days_threshold} days',
|
||||
'performed_by': admin_username,
|
||||
'performed_at': datetime.now().isoformat()
|
||||
})
|
||||
|
||||
except Exception as delete_error:
|
||||
logger_handler.log_database_error('api_clear_old_logs_delete', delete_error)
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Failed to clear old logs: {str(delete_error)}'
|
||||
}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('api_clear_old_logs', e)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Failed to clear old logs: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@bp.route('/api/logs/export', endpoint='api_export_logs')
|
||||
@admin_required
|
||||
def api_export_logs():
|
||||
"""API endpoint to export log entries"""
|
||||
try:
|
||||
days = request.args.get('days', 7, type=int)
|
||||
category = request.args.get('category', '')
|
||||
severity = request.args.get('severity', '')
|
||||
search = request.args.get('search', '')
|
||||
|
||||
admin_username = session.get('username', 'unknown')
|
||||
logger_handler.logger.info(
|
||||
f"Admin {admin_username} initiated log export: last {days} days, "
|
||||
f"category={category!r}, severity={severity!r}"
|
||||
)
|
||||
|
||||
cutoff_date = datetime.now() - timedelta(days=days)
|
||||
|
||||
# Build the SQL query with filters
|
||||
base_sql = """
|
||||
SELECT
|
||||
event_id,
|
||||
event_type,
|
||||
event_category,
|
||||
event_description,
|
||||
event_data,
|
||||
severity_level,
|
||||
created_timestamp,
|
||||
username,
|
||||
user_id,
|
||||
ip_address
|
||||
FROM log_events
|
||||
WHERE created_timestamp >= :cutoff_date
|
||||
"""
|
||||
|
||||
params = {'cutoff_date': cutoff_date}
|
||||
|
||||
# Add category filter
|
||||
if category:
|
||||
base_sql += " AND event_category = :category"
|
||||
params['category'] = category
|
||||
|
||||
# Add severity filter
|
||||
if severity:
|
||||
base_sql += " AND severity_level = :severity"
|
||||
params['severity'] = severity
|
||||
|
||||
# Add search filter
|
||||
if search:
|
||||
base_sql += " AND (event_type LIKE :search OR event_description LIKE :search OR username LIKE :search)"
|
||||
params['search'] = f'%{search}%'
|
||||
|
||||
base_sql += " ORDER BY created_timestamp DESC"
|
||||
|
||||
result = db.session.execute(text(base_sql), params).fetchall()
|
||||
|
||||
logs = []
|
||||
for row in result:
|
||||
# Parse event_data if it's JSON
|
||||
event_data = None
|
||||
if row.event_data:
|
||||
try:
|
||||
event_data = json.loads(row.event_data) if isinstance(row.event_data, str) else row.event_data
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
event_data = row.event_data
|
||||
|
||||
logs.append({
|
||||
'event_id': row.event_id,
|
||||
'event_type': row.event_type,
|
||||
'event_category': row.event_category,
|
||||
'description': row.event_description,
|
||||
'event_data': event_data,
|
||||
'severity': row.severity_level,
|
||||
'timestamp': row.created_timestamp.isoformat(),
|
||||
'username': row.username or 'System',
|
||||
'user_id': row.user_id,
|
||||
'ip_address': row.ip_address or '-'
|
||||
})
|
||||
|
||||
# Log the export operation
|
||||
logger_handler.log_security_event(
|
||||
event_type="admin_log_export",
|
||||
description=f"Admin {admin_username} exported {len(logs)} log entries (last {days} days)",
|
||||
severity="MEDIUM",
|
||||
additional_data={
|
||||
'admin_user': admin_username,
|
||||
'exported_count': len(logs),
|
||||
'days_exported': days,
|
||||
'filters': {
|
||||
'category': category,
|
||||
'severity': severity,
|
||||
'search': search
|
||||
},
|
||||
'ip_address': request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr)
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'logs': logs,
|
||||
'total': len(logs),
|
||||
'filters_applied': {
|
||||
'days': days,
|
||||
'category': category,
|
||||
'severity': severity,
|
||||
'search': search
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('api_export_logs', e)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Failed to export logs: {str(e)}'
|
||||
}), 500
|
||||
|
||||
# PROJECT MANAGEMENT ROUTES
|
||||
@@ -0,0 +1,866 @@
|
||||
"""
|
||||
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 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,
|
||||
expand_employee_id_filter,
|
||||
get_base_employee_id,
|
||||
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__)
|
||||
|
||||
# Case-folded employee_id, matched against the REGEXP patterns from
|
||||
# build_employee_id_regex() so any separator style ("1234 SP", "1234.PW",
|
||||
# "1234-PT", "SP1234") is found regardless of the column's collation.
|
||||
UPPER_EMPLOYEE_ID_SQL = "UPPER(ad.employee_id)"
|
||||
|
||||
|
||||
|
||||
@bp.route('/attendance', endpoint='attendance_report')
|
||||
@login_required
|
||||
def attendance_report():
|
||||
"""Safe attendance report with backward compatibility for location_accuracy and fixed datetime handling"""
|
||||
try:
|
||||
logger_handler.logger.debug("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:
|
||||
pass
|
||||
|
||||
# Check if location_accuracy column exists
|
||||
has_location_accuracy = check_location_accuracy_column_exists()
|
||||
logger_handler.logger.debug(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:
|
||||
# Use the base ID so work-type IDs ("1234SP") still resolve a name
|
||||
emp = Employee.query.filter_by(id=int(get_base_employee_id(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':
|
||||
logger_handler.logger.debug(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}"
|
||||
)
|
||||
|
||||
logger_handler.logger.debug(f"PM allowed projects: {allowed_project_ids}, locations: {allowed_location_names}")
|
||||
except Exception as perm_error:
|
||||
logger_handler.logger.warning(f"Error loading PM 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,
|
||||
COALESCE(ad.qr_address, 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,
|
||||
COALESCE(ad.is_dynamic_qr, 0) as is_dynamic_qr
|
||||
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,
|
||||
COALESCE(ad.qr_address, 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,
|
||||
COALESCE(ad.is_dynamic_qr, 0) as is_dynamic_qr
|
||||
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:
|
||||
# Exact match — dropdown value IS the exact location_name string
|
||||
filter_conditions.append("ad.location_name = :location")
|
||||
query_params['location'] = location_filter
|
||||
|
||||
if employee_ids:
|
||||
# Expand each selected ID into every stored spelling so extra-work
|
||||
# check-ins (SP / PW / PT) are included alongside regular records.
|
||||
# Two branches: exact match on the raw column (uses the index), plus a
|
||||
# REGEXP match that catches any separator style ("1234.PW", "1234-SP").
|
||||
exact_variants, regex_patterns = expand_employee_id_filter(employee_ids)
|
||||
# Never emit an empty IN () — fall back to the raw selection if expansion
|
||||
# somehow produced nothing, so the filter can't degrade into "match all".
|
||||
if not exact_variants:
|
||||
exact_variants = list(employee_ids)
|
||||
|
||||
exact_placeholders = ', '.join([f':employee_{i}' for i in range(len(exact_variants))])
|
||||
exact_condition = f"ad.employee_id IN ({exact_placeholders})"
|
||||
for i, variant in enumerate(exact_variants):
|
||||
query_params[f'employee_{i}'] = variant
|
||||
|
||||
if regex_patterns:
|
||||
regex_clauses = ' OR '.join([
|
||||
f"{UPPER_EMPLOYEE_ID_SQL} REGEXP :employee_re_{i}"
|
||||
for i in range(len(regex_patterns))
|
||||
])
|
||||
filter_conditions.append(f"({exact_condition} OR {regex_clauses})")
|
||||
for i, pattern in enumerate(regex_patterns):
|
||||
query_params[f'employee_re_{i}'] = pattern
|
||||
else:
|
||||
filter_conditions.append(exact_condition)
|
||||
logger_handler.logger.info(
|
||||
f"Attendance report filtered by employee IDs: {employee_ids} "
|
||||
f"(matching {len(exact_variants)} ID variants incl. SP/PW/PT) "
|
||||
f"by user {session.get('username', 'unknown')}"
|
||||
)
|
||||
|
||||
if project_filter:
|
||||
# For standard QR records: match by the QR code's project_id directly.
|
||||
# For dynamic QR records: the dynamic QR itself may not be in any project,
|
||||
# but the employee-selected location corresponds to a standard QR in that
|
||||
# project. Match those by checking if attendance_data.location_name
|
||||
# appears in the locations of QR codes belonging to the selected project.
|
||||
filter_conditions.append(
|
||||
"(qc.project_id = :project OR "
|
||||
"(ad.is_dynamic_qr = 1 AND ad.location_name IN ("
|
||||
" SELECT DISTINCT qc2.location FROM qr_codes qc2 "
|
||||
" WHERE qc2.project_id = :project AND qc2.qr_type = 'standard' "
|
||||
" AND qc2.location IS NOT NULL AND qc2.location != ''"
|
||||
")))"
|
||||
)
|
||||
query_params['project'] = project_filter
|
||||
|
||||
# Combine query with filters
|
||||
if filter_conditions:
|
||||
base_query += " AND " + " AND ".join(filter_conditions)
|
||||
|
||||
# Fetch one extra record to detect truncation without a separate COUNT query
|
||||
ATTENDANCE_PAGE_LIMIT = 1000
|
||||
base_query += f" ORDER BY ad.check_in_date DESC, ad.check_in_time DESC LIMIT {ATTENDANCE_PAGE_LIMIT + 1}"
|
||||
|
||||
logger_handler.logger.debug(f"Executing attendance query with filters: {list(query_params.keys())}")
|
||||
|
||||
# Execute query
|
||||
result = db.session.execute(text(base_query), query_params)
|
||||
records = result.fetchall()
|
||||
# If we got more than the limit, the result set is truncated
|
||||
records_truncated = len(records) > ATTENDANCE_PAGE_LIMIT
|
||||
if records_truncated:
|
||||
records = records[:ATTENDANCE_PAGE_LIMIT]
|
||||
logger_handler.logger.debug(f"Loaded {len(records)} attendance records (truncated={records_truncated})")
|
||||
|
||||
# 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,
|
||||
'is_dynamic_qr': bool(record[19]) if len(record) > 19 else False
|
||||
}
|
||||
|
||||
# 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:
|
||||
logger_handler.logger.warning(f"Error processing attendance 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)
|
||||
logger_handler.logger.debug(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
|
||||
AND location_name != 'Dynamic'
|
||||
AND location_name != ''
|
||||
ORDER BY location_name
|
||||
"""))
|
||||
locations = [row[0] for row in locations_query.fetchall()]
|
||||
logger_handler.logger.debug(f"Found {len(locations)} unique locations")
|
||||
# ============================================================
|
||||
# END: FILTER LOCATIONS FOR PROJECT MANAGER
|
||||
# ============================================================
|
||||
except Exception as e:
|
||||
logger_handler.logger.warning(f"Error loading locations filter: {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()
|
||||
logger_handler.logger.debug(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()
|
||||
logger_handler.logger.debug(f"Loaded {len(projects)} projects with attendance data")
|
||||
# ============================================================
|
||||
# END: FILTER PROJECTS FOR PROJECT MANAGER
|
||||
# ============================================================
|
||||
except Exception as e:
|
||||
logger_handler.logger.warning(f"Error loading projects filter: {e}")
|
||||
projects = []
|
||||
|
||||
# ============================================================
|
||||
# STATISTICS - COMPLETELY REWRITTEN FOR SAFETY
|
||||
# ============================================================
|
||||
logger_handler.logger.debug("Loading attendance 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)
|
||||
|
||||
logger_handler.logger.debug(f"Executing stats query with params: {list(stats_params.keys())}")
|
||||
|
||||
# Execute stats query
|
||||
stats_result = db.session.execute(text(stats_query_text), stats_params)
|
||||
stats_row = stats_result.fetchone()
|
||||
|
||||
logger_handler.logger.debug(f"Stats row type: {type(stats_row).__name__}")
|
||||
|
||||
# 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
|
||||
logger_handler.logger.debug(f"Loaded statistics: {stats_dict['total_checkins']} total check-ins")
|
||||
except (IndexError, TypeError, ValueError) as extract_error:
|
||||
logger_handler.logger.warning(f"Error extracting stats values: {extract_error}")
|
||||
# stats_dict already has default values
|
||||
else:
|
||||
logger_handler.logger.warning("Stats query returned None or insufficient columns, using default stats")
|
||||
|
||||
except Exception as stats_error:
|
||||
logger_handler.logger.error(f"Error loading statistics: {stats_error}", exc_info=True)
|
||||
# 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)
|
||||
logger_handler.logger.debug(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')
|
||||
|
||||
logger_handler.logger.debug("Rendering attendance report template")
|
||||
|
||||
return render_template('attendance_report.html',
|
||||
attendance_records=processed_records,
|
||||
records_truncated=records_truncated,
|
||||
records_limit=ATTENDANCE_PAGE_LIMIT,
|
||||
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:
|
||||
logger_handler.logger.error(f"Error loading attendance report: {e}", exc_info=True)
|
||||
|
||||
error_traceback = traceback.format_exc()
|
||||
|
||||
|
||||
# Log the error
|
||||
try:
|
||||
logger_handler.log_database_error('attendance_report', e)
|
||||
except Exception as log_error:
|
||||
logger_handler.logger.warning(f"Additional logging error: {log_error}")
|
||||
|
||||
flash('Error loading attendance report. Please check the server logs for details.', 'error')
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
|
||||
@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."""
|
||||
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."""
|
||||
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.
|
||||
"""
|
||||
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
|
||||
"""
|
||||
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)"""
|
||||
# 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.attendance_report'))
|
||||
|
||||
try:
|
||||
attendance_record = db.session.get(AttendanceData, record_id)
|
||||
if attendance_record is None:
|
||||
abort(404)
|
||||
|
||||
# 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()
|
||||
|
||||
logger_handler.logger.info(
|
||||
f"User {session.get('username')} ({session.get('role', 'unknown')}) "
|
||||
f"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.attendance_report'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('attendance_delete', e)
|
||||
logger_handler.logger.error(f"Error deleting attendance record {record_id}: {e}", exc_info=True)
|
||||
|
||||
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.attendance_report'))
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
"""
|
||||
routes/attendance_edit.py
|
||||
=========================
|
||||
Attendance record edit, manual entry, and delete routes.
|
||||
|
||||
Routes: /attendance/<id>/edit, /attendance/add,
|
||||
/attendance/save_manual, /attendance/<id>/delete
|
||||
|
||||
"""
|
||||
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,
|
||||
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('/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)"""
|
||||
# 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.attendance_report'))
|
||||
|
||||
try:
|
||||
attendance_record = db.session.get(AttendanceData, record_id)
|
||||
if attendance_record is None:
|
||||
abort(404)
|
||||
|
||||
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 = db.session.get(QRCode, 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')
|
||||
}
|
||||
)
|
||||
logger_handler.logger.info(
|
||||
f"User {session.get('username')} ({session.get('role', 'unknown')}) "
|
||||
f"updated attendance record {record_id}: {changes}, 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')
|
||||
}
|
||||
)
|
||||
logger_handler.logger.info(
|
||||
f"User {session.get('username')} ({session.get('role', 'unknown')}) "
|
||||
f"edited attendance record {record_id} with no changes, reason: {edit_note}"
|
||||
)
|
||||
|
||||
flash(f'Attendance record for {new_employee_id} updated successfully! Edit reason logged for audit.', 'success')
|
||||
return redirect(url_for('attendance.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)
|
||||
logger_handler.logger.error(f"Error updating attendance record {record_id}: {e}", exc_info=True)
|
||||
flash('Error updating attendance record. Please try again.', 'error')
|
||||
return redirect(url_for('attendance.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
|
||||
"""
|
||||
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.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.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
|
||||
"""
|
||||
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('attendance.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('attendance.add_manual_attendance'))
|
||||
|
||||
# Get QR code (location)
|
||||
qr_code = db.session.get(QRCode, int(location_id))
|
||||
if not qr_code:
|
||||
flash('Selected location not found.', 'error')
|
||||
return redirect(url_for('attendance.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('attendance.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('attendance.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.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"Error saving manual attendance record: {e}", exc_info=True)
|
||||
flash('Error saving attendance record. Please try again.', 'error')
|
||||
return redirect(url_for('attendance.add_manual_attendance'))
|
||||
|
||||
|
||||
@@ -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
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
routes/auth.py
|
||||
==============
|
||||
Authentication and user-profile routes.
|
||||
|
||||
Routes: /, /register, /login, /logout, /profile
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, url_for
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlparse, urljoin
|
||||
import json
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from models.user import User
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import admin_required, login_required, staff_or_admin_required
|
||||
from turnstile_utils import turnstile_utils
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
|
||||
|
||||
|
||||
@bp.route('/', endpoint='index')
|
||||
def index():
|
||||
"""Home page - redirect to login if not authenticated"""
|
||||
if 'user_id' in session:
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'], endpoint='register')
|
||||
@login_required
|
||||
@admin_required
|
||||
@log_user_activity('user_registration')
|
||||
def register():
|
||||
"""User registration endpoint"""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
full_name = request.form['full_name']
|
||||
email = request.form['email']
|
||||
username = request.form['username']
|
||||
password = request.form['password']
|
||||
|
||||
# Check if user already exists
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists.', 'error')
|
||||
return render_template('register.html')
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already registered.', 'error')
|
||||
return render_template('register.html')
|
||||
|
||||
# Create new user (default role: staff)
|
||||
new_user = User(
|
||||
full_name=full_name,
|
||||
email=email,
|
||||
username=username,
|
||||
role='staff'
|
||||
)
|
||||
new_user.set_password(password)
|
||||
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
# Log successful user registration
|
||||
logger_handler.logger.info(f"New user registered: {username} ({email})")
|
||||
|
||||
flash('Registration successful! Please log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_registration', e)
|
||||
flash('Registration failed. Please try again.', 'error')
|
||||
|
||||
return render_template('register.html')
|
||||
|
||||
@bp.route('/login', methods=['GET', 'POST'], endpoint='login')
|
||||
def login():
|
||||
"""Enhanced user authentication with Turnstile and comprehensive logging"""
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
turnstile_response = request.form.get('cf-turnstile-response', '')
|
||||
|
||||
if not username or not password:
|
||||
flash('Please enter both username and password.', 'error')
|
||||
return render_template('login.html')
|
||||
|
||||
# Rate-limit check — blocks IPs with 5+ failed attempts in 15 minutes
|
||||
from flask import current_app
|
||||
sec_mgr = getattr(current_app, 'security_manager', None)
|
||||
if sec_mgr and sec_mgr.is_auth_rate_limited():
|
||||
logger_handler.log_security_event(
|
||||
event_type="login_rate_limited",
|
||||
description=f"Login blocked by rate limiter for username: {username}",
|
||||
severity="HIGH"
|
||||
)
|
||||
flash('Too many failed attempts. Please wait 15 minutes before trying again.', 'error')
|
||||
return render_template('login.html')
|
||||
|
||||
# Verify Turnstile if enabled
|
||||
if turnstile_utils.is_enabled():
|
||||
if not turnstile_utils.verify_turnstile(turnstile_response):
|
||||
# Log failed Turnstile attempt
|
||||
logger_handler.log_security_event(
|
||||
event_type="turnstile_verification_failed",
|
||||
description=f"Failed Turnstile verification for username: {username}",
|
||||
severity="HIGH"
|
||||
)
|
||||
flash('Please complete the security verification.', 'error')
|
||||
return render_template('login.html')
|
||||
|
||||
try:
|
||||
# Find user (case-insensitive username)
|
||||
user = User.query.filter(
|
||||
User.username.like(username),
|
||||
User.active_status == True
|
||||
).first()
|
||||
|
||||
if user and user.check_password(password):
|
||||
# Check if "Remember Me" is checked
|
||||
remember_me = request.form.get('remember_me') == 'on'
|
||||
|
||||
# Invalidate the pre-login session to prevent session fixation attacks,
|
||||
# then re-apply the remember_me permanence flag on the fresh session.
|
||||
session.clear()
|
||||
|
||||
# Set session as permanent if "Remember Me" is checked
|
||||
if remember_me:
|
||||
session.permanent = True
|
||||
session['remember_me'] = True
|
||||
else:
|
||||
session.permanent = False
|
||||
session['remember_me'] = False
|
||||
|
||||
# Successful login
|
||||
session['user_id'] = user.id
|
||||
session['username'] = user.username
|
||||
session['role'] = user.role
|
||||
session['full_name'] = user.full_name
|
||||
session['login_time'] = datetime.now().isoformat()
|
||||
|
||||
# Create a secure session token (also clears failed attempts for this IP)
|
||||
if sec_mgr:
|
||||
sec_mgr.create_secure_session(user.id)
|
||||
|
||||
# Update last login date
|
||||
user.last_login_date = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
# Log successful login with Turnstile info
|
||||
logger_handler.log_user_login(
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
success=True
|
||||
)
|
||||
|
||||
# Log successful Turnstile verification
|
||||
if turnstile_utils.is_enabled():
|
||||
logger_handler.log_security_event(
|
||||
event_type="turnstile_verification_success",
|
||||
description=f"Successful Turnstile verification for user: {user.username}",
|
||||
severity="INFO"
|
||||
)
|
||||
|
||||
flash(f'Welcome back, {user.full_name}!', 'success')
|
||||
logger_handler.logger.info(f"User {user.username} (ID: {user.id}) logged in successfully")
|
||||
|
||||
# Redirect to intended page or dashboard.
|
||||
# Validate next is a relative path on this host to prevent open-redirect attacks.
|
||||
def _is_safe_url(target):
|
||||
ref_url = urlparse(request.host_url)
|
||||
test_url = urlparse(urljoin(request.host_url, target))
|
||||
return (test_url.scheme in ('http', 'https')
|
||||
and ref_url.netloc == test_url.netloc)
|
||||
|
||||
next_page = request.args.get('next')
|
||||
if next_page and _is_safe_url(next_page):
|
||||
return redirect(next_page)
|
||||
return redirect(url_for('attendance.attendance_report'))
|
||||
|
||||
else:
|
||||
# Invalid credentials — record failed attempt for rate limiting
|
||||
if sec_mgr:
|
||||
sec_mgr.record_failed_attempt(username)
|
||||
|
||||
user_id = user.id if user else None
|
||||
logger_handler.log_user_login(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
success=False,
|
||||
failure_reason="Invalid credentials"
|
||||
)
|
||||
|
||||
flash('Invalid username or password.', 'error')
|
||||
logger_handler.logger.warning(f"Failed login attempt for username: {username}")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_login', e)
|
||||
logger_handler.logger.error(f"Login error for username '{username}': {e}")
|
||||
flash('Login error. Please try again.', 'error')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@bp.route('/logout', endpoint='logout')
|
||||
def logout():
|
||||
"""User logout endpoint with session duration logging"""
|
||||
user_id = session.get('user_id')
|
||||
username = session.get('username')
|
||||
login_time_str = session.get('login_time')
|
||||
|
||||
# Calculate session duration
|
||||
session_duration = None
|
||||
if login_time_str:
|
||||
try:
|
||||
login_time = datetime.fromisoformat(login_time_str)
|
||||
session_duration = (datetime.now() - login_time).total_seconds() / 60 # minutes
|
||||
except Exception as e:
|
||||
logger_handler.logger.debug(f"Could not parse login_time for session duration: {e}")
|
||||
|
||||
# Log user logout
|
||||
if user_id and username:
|
||||
logger_handler.log_user_logout(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
session_duration=session_duration
|
||||
)
|
||||
|
||||
session.clear()
|
||||
flash('You have been logged out.', 'info')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@bp.route('/profile', methods=['GET', 'POST'], endpoint='profile')
|
||||
@login_required
|
||||
@log_user_activity('profile_update')
|
||||
def profile():
|
||||
"""User profile management with logging"""
|
||||
try:
|
||||
user = db.session.get(User, session['user_id'])
|
||||
|
||||
if request.method == 'POST':
|
||||
form_type = request.form.get('form_type')
|
||||
|
||||
if form_type == 'profile':
|
||||
# Track changes for logging
|
||||
old_name = user.full_name
|
||||
old_email = user.email
|
||||
|
||||
# Update profile information
|
||||
user.full_name = request.form['full_name']
|
||||
user.email = request.form['email']
|
||||
|
||||
# Check for changes
|
||||
changes = {}
|
||||
if old_name != user.full_name:
|
||||
changes['full_name'] = {'old': old_name, 'new': user.full_name}
|
||||
if old_email != user.email:
|
||||
changes['email'] = {'old': old_email, 'new': user.email}
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Log profile update if there were changes
|
||||
if changes:
|
||||
logger_handler.logger.info(f"User profile updated: {user.username} - Changes: {json.dumps(changes)}")
|
||||
|
||||
flash('Profile updated successfully!', 'success')
|
||||
|
||||
elif form_type == 'password':
|
||||
# Update password
|
||||
current_password = request.form['current_password']
|
||||
new_password = request.form['new_password']
|
||||
|
||||
if user.check_password(current_password):
|
||||
user.set_password(new_password)
|
||||
db.session.commit()
|
||||
|
||||
# Log password change
|
||||
logger_handler.log_security_event(
|
||||
event_type="password_change",
|
||||
description=f"User {user.username} changed password",
|
||||
severity="MEDIUM"
|
||||
)
|
||||
|
||||
flash('Password updated successfully!', 'success')
|
||||
else:
|
||||
# Log failed password change attempt
|
||||
logger_handler.log_security_event(
|
||||
event_type="password_change_failed",
|
||||
description=f"Failed password change attempt for user {user.username}",
|
||||
severity="HIGH"
|
||||
)
|
||||
flash('Current password is incorrect.', 'error')
|
||||
|
||||
return render_template('profile.html', user=user)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('profile_update', e)
|
||||
flash('Profile update failed. Please try again.', 'error')
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
routes/dashboard.py
|
||||
===================
|
||||
Dashboard and related API routes.
|
||||
|
||||
Routes: /dashboard, /project/<id>/qr-codes, /dashboard/search,
|
||||
/api/dashboard/stats, /api/dashboard/realtime
|
||||
"""
|
||||
from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, url_for
|
||||
from datetime import datetime, timedelta, date, time
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from models.attendance import AttendanceData
|
||||
from models.project import Project
|
||||
from models.qrcode import QRCode
|
||||
from models.user import User
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import login_required
|
||||
|
||||
bp = Blueprint('dashboard', __name__)
|
||||
|
||||
|
||||
|
||||
@bp.route('/dashboard', endpoint='dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Enhanced project-centric dashboard with search filters"""
|
||||
try:
|
||||
user = db.session.get(User, session['user_id'])
|
||||
|
||||
# Get search parameters from URL
|
||||
search_name = request.args.get('search_name', '').strip()
|
||||
search_status = request.args.get('search_status', '').strip()
|
||||
|
||||
# Build QR codes query with filters
|
||||
qr_query = QRCode.query
|
||||
|
||||
# Apply name filter if provided
|
||||
if search_name:
|
||||
qr_query = qr_query.filter(QRCode.name.ilike(f'%{search_name}%'))
|
||||
|
||||
# Apply status filter if provided
|
||||
if search_status == 'active':
|
||||
qr_query = qr_query.filter(QRCode.active_status == True)
|
||||
elif search_status == 'inactive':
|
||||
qr_query = qr_query.filter(QRCode.active_status == False)
|
||||
|
||||
# Execute query
|
||||
qr_codes = qr_query.order_by(QRCode.created_date.desc()).all()
|
||||
projects = Project.query.order_by(Project.name.asc()).all()
|
||||
|
||||
# Log dashboard access with filter info
|
||||
filter_info = []
|
||||
if search_name:
|
||||
filter_info.append(f"name contains '{search_name}'")
|
||||
if search_status:
|
||||
filter_info.append(f"status is {search_status}")
|
||||
|
||||
log_message = f"User {session['username']} accessed dashboard: {len(qr_codes)} QR codes"
|
||||
if filter_info:
|
||||
log_message += f" (filtered: {', '.join(filter_info)})"
|
||||
|
||||
logger_handler.logger.info(log_message)
|
||||
|
||||
return render_template('dashboard.html',
|
||||
user=user,
|
||||
qr_codes=qr_codes,
|
||||
projects=projects,
|
||||
search_name=search_name,
|
||||
search_status=search_status)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('dashboard_load', e)
|
||||
flash('Error loading dashboard. Please try again.', 'error')
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
@bp.route('/project/<int:project_id>/qr-codes', endpoint='project_qr_codes')
|
||||
@login_required
|
||||
def project_qr_codes(project_id):
|
||||
"""
|
||||
View all QR codes for a specific project with search filters
|
||||
Allows filtering by name and status within the project
|
||||
"""
|
||||
try:
|
||||
# Get the project
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
|
||||
# Get search parameters from URL
|
||||
search_name = request.args.get('search_name', '').strip()
|
||||
search_status = request.args.get('search_status', '').strip()
|
||||
|
||||
# Build QR codes query with filters for this project only
|
||||
qr_query = QRCode.query.filter_by(project_id=project_id)
|
||||
|
||||
# Apply name filter if provided
|
||||
if search_name:
|
||||
qr_query = qr_query.filter(QRCode.name.ilike(f'%{search_name}%'))
|
||||
|
||||
# Apply status filter if provided
|
||||
if search_status == 'active':
|
||||
qr_query = qr_query.filter(QRCode.active_status == True)
|
||||
elif search_status == 'inactive':
|
||||
qr_query = qr_query.filter(QRCode.active_status == False)
|
||||
|
||||
# Execute query
|
||||
qr_codes = qr_query.order_by(QRCode.created_date.desc()).all()
|
||||
|
||||
# Log access with filter info
|
||||
filter_info = []
|
||||
if search_name:
|
||||
filter_info.append(f"name contains '{search_name}'")
|
||||
if search_status:
|
||||
filter_info.append(f"status is {search_status}")
|
||||
|
||||
log_message = f"User {session['username']} viewed project '{project.name}' QR codes: {len(qr_codes)} QR codes"
|
||||
if filter_info:
|
||||
log_message += f" (filtered: {', '.join(filter_info)})"
|
||||
|
||||
logger_handler.logger.info(log_message)
|
||||
|
||||
return render_template('project_qr_codes.html',
|
||||
project=project,
|
||||
qr_codes=qr_codes,
|
||||
search_name=search_name,
|
||||
search_status=search_status)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('project_qr_codes_view', e)
|
||||
flash('Error loading project QR codes. Please try again.', 'error')
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
|
||||
@bp.route('/dashboard/search', methods=['GET'], endpoint='search_qr_codes')
|
||||
@login_required
|
||||
def search_qr_codes():
|
||||
"""Search QR codes - redirect to dashboard with filters"""
|
||||
search_name = request.args.get('search_name', '').strip()
|
||||
search_status = request.args.get('search_status', '').strip()
|
||||
|
||||
# Log search activity
|
||||
logger_handler.logger.info(
|
||||
f"User {session['username']} searched QR codes: "
|
||||
f"name='{search_name}', status='{search_status}'"
|
||||
)
|
||||
|
||||
# Redirect to dashboard with search parameters
|
||||
return redirect(url_for('dashboard.dashboard', search_name=search_name, search_status=search_status))
|
||||
|
||||
@bp.route('/api/dashboard/stats', endpoint='dashboard_stats_api')
|
||||
@login_required
|
||||
def dashboard_stats_api():
|
||||
"""API endpoint for dashboard statistics"""
|
||||
try:
|
||||
# Get current stats
|
||||
total_qr_codes = QRCode.query.filter_by(active_status=True).count()
|
||||
|
||||
# Today's check-ins
|
||||
today = datetime.utcnow().date()
|
||||
today_checkins = AttendanceData.query.filter(
|
||||
AttendanceData.check_in_date == today
|
||||
).count()
|
||||
|
||||
# Active projects
|
||||
active_projects = Project.query.filter_by(active_status=True).count()
|
||||
|
||||
# Unique locations
|
||||
unique_locations = db.session.query(
|
||||
AttendanceData.location_name
|
||||
).distinct().count()
|
||||
|
||||
# Calculate trends (compared to last month)
|
||||
last_month = datetime.utcnow() - timedelta(days=30)
|
||||
|
||||
# QR codes trend
|
||||
old_qr_count = QRCode.query.filter(
|
||||
QRCode.created_date <= last_month,
|
||||
QRCode.active_status == True
|
||||
).count()
|
||||
qr_change = ((total_qr_codes - old_qr_count) / max(old_qr_count, 1)) * 100
|
||||
|
||||
# Check-ins trend (yesterday)
|
||||
yesterday = today - timedelta(days=1)
|
||||
yesterday_checkins = AttendanceData.query.filter(
|
||||
AttendanceData.check_in_date == yesterday
|
||||
).count()
|
||||
checkin_change = ((today_checkins - yesterday_checkins) / max(yesterday_checkins, 1)) * 100
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'total_qr_codes': total_qr_codes,
|
||||
'today_checkins': today_checkins,
|
||||
'active_projects': active_projects,
|
||||
'unique_locations': unique_locations,
|
||||
'qr_change': round(qr_change, 1),
|
||||
'checkin_change': round(checkin_change, 1),
|
||||
'project_change': 0, # You can calculate this based on your needs
|
||||
'location_change': 0 # You can calculate this based on your needs
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('dashboard_stats_api', e)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to fetch dashboard statistics'
|
||||
}), 500
|
||||
|
||||
@bp.route('/api/dashboard/realtime', endpoint='dashboard_realtime_api')
|
||||
@login_required
|
||||
def dashboard_realtime_api():
|
||||
"""API endpoint for real-time dashboard data"""
|
||||
try:
|
||||
# Get recent activity (last 10 check-ins)
|
||||
recent_activity = db.session.query(
|
||||
AttendanceData.employee_id,
|
||||
AttendanceData.location_name,
|
||||
AttendanceData.check_in_time,
|
||||
AttendanceData.check_in_date
|
||||
).order_by(
|
||||
AttendanceData.check_in_date.desc(),
|
||||
AttendanceData.check_in_time.desc()
|
||||
).limit(10).all()
|
||||
|
||||
activity_data = [
|
||||
{
|
||||
'employee_id': activity.employee_id,
|
||||
'location': activity.location_name,
|
||||
'time': activity.check_in_time.strftime('%H:%M'),
|
||||
'date': activity.check_in_date.strftime('%Y-%m-%d')
|
||||
}
|
||||
for activity in recent_activity
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'recent_activity': activity_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('dashboard_realtime_api', e)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to fetch real-time data'
|
||||
}), 500
|
||||
|
||||
# USER MANAGEMENT ROUTES
|
||||
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
routes/employees.py
|
||||
===================
|
||||
Employee CRUD and search routes.
|
||||
|
||||
Routes: /employees, /employees/create, /employees/<id>/edit,
|
||||
/employees/<id>/delete, /api/employees/search, /employees/<id>
|
||||
"""
|
||||
from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, url_for
|
||||
from datetime import datetime, date
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from models.attendance import AttendanceData
|
||||
from models.employee import Employee
|
||||
from models.project import Project
|
||||
from models.qrcode import QRCode
|
||||
from models.user import User
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import (
|
||||
admin_required,
|
||||
has_admin_privileges,
|
||||
has_staff_level_access,
|
||||
login_required,
|
||||
staff_or_admin_required)
|
||||
|
||||
bp = Blueprint('employees', __name__)
|
||||
|
||||
|
||||
|
||||
@bp.route('/employees', endpoint='employees')
|
||||
@login_required
|
||||
def employees():
|
||||
"""Display employee management page with search and pagination"""
|
||||
try:
|
||||
logger_handler.logger.info(f"User {session['username']} accessed employee management list")
|
||||
|
||||
# Get search parameters
|
||||
search = request.args.get('search', '').strip()
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 20 # Number of employees per page
|
||||
|
||||
# Build query based on search
|
||||
query = Employee.query.outerjoin(Project, Employee.contractId == Project.id)
|
||||
|
||||
if search:
|
||||
search_pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
db.or_(
|
||||
Employee.firstName.like(search_pattern),
|
||||
Employee.lastName.like(search_pattern),
|
||||
Employee.title.like(search_pattern),
|
||||
Employee.id.like(search_pattern)
|
||||
)
|
||||
)
|
||||
|
||||
# Order by first name, then last name
|
||||
query = query.order_by(Employee.firstName, Employee.lastName)
|
||||
|
||||
# Paginate results
|
||||
employees = query.paginate(
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
error_out=False
|
||||
)
|
||||
|
||||
# Get summary statistics
|
||||
total_employees = Employee.query.count()
|
||||
employees_with_title = Employee.query.filter(Employee.title.isnot(None)).filter(Employee.title != '').count()
|
||||
unique_titles = db.session.query(Employee.title).filter(Employee.title.isnot(None)).filter(Employee.title != '').distinct().count()
|
||||
|
||||
stats = {
|
||||
'total_employees': total_employees,
|
||||
'employees_with_title': employees_with_title,
|
||||
'unique_titles': unique_titles,
|
||||
'search_results': employees.total if search else total_employees
|
||||
}
|
||||
|
||||
return render_template('employees.html',
|
||||
employees=employees,
|
||||
search=search,
|
||||
stats=stats)
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('employee_list', e)
|
||||
flash('Error loading employee list. Please try again.', 'error')
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
|
||||
@bp.route('/employees/create', methods=['GET', 'POST'], endpoint='create_employee')
|
||||
@login_required
|
||||
@log_database_operations('employee_creation')
|
||||
def create_employee():
|
||||
"""Create new employee (Admin only)"""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
# Get form data
|
||||
employee_id = request.form['employee_id'].strip()
|
||||
first_name = request.form['first_name'].strip()
|
||||
last_name = request.form['last_name'].strip()
|
||||
title = request.form.get('title', '').strip()
|
||||
contract_id = request.form.get('contract_id', '1').strip()
|
||||
|
||||
# Validate required fields
|
||||
if not all([employee_id, first_name, last_name, contract_id]):
|
||||
flash('Employee ID, First Name, Last Name, and Project are required.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
return render_template('create_employee.html', projects=projects)
|
||||
|
||||
# Validate employee ID is numeric
|
||||
try:
|
||||
employee_id_int = int(employee_id)
|
||||
contract_id_int = int(contract_id)
|
||||
except ValueError:
|
||||
flash('Employee ID must be numeric and Project must be selected.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
return render_template('create_employee.html', projects=projects)
|
||||
|
||||
# Check if employee ID already exists
|
||||
existing_employee = Employee.query.filter_by(id=employee_id_int).first()
|
||||
if existing_employee:
|
||||
flash(f'Employee with ID {employee_id} already exists.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
return render_template('create_employee.html', projects=projects)
|
||||
|
||||
# Create new employee
|
||||
new_employee = Employee(
|
||||
id=employee_id_int,
|
||||
firstName=first_name,
|
||||
lastName=last_name,
|
||||
title=title if title else None,
|
||||
contractId=contract_id_int
|
||||
)
|
||||
|
||||
db.session.add(new_employee)
|
||||
db.session.commit()
|
||||
|
||||
# Log employee creation with project info
|
||||
project = db.session.get(Project, contract_id_int)
|
||||
project_name = project.name if project else f"Project {contract_id_int}"
|
||||
logger_handler.logger.info(
|
||||
f"User {session['username']} created new employee: "
|
||||
f"{employee_id_int} - {first_name} {last_name} assigned to {project_name}"
|
||||
)
|
||||
|
||||
flash(f'Employee "{first_name} {last_name}" (ID: {employee_id}) created successfully.', 'success')
|
||||
return redirect(url_for('employees.employees'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('employee_creation', e)
|
||||
flash('Failed to create employee. Please try again.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
return render_template('create_employee.html', projects=projects)
|
||||
|
||||
# GET request - load the form with projects
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
return render_template('create_employee.html', projects=projects)
|
||||
|
||||
@bp.route('/employees/<int:employee_index>/edit', methods=['GET', 'POST'], endpoint='edit_employee')
|
||||
@login_required
|
||||
@log_database_operations('employee_update')
|
||||
def edit_employee(employee_index):
|
||||
"""Edit existing employee (Admin only)"""
|
||||
try:
|
||||
# Get employee by index (primary key)
|
||||
employee = db.session.get(Employee, employee_index)
|
||||
if employee is None:
|
||||
abort(404)
|
||||
|
||||
if request.method == 'POST':
|
||||
# Get form data
|
||||
employee_id = request.form['employee_id'].strip()
|
||||
first_name = request.form['first_name'].strip()
|
||||
last_name = request.form['last_name'].strip()
|
||||
title = request.form.get('title', '').strip()
|
||||
contract_id = request.form.get('contract_id', '1').strip()
|
||||
|
||||
# Validate required fields
|
||||
if not all([employee_id, first_name, last_name, contract_id]):
|
||||
flash('Employee ID, First Name, Last Name, and Project are required.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
return render_template('edit_employee.html', employee=employee, projects=projects)
|
||||
|
||||
# Validate numeric fields
|
||||
try:
|
||||
employee_id_int = int(employee_id)
|
||||
contract_id_int = int(contract_id)
|
||||
except ValueError:
|
||||
flash('Employee ID must be numeric and Project must be selected.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
return render_template('edit_employee.html', employee=employee, projects=projects)
|
||||
|
||||
# Check if employee ID already exists (but not for this employee)
|
||||
existing_employee = Employee.query.filter_by(id=employee_id_int).first()
|
||||
if existing_employee and existing_employee.index != employee.index:
|
||||
flash(f'Employee with ID {employee_id} already exists.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
return render_template('edit_employee.html', employee=employee, projects=projects)
|
||||
|
||||
# Store original values for logging
|
||||
original_data = {
|
||||
'id': employee.id,
|
||||
'firstName': employee.firstName,
|
||||
'lastName': employee.lastName,
|
||||
'title': employee.title,
|
||||
'contractId': employee.contractId
|
||||
}
|
||||
|
||||
# Update employee data
|
||||
employee.id = employee_id_int
|
||||
employee.firstName = first_name
|
||||
employee.lastName = last_name
|
||||
employee.title = title if title else None
|
||||
employee.contractId = contract_id_int
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Log employee update with project info
|
||||
project = db.session.get(Project, contract_id_int)
|
||||
project_name = project.name if project else f"Project {contract_id_int}"
|
||||
logger_handler.logger.info(
|
||||
f"User {session['username']} updated employee: "
|
||||
f"{employee_index} - {first_name} {last_name} assigned to {project_name}"
|
||||
)
|
||||
|
||||
flash(f'Employee "{first_name} {last_name}" updated successfully.', 'success')
|
||||
return redirect(url_for('employees.employees'))
|
||||
|
||||
# GET request - load the form with projects
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
return render_template('edit_employee.html', employee=employee, projects=projects)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('employee_update', e)
|
||||
flash('Error updating employee. Please try again.', 'error')
|
||||
return redirect(url_for('employees.employees'))
|
||||
|
||||
@bp.route('/employees/<int:employee_index>/delete', methods=['POST'], endpoint='delete_employee')
|
||||
@login_required
|
||||
@log_database_operations('employee_deletion')
|
||||
def delete_employee(employee_index):
|
||||
"""Delete employee (Admin only)"""
|
||||
try:
|
||||
logger_handler.logger.info(
|
||||
f"User {session.get('username', 'Unknown')} initiated delete for employee index {employee_index}"
|
||||
)
|
||||
|
||||
# Get employee by index (primary key)
|
||||
employee = db.session.get(Employee, employee_index)
|
||||
if employee is None:
|
||||
abort(404)
|
||||
|
||||
# Store employee data for logging before deletion
|
||||
employee_data = {
|
||||
'index': employee.index,
|
||||
'id': employee.id,
|
||||
'firstName': employee.firstName,
|
||||
'lastName': employee.lastName,
|
||||
'title': employee.title,
|
||||
'contractId': employee.contractId
|
||||
}
|
||||
|
||||
# Check if employee has attendance records
|
||||
attendance_count = AttendanceData.query.filter_by(employee_id=str(employee.id)).count()
|
||||
|
||||
if attendance_count > 0:
|
||||
error_msg = (
|
||||
f'Cannot delete employee "{employee.full_name}". '
|
||||
f'Employee has {attendance_count} attendance records. '
|
||||
f'Please contact system administrator.'
|
||||
)
|
||||
logger_handler.logger.warning(
|
||||
f"Deletion blocked for employee {employee_data['id']} "
|
||||
f"({employee_data['firstName']} {employee_data['lastName']}): "
|
||||
f"{attendance_count} attendance records exist"
|
||||
)
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for('employees.employees'))
|
||||
|
||||
db.session.delete(employee)
|
||||
db.session.commit()
|
||||
|
||||
logger_handler.logger.info(
|
||||
f"User {session['username']} deleted employee: "
|
||||
f"{employee_data['firstName']} {employee_data['lastName']} (ID: {employee_data['id']})"
|
||||
)
|
||||
|
||||
flash(
|
||||
f'Employee "{employee_data["firstName"]} {employee_data["lastName"]}" deleted successfully.',
|
||||
'success'
|
||||
)
|
||||
return redirect(url_for('employees.employees'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('employee_deletion', e)
|
||||
flash('Error deleting employee. Please try again.', 'error')
|
||||
return redirect(url_for('employees.employees'))
|
||||
|
||||
@bp.route('/api/employees/search', endpoint='api_employees_search')
|
||||
@login_required
|
||||
def api_employees_search():
|
||||
"""API endpoint for employee search (for AJAX)"""
|
||||
try:
|
||||
search = request.args.get('q', '').strip()
|
||||
limit = request.args.get('limit', 10, type=int)
|
||||
|
||||
if not search:
|
||||
return jsonify({'employees': []})
|
||||
|
||||
employees = Employee.search_employees(search)[:limit]
|
||||
|
||||
result = {
|
||||
'employees': [emp.to_dict() for emp in employees]
|
||||
}
|
||||
|
||||
return jsonify(result)
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('employee_search_api', e)
|
||||
return jsonify({'error': 'Search failed'}), 500
|
||||
|
||||
@bp.route('/employees/<int:employee_index>', endpoint='employee_detail')
|
||||
@login_required
|
||||
def employee_detail(employee_index):
|
||||
"""View employee details with attendance summary"""
|
||||
try:
|
||||
# Get employee by index (primary key)
|
||||
employee = Employee.query.outerjoin(Project, Employee.contractId == Project.id).filter(Employee.index == employee_index).first_or_404()
|
||||
|
||||
# Get attendance statistics for this employee
|
||||
|
||||
# Total attendance records
|
||||
total_attendance = AttendanceData.query.filter_by(employee_id=str(employee.id)).count()
|
||||
|
||||
# Recent attendance (last 30 days)
|
||||
from datetime import datetime, timedelta
|
||||
thirty_days_ago = datetime.now() - timedelta(days=30)
|
||||
recent_attendance = AttendanceData.query.filter(
|
||||
AttendanceData.employee_id == str(employee.id),
|
||||
AttendanceData.check_in_date >= thirty_days_ago.date()
|
||||
).count()
|
||||
|
||||
# Most recent attendance record
|
||||
latest_attendance = AttendanceData.query.filter_by(employee_id=str(employee.id)).order_by(
|
||||
AttendanceData.check_in_date.desc(),
|
||||
AttendanceData.check_in_time.desc()
|
||||
).first()
|
||||
|
||||
# Get unique projects this employee has attended
|
||||
unique_projects = db.session.query(Project).join(
|
||||
QRCode, Project.id == QRCode.project_id
|
||||
).join(
|
||||
AttendanceData, QRCode.id == AttendanceData.qr_code_id
|
||||
).filter(
|
||||
AttendanceData.employee_id == str(employee.id)
|
||||
).distinct().all()
|
||||
|
||||
attendance_stats = {
|
||||
'total_attendance': total_attendance,
|
||||
'recent_attendance': recent_attendance,
|
||||
'latest_attendance': latest_attendance,
|
||||
'unique_projects': len(unique_projects),
|
||||
'projects': unique_projects
|
||||
}
|
||||
|
||||
# Log employee detail view
|
||||
logger_handler.logger.info(
|
||||
f"User {session['username']} viewed employee detail: {employee.full_name} (ID: {employee.id})"
|
||||
)
|
||||
|
||||
return render_template('employee_detail.html',
|
||||
employee=employee,
|
||||
attendance_stats=attendance_stats)
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('employee_detail', e)
|
||||
flash('Error loading employee details. Please try again.', 'error')
|
||||
return redirect(url_for('employees.employees'))
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
routes/legacy_attendance.py
|
||||
============================
|
||||
"Legacy Attendance" — same look/feel as Time Attendance (dashboard,
|
||||
records list, Excel export) but sourced LIVE from the old remote MySQL
|
||||
server (contract / employee / locations / records tables) instead of
|
||||
Excel imports. Read-only: nothing is written to the remote server, and
|
||||
nothing is copied into the local database.
|
||||
|
||||
Routes: /legacy-attendance, /legacy-attendance/records,
|
||||
/legacy-attendance/export
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, flash, send_file, url_for
|
||||
from datetime import datetime
|
||||
|
||||
from extensions import logger_handler
|
||||
from logger_handler import log_user_activity
|
||||
from utils.helpers import login_required
|
||||
from legacy_attendance_service import (
|
||||
LegacyDbUnavailable,
|
||||
get_legacy_dashboard_stats,
|
||||
get_legacy_unique_locations,
|
||||
get_legacy_records,
|
||||
get_legacy_records_for_export,
|
||||
build_legacy_export_workbook,
|
||||
)
|
||||
|
||||
bp = Blueprint('legacy_attendance', __name__)
|
||||
|
||||
# Fixed dropdown values — confirmed values stored in the legacy `records.type` column
|
||||
LEGACY_RECORD_TYPES = ['CHECK IN', 'CHECK OUT']
|
||||
|
||||
|
||||
def _filters_from_request():
|
||||
return {
|
||||
'employee_search': request.args.get('employee_search', ''),
|
||||
'location': request.args.get('location', ''),
|
||||
'record_type': request.args.get('record_type', ''),
|
||||
'start_date': request.args.get('start_date', ''),
|
||||
'end_date': request.args.get('end_date', ''),
|
||||
}
|
||||
|
||||
|
||||
@bp.route('/legacy-attendance', endpoint='legacy_attendance_dashboard')
|
||||
@login_required
|
||||
@log_user_activity('legacy_attendance_view')
|
||||
def legacy_attendance_dashboard():
|
||||
"""Display legacy attendance dashboard with summary stats."""
|
||||
stats = {
|
||||
'total_records': 0,
|
||||
'unique_employees': 0,
|
||||
'unique_locations': 0,
|
||||
'earliest_record': None,
|
||||
'latest_record': None,
|
||||
}
|
||||
try:
|
||||
stats = get_legacy_dashboard_stats()
|
||||
except LegacyDbUnavailable as e:
|
||||
flash(str(e), 'error')
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error loading legacy attendance dashboard: {e}")
|
||||
flash('Error loading legacy attendance dashboard. The legacy database may be unreachable.', 'error')
|
||||
|
||||
return render_template('legacy_attendance_dashboard.html', stats=stats)
|
||||
|
||||
|
||||
@bp.route('/legacy-attendance/records', endpoint='legacy_attendance_records')
|
||||
@login_required
|
||||
@log_user_activity('legacy_attendance_records_view')
|
||||
def legacy_attendance_records():
|
||||
"""Display legacy attendance records with filtering + pagination."""
|
||||
filters = _filters_from_request()
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = 50
|
||||
|
||||
records = None
|
||||
unique_locations = []
|
||||
try:
|
||||
unique_locations = get_legacy_unique_locations()
|
||||
records = get_legacy_records(filters, page=page, per_page=per_page)
|
||||
except LegacyDbUnavailable as e:
|
||||
flash(str(e), 'error')
|
||||
return redirect(url_for('legacy_attendance.legacy_attendance_dashboard'))
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error loading legacy attendance records: {e}")
|
||||
flash('Error loading legacy attendance records. The legacy database may be unreachable.', 'error')
|
||||
return redirect(url_for('legacy_attendance.legacy_attendance_dashboard'))
|
||||
|
||||
return render_template(
|
||||
'legacy_attendance_records.html',
|
||||
records=records,
|
||||
unique_locations=unique_locations,
|
||||
record_types=LEGACY_RECORD_TYPES,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
|
||||
@bp.route('/legacy-attendance/export', endpoint='export_legacy_attendance')
|
||||
@login_required
|
||||
@log_user_activity('legacy_attendance_export')
|
||||
def export_legacy_attendance():
|
||||
"""Export the currently filtered legacy attendance records to Excel."""
|
||||
filters = _filters_from_request()
|
||||
|
||||
try:
|
||||
rows = get_legacy_records_for_export(filters)
|
||||
except LegacyDbUnavailable as e:
|
||||
flash(str(e), 'error')
|
||||
return redirect(url_for('legacy_attendance.legacy_attendance_records', **filters))
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error exporting legacy attendance records: {e}")
|
||||
flash('Error generating export file. Please try again.', 'error')
|
||||
return redirect(url_for('legacy_attendance.legacy_attendance_records', **filters))
|
||||
|
||||
if not rows:
|
||||
flash('No legacy records found to export.', 'warning')
|
||||
return redirect(url_for('legacy_attendance.legacy_attendance_records', **filters))
|
||||
|
||||
logger_handler.logger.info(f"Exported {len(rows)} legacy attendance records")
|
||||
|
||||
buffer = build_legacy_export_workbook(rows)
|
||||
filename = f"legacy_attendance_{datetime.now().strftime('%m%d%Y_%H%M%S')}.xlsx"
|
||||
|
||||
return send_file(
|
||||
buffer,
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
routes/projects.py
|
||||
==================
|
||||
Project CRUD and related API routes.
|
||||
|
||||
Routes: /projects, /projects/create, /projects/<id>/edit,
|
||||
/projects/<id>/toggle, /api/projects/active
|
||||
"""
|
||||
from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, url_for
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from models.project import Project
|
||||
from models.user import User
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import admin_required, login_required, staff_or_admin_required
|
||||
|
||||
bp = Blueprint('projects', __name__)
|
||||
|
||||
|
||||
|
||||
@bp.route('/projects', endpoint='projects')
|
||||
@admin_required
|
||||
def projects():
|
||||
"""Display all projects"""
|
||||
try:
|
||||
projects = Project.query.order_by(Project.created_date.desc()).all()
|
||||
return render_template('projects.html', projects=projects)
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('projects_list', e)
|
||||
flash('Error loading projects list.', 'error')
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
|
||||
@bp.route('/projects/create', methods=['GET', 'POST'], endpoint='create_project')
|
||||
@admin_required
|
||||
@log_database_operations('project_creation')
|
||||
def create_project():
|
||||
"""Create new project"""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
name = request.form['name']
|
||||
description = request.form.get('description', '')
|
||||
|
||||
# Check if project name already exists
|
||||
if Project.query.filter_by(name=name).first():
|
||||
flash('Project name already exists.', 'error')
|
||||
return render_template('create_project.html')
|
||||
|
||||
# Create new project
|
||||
new_project = Project(
|
||||
name=name,
|
||||
description=description,
|
||||
created_by=session['user_id']
|
||||
)
|
||||
|
||||
db.session.add(new_project)
|
||||
db.session.commit()
|
||||
|
||||
# Log project creation
|
||||
logger_handler.logger.info(f"User {session['username']} created new project: {name}")
|
||||
|
||||
flash(f'Project "{name}" created successfully.', 'success')
|
||||
return redirect(url_for('projects.projects'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('project_creation', e)
|
||||
flash('Project creation failed. Please try again.', 'error')
|
||||
|
||||
return render_template('create_project.html')
|
||||
|
||||
@bp.route('/projects/<int:project_id>/edit', methods=['GET', 'POST'], endpoint='edit_project')
|
||||
@admin_required
|
||||
@log_database_operations('project_edit')
|
||||
def edit_project(project_id):
|
||||
"""Edit existing project"""
|
||||
try:
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
|
||||
if request.method == 'POST':
|
||||
old_name = project.name
|
||||
old_description = project.description
|
||||
|
||||
project.name = request.form['name']
|
||||
project.description = request.form.get('description', '')
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Log project update
|
||||
changes = {}
|
||||
if old_name != project.name:
|
||||
changes['name'] = {'old': old_name, 'new': project.name}
|
||||
if old_description != project.description:
|
||||
changes['description'] = {'old': old_description, 'new': project.description}
|
||||
|
||||
if changes:
|
||||
logger_handler.logger.info(f"User {session['username']} updated project {project_id}: {json.dumps(changes)}")
|
||||
|
||||
flash(f'Project "{project.name}" updated successfully.', 'success')
|
||||
return redirect(url_for('projects.projects'))
|
||||
|
||||
return render_template('edit_project.html', project=project)
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('project_edit', e)
|
||||
flash('Project update failed. Please try again.', 'error')
|
||||
return redirect(url_for('projects.projects'))
|
||||
|
||||
@bp.route('/projects/<int:project_id>/toggle', methods=['POST'], endpoint='toggle_project')
|
||||
@admin_required
|
||||
@log_database_operations('project_toggle')
|
||||
def toggle_project(project_id):
|
||||
"""Toggle project active status"""
|
||||
try:
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
old_status = project.active_status
|
||||
project.active_status = not project.active_status
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Log status change
|
||||
status = "activated" if project.active_status else "deactivated"
|
||||
logger_handler.logger.info(f"User {session['username']} {status} project: {project.name}")
|
||||
|
||||
flash(f'Project "{project.name}" {status} successfully.', 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('project_toggle', e)
|
||||
flash('Failed to update project status.', 'error')
|
||||
|
||||
return redirect(url_for('projects.projects'))
|
||||
|
||||
# API ENDPOINTS FOR DROPDOWN FUNCTIONALITY
|
||||
@bp.route('/api/projects/active', endpoint='api_active_projects')
|
||||
@login_required
|
||||
def api_active_projects():
|
||||
"""Get active projects for dropdown"""
|
||||
try:
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name.asc()).all()
|
||||
|
||||
projects_data = [
|
||||
{
|
||||
'id': project.id,
|
||||
'name': project.name,
|
||||
'description': project.description,
|
||||
'qr_count': project.qr_count
|
||||
}
|
||||
for project in projects
|
||||
]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'projects': projects_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('api_active_projects', e)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to fetch projects'
|
||||
}), 500
|
||||
|
||||
# QR CODE MANAGEMENT ROUTES
|
||||
+1440
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
routes/statistics.py
|
||||
====================
|
||||
Statistics dashboard and export routes.
|
||||
|
||||
Routes: /statistics, /api/statistics/export
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, make_response, current_app, url_for
|
||||
from datetime import datetime, date, timedelta
|
||||
import io, json, traceback
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from models.employee import Employee
|
||||
from models.project import Project
|
||||
from models.user import User
|
||||
from sqlalchemy import text
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import login_required, staff_or_admin_required
|
||||
|
||||
bp = Blueprint('statistics', __name__)
|
||||
|
||||
|
||||
|
||||
@bp.route('/statistics', endpoint='qr_statistics')
|
||||
@login_required
|
||||
def qr_statistics():
|
||||
"""QR Code Statistics Dashboard with comprehensive analytics"""
|
||||
try:
|
||||
# Log statistics page access
|
||||
logger_handler.logger.info(f"User {session.get('username', 'unknown')} accessed QR code statistics dashboard")
|
||||
|
||||
# Get filter parameters
|
||||
date_from = request.args.get('date_from', '')
|
||||
date_to = request.args.get('date_to', '')
|
||||
qr_code_filter = request.args.get('qr_code', '')
|
||||
project_filter = request.args.get('project', '')
|
||||
|
||||
# Build parameterized filter conditions (fixes SQL injection)
|
||||
conditions = []
|
||||
params = {}
|
||||
|
||||
if date_from:
|
||||
conditions.append("ad.check_in_date >= :date_from")
|
||||
params["date_from"] = date_from
|
||||
if date_to:
|
||||
conditions.append("ad.check_in_date <= :date_to")
|
||||
params["date_to"] = date_to
|
||||
if qr_code_filter:
|
||||
try:
|
||||
params["qr_code_id"] = int(qr_code_filter)
|
||||
conditions.append("ad.qr_code_id = :qr_code_id")
|
||||
except (ValueError, TypeError):
|
||||
logger_handler.logger.warning(f"Invalid qr_code filter value ignored: {qr_code_filter!r}")
|
||||
if project_filter:
|
||||
try:
|
||||
params["project_id"] = int(project_filter)
|
||||
conditions.append("qc.project_id = :project_id")
|
||||
except (ValueError, TypeError):
|
||||
logger_handler.logger.warning(f"Invalid project filter value ignored: {project_filter!r}")
|
||||
|
||||
# Compose a reusable AND clause (empty string when no filters applied)
|
||||
filter_clause = (" AND " + " AND ".join(conditions)) if conditions else ""
|
||||
|
||||
# 1. General Statistics
|
||||
general_stats = db.session.execute(text("""
|
||||
SELECT
|
||||
COUNT(*) as total_scans,
|
||||
COUNT(DISTINCT ad.employee_id) as unique_users,
|
||||
COUNT(DISTINCT ad.qr_code_id) as active_qr_codes,
|
||||
COUNT(DISTINCT DATE(ad.check_in_date)) as active_days,
|
||||
COUNT(CASE WHEN ad.check_in_date = CURRENT_DATE THEN 1 END) as today_scans,
|
||||
COUNT(CASE WHEN ad.check_in_date >= DATE_SUB(CURRENT_DATE, INTERVAL 7 DAY) THEN 1 END) as week_scans,
|
||||
COUNT(CASE WHEN ad.latitude IS NOT NULL AND ad.longitude IS NOT NULL THEN 1 END) as gps_enabled_scans
|
||||
FROM attendance_data ad
|
||||
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||
WHERE 1=1
|
||||
""" + filter_clause), params).fetchone()
|
||||
|
||||
# 2. Device Statistics
|
||||
device_stats = db.session.execute(text("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN device_info LIKE '%iPhone%' OR device_info LIKE '%iOS%' THEN 'iOS'
|
||||
WHEN device_info LIKE '%Android%' THEN 'Android'
|
||||
WHEN device_info LIKE '%Windows%' THEN 'Windows'
|
||||
WHEN device_info LIKE '%Mac%' OR device_info LIKE '%macOS%' THEN 'macOS'
|
||||
WHEN device_info LIKE '%Linux%' THEN 'Linux'
|
||||
ELSE 'Other'
|
||||
END as device_type,
|
||||
COUNT(*) as scan_count,
|
||||
COUNT(DISTINCT employee_id) as unique_users
|
||||
FROM attendance_data ad
|
||||
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||
WHERE device_info IS NOT NULL
|
||||
""" + filter_clause + """
|
||||
GROUP BY device_type
|
||||
ORDER BY scan_count DESC
|
||||
"""), params).fetchall()
|
||||
|
||||
# 3. Browser Statistics (from User Agent)
|
||||
browser_stats = db.session.execute(text("""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN user_agent LIKE '%Chrome%' AND user_agent NOT LIKE '%Edge%' THEN 'Chrome'
|
||||
WHEN user_agent LIKE '%Safari%' AND user_agent NOT LIKE '%Chrome%' THEN 'Safari'
|
||||
WHEN user_agent LIKE '%Firefox%' THEN 'Firefox'
|
||||
WHEN user_agent LIKE '%Edge%' THEN 'Edge'
|
||||
WHEN user_agent LIKE '%Opera%' THEN 'Opera'
|
||||
ELSE 'Other'
|
||||
END as browser_type,
|
||||
COUNT(*) as scan_count,
|
||||
COUNT(DISTINCT employee_id) as unique_users
|
||||
FROM attendance_data ad
|
||||
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||
WHERE user_agent IS NOT NULL
|
||||
""" + filter_clause + """
|
||||
GROUP BY browser_type
|
||||
ORDER BY scan_count DESC
|
||||
"""), params).fetchall()
|
||||
|
||||
# 4. Location Statistics
|
||||
location_stats = db.session.execute(text("""
|
||||
SELECT
|
||||
qc.name as qr_name,
|
||||
qc.location as qr_location,
|
||||
qc.location_event,
|
||||
COUNT(*) as total_scans,
|
||||
COUNT(DISTINCT ad.employee_id) as unique_users,
|
||||
COUNT(CASE WHEN ad.latitude IS NOT NULL THEN 1 END) as gps_scans,
|
||||
MIN(ad.check_in_date) as first_scan,
|
||||
MAX(ad.check_in_date) as last_scan
|
||||
FROM attendance_data ad
|
||||
JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||
WHERE 1=1
|
||||
""" + filter_clause + """
|
||||
GROUP BY qc.id, qc.name, qc.location, qc.location_event
|
||||
ORDER BY total_scans DESC
|
||||
"""), params).fetchall()
|
||||
|
||||
# 5. IP Address Analysis (Top 3 Most Active)
|
||||
ip_stats = db.session.execute(text("""
|
||||
SELECT
|
||||
ip_address,
|
||||
COUNT(*) as scan_count,
|
||||
COUNT(DISTINCT employee_id) as unique_users,
|
||||
COUNT(DISTINCT qr_code_id) as qr_codes_used,
|
||||
MIN(check_in_date) as first_scan,
|
||||
MAX(check_in_date) as last_scan
|
||||
FROM attendance_data ad
|
||||
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||
WHERE ip_address IS NOT NULL
|
||||
""" + filter_clause + """
|
||||
GROUP BY ip_address
|
||||
ORDER BY scan_count DESC
|
||||
LIMIT 3
|
||||
"""), params).fetchall()
|
||||
|
||||
# 6. Project Statistics (if projects exist)
|
||||
project_stats = db.session.execute(text("""
|
||||
SELECT
|
||||
p.id,
|
||||
p.name as project_name,
|
||||
COUNT(*) as total_scans,
|
||||
COUNT(DISTINCT ad.employee_id) as unique_users,
|
||||
COUNT(DISTINCT ad.qr_code_id) as qr_codes_in_project,
|
||||
AVG(CASE WHEN ad.latitude IS NOT NULL THEN 1.0 ELSE 0.0 END) * 100 as gps_usage_percentage
|
||||
FROM attendance_data ad
|
||||
JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||
LEFT JOIN projects p ON qc.project_id = p.id
|
||||
WHERE p.id IS NOT NULL
|
||||
""" + filter_clause + """
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY total_scans DESC
|
||||
"""), params).fetchall()
|
||||
|
||||
# Get dropdown options for filters
|
||||
qr_codes_list = db.session.execute(text("""
|
||||
SELECT DISTINCT qc.id, qc.name, qc.location
|
||||
FROM qr_codes qc
|
||||
JOIN attendance_data ad ON qc.id = ad.qr_code_id
|
||||
WHERE qc.active_status = true
|
||||
ORDER BY qc.name
|
||||
""")).fetchall()
|
||||
|
||||
projects_list = db.session.execute(text("""
|
||||
SELECT DISTINCT p.id, p.name
|
||||
FROM projects p
|
||||
JOIN qr_codes qc ON p.id = qc.project_id
|
||||
JOIN attendance_data ad ON qc.id = ad.qr_code_id
|
||||
WHERE p.active_status = true
|
||||
ORDER BY p.name
|
||||
""")).fetchall()
|
||||
|
||||
# Log successful statistics generation
|
||||
logger_handler.logger.info(
|
||||
f"Generated statistics report for user {session.get('username', 'unknown')} "
|
||||
f"with {general_stats.total_scans} total scans. Filters applied: "
|
||||
f"date_from={date_from}, date_to={date_to}, qr_code={qr_code_filter}, project={project_filter}"
|
||||
)
|
||||
|
||||
return render_template('statistics.html',
|
||||
general_stats=general_stats,
|
||||
device_stats=device_stats,
|
||||
browser_stats=browser_stats,
|
||||
location_stats=location_stats,
|
||||
ip_stats=ip_stats,
|
||||
project_stats=project_stats,
|
||||
qr_codes_list=qr_codes_list,
|
||||
projects_list=projects_list,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
qr_code_filter=qr_code_filter,
|
||||
project_filter=project_filter,
|
||||
today_date=datetime.now().strftime('%Y-%m-%d'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
# Log the error using the correct method
|
||||
logger_handler.log_database_error('statistics_page_error', e)
|
||||
flash('Error loading statistics. Please try again.', 'error')
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
|
||||
|
||||
@bp.route('/api/statistics/export', endpoint='export_statistics')
|
||||
@login_required
|
||||
def export_statistics():
|
||||
"""Export statistics data to CSV/Excel"""
|
||||
try:
|
||||
# Check permissions
|
||||
if session.get('role') not in ['admin', 'payroll', 'accounting']:
|
||||
return jsonify({'error': 'Access denied'}), 403
|
||||
|
||||
# Log export attempt
|
||||
logger_handler.logger.info(
|
||||
f"User {session.get('username', 'unknown')} (role: {session.get('role')}) "
|
||||
f"attempted to export statistics data in {request.args.get('format', 'csv')} format"
|
||||
)
|
||||
|
||||
# Get comprehensive statistics for export
|
||||
export_data = db.session.execute(text("""
|
||||
SELECT
|
||||
ad.id,
|
||||
ad.employee_id,
|
||||
COALESCE(CONCAT(e.firstName, ' ', e.lastName), ad.employee_id) as employee_name,
|
||||
ad.check_in_date,
|
||||
ad.check_in_time,
|
||||
qc.name as qr_code_name,
|
||||
qc.location as qr_location,
|
||||
qc.location_event,
|
||||
p.name as project_name,
|
||||
ad.device_info,
|
||||
ad.user_agent,
|
||||
ad.ip_address,
|
||||
ad.latitude,
|
||||
ad.longitude,
|
||||
ad.address,
|
||||
ad.location_name,
|
||||
ad.created_timestamp
|
||||
FROM attendance_data ad
|
||||
JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||
LEFT JOIN projects p ON qc.project_id = p.id
|
||||
LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id
|
||||
ORDER BY ad.created_timestamp DESC
|
||||
""")).fetchall()
|
||||
|
||||
# Create CSV content
|
||||
import csv
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Write headers
|
||||
writer.writerow([
|
||||
'ID', 'Employee ID', 'Employee Name', 'Date', 'Time',
|
||||
'QR Code', 'QR Location', 'Event', 'Project', 'Device',
|
||||
'Browser Info', 'IP Address', 'Latitude', 'Longitude',
|
||||
'Address', 'Location Name', 'Timestamp'
|
||||
])
|
||||
|
||||
# Write data
|
||||
for row in export_data:
|
||||
writer.writerow([
|
||||
row.id, row.employee_id, row.employee_name,
|
||||
str(row.check_in_date), str(row.check_in_time),
|
||||
row.qr_code_name, row.qr_location, row.location_event,
|
||||
row.project_name or 'No Project', row.device_info or 'Unknown',
|
||||
row.user_agent or 'Unknown', row.ip_address or 'Unknown',
|
||||
row.latitude or '', row.longitude or '',
|
||||
row.address or '', row.location_name or '',
|
||||
str(row.created_timestamp)
|
||||
])
|
||||
|
||||
output.seek(0)
|
||||
|
||||
# Create response with proper file handling
|
||||
csv_data = output.getvalue()
|
||||
|
||||
# Log successful export
|
||||
logger_handler.logger.info(
|
||||
f"User {session.get('username', 'unknown')} successfully exported "
|
||||
f"{len(export_data)} statistics records"
|
||||
)
|
||||
|
||||
# Create response
|
||||
response = make_response(csv_data)
|
||||
response.headers["Content-Disposition"] = f"attachment; filename=qr_statistics_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
response.headers["Content-type"] = "text/csv"
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('statistics_export_error', e)
|
||||
return jsonify({'error': 'Export failed'}), 500
|
||||
|
||||
# EMPLOYEE MANAGEMENT ROUTES
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+974
@@ -0,0 +1,974 @@
|
||||
"""
|
||||
routes/users.py
|
||||
===============
|
||||
User management routes (admin-only operations).
|
||||
|
||||
Routes: /users/*, /api/users/stats, /api/locations-by-projects,
|
||||
/api/roles/permissions, /api/geocode, /api/reverse-geocode
|
||||
"""
|
||||
from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, url_for
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
|
||||
from extensions import db, logger_handler
|
||||
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
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import (
|
||||
admin_required,
|
||||
generate_qr_code,
|
||||
get_qr_styling,
|
||||
get_role_permissions,
|
||||
has_admin_privileges,
|
||||
has_staff_level_access,
|
||||
is_valid_role,
|
||||
login_required,
|
||||
staff_or_admin_required,
|
||||
VALID_ROLES,
|
||||
STAFF_LEVEL_ROLES)
|
||||
from utils.geocoding import (geocode_address_enhanced,
|
||||
get_all_locations_from_qr_codes,
|
||||
get_coordinates_from_address_enhanced,
|
||||
reverse_geocode_coordinates,
|
||||
gmaps_client)
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
bp = Blueprint('users', __name__)
|
||||
|
||||
|
||||
|
||||
@bp.route('/users', endpoint='users')
|
||||
@admin_required
|
||||
def users():
|
||||
"""Display all users (Admin only)"""
|
||||
try:
|
||||
users = User.query.order_by(User.created_date.desc()).all()
|
||||
return render_template('users.html', users=users)
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('users_list', e)
|
||||
flash('Error loading users list.', 'error')
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
|
||||
@bp.route('/users/create', methods=['GET', 'POST'], endpoint='create_user')
|
||||
@admin_required
|
||||
@log_database_operations('user_creation')
|
||||
def create_user():
|
||||
"""Create new user (Admin only) with Project Manager permissions support"""
|
||||
if request.method == 'POST':
|
||||
try:
|
||||
# Get basic form data
|
||||
full_name = request.form.get('full_name', '').strip()
|
||||
email = request.form.get('email', '').strip()
|
||||
username = request.form.get('username', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
role = request.form.get('role', '')
|
||||
|
||||
# Validate required fields
|
||||
if not all([full_name, email, username, password, role]):
|
||||
flash('All fields are required.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
locations = get_all_locations_from_qr_codes()
|
||||
return render_template('create_user.html', projects=projects, locations=locations)
|
||||
|
||||
# Validate role
|
||||
if role not in VALID_ROLES:
|
||||
flash(f'Invalid role selected. Valid roles: {", ".join(VALID_ROLES)}', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
locations = get_all_locations_from_qr_codes()
|
||||
return render_template('create_user.html', projects=projects, locations=locations)
|
||||
|
||||
# Check if user already exists
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash('Username already exists.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
locations = get_all_locations_from_qr_codes()
|
||||
return render_template('create_user.html', projects=projects, locations=locations)
|
||||
|
||||
if User.query.filter_by(email=email).first():
|
||||
flash('Email already registered.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
locations = get_all_locations_from_qr_codes()
|
||||
return render_template('create_user.html', projects=projects, locations=locations)
|
||||
|
||||
# Create new user
|
||||
new_user = User(
|
||||
full_name=full_name,
|
||||
email=email,
|
||||
username=username,
|
||||
role=role,
|
||||
created_by=session['user_id']
|
||||
)
|
||||
new_user.set_password(password)
|
||||
|
||||
db.session.add(new_user)
|
||||
db.session.flush() # Get the user ID without committing
|
||||
|
||||
# Handle Project Manager permissions
|
||||
if role == 'project_manager':
|
||||
# Get selected projects - getlist returns empty list if field doesn't exist
|
||||
selected_projects = request.form.getlist('assigned_projects')
|
||||
|
||||
# Validate and filter project IDs
|
||||
valid_project_ids = []
|
||||
if selected_projects:
|
||||
for pid in selected_projects:
|
||||
try:
|
||||
project_id = int(pid)
|
||||
# Verify project exists
|
||||
if db.session.get(Project, project_id):
|
||||
valid_project_ids.append(project_id)
|
||||
except (ValueError, TypeError):
|
||||
logger_handler.logger.warning(f"Invalid project ID received: {pid}")
|
||||
|
||||
# Add project permissions
|
||||
if valid_project_ids:
|
||||
for project_id in valid_project_ids:
|
||||
try:
|
||||
permission = UserProjectPermission(
|
||||
user_id=new_user.id,
|
||||
project_id=project_id
|
||||
)
|
||||
db.session.add(permission)
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error adding project permission: {e}")
|
||||
|
||||
logger_handler.logger.info(
|
||||
f"Admin {session['username']} assigned {len(valid_project_ids)} projects to new Project Manager {username}"
|
||||
)
|
||||
|
||||
# Get selected locations
|
||||
selected_locations = request.form.getlist('assigned_locations')
|
||||
|
||||
# Filter and clean location names
|
||||
valid_locations = []
|
||||
if selected_locations:
|
||||
for location in selected_locations:
|
||||
location_clean = location.strip()
|
||||
if location_clean:
|
||||
valid_locations.append(location_clean)
|
||||
|
||||
# Add location permissions
|
||||
if valid_locations:
|
||||
for location_name in valid_locations:
|
||||
try:
|
||||
permission = UserLocationPermission(
|
||||
user_id=new_user.id,
|
||||
location_name=location_name
|
||||
)
|
||||
db.session.add(permission)
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error adding location permission: {e}")
|
||||
|
||||
logger_handler.logger.info(
|
||||
f"Admin {session['username']} assigned {len(valid_locations)} locations to new Project Manager {username}"
|
||||
)
|
||||
|
||||
# Commit all changes
|
||||
db.session.commit()
|
||||
|
||||
# Log user creation
|
||||
logger_handler.logger.info(f"Admin user {session['username']} created new user: {username} with role {role}")
|
||||
|
||||
flash(f'User "{full_name}" created successfully with role "{role}".', 'success')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
except KeyError as e:
|
||||
db.session.rollback()
|
||||
logger_handler.logger.error(f"Missing form field: {e}")
|
||||
flash(f'Missing required field: {e}. Please fill in all fields.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
locations = get_all_locations_from_qr_codes()
|
||||
return render_template('create_user.html', projects=projects, locations=locations)
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_creation', e)
|
||||
logger_handler.logger.error(f"User creation error details: {str(e)}")
|
||||
flash('User creation failed. Please try again.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
locations = get_all_locations_from_qr_codes()
|
||||
return render_template('create_user.html', projects=projects, locations=locations)
|
||||
|
||||
# GET request - load form with projects and locations
|
||||
try:
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
locations = get_all_locations_from_qr_codes()
|
||||
return render_template('create_user.html', projects=projects, locations=locations)
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error loading create user form: {e}")
|
||||
flash('Error loading form. Please try again.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
@bp.route('/users/<int:user_id>/delete', methods=['GET', 'POST'], endpoint='delete_user')
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
"""Deactivate user (Admin only) - Fixed with proper validation"""
|
||||
try:
|
||||
user_to_delete = db.session.get(User, user_id)
|
||||
current_user = db.session.get(User, session['user_id'])
|
||||
|
||||
if not user_to_delete:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# Prevent self-deletion
|
||||
if user_to_delete.id == current_user.id:
|
||||
flash('You cannot deactivate your own account. Ask another admin to do this.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# Check if trying to delete the last admin
|
||||
if user_to_delete.role == 'admin':
|
||||
active_admin_count = User.query.filter_by(role='admin', active_status=True).count()
|
||||
if active_admin_count <= 1:
|
||||
flash('Cannot deactivate the last admin user. Promote another user to admin first.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# Deactivate the user instead of deleting
|
||||
user_to_delete.active_status = False
|
||||
db.session.commit()
|
||||
|
||||
logger_handler.logger.info(
|
||||
f"Admin {current_user.username} deactivated user: {user_to_delete.username} (ID: {user_to_delete.id})"
|
||||
)
|
||||
flash(f'User "{user_to_delete.full_name}" has been deactivated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_deactivation', e)
|
||||
flash('Error deactivating user. Please try again.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
@bp.route('/users/<int:user_id>/reactivate', methods=['GET', 'POST'], endpoint='reactivate_user')
|
||||
@admin_required
|
||||
def reactivate_user(user_id):
|
||||
"""Reactivate a deactivated user (Admin only)"""
|
||||
try:
|
||||
user_to_reactivate = db.session.get(User, user_id)
|
||||
current_user = db.session.get(User, session['user_id'])
|
||||
|
||||
if not user_to_reactivate:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
if user_to_reactivate.active_status:
|
||||
flash('User is already active.', 'info')
|
||||
else:
|
||||
user_to_reactivate.active_status = True
|
||||
db.session.commit()
|
||||
logger_handler.logger.info(
|
||||
f"Admin {current_user.username} reactivated user: {user_to_reactivate.username} (ID: {user_to_reactivate.id})"
|
||||
)
|
||||
flash(f'User "{user_to_reactivate.full_name}" has been reactivated successfully.', 'success')
|
||||
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_reactivation', e)
|
||||
flash('Error reactivating user. Please try again.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
@bp.route('/users/<int:user_id>/promote', methods=['GET', 'POST'], endpoint='promote_user')
|
||||
@admin_required
|
||||
def promote_user(user_id):
|
||||
"""Promote a staff user to admin (Admin only)"""
|
||||
try:
|
||||
user_to_promote = db.session.get(User, user_id)
|
||||
current_user = db.session.get(User, session['user_id'])
|
||||
|
||||
if not user_to_promote:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
if user_to_promote.role == 'admin':
|
||||
flash('User is already an admin.', 'info')
|
||||
else:
|
||||
user_to_promote.role = 'admin'
|
||||
db.session.commit()
|
||||
logger_handler.logger.info(
|
||||
f"Admin {current_user.username} promoted user {user_to_promote.username} (ID: {user_to_promote.id}) to admin"
|
||||
)
|
||||
flash(f'"{user_to_promote.full_name}" has been promoted to admin.', 'success')
|
||||
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_promotion', e)
|
||||
flash('Error promoting user. Please try again.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
@bp.route('/users/<int:user_id>/demote', methods=['GET', 'POST'], endpoint='demote_user')
|
||||
@admin_required
|
||||
def demote_user(user_id):
|
||||
"""Demote an admin user to staff (Admin only)"""
|
||||
try:
|
||||
user_to_demote = db.session.get(User, user_id)
|
||||
current_user = db.session.get(User, session['user_id'])
|
||||
|
||||
if not user_to_demote:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# Prevent self-demotion
|
||||
if user_to_demote.id == current_user.id:
|
||||
flash('You cannot demote yourself. Have another admin do this.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# Check if this is the last admin
|
||||
active_admin_count = User.query.filter_by(role='admin', active_status=True).count()
|
||||
if active_admin_count <= 1 and user_to_demote.role == 'admin':
|
||||
flash('Cannot demote the last admin user. Promote another user to admin first.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
if has_staff_level_access(user_to_demote.role):
|
||||
flash('User already has staff-level permissions.', 'info')
|
||||
else:
|
||||
user_to_demote.role = 'staff'
|
||||
db.session.commit()
|
||||
logger_handler.logger.info(
|
||||
f"Admin {current_user.username} demoted user {user_to_demote.username} (ID: {user_to_demote.id}) to staff"
|
||||
)
|
||||
flash(f'"{user_to_demote.full_name}" has been demoted to staff.', 'success')
|
||||
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_demotion', e)
|
||||
flash('Error demoting user. Please try again.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
@bp.route('/users/<int:user_id>/edit', methods=['GET', 'POST'], endpoint='edit_user')
|
||||
@admin_required
|
||||
@log_database_operations('user_edit')
|
||||
def edit_user(user_id):
|
||||
"""Edit existing user with Project Manager permissions support"""
|
||||
try:
|
||||
user_to_edit = db.session.get(User, user_id)
|
||||
if user_to_edit is None:
|
||||
abort(404)
|
||||
|
||||
# Track old role for permission cleanup
|
||||
old_role = user_to_edit.role
|
||||
|
||||
if request.method == 'POST':
|
||||
# Store old values for change tracking
|
||||
old_values = {
|
||||
'full_name': user_to_edit.full_name,
|
||||
'email': user_to_edit.email,
|
||||
'username': user_to_edit.username,
|
||||
'role': user_to_edit.role,
|
||||
'active_status': user_to_edit.active_status
|
||||
}
|
||||
changes = {}
|
||||
|
||||
# Update basic info with validation
|
||||
full_name = request.form.get('full_name', '').strip()
|
||||
email = request.form.get('email', '').strip()
|
||||
username = request.form.get('username', '').strip()
|
||||
|
||||
if not all([full_name, email, username]):
|
||||
flash('Name, email, and username are required.', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
locations = get_all_locations_from_qr_codes()
|
||||
assigned_project_ids = []
|
||||
assigned_location_names = []
|
||||
if user_to_edit.role == 'project_manager':
|
||||
assigned_project_ids = [p.project_id for p in UserProjectPermission.query.filter_by(user_id=user_id).all()]
|
||||
assigned_location_names = [l.location_name for l in UserLocationPermission.query.filter_by(user_id=user_id).all()]
|
||||
return render_template('edit_user.html', user=user_to_edit, valid_roles=VALID_ROLES,
|
||||
projects=projects, locations=locations,
|
||||
assigned_project_ids=assigned_project_ids,
|
||||
assigned_location_names=assigned_location_names)
|
||||
|
||||
user_to_edit.full_name = full_name
|
||||
user_to_edit.email = email
|
||||
user_to_edit.username = username
|
||||
|
||||
# Update role with validation
|
||||
new_role = request.form.get('role', '')
|
||||
if new_role not in VALID_ROLES:
|
||||
flash(f'Invalid role selected. Valid roles: {", ".join(VALID_ROLES)}', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
locations = get_all_locations_from_qr_codes()
|
||||
assigned_project_ids = []
|
||||
assigned_location_names = []
|
||||
if user_to_edit.role == 'project_manager':
|
||||
assigned_project_ids = [p.project_id for p in UserProjectPermission.query.filter_by(user_id=user_id).all()]
|
||||
assigned_location_names = [l.location_name for l in UserLocationPermission.query.filter_by(user_id=user_id).all()]
|
||||
return render_template('edit_user.html', user=user_to_edit, valid_roles=VALID_ROLES,
|
||||
projects=projects, locations=locations,
|
||||
assigned_project_ids=assigned_project_ids,
|
||||
assigned_location_names=assigned_location_names)
|
||||
|
||||
user_to_edit.role = new_role
|
||||
|
||||
# Handle password update if provided
|
||||
new_password = request.form.get('new_password', '')
|
||||
if new_password and new_password.strip():
|
||||
user_to_edit.set_password(new_password)
|
||||
changes['password'] = 'Password updated'
|
||||
# Log password change
|
||||
logger_handler.log_security_event(
|
||||
event_type="admin_password_change",
|
||||
description=f"Admin {session['username']} changed password for user {user_to_edit.username}",
|
||||
severity="MEDIUM"
|
||||
)
|
||||
|
||||
# Handle Project Manager permissions
|
||||
if new_role == 'project_manager':
|
||||
# Update project permissions
|
||||
# First, remove existing project permissions
|
||||
try:
|
||||
UserProjectPermission.query.filter_by(user_id=user_id).delete()
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error deleting old project permissions: {e}")
|
||||
|
||||
# Add new project permissions
|
||||
selected_projects = request.form.getlist('assigned_projects')
|
||||
|
||||
# Validate project IDs
|
||||
valid_project_ids = []
|
||||
if selected_projects:
|
||||
for pid in selected_projects:
|
||||
try:
|
||||
project_id = int(pid)
|
||||
# Verify project exists
|
||||
if db.session.get(Project, project_id):
|
||||
valid_project_ids.append(project_id)
|
||||
except (ValueError, TypeError):
|
||||
logger_handler.logger.warning(f"Invalid project ID received: {pid}")
|
||||
|
||||
# Add validated project permissions
|
||||
if valid_project_ids:
|
||||
for project_id in valid_project_ids:
|
||||
try:
|
||||
permission = UserProjectPermission(
|
||||
user_id=user_id,
|
||||
project_id=project_id
|
||||
)
|
||||
db.session.add(permission)
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error adding project permission: {e}")
|
||||
|
||||
changes['assigned_projects'] = f'{len(valid_project_ids)} projects assigned'
|
||||
logger_handler.logger.info(
|
||||
f"Admin {session['username']} updated project permissions for Project Manager {user_to_edit.username}: {len(valid_project_ids)} projects"
|
||||
)
|
||||
|
||||
# Update location permissions
|
||||
# First, remove existing location permissions
|
||||
try:
|
||||
UserLocationPermission.query.filter_by(user_id=user_id).delete()
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error deleting old location permissions: {e}")
|
||||
|
||||
# Add new location permissions
|
||||
selected_locations = request.form.getlist('assigned_locations')
|
||||
|
||||
# Validate and clean locations
|
||||
valid_locations = []
|
||||
if selected_locations:
|
||||
for location in selected_locations:
|
||||
location_clean = location.strip()
|
||||
if location_clean:
|
||||
valid_locations.append(location_clean)
|
||||
|
||||
# Add validated location permissions
|
||||
if valid_locations:
|
||||
for location_name in valid_locations:
|
||||
try:
|
||||
permission = UserLocationPermission(
|
||||
user_id=user_id,
|
||||
location_name=location_name
|
||||
)
|
||||
db.session.add(permission)
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error adding location permission: {e}")
|
||||
|
||||
changes['assigned_locations'] = f'{len(valid_locations)} locations assigned'
|
||||
logger_handler.logger.info(
|
||||
f"Admin {session['username']} updated location permissions for Project Manager {user_to_edit.username}: {len(valid_locations)} locations"
|
||||
)
|
||||
|
||||
# If role changed from project_manager to something else, remove permissions
|
||||
elif old_role == 'project_manager' and new_role != 'project_manager':
|
||||
try:
|
||||
UserProjectPermission.query.filter_by(user_id=user_id).delete()
|
||||
UserLocationPermission.query.filter_by(user_id=user_id).delete()
|
||||
logger_handler.logger.info(
|
||||
f"Admin {session['username']} removed Project Manager permissions from user {user_to_edit.username} (role changed to {new_role})"
|
||||
)
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error removing permissions: {e}")
|
||||
|
||||
# Track changes
|
||||
for field, old_value in old_values.items():
|
||||
new_value = getattr(user_to_edit, field)
|
||||
if old_value != new_value:
|
||||
changes[field] = {'old': old_value, 'new': new_value}
|
||||
|
||||
# Commit all changes
|
||||
db.session.commit()
|
||||
|
||||
# Log user update
|
||||
if changes:
|
||||
logger_handler.logger.info(f"Admin user {session['username']} updated user {user_to_edit.username}: {json.dumps(changes, default=str)}")
|
||||
|
||||
flash(f'User "{user_to_edit.full_name}" updated successfully.', 'success')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# GET request - load form with current assignments
|
||||
try:
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
locations = get_all_locations_from_qr_codes()
|
||||
|
||||
# Get current assignments if user is a project manager
|
||||
assigned_project_ids = []
|
||||
assigned_location_names = []
|
||||
|
||||
if user_to_edit.role == 'project_manager':
|
||||
try:
|
||||
assigned_project_ids = [p.project_id for p in UserProjectPermission.query.filter_by(user_id=user_id).all()]
|
||||
assigned_location_names = [l.location_name for l in UserLocationPermission.query.filter_by(user_id=user_id).all()]
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error loading current permissions: {e}")
|
||||
|
||||
return render_template('edit_user.html',
|
||||
user=user_to_edit,
|
||||
valid_roles=VALID_ROLES,
|
||||
projects=projects,
|
||||
locations=locations,
|
||||
assigned_project_ids=assigned_project_ids,
|
||||
assigned_location_names=assigned_location_names)
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error loading edit user form: {e}")
|
||||
flash('Error loading edit form. Please try again.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_update', e)
|
||||
logger_handler.logger.error(f"User update error details: {str(e)}")
|
||||
flash('Error updating user. Please try again.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
@bp.route('/users/<int:user_id>/toggle-status', methods=['POST'], endpoint='toggle_user_status')
|
||||
@admin_required
|
||||
def toggle_user_status(user_id):
|
||||
"""Toggle user active status via AJAX (Admin only)"""
|
||||
try:
|
||||
user_to_toggle = db.session.get(User, user_id)
|
||||
current_user = db.session.get(User, session['user_id'])
|
||||
|
||||
if not user_to_toggle:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'User not found.'
|
||||
}), 404
|
||||
|
||||
# Prevent self-deactivation
|
||||
if user_to_toggle.id == current_user.id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'You cannot deactivate yourself.'
|
||||
}), 400
|
||||
|
||||
# Check if trying to deactivate the last admin
|
||||
if (user_to_toggle.role == 'admin' and
|
||||
user_to_toggle.active_status and
|
||||
User.query.filter_by(role='admin', active_status=True).count() <= 1):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Cannot deactivate the last admin user.'
|
||||
}), 400
|
||||
|
||||
# Toggle the status
|
||||
new_status = not user_to_toggle.active_status
|
||||
user_to_toggle.active_status = new_status
|
||||
db.session.commit()
|
||||
|
||||
action = 'activated' if new_status else 'deactivated'
|
||||
message = f'"{user_to_toggle.full_name}" has been {action} successfully.'
|
||||
|
||||
# Log status change
|
||||
logger_handler.logger.info(
|
||||
f"Admin {current_user.username} {action} user {user_to_toggle.username} (ID: {user_to_toggle.id})"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': message,
|
||||
'new_status': new_status,
|
||||
'user_id': user_id
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_status_toggle', e)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Error updating user status. Please try again.'
|
||||
}), 500
|
||||
|
||||
@bp.route('/users/<int:user_id>/activate', methods=['GET', 'POST'], endpoint='activate_user')
|
||||
@admin_required
|
||||
def activate_user(user_id):
|
||||
"""Activate a user (Admin only) - Alternative route"""
|
||||
try:
|
||||
user_to_activate = db.session.get(User, user_id)
|
||||
current_user = db.session.get(User, session['user_id'])
|
||||
|
||||
if not user_to_activate:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
if user_to_activate.active_status:
|
||||
flash('User is already active.', 'info')
|
||||
else:
|
||||
user_to_activate.active_status = True
|
||||
db.session.commit()
|
||||
|
||||
# Log activation
|
||||
logger_handler.logger.info(
|
||||
f"Admin {current_user.username} activated user {user_to_activate.username} (ID: {user_to_activate.id})"
|
||||
)
|
||||
|
||||
flash(f'"{user_to_activate.full_name}" has been activated.', 'success')
|
||||
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_activation', e)
|
||||
flash('Error activating user. Please try again.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
@bp.route('/users/<int:user_id>/deactivate', methods=['GET', 'POST'], endpoint='deactivate_user')
|
||||
@admin_required
|
||||
def deactivate_user(user_id):
|
||||
"""Deactivate a user (Admin only) - Alternative route"""
|
||||
try:
|
||||
user_to_deactivate = db.session.get(User, user_id)
|
||||
current_user = db.session.get(User, session['user_id'])
|
||||
|
||||
if not user_to_deactivate:
|
||||
flash('User not found.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# Prevent self-deactivation
|
||||
if user_to_deactivate.id == current_user.id:
|
||||
flash('You cannot deactivate yourself.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# Check if this is the last admin
|
||||
if user_to_deactivate.role == 'admin' and user_to_deactivate.active_status:
|
||||
active_admin_count = User.query.filter_by(role='admin', active_status=True).count()
|
||||
if active_admin_count <= 1:
|
||||
flash('Cannot deactivate the last admin user.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
if not user_to_deactivate.active_status:
|
||||
flash('User is already inactive.', 'info')
|
||||
else:
|
||||
user_to_deactivate.active_status = False
|
||||
db.session.commit()
|
||||
|
||||
# Log deactivation
|
||||
logger_handler.logger.info(
|
||||
f"Admin {current_user.username} deactivated user {user_to_deactivate.username} (ID: {user_to_deactivate.id})"
|
||||
)
|
||||
|
||||
flash(f'"{user_to_deactivate.full_name}" has been deactivated.', 'success')
|
||||
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_deactivation', e)
|
||||
flash('Error deactivating user. Please try again.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# ENHANCED USER STATISTICS API
|
||||
@bp.route('/api/users/stats', endpoint='user_stats_api')
|
||||
@admin_required
|
||||
def user_stats_api():
|
||||
"""API endpoint to get user statistics for dashboard"""
|
||||
try:
|
||||
# Get current date for recent activity calculations
|
||||
one_week_ago = datetime.now() - timedelta(days=7)
|
||||
|
||||
total_users = User.query.count()
|
||||
active_users = User.query.filter_by(active_status=True).count()
|
||||
admin_users = User.query.filter_by(role='admin', active_status=True).count()
|
||||
staff_users = User.query.filter_by(role='staff', active_status=True).count()
|
||||
payroll_users = User.query.filter_by(role='payroll', active_status=True).count()
|
||||
project_manager_users = User.query.filter_by(role='project_manager', active_status=True).count()
|
||||
accounting_users = User.query.filter_by(role='accounting', active_status=True).count()
|
||||
inactive_users = User.query.filter_by(active_status=False).count()
|
||||
|
||||
recent_registrations = User.query.filter(
|
||||
User.created_date >= one_week_ago
|
||||
).count()
|
||||
|
||||
recent_logins = User.query.filter(
|
||||
User.last_login_date >= one_week_ago
|
||||
).count()
|
||||
|
||||
return jsonify({
|
||||
'total_users': total_users,
|
||||
'active_users': active_users,
|
||||
'admin_users': admin_users,
|
||||
'staff_users': staff_users,
|
||||
'payroll_users': payroll_users,
|
||||
'project_manager_users': project_manager_users,
|
||||
'accounting_users': accounting_users,
|
||||
'inactive_users': inactive_users,
|
||||
'recent_registrations': recent_registrations,
|
||||
'recent_logins': recent_logins
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('user_stats_api', e)
|
||||
return jsonify({'error': 'Failed to fetch user statistics'}), 500
|
||||
|
||||
@bp.route('/api/locations-by-projects', methods=['POST'], endpoint='get_locations_by_projects')
|
||||
@admin_required
|
||||
def get_locations_by_projects():
|
||||
"""Get locations that belong to selected projects"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
project_ids = data.get('project_ids', [])
|
||||
|
||||
if not project_ids:
|
||||
# No projects selected, return empty list
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'locations': [],
|
||||
'message': 'No projects selected'
|
||||
})
|
||||
|
||||
# Get unique locations from QR codes that belong to selected projects
|
||||
result = db.session.execute(text("""
|
||||
SELECT DISTINCT location
|
||||
FROM qr_codes
|
||||
WHERE project_id IN :project_ids
|
||||
AND location IS NOT NULL
|
||||
AND active_status = 1
|
||||
ORDER BY location
|
||||
"""), {'project_ids': tuple(project_ids)})
|
||||
|
||||
locations = [row[0] for row in result.fetchall()]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'locations': locations,
|
||||
'count': len(locations)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error fetching locations by projects: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
@bp.route('/api/roles/permissions', endpoint='role_permissions_api')
|
||||
@admin_required
|
||||
def role_permissions_api():
|
||||
"""API endpoint to get role permissions data"""
|
||||
try:
|
||||
permissions_data = {}
|
||||
for role in VALID_ROLES:
|
||||
permissions_data[role] = get_role_permissions(role)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'roles': permissions_data,
|
||||
'valid_roles': VALID_ROLES,
|
||||
'staff_level_roles': STAFF_LEVEL_ROLES
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('role_permissions_api', e)
|
||||
return jsonify({'error': 'Failed to fetch role permissions'}), 500
|
||||
|
||||
@bp.route('/api/geocode', methods=['POST'], endpoint='geocode_address_api')
|
||||
@login_required
|
||||
def geocode_address_api():
|
||||
"""API endpoint to geocode an address and return coordinates using Google Maps"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
address = data.get('address', '').strip()
|
||||
|
||||
if not address:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Address is required'
|
||||
}), 400
|
||||
|
||||
# Log API geocoding request
|
||||
logger_handler.logger.info(f"API geocoding request from user {session.get('username', 'unknown')}: {address[:50]}")
|
||||
|
||||
# Use the enhanced function that returns 3 values
|
||||
lat, lng, accuracy = get_coordinates_from_address_enhanced(address)
|
||||
|
||||
if lat is not None and lng is not None:
|
||||
logger_handler.logger.info(
|
||||
f"API geocoding success for user {session.get('username', 'unknown')}: "
|
||||
f"{address[:50]} -> {lat}, {lng} ({accuracy})"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'latitude': lat,
|
||||
'longitude': lng,
|
||||
'accuracy': accuracy,
|
||||
'coordinates_display': f"{lat:.10f}, {lng:.10f}",
|
||||
'service_used': 'Google Maps' if gmaps_client else 'OpenStreetMap'
|
||||
},
|
||||
'message': f'Address geocoded successfully with {accuracy} accuracy using {"Google Maps" if gmaps_client else "OpenStreetMap"}'
|
||||
})
|
||||
else:
|
||||
logger_handler.logger.warning(
|
||||
f"API geocoding failed for user {session.get('username', 'unknown')}: {address[:50]}"
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Unable to geocode the provided address. Please verify the address is complete and accurate.'
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_flask_error('api_geocoding_error', f'API geocoding error: {str(e)}')
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Internal server error during geocoding. Please try again.'
|
||||
}), 500
|
||||
|
||||
@bp.route('/api/reverse-geocode', methods=['POST'], endpoint='reverse_geocode_api')
|
||||
@login_required
|
||||
def reverse_geocode_api():
|
||||
"""API endpoint for reverse geocoding coordinates to address using Google Maps"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
latitude = data.get('latitude')
|
||||
longitude = data.get('longitude')
|
||||
|
||||
if not latitude or not longitude:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Latitude and longitude are required'
|
||||
}), 400
|
||||
|
||||
# Log API reverse geocoding request
|
||||
logger_handler.logger.info(
|
||||
f"API reverse geocoding request from user {session.get('username', 'unknown')}: {latitude}, {longitude}"
|
||||
)
|
||||
|
||||
# Use the reverse geocoding function
|
||||
address = reverse_geocode_coordinates(latitude, longitude)
|
||||
|
||||
if address:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'address': address,
|
||||
'coordinates': f"{latitude}, {longitude}",
|
||||
'service_used': 'Google Maps' if gmaps_client else 'OpenStreetMap'
|
||||
},
|
||||
'message': f'Coordinates reverse geocoded successfully using {"Google Maps" if gmaps_client else "OpenStreetMap"}'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Unable to reverse geocode the provided coordinates.'
|
||||
}), 404
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_flask_error('api_reverse_geocoding_error', f'API reverse geocoding error: {str(e)}')
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Internal server error during reverse geocoding. Please try again.'
|
||||
}), 500
|
||||
|
||||
@bp.route('/users/<int:user_id>/permanently-delete', methods=['GET', 'POST'], endpoint='permanently_delete_user')
|
||||
@admin_required
|
||||
def permanently_delete_user(user_id):
|
||||
"""Permanently delete user but preserve associated QR codes (Admin only)"""
|
||||
try:
|
||||
user_to_delete = db.session.get(User, user_id)
|
||||
if user_to_delete is None:
|
||||
abort(404)
|
||||
current_user = db.session.get(User, session['user_id'])
|
||||
|
||||
# Security checks
|
||||
if user_to_delete.id == current_user.id:
|
||||
flash('You cannot delete your own account.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# Only allow deletion of inactive users for safety
|
||||
if user_to_delete.active_status:
|
||||
flash('User must be deactivated before permanent deletion.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# If deleting an admin, ensure at least one admin remains
|
||||
if user_to_delete.role == 'admin':
|
||||
active_admin_count = User.query.filter_by(role='admin', active_status=True).count()
|
||||
if active_admin_count <= 1:
|
||||
flash('Cannot delete the last admin user in the system.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
user_name = user_to_delete.full_name
|
||||
user_qr_count = user_to_delete.created_qr_codes.count()
|
||||
username = user_to_delete.username
|
||||
|
||||
# MODIFIED: Preserve QR codes by setting created_by to NULL instead of deleting them
|
||||
orphaned_qr_codes = QRCode.query.filter_by(created_by=user_id).all()
|
||||
for qr_code in orphaned_qr_codes:
|
||||
qr_code.created_by = None
|
||||
|
||||
# Update any users that were created by this user (set created_by to None)
|
||||
created_users = User.query.filter_by(created_by=user_id).all()
|
||||
for created_user in created_users:
|
||||
created_user.created_by = None
|
||||
|
||||
# Log user deletion before actual deletion
|
||||
logger_handler.log_security_event(
|
||||
event_type="user_permanent_deletion",
|
||||
description=f"Admin {current_user.username} permanently deleted user {username}",
|
||||
severity="HIGH",
|
||||
additional_data={'deleted_user': username, 'qr_codes_orphaned': user_qr_count}
|
||||
)
|
||||
|
||||
# Delete the user
|
||||
db.session.delete(user_to_delete)
|
||||
db.session.commit()
|
||||
|
||||
logger_handler.logger.info(
|
||||
f"Admin {current_user.username} permanently deleted user: {username}, "
|
||||
f"preserved {user_qr_count} QR codes"
|
||||
)
|
||||
flash(
|
||||
f'User "{user_name}" has been permanently deleted. '
|
||||
f'{user_qr_count} QR codes created by this user are now orphaned but preserved.',
|
||||
'success'
|
||||
)
|
||||
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('user_permanent_deletion', e)
|
||||
flash('Error deleting user. Please try again.', 'error')
|
||||
return redirect(url_for('users.users'))
|
||||
|
||||
# Admin logging routes
|
||||
@@ -0,0 +1,422 @@
|
||||
"""
|
||||
routes/verification.py
|
||||
======================
|
||||
Verification review routes for attendance records requiring photo verification.
|
||||
|
||||
Routes: /verification-review, /verification-review/<id>,
|
||||
/verification-review/<id>/update,
|
||||
/api/attendance/<id>/verification-details,
|
||||
/api/attendance/stats
|
||||
|
||||
"""
|
||||
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,
|
||||
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('/verification-review', endpoint='verification_review')
|
||||
@login_required
|
||||
def verification_review():
|
||||
"""Admin page to review pending photo verifications"""
|
||||
try:
|
||||
# Only admins can access
|
||||
if session.get('role') not in ['admin', 'payroll', 'accounting']:
|
||||
flash('Unauthorized access.', 'error')
|
||||
return redirect(url_for('dashboard.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 = db.session.get(Project, 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.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)"""
|
||||
try:
|
||||
# Only admins can update
|
||||
if session.get('role') not in ['admin', 'payroll', 'accounting']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Unauthorized access'
|
||||
}), 403
|
||||
|
||||
record = db.session.get(AttendanceData, record_id)
|
||||
if record is None:
|
||||
abort(404)
|
||||
|
||||
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"""
|
||||
try:
|
||||
# Get the attendance record with verification data
|
||||
record = db.session.get(AttendanceData, record_id)
|
||||
if record is None:
|
||||
abort(404)
|
||||
|
||||
# DEBUG: Log record details
|
||||
logger_handler.logger.debug(
|
||||
f"Verification details: record={record.id}, employee={record.employee_id}, "
|
||||
f"date={record.check_in_date}, time={record.check_in_time}, "
|
||||
f"has_photo={record.verification_photo is not None}, status={record.verification_status}"
|
||||
)
|
||||
|
||||
# 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:
|
||||
logger_handler.logger.warning(f"Error formatting check_in_date for record {record_id}: {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:
|
||||
logger_handler.logger.warning(f"Error formatting check_in_time for record {record_id}: {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 Exception as e:
|
||||
logger_handler.logger.debug(f"check_in_date strftime failed: {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:
|
||||
logger_handler.logger.debug(f"check_in_time strftime failed: {e}")
|
||||
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 in get_verification_details for record {record_id}: {e}", exc_info=True)
|
||||
|
||||
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"""
|
||||
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.attendance_report'))
|
||||
|
||||
# Get the attendance record
|
||||
record = db.session.get(AttendanceData, record_id)
|
||||
if record is None:
|
||||
abort(404)
|
||||
|
||||
# Check if this record has verification
|
||||
if not record.verification_required:
|
||||
flash('This record does not require verification.', 'warning')
|
||||
return redirect(url_for('attendance.attendance_report'))
|
||||
|
||||
# Get the QR code information for additional context
|
||||
qr_code = db.session.get(QRCode, 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 Exception as e:
|
||||
logger_handler.logger.debug(f"check_in_date strftime failed: {e}")
|
||||
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 Exception as e:
|
||||
logger_handler.logger.debug(f"check_in_time strftime failed: {e}")
|
||||
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.attendance_report'))
|
||||
|
||||
@bp.route('/api/attendance/stats', endpoint='attendance_stats_api')
|
||||
@admin_required
|
||||
def attendance_stats_api():
|
||||
"""API endpoint for attendance statistics"""
|
||||
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:
|
||||
logger_handler.logger.error(f"Error fetching attendance stats: {e}", exc_info=True)
|
||||
return jsonify({'error': 'Failed to fetch attendance statistics'}), 500
|
||||
Reference in New Issue
Block a user