Sep 11 - Reupload the code

This commit is contained in:
2026-09-11 22:42:45 -04:00
commit d92cff81e5
130 changed files with 73508 additions and 0 deletions
View File
+580
View File
@@ -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
+866
View File
@@ -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'))
+348
View File
@@ -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'))
+962
View File
@@ -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
View File
@@ -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'))
+250
View File
@@ -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
+379
View File
@@ -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'))
+129
View File
@@ -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'
)
+170
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+315
View File
@@ -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
View File
@@ -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
+422
View File
@@ -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