Mar 20 2026: refactor app.py
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
extensions.py
|
||||
=============
|
||||
Shared Flask extension instances (SQLAlchemy db + AppLogger).
|
||||
|
||||
All Blueprints import from here to avoid circular imports.
|
||||
|
||||
Initialization order (enforced in app.py):
|
||||
1. db = SQLAlchemy() -- created here at module level
|
||||
2. app.py configures Flask app
|
||||
3. db.init_app(app) -- called in app.py
|
||||
4. set_db(db) -- unpacks model classes
|
||||
5. init_logger(app, db) -- binds logger_handler here
|
||||
6. Blueprints are registered
|
||||
"""
|
||||
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from logger_handler import AppLogger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database — single shared instance
|
||||
# ---------------------------------------------------------------------------
|
||||
db = SQLAlchemy()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Application-level logger — initialized via init_logger() below
|
||||
# ---------------------------------------------------------------------------
|
||||
logger_handler: "AppLogger | None" = None
|
||||
|
||||
|
||||
def init_logger(app, database) -> AppLogger:
|
||||
"""
|
||||
Instantiate AppLogger and bind it to the module-level ``logger_handler``
|
||||
variable so every Blueprint that does ``from extensions import logger_handler``
|
||||
receives the same fully-initialized instance.
|
||||
"""
|
||||
global logger_handler
|
||||
logger_handler = AppLogger(app, database)
|
||||
return logger_handler
|
||||
+9
-3
@@ -25,7 +25,7 @@ import os
|
||||
import traceback
|
||||
from datetime import datetime, date, timedelta
|
||||
from functools import wraps
|
||||
from flask import request, session, g, render_template
|
||||
from flask import request, session, g, render_template, has_request_context
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
import uuid
|
||||
@@ -201,8 +201,14 @@ class AppLogger:
|
||||
return render_template('errors/404.html'), 404
|
||||
|
||||
def _get_request_context(self):
|
||||
"""Get current request context information"""
|
||||
if not request:
|
||||
"""Get current request context information.
|
||||
Safe to call from background threads — returns empty dict when no
|
||||
request context is active (e.g. during background import jobs).
|
||||
"""
|
||||
try:
|
||||
if not has_request_context():
|
||||
return {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
return {
|
||||
|
||||
+591
@@ -0,0 +1,591 @@
|
||||
"""
|
||||
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
|
||||
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 url_for, admin_required, login_required
|
||||
|
||||
bp = Blueprint('admin', __name__)
|
||||
|
||||
def _get_models():
|
||||
"""Return model classes from the current app context."""
|
||||
from flask import current_app
|
||||
return current_app.config['_models']
|
||||
|
||||
|
||||
@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'))
|
||||
|
||||
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', '')
|
||||
|
||||
print(f"📊 API request - Days: {days}, Limit: {limit}, Page: {page}")
|
||||
print(f"📊 Filters - Category: {category}, Severity: {severity}, Search: {search}")
|
||||
|
||||
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 '-'
|
||||
})
|
||||
|
||||
print(f"📊 Returning {len(logs)} logs out of {total_count} total")
|
||||
|
||||
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)
|
||||
print(f"Error in 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)
|
||||
print(f"📊 Getting log statistics for last {days} days")
|
||||
|
||||
# Get statistics from logger handler
|
||||
stats = logger_handler.get_log_statistics(days=days)
|
||||
print(f"📈 Retrieved stats: {stats}")
|
||||
|
||||
# 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)
|
||||
print(f"❌ Error in 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:
|
||||
print("❌ No JSON data provided")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No JSON data provided'
|
||||
}), 400
|
||||
|
||||
days_to_keep = data.get('days_to_keep', 90)
|
||||
print(f"🧹 Cleanup request: keep last {days_to_keep} days")
|
||||
|
||||
# Validate input
|
||||
if not isinstance(days_to_keep, int) or days_to_keep < 7:
|
||||
print(f"❌ Invalid days_to_keep: {days_to_keep}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'days_to_keep must be an integer >= 7'
|
||||
}), 400
|
||||
|
||||
if days_to_keep > 365:
|
||||
print(f"❌ days_to_keep too large: {days_to_keep}")
|
||||
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')
|
||||
print(f"✅ Cleanup completed by {admin_username}: {deleted_count} records deleted")
|
||||
|
||||
# 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)
|
||||
print(f"❌ Error in 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')
|
||||
print(f"🧹 Clear logs request by admin: {admin_username}")
|
||||
|
||||
# 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
|
||||
|
||||
print(f"📊 Total logs to be cleared: {total_logs}")
|
||||
|
||||
if total_logs == 0:
|
||||
print("✅ No logs found to clear")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_count': 0,
|
||||
'message': 'No logs found to clear'
|
||||
})
|
||||
|
||||
except Exception as count_error:
|
||||
print(f"⚠️ Error counting logs: {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()
|
||||
|
||||
print(f"🗑️ Successfully cleared {deleted_count} log entries")
|
||||
|
||||
# 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:
|
||||
print(f"❌ Error during log clearing: {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)
|
||||
print(f"❌ Error in 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:
|
||||
print("❌ No JSON data provided")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No JSON data provided'
|
||||
}), 400
|
||||
|
||||
days_threshold = data.get('days_threshold', 90)
|
||||
admin_username = session.get('username', 'unknown')
|
||||
print(f"🧹 Clear old logs request by admin: {admin_username}, threshold: {days_threshold} days")
|
||||
|
||||
# Validate input
|
||||
if not isinstance(days_threshold, int) or days_threshold not in [30, 60, 90]:
|
||||
print(f"❌ Invalid days_threshold: {days_threshold}")
|
||||
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
|
||||
|
||||
print(f"📊 Total logs older than {days_threshold} days to be cleared: {total_logs}")
|
||||
|
||||
if total_logs == 0:
|
||||
print("✅ No old logs found to clear")
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'deleted_count': 0,
|
||||
'message': f'No logs older than {days_threshold} days found to clear'
|
||||
})
|
||||
|
||||
except Exception as count_error:
|
||||
print(f"⚠️ Error counting old logs: {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()
|
||||
|
||||
print(f"🗑️ Successfully 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:
|
||||
print(f"❌ Error during old log clearing: {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)
|
||||
print(f"❌ Error in 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')
|
||||
print(f"📊 Export logs request by admin: {admin_username}")
|
||||
|
||||
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)
|
||||
print(f"❌ Error in api_export_logs: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': f'Failed to export logs: {str(e)}'
|
||||
}), 500
|
||||
|
||||
# PROJECT MANAGEMENT ROUTES
|
||||
File diff suppressed because it is too large
Load Diff
+272
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
routes/auth.py
|
||||
==============
|
||||
Authentication and user-profile routes.
|
||||
|
||||
Routes: /, /register, /login, /logout, /profile
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, flash, session, jsonify
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import url_for, admin_required, login_required, staff_or_admin_required
|
||||
from turnstile_utils import turnstile_utils
|
||||
|
||||
bp = Blueprint('auth', __name__)
|
||||
|
||||
def _get_models():
|
||||
"""Return model classes from the current app context."""
|
||||
from flask import current_app
|
||||
return current_app.config['_models']
|
||||
|
||||
|
||||
@bp.route('/', endpoint='index')
|
||||
def index():
|
||||
"""Home page - redirect to login if not authenticated"""
|
||||
User = _get_models()["User"]
|
||||
if 'user_id' in session:
|
||||
return redirect(url_for('dashboard'))
|
||||
return redirect(url_for('login'))
|
||||
|
||||
@bp.route('/register', methods=['GET', 'POST'], endpoint='register')
|
||||
@log_user_activity('user_registration')
|
||||
def register():
|
||||
"""User registration endpoint"""
|
||||
User = _get_models()["User"]
|
||||
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('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"""
|
||||
User = _get_models()["User"]
|
||||
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')
|
||||
|
||||
# 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'
|
||||
|
||||
# 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()
|
||||
|
||||
# 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')
|
||||
print(f"User {user.username} logged in successfully")
|
||||
|
||||
# Redirect to intended page or dashboard
|
||||
next_page = request.args.get('next')
|
||||
return redirect(next_page) if next_page else redirect(url_for('attendance_report'))
|
||||
|
||||
else:
|
||||
# Invalid credentials - log failed attempt
|
||||
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')
|
||||
print(f"Failed login attempt for username: {username}")
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('user_login', e)
|
||||
print(f"Login error: {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 = _get_models()["User"]
|
||||
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:
|
||||
pass
|
||||
|
||||
# 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('login'))
|
||||
|
||||
@bp.route('/profile', methods=['GET', 'POST'], endpoint='profile')
|
||||
@login_required
|
||||
@log_user_activity('profile_update')
|
||||
def profile():
|
||||
"""User profile management with logging"""
|
||||
User = _get_models()["User"]
|
||||
try:
|
||||
user = User.query.get(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:
|
||||
logger_handler.log_database_error('profile_update', e)
|
||||
flash('Profile update failed. Please try again.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
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 Blueprint, render_template, request, redirect, flash, session, jsonify
|
||||
from datetime import datetime, timedelta, date, time
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import url_for, login_required
|
||||
|
||||
bp = Blueprint('dashboard', __name__)
|
||||
|
||||
def _get_models():
|
||||
"""Return model classes from the current app context."""
|
||||
from flask import current_app
|
||||
return current_app.config['_models']
|
||||
|
||||
|
||||
@bp.route('/dashboard', endpoint='dashboard')
|
||||
@login_required
|
||||
def dashboard():
|
||||
"""Enhanced project-centric dashboard with search filters"""
|
||||
User, QRCode, Project, AttendanceData = _get_models()["User"], _get_models()["QRCode"], _get_models()["Project"], _get_models()["AttendanceData"]
|
||||
try:
|
||||
user = User.query.get(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:
|
||||
logger_handler.log_database_error('dashboard_load', e)
|
||||
print(f"Error loading dashboard: {e}")
|
||||
flash('Error loading dashboard. Please try again.', 'error')
|
||||
return redirect(url_for('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
|
||||
"""
|
||||
User, QRCode, Project, AttendanceData = _get_models()["User"], _get_models()["QRCode"], _get_models()["Project"], _get_models()["AttendanceData"]
|
||||
try:
|
||||
# Get the project
|
||||
project = Project.query.get_or_404(project_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 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:
|
||||
logger_handler.log_database_error('project_qr_codes_view', e)
|
||||
print(f"Error loading project QR codes: {e}")
|
||||
flash('Error loading project QR codes. Please try again.', 'error')
|
||||
return redirect(url_for('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"""
|
||||
User, QRCode, Project, AttendanceData = _get_models()["User"], _get_models()["QRCode"], _get_models()["Project"], _get_models()["AttendanceData"]
|
||||
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', 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"""
|
||||
User, QRCode, Project, AttendanceData = _get_models()["User"], _get_models()["QRCode"], _get_models()["Project"], _get_models()["AttendanceData"]
|
||||
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:
|
||||
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"""
|
||||
User, QRCode, Project, AttendanceData = _get_models()["User"], _get_models()["QRCode"], _get_models()["Project"], _get_models()["AttendanceData"]
|
||||
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:
|
||||
logger_handler.log_database_error('dashboard_realtime_api', e)
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'Failed to fetch real-time data'
|
||||
}), 500
|
||||
|
||||
# USER MANAGEMENT ROUTES
|
||||
@@ -0,0 +1,390 @@
|
||||
"""
|
||||
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 Blueprint, render_template, request, redirect, flash, session, jsonify
|
||||
from datetime import datetime, date
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import (url_for,
|
||||
admin_required,
|
||||
has_admin_privileges,
|
||||
has_staff_level_access,
|
||||
login_required,
|
||||
staff_or_admin_required)
|
||||
|
||||
bp = Blueprint('employees', __name__)
|
||||
|
||||
def _get_models():
|
||||
"""Return model classes from the current app context."""
|
||||
from flask import current_app
|
||||
return current_app.config['_models']
|
||||
|
||||
|
||||
@bp.route('/employees', endpoint='employees')
|
||||
@login_required
|
||||
def employees():
|
||||
"""Display employee management page with search and pagination"""
|
||||
Employee, AttendanceData, Project, QRCode, User = _get_models()["Employee"], _get_models()["AttendanceData"], _get_models()["Project"], _get_models()["QRCode"], _get_models()["User"]
|
||||
try:
|
||||
# Log user accessing employee management
|
||||
try:
|
||||
logger_handler.logger.info(f"User {session['username']} accessed employee management list")
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
|
||||
# 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'))
|
||||
|
||||
@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)"""
|
||||
Employee, AttendanceData, Project, QRCode, User = _get_models()["Employee"], _get_models()["AttendanceData"], _get_models()["Project"], _get_models()["QRCode"], _get_models()["User"]
|
||||
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
|
||||
try:
|
||||
project = Project.query.get(contract_id_int)
|
||||
project_name = project.name if project else f"Project {contract_id_int}"
|
||||
logger_handler.logger.info(f"Admin user {session['username']} created new employee: {employee_id_int} - {first_name} {last_name} assigned to {project_name}")
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
|
||||
flash(f'Employee "{first_name} {last_name}" (ID: {employee_id}) created successfully.', 'success')
|
||||
return redirect(url_for('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)"""
|
||||
Employee, AttendanceData, Project, QRCode, User = _get_models()["Employee"], _get_models()["AttendanceData"], _get_models()["Project"], _get_models()["QRCode"], _get_models()["User"]
|
||||
try:
|
||||
# Get employee by index (primary key)
|
||||
employee = Employee.query.get_or_404(employee_index)
|
||||
|
||||
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
|
||||
try:
|
||||
project = Project.query.get(contract_id_int)
|
||||
project_name = project.name if project else f"Project {contract_id_int}"
|
||||
logger_handler.logger.info(f"Admin user {session['username']} updated employee: {employee_index} - {first_name} {last_name} assigned to {project_name}")
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
|
||||
flash(f'Employee "{first_name} {last_name}" updated successfully.', 'success')
|
||||
return redirect(url_for('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'))
|
||||
|
||||
@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) - Enhanced with better logging"""
|
||||
Employee, AttendanceData, Project, QRCode, User = _get_models()["Employee"], _get_models()["AttendanceData"], _get_models()["Project"], _get_models()["QRCode"], _get_models()["User"]
|
||||
try:
|
||||
print(f"🗑️ DELETE REQUEST: Employee index {employee_index}")
|
||||
print(f"📋 Request method: {request.method}")
|
||||
print(f"👤 User: {session.get('username', 'Unknown')}")
|
||||
|
||||
# Get employee by index (primary key)
|
||||
employee = Employee.query.get_or_404(employee_index)
|
||||
print(f"✅ Found employee: {employee.firstName} {employee.lastName} (ID: {employee.id})")
|
||||
|
||||
# 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
|
||||
from models.attendance import AttendanceData
|
||||
attendance_count = AttendanceData.query.filter_by(employee_id=str(employee.id)).count()
|
||||
print(f"📊 Attendance records found: {attendance_count}")
|
||||
|
||||
if attendance_count > 0:
|
||||
error_msg = f'Cannot delete employee "{employee.full_name}". Employee has {attendance_count} attendance records. Please contact system administrator.'
|
||||
print(f"❌ DELETION BLOCKED: {error_msg}")
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for('employees'))
|
||||
|
||||
# Proceed with deletion
|
||||
print(f"🗑️ Proceeding with deletion of employee: {employee_data['firstName']} {employee_data['lastName']}")
|
||||
|
||||
db.session.delete(employee)
|
||||
db.session.commit()
|
||||
print("✅ Employee successfully deleted from database")
|
||||
|
||||
# Log employee deletion
|
||||
try:
|
||||
logger_handler.logger.info(f"Admin user {session['username']} deleted employee: {employee_data['firstName']} {employee_data['lastName']} (ID: {employee_data['id']})")
|
||||
print(f"📋 Deletion logged successfully")
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
|
||||
success_msg = f'Employee "{employee_data["firstName"]} {employee_data["lastName"]}" deleted successfully.'
|
||||
flash(success_msg, 'success')
|
||||
print(f"✅ SUCCESS: {success_msg}")
|
||||
|
||||
return redirect(url_for('employees'))
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger_handler.log_database_error('employee_deletion', e)
|
||||
error_msg = f'Error deleting employee. Please try again.'
|
||||
print(f"❌ ERROR in delete_employee: {e}")
|
||||
print(f"❌ Exception type: {type(e)}")
|
||||
flash(error_msg, 'error')
|
||||
return redirect(url_for('employees'))
|
||||
|
||||
@bp.route('/api/employees/search', endpoint='api_employees_search')
|
||||
@login_required
|
||||
def api_employees_search():
|
||||
"""API endpoint for employee search (for AJAX)"""
|
||||
Employee, AttendanceData, Project, QRCode, User = _get_models()["Employee"], _get_models()["AttendanceData"], _get_models()["Project"], _get_models()["QRCode"], _get_models()["User"]
|
||||
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"""
|
||||
Employee, AttendanceData, Project, QRCode, User = _get_models()["Employee"], _get_models()["AttendanceData"], _get_models()["Project"], _get_models()["QRCode"], _get_models()["User"]
|
||||
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
|
||||
from models.attendance import AttendanceData
|
||||
|
||||
# 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
|
||||
try:
|
||||
logger_handler.logger.info(f"User {session['username']} viewed employee detail: {employee.full_name} (ID: {employee.id})")
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
|
||||
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'))
|
||||
@@ -0,0 +1,709 @@
|
||||
"""
|
||||
routes/payroll.py
|
||||
=================
|
||||
Payroll dashboard and Excel export routes.
|
||||
|
||||
Routes: /payroll, /payroll/export-excel, /api/working-hours/calculate,
|
||||
/api/employee/<id>/miss-punch-details
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, send_file
|
||||
from datetime import datetime, date, timedelta, time
|
||||
import io, json, traceback, os
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from sqlalchemy import text
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import (url_for,
|
||||
admin_required,
|
||||
has_admin_privileges,
|
||||
has_staff_level_access,
|
||||
login_required,
|
||||
staff_or_admin_required)
|
||||
from working_hours_calculator import WorkingHoursCalculator, round_time_to_quarter_hour, convert_minutes_to_base100, round_base100_hours
|
||||
from payroll_excel_exporter import PayrollExcelExporter
|
||||
from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter
|
||||
|
||||
bp = Blueprint('payroll', __name__)
|
||||
|
||||
def _get_models():
|
||||
"""Return model classes from the current app context."""
|
||||
from flask import current_app
|
||||
return current_app.config['_models']
|
||||
|
||||
|
||||
@bp.route('/payroll', endpoint='payroll_dashboard')
|
||||
@login_required
|
||||
def payroll_dashboard():
|
||||
"""Payroll dashboard for calculating and exporting working hours"""
|
||||
AttendanceData, Employee, Project, TimeAttendance, QRCode, User = _get_models()["AttendanceData"], _get_models()["Employee"], _get_models()["Project"], _get_models()["TimeAttendance"], _get_models()["QRCode"], _get_models()["User"]
|
||||
try:
|
||||
# Check if user has payroll access
|
||||
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 to access payroll dashboard without permissions")
|
||||
flash('Access denied. Only administrators and payroll staff can access payroll features.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
print("📊 Loading payroll dashboard")
|
||||
|
||||
# Log payroll dashboard access
|
||||
logger_handler.logger.info(f"User {session.get('username', 'unknown')} accessed payroll dashboard")
|
||||
|
||||
# Get filter parameters with defaults
|
||||
date_from = request.args.get('date_from', '')
|
||||
date_to = request.args.get('date_to', '')
|
||||
project_filter = request.args.get('project_filter', '')
|
||||
|
||||
# Set default date range if not provided (last 2 weeks)
|
||||
if not date_from or not date_to:
|
||||
end_date = datetime.now().date()
|
||||
start_date = end_date - timedelta(days=13) # 2 weeks (14 days)
|
||||
date_from = start_date.strftime('%Y-%m-%d')
|
||||
date_to = end_date.strftime('%Y-%m-%d')
|
||||
|
||||
# Get list of projects for dropdown
|
||||
projects = []
|
||||
try:
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
print(f"📊 Found {len(projects)} active projects for filter")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error loading projects: {e}")
|
||||
|
||||
# Get attendance records for the period
|
||||
attendance_records = []
|
||||
working_hours_data = None
|
||||
|
||||
if date_from and date_to:
|
||||
try:
|
||||
start_date = datetime.strptime(date_from, '%Y-%m-%d')
|
||||
end_date = datetime.strptime(date_to, '%Y-%m-%d')
|
||||
|
||||
# Query attendance records with optional project filter
|
||||
query = db.session.query(AttendanceData).join(QRCode, AttendanceData.qr_code_id == QRCode.id)
|
||||
|
||||
# Apply date filter
|
||||
query = query.filter(
|
||||
AttendanceData.check_in_date >= start_date.date(),
|
||||
AttendanceData.check_in_date <= end_date.date()
|
||||
)
|
||||
|
||||
# Apply project filter if selected
|
||||
if project_filter and project_filter != '':
|
||||
query = query.filter(QRCode.project_id == int(project_filter))
|
||||
print(f"📊 Applied project filter: {project_filter}")
|
||||
|
||||
query = query.order_by(AttendanceData.employee_id, AttendanceData.check_in_date, AttendanceData.check_in_time)
|
||||
|
||||
attendance_records = query.all()
|
||||
print(f"📊 Found {len(attendance_records)} attendance records for payroll calculation")
|
||||
|
||||
# Calculate working hours if we have records
|
||||
if attendance_records:
|
||||
calculator = WorkingHoursCalculator()
|
||||
working_hours_data = calculator.calculate_all_employees_hours(
|
||||
start_date, end_date, attendance_records
|
||||
)
|
||||
print(f"📊 Calculated hours for {working_hours_data['employee_count']} employees")
|
||||
|
||||
except ValueError as e:
|
||||
print(f"⚠️ Invalid date format: {e}")
|
||||
flash('Invalid date format. Please use YYYY-MM-DD format.', 'error')
|
||||
except Exception as e:
|
||||
print(f"❌ Error calculating working hours: {e}")
|
||||
logger_handler.log_database_error('payroll_calculation', e)
|
||||
flash('Error calculating working hours. Please check the server logs.', 'error')
|
||||
|
||||
# Get employee names for display
|
||||
employee_names = {}
|
||||
if working_hours_data:
|
||||
try:
|
||||
# Use the same SQL approach as attendance report - JOIN with CAST
|
||||
employee_ids = list(working_hours_data['employees'].keys())
|
||||
if employee_ids:
|
||||
# Build a query similar to attendance report
|
||||
placeholders = ','.join([f"'{emp_id}'" for emp_id in employee_ids])
|
||||
employee_query = db.session.execute(text(f"""
|
||||
SELECT
|
||||
ad.employee_id,
|
||||
CONCAT(e.lastName, ',', e.firstName) as full_name
|
||||
FROM attendance_data ad
|
||||
LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id
|
||||
WHERE ad.employee_id IN ({placeholders})
|
||||
GROUP BY ad.employee_id, e.firstName, e.lastName
|
||||
"""))
|
||||
|
||||
for row in employee_query:
|
||||
if row[1]: # Only add if we got a name
|
||||
employee_names[str(row[0])] = row[1]
|
||||
|
||||
print(f"📊 Retrieved names for {len(employee_names)} employees using CAST method")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not load employee names: {e}")
|
||||
import traceback
|
||||
print(f"⚠️ Traceback: {traceback.format_exc()}")
|
||||
# Continue without names - will use employee IDs
|
||||
|
||||
# Get selected project name for display
|
||||
selected_project_name = ''
|
||||
if project_filter:
|
||||
try:
|
||||
selected_project = Project.query.get(int(project_filter))
|
||||
if selected_project:
|
||||
selected_project_name = selected_project.name
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error getting selected project name: {e}")
|
||||
|
||||
return render_template('payroll_dashboard.html',
|
||||
working_hours_data=working_hours_data,
|
||||
employee_names=employee_names,
|
||||
projects=projects,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
project_filter=project_filter,
|
||||
selected_project_name=selected_project_name,
|
||||
user_role=user_role)
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error loading payroll dashboard: {e}")
|
||||
import traceback
|
||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||
|
||||
logger_handler.log_flask_error(
|
||||
'payroll_dashboard_error',
|
||||
str(e),
|
||||
stack_trace=traceback.format_exc()
|
||||
)
|
||||
|
||||
flash('Error loading payroll dashboard. Please check the server logs.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
@bp.route('/payroll/export-excel', methods=['POST'], endpoint='export_payroll_excel')
|
||||
@login_required
|
||||
@log_database_operations('payroll_excel_export')
|
||||
def export_payroll_excel():
|
||||
"""Export payroll report to Excel with working hours calculations including SP/PW support"""
|
||||
AttendanceData, Employee, Project, TimeAttendance, QRCode, User = _get_models()["AttendanceData"], _get_models()["Employee"], _get_models()["Project"], _get_models()["TimeAttendance"], _get_models()["QRCode"], _get_models()["User"]
|
||||
try:
|
||||
# Check permissions
|
||||
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 payroll Excel export")
|
||||
flash('Access denied. Only administrators and payroll staff can export payroll data.', 'error')
|
||||
return redirect(url_for('payroll_dashboard'))
|
||||
|
||||
print("📊 Payroll Excel export started")
|
||||
|
||||
# Get parameters from form
|
||||
date_from = request.form.get('date_from')
|
||||
date_to = request.form.get('date_to')
|
||||
project_filter = request.form.get('project_filter', '')
|
||||
report_type = request.form.get('report_type', 'payroll') # 'payroll', 'detailed', 'template', 'enhanced', 'detailed_sp_pw'
|
||||
|
||||
if not date_from or not date_to:
|
||||
flash('Please provide both start and end dates for the export.', 'error')
|
||||
return redirect(url_for('payroll_dashboard'))
|
||||
|
||||
try:
|
||||
start_date = datetime.strptime(date_from, '%Y-%m-%d')
|
||||
end_date = datetime.strptime(date_to, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
flash('Invalid date format. Please use YYYY-MM-DD format.', 'error')
|
||||
return redirect(url_for('payroll_dashboard'))
|
||||
|
||||
# Get attendance records with project filter and QR code data
|
||||
query = db.session.query(AttendanceData, QRCode).join(QRCode, AttendanceData.qr_code_id == QRCode.id)
|
||||
|
||||
# Apply date filter
|
||||
query = query.filter(
|
||||
AttendanceData.check_in_date >= start_date.date(),
|
||||
AttendanceData.check_in_date <= end_date.date()
|
||||
)
|
||||
|
||||
# Apply project filter if selected
|
||||
if project_filter and project_filter != '':
|
||||
query = query.filter(QRCode.project_id == int(project_filter))
|
||||
print(f"📊 Applied project filter to export: {project_filter}")
|
||||
|
||||
query = query.order_by(AttendanceData.employee_id, AttendanceData.check_in_date, AttendanceData.check_in_time)
|
||||
|
||||
# Get the results and attach QR code data to attendance records
|
||||
query_results = query.all()
|
||||
attendance_records = []
|
||||
|
||||
for attendance_data, qr_code in query_results:
|
||||
# Attach the QR code object to the attendance record
|
||||
attendance_data.qr_code = qr_code
|
||||
attendance_records.append(attendance_data)
|
||||
|
||||
print(f"📊 Export: Found {len(attendance_records)} records with QR data")
|
||||
|
||||
if not attendance_records:
|
||||
flash('No attendance records found for the selected date range and project.', 'warning')
|
||||
return redirect(url_for('payroll_dashboard'))
|
||||
|
||||
print(f"📊 Exporting {len(attendance_records)} attendance records to Excel")
|
||||
|
||||
# Get employee names using the same method as dashboard
|
||||
employee_names = {}
|
||||
try:
|
||||
employee_ids = list(set(str(record.employee_id) for record in attendance_records))
|
||||
if employee_ids:
|
||||
# Use the same SQL approach as attendance report - JOIN with CAST
|
||||
placeholders = ','.join([f"'{emp_id}'" for emp_id in employee_ids])
|
||||
employee_query = db.session.execute(text(f"""
|
||||
SELECT
|
||||
ad.employee_id,
|
||||
CONCAT(e.firstName, ' ', e.lastName) as full_name
|
||||
FROM attendance_data ad
|
||||
LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id
|
||||
WHERE ad.employee_id IN ({placeholders})
|
||||
GROUP BY ad.employee_id, e.firstName, e.lastName
|
||||
"""))
|
||||
|
||||
for row in employee_query:
|
||||
if row[1]: # Only add if we got a name
|
||||
employee_names[str(row[0])] = row[1]
|
||||
|
||||
print(f"📊 Retrieved names for {len(employee_names)} employees for export using CAST method")
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not load employee names for export: {e}")
|
||||
import traceback
|
||||
print(f"⚠️ Traceback: {traceback.format_exc()}")
|
||||
|
||||
# Get project name for enhanced reports and filename
|
||||
project_name = None
|
||||
project_name_for_filename = ''
|
||||
if project_filter:
|
||||
try:
|
||||
project = Project.query.get(int(project_filter))
|
||||
if project:
|
||||
project_name = project.name
|
||||
project_name_for_filename = f"_{project.name.replace(' ', '_')}"
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error getting project name: {e}")
|
||||
|
||||
# Generate Excel file based on report type
|
||||
excel_file = None
|
||||
filename_prefix = 'payroll_report'
|
||||
|
||||
if report_type == 'enhanced':
|
||||
# Use enhanced exporter for SP/PW reports
|
||||
print("📊 Creating enhanced payroll report with SP/PW support")
|
||||
try:
|
||||
from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter
|
||||
exporter = EnhancedPayrollExcelExporter(company_name=os.environ.get('COMPANY_NAME', 'Your Company'))
|
||||
excel_file = exporter.create_enhanced_payroll_report(
|
||||
start_date, end_date, attendance_records, employee_names, project_name
|
||||
)
|
||||
filename_prefix = 'enhanced_payroll_report'
|
||||
print("✅ Enhanced payroll report created successfully")
|
||||
except ImportError:
|
||||
print("⚠️ Enhanced exporter not available, falling back to standard exporter")
|
||||
# Fall back to standard exporter
|
||||
exporter = PayrollExcelExporter(
|
||||
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
|
||||
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
|
||||
)
|
||||
excel_file = exporter.create_payroll_report(
|
||||
start_date, end_date, attendance_records, employee_names
|
||||
)
|
||||
filename_prefix = 'payroll_report'
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error with enhanced exporter: {e}, falling back to standard exporter")
|
||||
# Fall back to standard exporter
|
||||
exporter = PayrollExcelExporter(
|
||||
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
|
||||
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
|
||||
)
|
||||
excel_file = exporter.create_payroll_report(
|
||||
start_date, end_date, attendance_records, employee_names
|
||||
)
|
||||
filename_prefix = 'payroll_report'
|
||||
|
||||
elif report_type == 'detailed_sp_pw':
|
||||
# Detailed daily SP/PW breakdown
|
||||
print("📊 Creating detailed SP/PW daily breakdown report")
|
||||
try:
|
||||
from enhanced_payroll_excel_exporter import EnhancedPayrollExcelExporter
|
||||
exporter = EnhancedPayrollExcelExporter(company_name=os.environ.get('COMPANY_NAME', 'Your Company'))
|
||||
excel_file = exporter.create_detailed_sp_pw_report(
|
||||
start_date, end_date, attendance_records, employee_names
|
||||
)
|
||||
filename_prefix = 'detailed_sp_pw_report'
|
||||
print("✅ Detailed SP/PW report created successfully")
|
||||
except ImportError:
|
||||
print("⚠️ Enhanced exporter not available, falling back to detailed hours report")
|
||||
# Fall back to standard detailed report
|
||||
exporter = PayrollExcelExporter(
|
||||
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
|
||||
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
|
||||
)
|
||||
excel_file = exporter.create_detailed_hours_report(
|
||||
start_date, end_date, attendance_records, employee_names
|
||||
)
|
||||
filename_prefix = 'detailed_hours_report'
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error with enhanced exporter: {e}, falling back to detailed hours report")
|
||||
# Fall back to standard detailed report
|
||||
exporter = PayrollExcelExporter(
|
||||
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
|
||||
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
|
||||
)
|
||||
excel_file = exporter.create_detailed_hours_report(
|
||||
start_date, end_date, attendance_records, employee_names
|
||||
)
|
||||
filename_prefix = 'detailed_hours_report'
|
||||
|
||||
else:
|
||||
# Use standard exporter for existing report types
|
||||
exporter = PayrollExcelExporter(
|
||||
company_name=os.environ.get('COMPANY_NAME', 'Your Company'),
|
||||
contract_name=os.environ.get('CONTRACT_NAME', 'Default Contract')
|
||||
)
|
||||
|
||||
if report_type == 'detailed':
|
||||
excel_file = exporter.create_detailed_hours_report(
|
||||
start_date, end_date, attendance_records, employee_names
|
||||
)
|
||||
filename_prefix = 'detailed_hours_report'
|
||||
elif report_type == 'template':
|
||||
excel_file = exporter.create_template_format_report(
|
||||
start_date, end_date, attendance_records, employee_names, project_name
|
||||
)
|
||||
filename_prefix = 'time_attendance_report'
|
||||
else:
|
||||
# Default payroll report
|
||||
excel_file = exporter.create_payroll_report(
|
||||
start_date, end_date, attendance_records, employee_names
|
||||
)
|
||||
filename_prefix = 'payroll_report'
|
||||
|
||||
if excel_file:
|
||||
# Generate filename with timestamp and project name
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f'{filename_prefix}_{date_from}_to_{date_to}{project_name_for_filename}_{timestamp}.xlsx'
|
||||
|
||||
print(f"📊 Payroll Excel file generated successfully: {filename}")
|
||||
|
||||
# Log successful export
|
||||
logger_handler.logger.info(f"Payroll Excel export generated by user {session.get('username', 'unknown')}: {filename}")
|
||||
if report_type == 'template':
|
||||
logger_handler.logger.info(f"Template format hours export generated by user {session.get('username', 'unknown')}: {filename}")
|
||||
elif report_type == 'enhanced':
|
||||
logger_handler.logger.info(f"Enhanced payroll export with SP/PW generated by user {session.get('username', 'unknown')}: {filename}")
|
||||
elif report_type == 'detailed_sp_pw':
|
||||
logger_handler.logger.info(f"Detailed SP/PW breakdown export generated by user {session.get('username', 'unknown')}: {filename}")
|
||||
|
||||
return send_file(
|
||||
excel_file,
|
||||
as_attachment=True,
|
||||
download_name=filename,
|
||||
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
)
|
||||
else:
|
||||
flash('Error generating payroll Excel file.', 'error')
|
||||
return redirect(url_for('payroll_dashboard'))
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error in export_payroll_excel route: {e}")
|
||||
import traceback
|
||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||
|
||||
logger_handler.log_flask_error(
|
||||
'payroll_excel_export_error',
|
||||
str(e),
|
||||
stack_trace=traceback.format_exc()
|
||||
)
|
||||
|
||||
flash('Error generating payroll Excel export. Please check the server logs.', 'error')
|
||||
return redirect(url_for('payroll_dashboard'))
|
||||
|
||||
@bp.route('/api/working-hours/calculate', methods=['POST'], endpoint='calculate_working_hours_api')
|
||||
@login_required
|
||||
@log_database_operations('working_hours_api_calculation')
|
||||
def calculate_working_hours_api():
|
||||
"""API endpoint for calculating working hours"""
|
||||
AttendanceData, Employee, Project, TimeAttendance, QRCode, User = _get_models()["AttendanceData"], _get_models()["Employee"], _get_models()["Project"], _get_models()["TimeAttendance"], _get_models()["QRCode"], _get_models()["User"]
|
||||
try:
|
||||
# Check permissions
|
||||
user_role = session.get('role')
|
||||
if user_role not in ['admin', 'payroll', 'accounting']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Access denied. Insufficient permissions.'
|
||||
}), 403
|
||||
|
||||
# Get parameters from JSON request
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'No data provided'
|
||||
}), 400
|
||||
|
||||
employee_id = data.get('employee_id')
|
||||
date_from = data.get('date_from')
|
||||
date_to = data.get('date_to')
|
||||
|
||||
if not all([employee_id, date_from, date_to]):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Missing required parameters: employee_id, date_from, date_to'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
start_date = datetime.strptime(date_from, '%Y-%m-%d')
|
||||
end_date = datetime.strptime(date_to, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Invalid date format. Use YYYY-MM-DD.'
|
||||
}), 400
|
||||
|
||||
# Get attendance records for the employee
|
||||
query = db.session.query(AttendanceData).filter(
|
||||
AttendanceData.employee_id == str(employee_id),
|
||||
AttendanceData.check_in_date >= start_date.date(),
|
||||
AttendanceData.check_in_date <= end_date.date()
|
||||
).order_by(AttendanceData.check_in_date, AttendanceData.check_in_time)
|
||||
|
||||
attendance_records = query.all()
|
||||
|
||||
# Calculate working hours using WorkingHoursCalculator
|
||||
calculator = WorkingHoursCalculator()
|
||||
hours_data = calculator.calculate_employee_hours(
|
||||
str(employee_id), start_date, end_date, attendance_records
|
||||
)
|
||||
|
||||
# Log API usage
|
||||
logger_handler.logger.info(f"Working hours API used by {session.get('username', 'unknown')} for employee {employee_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': hours_data
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error in calculate_working_hours_api: {e}")
|
||||
logger_handler.log_flask_error(
|
||||
'working_hours_api_error',
|
||||
str(e),
|
||||
stack_trace=traceback.format_exc()
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Internal server error. Please check the server logs.'
|
||||
}), 500
|
||||
|
||||
@bp.route('/api/employee/<employee_id>/miss-punch-details', methods=['GET'], endpoint='get_miss_punch_details')
|
||||
@login_required
|
||||
@log_database_operations('miss_punch_details_api')
|
||||
def get_miss_punch_details(employee_id):
|
||||
"""API endpoint to get detailed miss punch information for an employee"""
|
||||
AttendanceData, Employee, Project, TimeAttendance, QRCode, User = _get_models()["AttendanceData"], _get_models()["Employee"], _get_models()["Project"], _get_models()["TimeAttendance"], _get_models()["QRCode"], _get_models()["User"]
|
||||
try:
|
||||
# Check permissions
|
||||
user_role = session.get('role')
|
||||
if user_role not in ['admin', 'payroll', 'accounting']:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Access denied. Insufficient permissions.'
|
||||
}), 403
|
||||
|
||||
# Get date parameters from query string (from the current payroll filters)
|
||||
date_from = request.args.get('date_from')
|
||||
date_to = request.args.get('date_to')
|
||||
project_filter = request.args.get('project_filter', '')
|
||||
|
||||
if not all([date_from, date_to]):
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Missing required parameters: date_from, date_to'
|
||||
}), 400
|
||||
|
||||
try:
|
||||
start_date = datetime.strptime(date_from, '%Y-%m-%d')
|
||||
end_date = datetime.strptime(date_to, '%Y-%m-%d')
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Invalid date format. Use YYYY-MM-DD.'
|
||||
}), 400
|
||||
|
||||
# Get employee name using proper firstName and lastName fields
|
||||
try:
|
||||
employee_query = db.session.execute(text("""
|
||||
SELECT e.id,
|
||||
CONCAT(e.firstName, ' ', e.lastName) as full_name
|
||||
FROM employee e
|
||||
WHERE e.id = :emp_id
|
||||
"""), {'emp_id': int(employee_id)})
|
||||
|
||||
employee_row = employee_query.fetchone()
|
||||
employee_name = employee_row.full_name if employee_row and employee_row.full_name else f"Employee {employee_id}"
|
||||
print(f"📋 Retrieved employee name: {employee_name} for ID: {employee_id}")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Could not load employee name for ID {employee_id}: {e}")
|
||||
import traceback
|
||||
print(f"⚠️ Traceback: {traceback.format_exc()}")
|
||||
employee_name = f"Employee {employee_id}"
|
||||
|
||||
# Get attendance records for the employee within the period
|
||||
query = db.session.query(AttendanceData).filter(
|
||||
AttendanceData.employee_id == str(employee_id),
|
||||
AttendanceData.check_in_date >= start_date.date(),
|
||||
AttendanceData.check_in_date <= end_date.date()
|
||||
)
|
||||
|
||||
# Apply project filter if provided
|
||||
if project_filter:
|
||||
try:
|
||||
project_id = int(project_filter)
|
||||
query = query.join(QRCode, AttendanceData.qr_code_id == QRCode.id) \
|
||||
.filter(QRCode.project_id == project_id)
|
||||
except ValueError:
|
||||
pass # Invalid project_id, ignore filter
|
||||
|
||||
attendance_records = query.order_by(
|
||||
AttendanceData.check_in_date,
|
||||
AttendanceData.check_in_time
|
||||
).all()
|
||||
|
||||
# Convert to the format expected by the calculator
|
||||
converted_records = []
|
||||
for record in records:
|
||||
# Get distance from the TimeAttendance record
|
||||
distance_value = getattr(record, 'distance', None)
|
||||
|
||||
converted_record = type('Record', (), {
|
||||
'id': record.id,
|
||||
'employee_id': str(record.employee_id),
|
||||
'check_in_date': record.attendance_date,
|
||||
'check_in_time': record.attendance_time,
|
||||
'location_name': record.location_name,
|
||||
'latitude': None,
|
||||
'longitude': None,
|
||||
'distance': distance_value, # ADD THIS LINE
|
||||
'qr_code': type('QRCode', (), {
|
||||
'location': record.location_name,
|
||||
'location_address': record.recorded_address or '',
|
||||
'project': None
|
||||
})()
|
||||
})()
|
||||
converted_records.append(converted_record)
|
||||
|
||||
# Calculate working hours using the same calculator as the dashboard
|
||||
|
||||
# Calculate hours for this employee
|
||||
hours_data = calculator.calculate_employee_hours(
|
||||
str(employee_id), start_date, end_date, converted_records
|
||||
)
|
||||
|
||||
# Extract miss punch details
|
||||
miss_punch_days = []
|
||||
if 'daily_hours' in hours_data:
|
||||
for date_str, day_data in hours_data['daily_hours'].items():
|
||||
if day_data.get('is_miss_punch', False):
|
||||
# Get the actual records for this day
|
||||
date_obj = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
day_records = [r for r in converted_records if r.check_in_date == date_obj]
|
||||
|
||||
# Format the records information with event types
|
||||
record_details = []
|
||||
for i, record in enumerate(day_records):
|
||||
# Determine event type based on position (alternating check-in/check-out)
|
||||
# First record is always check-in, then alternates
|
||||
event_type = "Check In" if i % 2 == 0 else "Check Out"
|
||||
|
||||
record_details.append({
|
||||
'time': record.check_in_time.strftime('%H:%M:%S'),
|
||||
'event_type': event_type,
|
||||
'location': record.location_name or 'Unknown Location',
|
||||
'has_gps': record.latitude is not None and record.longitude is not None
|
||||
})
|
||||
|
||||
miss_punch_days.append({
|
||||
'date': date_str,
|
||||
'date_formatted': datetime.strptime(date_str, '%Y-%m-%d').strftime('%B %d, %Y (%A)'),
|
||||
'records_count': day_data.get('records_count', 0),
|
||||
'records': record_details,
|
||||
'reason': 'Incomplete punch pairs - missing check-in or check-out' if len(
|
||||
day_records) % 2 != 0 else 'Invalid work period duration'
|
||||
})
|
||||
|
||||
# Log the API access
|
||||
logger_handler.logger.info(
|
||||
f"Miss punch details API accessed by {session.get('username', 'unknown')} for employee {employee_id}")
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'employee_id': employee_id,
|
||||
'employee_name': employee_name,
|
||||
'period': f"{date_from} to {date_to}",
|
||||
'miss_punch_count': len(miss_punch_days),
|
||||
'miss_punch_days': miss_punch_days
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error in get_miss_punch_details: {e}")
|
||||
import traceback
|
||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||
|
||||
logger_handler.log_flask_error(
|
||||
'miss_punch_details_api_error',
|
||||
str(e),
|
||||
stack_trace=traceback.format_exc()
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Internal server error. Please check the server logs.'
|
||||
}), 500
|
||||
|
||||
def get_employee_name(employee_id):
|
||||
"""Helper function to get employee full name by ID"""
|
||||
Employee = _get_models()["Employee"]
|
||||
try:
|
||||
result = db.session.execute(text("""
|
||||
SELECT CONCAT(firstName, ' ', lastName) as full_name
|
||||
FROM employee
|
||||
WHERE id = :employee_id
|
||||
"""), {'employee_id': employee_id})
|
||||
|
||||
row = result.fetchone()
|
||||
return row[0] if row else f"Employee {employee_id}"
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error getting employee name for ID {employee_id}: {e}")
|
||||
return f"Employee {employee_id}"
|
||||
|
||||
def get_qr_code_checkin_count(qr_code_id):
|
||||
"""Helper function to get total check-ins count for a QR code"""
|
||||
AttendanceData = _get_models()["AttendanceData"]
|
||||
try:
|
||||
count = AttendanceData.query.filter_by(qr_code_id=qr_code_id).count()
|
||||
logger_handler.logger.info(f"QR Code {qr_code_id} total check-ins: {count}")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error getting check-ins count for QR {qr_code_id}: {e}")
|
||||
return 0
|
||||
|
||||
@bp.context_processor
|
||||
def inject_payroll_utils():
|
||||
"""Inject payroll utility functions into templates"""
|
||||
return {
|
||||
'get_employee_name': get_employee_name,
|
||||
'format_hours': lambda hours: f"{hours:.2f}" if hours else "0.00"
|
||||
}
|
||||
|
||||
@bp.context_processor
|
||||
def inject_dashboard_utils():
|
||||
"""Inject dashboard utility functions into templates"""
|
||||
return {
|
||||
'get_qr_code_checkin_count': get_qr_code_checkin_count
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
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 Blueprint, render_template, request, redirect, flash, session, jsonify
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import url_for, admin_required, login_required, staff_or_admin_required
|
||||
|
||||
bp = Blueprint('projects', __name__)
|
||||
|
||||
def _get_models():
|
||||
"""Return model classes from the current app context."""
|
||||
from flask import current_app
|
||||
return current_app.config['_models']
|
||||
|
||||
|
||||
@bp.route('/projects', endpoint='projects')
|
||||
@admin_required
|
||||
def projects():
|
||||
"""Display all projects"""
|
||||
Project, QRCode, User = _get_models()["Project"], _get_models()["QRCode"], _get_models()["User"]
|
||||
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'))
|
||||
|
||||
@bp.route('/projects/create', methods=['GET', 'POST'], endpoint='create_project')
|
||||
@admin_required
|
||||
@log_database_operations('project_creation')
|
||||
def create_project():
|
||||
"""Create new project"""
|
||||
Project, QRCode, User = _get_models()["Project"], _get_models()["QRCode"], _get_models()["User"]
|
||||
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'))
|
||||
|
||||
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"""
|
||||
Project, QRCode, User = _get_models()["Project"], _get_models()["QRCode"], _get_models()["User"]
|
||||
try:
|
||||
project = Project.query.get_or_404(project_id)
|
||||
|
||||
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'))
|
||||
|
||||
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'))
|
||||
|
||||
@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"""
|
||||
Project, QRCode, User = _get_models()["Project"], _get_models()["QRCode"], _get_models()["User"]
|
||||
try:
|
||||
project = Project.query.get_or_404(project_id)
|
||||
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'))
|
||||
|
||||
# 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"""
|
||||
Project, QRCode, User = _get_models()["Project"], _get_models()["QRCode"], _get_models()["User"]
|
||||
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
|
||||
+1040
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,315 @@
|
||||
"""
|
||||
routes/statistics.py
|
||||
====================
|
||||
Statistics dashboard and export routes.
|
||||
|
||||
Routes: /statistics, /api/statistics/export
|
||||
"""
|
||||
from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, make_response, current_app
|
||||
from datetime import datetime, date, timedelta
|
||||
import io, json, traceback
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from sqlalchemy import text
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import url_for, login_required, staff_or_admin_required
|
||||
|
||||
bp = Blueprint('statistics', __name__)
|
||||
|
||||
def _get_models():
|
||||
"""Return model classes from the current app context."""
|
||||
from flask import current_app
|
||||
return current_app.config['_models']
|
||||
|
||||
|
||||
@bp.route('/statistics', endpoint='qr_statistics')
|
||||
@login_required
|
||||
def qr_statistics():
|
||||
"""QR Code Statistics Dashboard with comprehensive analytics"""
|
||||
AttendanceData, QRCode, Project, Employee, User = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Project"], _get_models()["Employee"], _get_models()["User"]
|
||||
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 date filter
|
||||
date_filter = ""
|
||||
if date_from:
|
||||
date_filter += f" AND ad.check_in_date >= '{date_from}'"
|
||||
if date_to:
|
||||
date_filter += f" AND ad.check_in_date <= '{date_to}'"
|
||||
|
||||
# QR Code filter
|
||||
qr_filter = ""
|
||||
if qr_code_filter:
|
||||
qr_filter = f" AND ad.qr_code_id = {qr_code_filter}"
|
||||
|
||||
# Project filter
|
||||
project_filter_clause = ""
|
||||
if project_filter:
|
||||
project_filter_clause = f" AND qc.project_id = {project_filter}"
|
||||
|
||||
# 1. General Statistics
|
||||
general_stats = db.session.execute(text(f"""
|
||||
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 {date_filter} {qr_filter} {project_filter_clause}
|
||||
""")).fetchone()
|
||||
|
||||
# 2. Device Statistics
|
||||
device_stats = db.session.execute(text(f"""
|
||||
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 {date_filter} {qr_filter} {project_filter_clause}
|
||||
GROUP BY device_type
|
||||
ORDER BY scan_count DESC
|
||||
""")).fetchall()
|
||||
|
||||
# 3. Browser Statistics (from User Agent)
|
||||
browser_stats = db.session.execute(text(f"""
|
||||
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 {date_filter} {qr_filter} {project_filter_clause}
|
||||
GROUP BY browser_type
|
||||
ORDER BY scan_count DESC
|
||||
""")).fetchall()
|
||||
|
||||
# 4. Location Statistics
|
||||
location_stats = db.session.execute(text(f"""
|
||||
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 {date_filter} {qr_filter} {project_filter_clause}
|
||||
GROUP BY qc.id, qc.name, qc.location, qc.location_event
|
||||
ORDER BY total_scans DESC
|
||||
""")).fetchall()
|
||||
|
||||
# 5. IP Address Analysis (Top 3 Most Active)
|
||||
ip_stats = db.session.execute(text(f"""
|
||||
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 {date_filter} {qr_filter} {project_filter_clause}
|
||||
GROUP BY ip_address
|
||||
ORDER BY scan_count DESC
|
||||
LIMIT 3
|
||||
""")).fetchall()
|
||||
|
||||
# 6. Project Statistics (if projects exist)
|
||||
project_stats = db.session.execute(text(f"""
|
||||
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 {date_filter} {qr_filter} {project_filter_clause}
|
||||
GROUP BY p.id, p.name
|
||||
ORDER BY total_scans DESC
|
||||
""")).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:
|
||||
# Log the error using the correct method
|
||||
logger_handler.log_database_error('statistics_page_error', e)
|
||||
print(f"❌ Error loading statistics: {e}")
|
||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||
|
||||
flash('Error loading statistics. Please try again.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
|
||||
@bp.route('/api/statistics/export', endpoint='export_statistics')
|
||||
@login_required
|
||||
def export_statistics():
|
||||
"""Export statistics data to CSV/Excel"""
|
||||
AttendanceData, QRCode, Project, Employee, User = _get_models()["AttendanceData"], _get_models()["QRCode"], _get_models()["Project"], _get_models()["Employee"], _get_models()["User"]
|
||||
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:
|
||||
logger_handler.log_database_error('statistics_export_error', e)
|
||||
print(f"❌ Error exporting statistics: {e}")
|
||||
return jsonify({'error': 'Export failed'}), 500
|
||||
|
||||
except Exception as e:
|
||||
# Log the error
|
||||
logger_handler.log_database_error('statistics_page_error', e)
|
||||
print(f"❌ Error loading statistics: {e}")
|
||||
print(f"❌ Traceback: {traceback.format_exc()}")
|
||||
|
||||
flash('Error loading statistics. Please try again.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
|
||||
# EMPLOYEE MANAGEMENT ROUTES
|
||||
File diff suppressed because it is too large
Load Diff
+1009
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,814 @@
|
||||
"""
|
||||
utils/geocoding.py
|
||||
==================
|
||||
All geocoding, distance calculation, and location-accuracy helpers.
|
||||
|
||||
Extracted verbatim from app.py (lines 188–1317).
|
||||
No logic changes — only import paths updated.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import requests
|
||||
import traceback
|
||||
from datetime import datetime, timedelta
|
||||
from math import radians, sin, cos, asin, sqrt
|
||||
|
||||
import googlemaps
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from address_normalization_fix import normalize_address, addresses_are_similar
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Google Maps client (initialized once at module import)
|
||||
# ---------------------------------------------------------------------------
|
||||
try:
|
||||
GOOGLE_MAPS_API_KEY = os.environ.get('GOOGLE_MAPS_API_KEY')
|
||||
if GOOGLE_MAPS_API_KEY:
|
||||
gmaps_client = googlemaps.Client(key=GOOGLE_MAPS_API_KEY)
|
||||
print("✅ Google Maps client initialized successfully")
|
||||
else:
|
||||
gmaps_client = None
|
||||
print("⚠️ Google Maps API key not found, falling back to OpenStreetMap")
|
||||
except Exception as e:
|
||||
gmaps_client = None
|
||||
print(f"❌ Error initializing Google Maps client: {e}")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Geocoding cache
|
||||
# ---------------------------------------------------------------------------
|
||||
geocoding_cache = {}
|
||||
CACHE_MAX_SIZE = 1000
|
||||
CACHE_EXPIRY_HOURS = 24
|
||||
|
||||
|
||||
def get_cached_coordinates(address):
|
||||
"""Get coordinates from cache if available and not expired"""
|
||||
if address in geocoding_cache:
|
||||
cached_data = geocoding_cache[address]
|
||||
cache_time = cached_data.get('timestamp', datetime.min)
|
||||
if datetime.now() - cache_time < timedelta(hours=CACHE_EXPIRY_HOURS):
|
||||
print(f"📋 Using cached coordinates for: {address[:50]}...")
|
||||
return cached_data.get('lat'), cached_data.get('lng'), cached_data.get('accuracy')
|
||||
return None, None, None
|
||||
|
||||
|
||||
def cache_coordinates(address, lat, lng, accuracy):
|
||||
"""Cache coordinates to reduce future API calls"""
|
||||
try:
|
||||
if len(geocoding_cache) >= CACHE_MAX_SIZE:
|
||||
oldest_key = min(geocoding_cache.keys(), key=lambda k: geocoding_cache[k]['timestamp'])
|
||||
del geocoding_cache[oldest_key]
|
||||
geocoding_cache[address] = {
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
'accuracy': accuracy,
|
||||
'timestamp': datetime.now()
|
||||
}
|
||||
print(f"💾 Cached coordinates for: {address[:50]}...")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Error caching coordinates: {e}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Geocoding helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def log_google_maps_usage(operation_type):
|
||||
"""Log Google Maps API usage for monitoring"""
|
||||
try:
|
||||
logger_handler.log_user_activity('google_maps_api_usage', f'Google Maps API used: {operation_type}')
|
||||
except Exception as e:
|
||||
print(f"⚠️ Usage logging error: {e}")
|
||||
|
||||
|
||||
def get_coordinates_from_address(address):
|
||||
"""
|
||||
Get latitude and longitude from address using Google Maps Geocoding API.
|
||||
Falls back to OpenStreetMap if Google Maps is unavailable.
|
||||
Returns (lat, lng) tuple or (None, None) if failed.
|
||||
"""
|
||||
if not address or address.strip() == '':
|
||||
return None, None
|
||||
|
||||
address = address.strip()
|
||||
print(f"🌍 Geocoding address: {address}")
|
||||
|
||||
try:
|
||||
logger_handler.log_user_activity('geocoding', f'Geocoding address: {address[:50]}...')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
|
||||
try:
|
||||
if gmaps_client:
|
||||
print("🗺️ Using Google Maps Geocoding API")
|
||||
geocode_result = gmaps_client.geocode(address)
|
||||
if geocode_result:
|
||||
location = geocode_result[0]['geometry']['location']
|
||||
lat = location['lat']
|
||||
lng = location['lng']
|
||||
print(f"✅ Google Maps geocoded address '{address[:50]}...' to coordinates: {lat}, {lng}")
|
||||
try:
|
||||
logger_handler.log_user_activity('geocoding_success', f'Successfully geocoded: {address[:50]}... -> {lat}, {lng}')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
return lat, lng
|
||||
else:
|
||||
print(f"⚠️ Google Maps: No results found for address: {address}")
|
||||
|
||||
print("🌐 Falling back to OpenStreetMap Nominatim")
|
||||
url = "https://nominatim.openstreetmap.org/search"
|
||||
params = {'q': address, 'format': 'json', 'limit': 1, 'addressdetails': 1}
|
||||
headers = {'User-Agent': 'QR-Attendance-System/1.0'}
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data and len(data) > 0:
|
||||
lat = float(data[0]['lat'])
|
||||
lng = float(data[0]['lon'])
|
||||
print(f"✅ OSM geocoded address '{address[:50]}...' to coordinates: {lat}, {lng}")
|
||||
try:
|
||||
logger_handler.log_user_activity('geocoding_fallback', f'OSM fallback geocoded: {address[:50]}... -> {lat}, {lng}')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
return lat, lng
|
||||
|
||||
print(f"⚠️ Could not geocode address: {address}")
|
||||
try:
|
||||
logger_handler.log_user_activity('geocoding_failed', f'Failed to geocode: {address[:50]}...')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
return None, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error geocoding address '{address}': {e}")
|
||||
try:
|
||||
logger_handler.log_flask_error('geocoding_error', f'Error geocoding {address[:50]}...: {str(e)}')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
return None, None
|
||||
|
||||
|
||||
def get_coordinates_from_address_enhanced(address):
|
||||
"""
|
||||
Enhanced geocoding function using Google Maps with caching and better error handling.
|
||||
Returns (latitude, longitude, accuracy_level).
|
||||
"""
|
||||
if not address or address.strip() == "":
|
||||
return None, None, None
|
||||
|
||||
address = address.strip()
|
||||
print(f"🌍 Enhanced geocoding for: {address}")
|
||||
|
||||
normalized_address = normalize_address(address)
|
||||
cached_lat, cached_lng, cached_accuracy = get_cached_coordinates(normalized_address)
|
||||
if cached_lat is not None:
|
||||
print("✅ Using cached coordinates for normalized address")
|
||||
return cached_lat, cached_lng, cached_accuracy
|
||||
|
||||
try:
|
||||
logger_handler.log_user_activity('enhanced_geocoding', f'Enhanced geocoding: {address[:50]}...')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
|
||||
try:
|
||||
if gmaps_client:
|
||||
print("🗺️ Using Google Maps Geocoding API (Enhanced)")
|
||||
geocode_result = gmaps_client.geocode(address)
|
||||
if geocode_result:
|
||||
result = geocode_result[0]
|
||||
location = result['geometry']['location']
|
||||
lat = location['lat']
|
||||
lng = location['lng']
|
||||
location_type = result['geometry'].get('location_type', 'UNKNOWN')
|
||||
place_types = result.get('types', [])
|
||||
|
||||
if location_type == 'ROOFTOP':
|
||||
accuracy = 'excellent'
|
||||
elif location_type == 'RANGE_INTERPOLATED':
|
||||
accuracy = 'good'
|
||||
elif location_type == 'GEOMETRIC_CENTER':
|
||||
if any(ptype in place_types for ptype in ['premise', 'subpremise', 'street_address']):
|
||||
accuracy = 'good'
|
||||
elif any(ptype in place_types for ptype in ['neighborhood', 'sublocality']):
|
||||
accuracy = 'fair'
|
||||
else:
|
||||
accuracy = 'poor'
|
||||
elif location_type == 'APPROXIMATE':
|
||||
accuracy = 'poor'
|
||||
else:
|
||||
accuracy = 'fair'
|
||||
|
||||
print(f"✅ Google Maps enhanced geocoding successful:")
|
||||
print(f" Coordinates: {lat:.10f}, {lng:.10f}")
|
||||
print(f" Accuracy: {accuracy} (location_type: {location_type})")
|
||||
print(f" Place types: {place_types[:3]}")
|
||||
|
||||
cache_coordinates(normalized_address, lat, lng, accuracy)
|
||||
try:
|
||||
logger_handler.log_user_activity('enhanced_geocoding_success', f'Google Maps enhanced: {address[:50]}... -> {lat}, {lng} ({accuracy})')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
return lat, lng, accuracy
|
||||
else:
|
||||
print(f"⚠️ Google Maps: No results found for enhanced geocoding: {address}")
|
||||
|
||||
print("🌐 Falling back to OpenStreetMap Nominatim (Enhanced)")
|
||||
nominatim_url = "https://nominatim.openstreetmap.org/search"
|
||||
params = {'q': address, 'format': 'json', 'limit': 1, 'addressdetails': 1, 'extratags': 1}
|
||||
headers = {'User-Agent': 'QR-Attendance-System/1.0 (Enhanced Location Accuracy)'}
|
||||
response = requests.get(nominatim_url, params=params, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
results = response.json()
|
||||
if results:
|
||||
result = results[0]
|
||||
lat = float(result['lat'])
|
||||
lng = float(result['lon'])
|
||||
place_type = result.get('type', 'unknown')
|
||||
osm_type = result.get('osm_type', 'unknown')
|
||||
|
||||
if place_type in ['house', 'building', 'shop', 'office'] or osm_type == 'way':
|
||||
accuracy = 'good'
|
||||
elif place_type in ['neighbourhood', 'suburb', 'quarter', 'residential']:
|
||||
accuracy = 'fair'
|
||||
elif place_type in ['city', 'town', 'village']:
|
||||
accuracy = 'poor'
|
||||
else:
|
||||
accuracy = 'poor'
|
||||
|
||||
print(f"✅ OSM enhanced geocoding successful:")
|
||||
print(f" Coordinates: {lat:.10f}, {lng:.10f}")
|
||||
print(f" Accuracy: {accuracy} (fallback)")
|
||||
cache_coordinates(normalized_address, lat, lng, accuracy)
|
||||
try:
|
||||
logger_handler.log_user_activity('enhanced_geocoding_fallback', f'OSM enhanced fallback: {address[:50]}... -> {lat}, {lng} ({accuracy})')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
return lat, lng, accuracy
|
||||
|
||||
print(f"⚠️ No results from enhanced geocoding for: {address}")
|
||||
try:
|
||||
logger_handler.log_user_activity('enhanced_geocoding_failed', f'Enhanced geocoding failed: {address[:50]}...')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
return None, None, None
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Enhanced geocoding error: {e}")
|
||||
try:
|
||||
logger_handler.log_flask_error('enhanced_geocoding_error', f'Enhanced geocoding error {address[:50]}...: {str(e)}')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
return None, None, None
|
||||
|
||||
|
||||
def geocode_address_enhanced(address):
|
||||
"""
|
||||
Enhanced geocoding using Nominatim API with better accuracy classification.
|
||||
Returns: (latitude, longitude, accuracy_level)
|
||||
"""
|
||||
if not address or len(address.strip()) < 5:
|
||||
print("❌ Address too short for geocoding")
|
||||
return None, None, None
|
||||
|
||||
try:
|
||||
url = "https://nominatim.openstreetmap.org/search"
|
||||
params = {'q': address.strip(), 'format': 'json', 'limit': 1, 'addressdetails': 1}
|
||||
headers = {'User-Agent': 'QR-Attendance-System/1.0'}
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data and len(data) > 0:
|
||||
result = data[0]
|
||||
lat = float(result['lat'])
|
||||
lng = float(result['lon'])
|
||||
place_type = result.get('type', 'unknown')
|
||||
osm_type = result.get('osm_type', 'unknown')
|
||||
|
||||
if place_type in ['house', 'building'] or osm_type == 'way':
|
||||
accuracy = 'high'
|
||||
elif place_type in ['neighbourhood', 'suburb', 'quarter']:
|
||||
accuracy = 'medium'
|
||||
else:
|
||||
accuracy = 'low'
|
||||
|
||||
print(f"✅ Geocoded address: {address}")
|
||||
print(f" Coordinates: {lat:.10f}, {lng:.10f}")
|
||||
print(f" Accuracy: {accuracy} ({place_type})")
|
||||
return lat, lng, accuracy
|
||||
|
||||
print(f"⚠️ No geocoding results for address: {address}")
|
||||
return None, None, None
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_flask_error('geocoding_error', str(e))
|
||||
print(f"❌ Geocoding error: {e}")
|
||||
return None, None, None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Distance / accuracy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def calculate_distance_miles(lat1, lng1, lat2, lng2):
|
||||
"""
|
||||
Calculate DIRECT straight-line distance between two points using Haversine formula.
|
||||
Returns distance in miles (float) or None if calculation fails.
|
||||
"""
|
||||
if any(coord is None for coord in [lat1, lng1, lat2, lng2]):
|
||||
print("⚠️ Missing coordinates for distance calculation")
|
||||
return None
|
||||
|
||||
try:
|
||||
try:
|
||||
lat1_val = float(lat1)
|
||||
lng1_val = float(lng1)
|
||||
lat2_val = float(lat2)
|
||||
lng2_val = float(lng2)
|
||||
except (ValueError, TypeError) as e:
|
||||
print(f"⚠️ Invalid coordinate format: {e}")
|
||||
return None
|
||||
|
||||
if not (-90 <= lat1_val <= 90) or not (-90 <= lat2_val <= 90):
|
||||
print(f"⚠️ Invalid latitude values: {lat1_val}, {lat2_val}")
|
||||
return None
|
||||
if not (-180 <= lng1_val <= 180) or not (-180 <= lng2_val <= 180):
|
||||
print(f"⚠️ Invalid longitude values: {lng1_val}, {lng2_val}")
|
||||
return None
|
||||
|
||||
try:
|
||||
logger_handler.log_user_activity(
|
||||
'distance_calculation',
|
||||
f'Calculating direct distance: ({lat1_val:.6f}, {lng1_val:.6f}) to ({lat2_val:.6f}, {lng2_val:.6f})'
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
print("📐 Calculating direct straight-line distance using Haversine formula")
|
||||
|
||||
lat1_rad = radians(lat1_val)
|
||||
lng1_rad = radians(lng1_val)
|
||||
lat2_rad = radians(lat2_val)
|
||||
lng2_rad = radians(lng2_val)
|
||||
|
||||
dlat = lat2_rad - lat1_rad
|
||||
dlng = lng2_rad - lng1_rad
|
||||
|
||||
sin_dlat_half = sin(dlat / 2.0)
|
||||
sin_dlng_half = sin(dlng / 2.0)
|
||||
|
||||
a = (sin_dlat_half * sin_dlat_half +
|
||||
cos(lat1_rad) * cos(lat2_rad) * sin_dlng_half * sin_dlng_half)
|
||||
a = max(0.0, min(1.0, a))
|
||||
c = 2.0 * asin(sqrt(a))
|
||||
|
||||
# DO NOT CHANGE Earth's mean radius value
|
||||
EARTH_RADIUS_MILES = 3959.87433
|
||||
distance = round(c * EARTH_RADIUS_MILES, 4)
|
||||
|
||||
print(f"📏 Direct straight-line distance calculation:")
|
||||
print(f" Point 1: ({lat1_val:.10f}, {lng1_val:.10f})")
|
||||
print(f" Point 2: ({lat2_val:.10f}, {lng2_val:.10f})")
|
||||
print(f" Δlat: {abs(lat2_val - lat1_val):.10f}° = {dlat:.12f} radians")
|
||||
print(f" Δlng: {abs(lng2_val - lng1_val):.10f}° = {dlng:.12f} radians")
|
||||
print(f" a value: {a:.15f}")
|
||||
print(f" c value (central angle): {c:.15f} radians")
|
||||
print(f" 🎯 Distance: {distance:.4f} miles = {distance * 5280:.2f} feet = {distance * 1609.34:.2f} meters")
|
||||
|
||||
try:
|
||||
logger_handler.log_user_activity('distance_calculation_success', f'Direct distance: {distance:.4f} miles')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return distance
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error in distance calculation: {e}")
|
||||
print(f" Traceback: {traceback.format_exc()}")
|
||||
try:
|
||||
logger_handler.log_flask_error('distance_calculation_error', f'Distance calculation error: {str(e)}')
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_location_accuracy_level_enhanced(location_accuracy):
|
||||
"""
|
||||
Enhanced function to categorize location accuracy with more granular levels.
|
||||
"""
|
||||
if not location_accuracy or location_accuracy is None:
|
||||
return 'unknown'
|
||||
if location_accuracy <= 0.05:
|
||||
return 'excellent'
|
||||
elif location_accuracy <= 0.1:
|
||||
return 'very_good'
|
||||
elif location_accuracy <= 0.25:
|
||||
return 'good'
|
||||
elif location_accuracy <= 0.5:
|
||||
return 'fair'
|
||||
elif location_accuracy <= 1.0:
|
||||
return 'poor'
|
||||
else:
|
||||
return 'very_poor'
|
||||
|
||||
|
||||
def calculate_location_accuracy(qr_address, checkin_address, checkin_lat=None, checkin_lng=None):
|
||||
"""
|
||||
Calculate location accuracy by comparing QR code address with check-in location.
|
||||
Returns distance in miles between the two locations.
|
||||
"""
|
||||
print(f"\n📍 CALCULATING LOCATION ACCURACY:")
|
||||
print(f" QR Address: {qr_address}")
|
||||
print(f" Check-in Address: {checkin_address}")
|
||||
print(f" Check-in Coordinates: {checkin_lat}, {checkin_lng}")
|
||||
|
||||
qr_lat, qr_lng = get_coordinates_from_address(qr_address)
|
||||
if qr_lat is None or qr_lng is None:
|
||||
print("⚠️ Could not geocode QR address, cannot calculate accuracy")
|
||||
return None
|
||||
|
||||
if checkin_lat is not None and checkin_lng is not None:
|
||||
checkin_coords_lat, checkin_coords_lng = checkin_lat, checkin_lng
|
||||
print("✅ Using GPS coordinates for check-in location")
|
||||
else:
|
||||
checkin_coords_lat, checkin_coords_lng = get_coordinates_from_address(checkin_address)
|
||||
if checkin_coords_lat is None or checkin_coords_lng is None:
|
||||
print("⚠️ Could not geocode check-in address, cannot calculate accuracy")
|
||||
return None
|
||||
print("✅ Using geocoded coordinates for check-in address")
|
||||
|
||||
distance = calculate_distance_miles(qr_lat, qr_lng, checkin_coords_lat, checkin_coords_lng)
|
||||
if distance is not None:
|
||||
print(f"✅ Location accuracy calculated: {distance} miles")
|
||||
return distance
|
||||
|
||||
|
||||
def calculate_location_accuracy_enhanced(qr_address, checkin_address, checkin_lat=None, checkin_lng=None):
|
||||
"""
|
||||
ENHANCED location accuracy calculation comparing QR address with check-in location.
|
||||
Returns distance in miles between QR location and check-in location.
|
||||
"""
|
||||
print(f"\n🎯 ENHANCED LOCATION ACCURACY CALCULATION:")
|
||||
print(f" QR Address: {qr_address}")
|
||||
print(f" Check-in Address: {checkin_address}")
|
||||
print(f" Check-in GPS: {checkin_lat}, {checkin_lng}")
|
||||
print(f" Timestamp: {datetime.now()}")
|
||||
|
||||
if not qr_address or qr_address.strip() == "":
|
||||
print("❌ QR address is empty or invalid")
|
||||
return None
|
||||
|
||||
print("\n📍 Step 1: Geocoding QR address...")
|
||||
try:
|
||||
if addresses_are_similar(qr_address, checkin_address, threshold=0.90):
|
||||
print("🎯 Addresses are essentially identical - returning near-zero distance")
|
||||
return 0.01
|
||||
|
||||
qr_lat, qr_lng, qr_accuracy = get_coordinates_from_address_enhanced(qr_address)
|
||||
print(f" Geocoding result: lat={qr_lat}, lng={qr_lng}, accuracy={qr_accuracy}")
|
||||
if qr_lat is None or qr_lng is None:
|
||||
print(f"❌ Could not geocode QR address: {qr_address}")
|
||||
return None
|
||||
print(f"✅ QR location coordinates: {qr_lat:.10f}, {qr_lng:.10f} (accuracy: {qr_accuracy})")
|
||||
except Exception as e:
|
||||
print(f"❌ Error geocoding QR address: {e}")
|
||||
return None
|
||||
|
||||
print("\n📱 Step 2: Determining check-in coordinates...")
|
||||
checkin_coords_lat = None
|
||||
checkin_coords_lng = None
|
||||
checkin_source = "unknown"
|
||||
|
||||
if checkin_lat is not None and checkin_lng is not None:
|
||||
try:
|
||||
lat_val = float(checkin_lat)
|
||||
lng_val = float(checkin_lng)
|
||||
if -90 <= lat_val <= 90 and -180 <= lng_val <= 180:
|
||||
checkin_coords_lat = lat_val
|
||||
checkin_coords_lng = lng_val
|
||||
checkin_source = "gps"
|
||||
print(f"✅ Using GPS coordinates: {lat_val:.10f}, {lng_val:.10f}")
|
||||
else:
|
||||
print(f"⚠️ Invalid GPS coordinates: {lat_val}, {lng_val}")
|
||||
except (ValueError, TypeError) as e:
|
||||
print(f"⚠️ Could not parse GPS coordinates: {e}")
|
||||
|
||||
if checkin_coords_lat is None and checkin_address:
|
||||
print("🌍 Falling back to geocoding check-in address...")
|
||||
try:
|
||||
checkin_coords_lat, checkin_coords_lng, checkin_accuracy = get_coordinates_from_address_enhanced(checkin_address)
|
||||
print(f" Checkin geocoding result: lat={checkin_coords_lat}, lng={checkin_coords_lng}, accuracy={checkin_accuracy}")
|
||||
if checkin_coords_lat is not None:
|
||||
checkin_source = "address"
|
||||
print(f"✅ Using geocoded coordinates: {checkin_coords_lat:.10f}, {checkin_coords_lng:.10f} (accuracy: {checkin_accuracy})")
|
||||
except Exception as e:
|
||||
print(f"❌ Error geocoding check-in address: {e}")
|
||||
|
||||
if checkin_coords_lat is None or checkin_coords_lng is None:
|
||||
print(f"❌ Could not determine check-in coordinates")
|
||||
print(f" GPS: {checkin_lat}, {checkin_lng}")
|
||||
print(f" Address: {checkin_address}")
|
||||
return None
|
||||
|
||||
print("\n📏 Step 3: Calculating distance...")
|
||||
try:
|
||||
print(f" QR coordinates: {qr_lat:.10f}, {qr_lng:.10f}")
|
||||
print(f" Check-in coordinates: {checkin_coords_lat:.10f}, {checkin_coords_lng:.10f}")
|
||||
print(f" Source: {checkin_source}")
|
||||
|
||||
distance = calculate_distance_miles(qr_lat, qr_lng, checkin_coords_lat, checkin_coords_lng)
|
||||
print(f" Distance calculation result: {distance}")
|
||||
|
||||
if distance is not None:
|
||||
accuracy_level = get_location_accuracy_level_enhanced(distance)
|
||||
print(f"✅ Enhanced location accuracy calculated successfully!")
|
||||
print(f" Distance: {distance:.4f} miles")
|
||||
print(f" Accuracy Level: {accuracy_level}")
|
||||
return distance
|
||||
else:
|
||||
print("❌ Distance calculation returned None")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"❌ Error calculating distance: {e}")
|
||||
print(f"❌ Distance calculation traceback: {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reverse geocoding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def reverse_geocode_coordinates(latitude, longitude):
|
||||
"""
|
||||
Convert GPS coordinates to human-readable address.
|
||||
Falls back to OpenStreetMap if Google Maps is unavailable.
|
||||
Returns address string or None if failed.
|
||||
"""
|
||||
if not latitude or not longitude:
|
||||
return None
|
||||
|
||||
try:
|
||||
print(f"🌍 Reverse geocoding coordinates: {latitude}, {longitude}")
|
||||
try:
|
||||
logger_handler.log_user_activity('reverse_geocoding', f'Reverse geocoding: {latitude}, {longitude}')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
|
||||
if gmaps_client:
|
||||
print("🗺️ Using Google Maps Reverse Geocoding API")
|
||||
reverse_geocode_result = gmaps_client.reverse_geocode((latitude, longitude))
|
||||
if reverse_geocode_result:
|
||||
address = reverse_geocode_result[0]['formatted_address']
|
||||
print(f"✅ Google Maps reverse geocoded address: {address}")
|
||||
try:
|
||||
logger_handler.log_user_activity('reverse_geocoding_success', f'Google Maps reverse geocoded: {latitude}, {longitude} -> {address[:50]}...')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
return address
|
||||
else:
|
||||
print("⚠️ Google Maps: No address found for coordinates")
|
||||
|
||||
print("🌐 Falling back to OpenStreetMap Nominatim reverse geocoding")
|
||||
url = "https://nominatim.openstreetmap.org/reverse"
|
||||
params = {'lat': latitude, 'lon': longitude, 'format': 'json', 'addressdetails': 1, 'zoom': 18}
|
||||
headers = {'User-Agent': 'QR-Attendance-System/1.0'}
|
||||
response = requests.get(url, params=params, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data and 'display_name' in data:
|
||||
address = data['display_name']
|
||||
print(f"✅ OSM reverse geocoded address: {address}")
|
||||
try:
|
||||
logger_handler.log_user_activity('reverse_geocoding_fallback', f'OSM reverse geocoded: {latitude}, {longitude} -> {address[:50]}...')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
return address
|
||||
else:
|
||||
print("⚠️ No address found for coordinates")
|
||||
return None
|
||||
else:
|
||||
print(f"⚠️ Reverse geocoding API returned status: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error in reverse geocoding: {e}")
|
||||
try:
|
||||
logger_handler.log_flask_error('reverse_geocoding_error', f'Reverse geocoding error {latitude}, {longitude}: {str(e)}')
|
||||
except Exception as log_error:
|
||||
print(f"⚠️ Logging error (non-critical): {log_error}")
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Location data processing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def process_location_data(location_data):
|
||||
"""
|
||||
Process and validate location data from form.
|
||||
Returns clean location data or None values for invalid data.
|
||||
"""
|
||||
processed = {
|
||||
'latitude': None,
|
||||
'longitude': None,
|
||||
'accuracy': None,
|
||||
'altitude': None,
|
||||
'source': location_data.get('location_source', 'manual'),
|
||||
'address': location_data.get('address', '')[:500] if location_data.get('address') else None
|
||||
}
|
||||
|
||||
try:
|
||||
if location_data.get('latitude') and location_data['latitude'] not in ['null', '']:
|
||||
lat = float(location_data['latitude'])
|
||||
if -90 <= lat <= 90:
|
||||
processed['latitude'] = lat
|
||||
else:
|
||||
print(f"⚠️ Invalid latitude: {lat}")
|
||||
|
||||
if location_data.get('longitude') and location_data['longitude'] not in ['null', '']:
|
||||
lng = float(location_data['longitude'])
|
||||
if -180 <= lng <= 180:
|
||||
processed['longitude'] = lng
|
||||
else:
|
||||
print(f"⚠️ Invalid longitude: {lng}")
|
||||
|
||||
if location_data.get('accuracy') and location_data['accuracy'] not in ['null', '']:
|
||||
acc = float(location_data['accuracy'])
|
||||
if acc >= 0:
|
||||
processed['accuracy'] = acc
|
||||
else:
|
||||
print(f"⚠️ Invalid accuracy: {acc}")
|
||||
|
||||
if location_data.get('altitude') and location_data['altitude'] not in ['null', '']:
|
||||
alt = float(location_data['altitude'])
|
||||
processed['altitude'] = alt
|
||||
|
||||
except (ValueError, TypeError) as e:
|
||||
print(f"⚠️ Error processing location data: {e}")
|
||||
|
||||
return processed
|
||||
|
||||
|
||||
def process_location_data_enhanced(form_data):
|
||||
"""
|
||||
Enhanced processing of location data from form submission.
|
||||
Validates and cleans location data for storage, including reverse geocoding.
|
||||
"""
|
||||
processed = {
|
||||
'latitude': None,
|
||||
'longitude': None,
|
||||
'accuracy': None,
|
||||
'altitude': None,
|
||||
'source': form_data.get('location_source', 'manual'),
|
||||
'address': None
|
||||
}
|
||||
|
||||
try:
|
||||
if form_data.get('latitude') and form_data['latitude'] not in ['null', '', 'undefined']:
|
||||
lat = float(form_data['latitude'])
|
||||
if -90 <= lat <= 90:
|
||||
processed['latitude'] = lat
|
||||
else:
|
||||
print(f"⚠️ Invalid latitude: {lat}")
|
||||
|
||||
if form_data.get('longitude') and form_data['longitude'] not in ['null', '', 'undefined']:
|
||||
lng = float(form_data['longitude'])
|
||||
if -180 <= lng <= 180:
|
||||
processed['longitude'] = lng
|
||||
else:
|
||||
print(f"⚠️ Invalid longitude: {lng}")
|
||||
|
||||
if form_data.get('accuracy') and form_data['accuracy'] not in ['null', '', 'undefined']:
|
||||
acc = float(form_data['accuracy'])
|
||||
if acc >= 0:
|
||||
processed['accuracy'] = acc
|
||||
else:
|
||||
print(f"⚠️ Invalid GPS accuracy: {acc}")
|
||||
|
||||
if form_data.get('altitude') and form_data['altitude'] not in ['null', '', 'undefined']:
|
||||
alt = float(form_data['altitude'])
|
||||
processed['altitude'] = alt
|
||||
|
||||
if form_data.get('address'):
|
||||
address = form_data['address'].strip()
|
||||
if address and address not in ['null', '', 'undefined']:
|
||||
if re.match(r'^-?\d+\.\d+,?\s*-?\d+\.\d+$', address.replace(' ', '')):
|
||||
print(f"🔍 Detected coordinate-format address: {address}")
|
||||
processed['address'] = None
|
||||
else:
|
||||
processed['address'] = address[:500]
|
||||
print(f"✅ Using provided address: {processed['address'][:100]}...")
|
||||
|
||||
if (processed['latitude'] is not None and processed['longitude'] is not None
|
||||
and not processed['address']):
|
||||
print(f"🌍 Performing reverse geocoding for coordinates: {processed['latitude']}, {processed['longitude']}")
|
||||
reverse_geocoded_address = reverse_geocode_coordinates(processed['latitude'], processed['longitude'])
|
||||
if reverse_geocoded_address:
|
||||
processed['address'] = reverse_geocoded_address[:500]
|
||||
print(f"✅ Reverse geocoded address: {processed['address']}")
|
||||
else:
|
||||
print("⚠️ Could not reverse geocode coordinates, keeping coordinates as fallback")
|
||||
processed['address'] = f"{processed['latitude']:.10f}, {processed['longitude']:.10f}"
|
||||
|
||||
print("📍 Final processed location data:")
|
||||
print(f" Coordinates: {processed['latitude']}, {processed['longitude']}")
|
||||
print(f" GPS Accuracy: {processed['accuracy']}m")
|
||||
print(f" Source: {processed['source']}")
|
||||
print(f" Address: {processed['address'][:100] if processed['address'] else 'None'}...")
|
||||
|
||||
return processed
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error processing location data: {e}")
|
||||
return processed
|
||||
|
||||
|
||||
def migrate_to_enhanced_location_accuracy():
|
||||
"""Migration function to recalculate all existing records with enhanced accuracy."""
|
||||
from sqlalchemy import text as sa_text
|
||||
try:
|
||||
print("🔄 Starting enhanced location accuracy migration...")
|
||||
records = db.session.execute(sa_text("""
|
||||
SELECT ad.id, qc.location_address, ad.address, ad.latitude, ad.longitude, ad.location_accuracy
|
||||
FROM attendance_data ad
|
||||
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||
WHERE qc.location_address IS NOT NULL
|
||||
""")).fetchall()
|
||||
|
||||
print(f"📊 Found {len(records)} records to process")
|
||||
updated_count = 0
|
||||
improved_count = 0
|
||||
|
||||
for record in records:
|
||||
try:
|
||||
new_accuracy = calculate_location_accuracy_enhanced(
|
||||
qr_address=record.location_address,
|
||||
checkin_address=record.address,
|
||||
checkin_lat=record.latitude,
|
||||
checkin_lng=record.longitude
|
||||
)
|
||||
if new_accuracy is not None:
|
||||
db.session.execute(sa_text("""
|
||||
UPDATE attendance_data SET location_accuracy = :accuracy WHERE id = :record_id
|
||||
"""), {'accuracy': new_accuracy, 'record_id': record.id})
|
||||
updated_count += 1
|
||||
if record.location_accuracy is None or abs(new_accuracy - (record.location_accuracy or 0)) > 0.001:
|
||||
improved_count += 1
|
||||
print(f" ✅ Updated record {record.id}: {record.location_accuracy} → {new_accuracy:.4f} miles")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Error processing record {record.id}: {e}")
|
||||
|
||||
db.session.commit()
|
||||
print(f"✅ Enhanced migration completed!")
|
||||
print(f" 📊 Records processed: {len(records)}")
|
||||
print(f" ✅ Records updated: {updated_count}")
|
||||
print(f" 📈 Records improved: {improved_count}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Enhanced migration failed: {e}")
|
||||
db.session.rollback()
|
||||
return False
|
||||
|
||||
|
||||
def check_location_accuracy_column_exists():
|
||||
"""Check if location_accuracy column exists in attendance_data table (MySQL compatible)."""
|
||||
from sqlalchemy import text as sa_text
|
||||
try:
|
||||
result = db.session.execute(sa_text("""
|
||||
SELECT COUNT(*) as count
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'attendance_data'
|
||||
AND COLUMN_NAME = 'location_accuracy'
|
||||
"""))
|
||||
count = result.fetchone().count
|
||||
return count > 0
|
||||
except Exception as e:
|
||||
print(f"Error checking location_accuracy column: {e}")
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR code location helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_all_locations_from_qr_codes():
|
||||
"""Helper function to get all unique locations from QR codes"""
|
||||
from sqlalchemy import text as sa_text
|
||||
try:
|
||||
result = db.session.execute(sa_text("""
|
||||
SELECT DISTINCT location
|
||||
FROM qr_codes
|
||||
WHERE location IS NOT NULL
|
||||
AND active_status = 1
|
||||
ORDER BY location
|
||||
"""))
|
||||
return [row[0] for row in result.fetchall()]
|
||||
except Exception as e:
|
||||
logger_handler.logger.error(f"Error loading locations: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,414 @@
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# url_for compatibility shim
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flask Blueprints prefix endpoint names (e.g. 'attendance.attendance_report').
|
||||
# The original codebase uses bare names (e.g. url_for('attendance_report')).
|
||||
# This wrapper resolves bare names by searching registered blueprints,
|
||||
# so zero url_for() calls in routes or templates need to change.
|
||||
#
|
||||
# IMPORTANT: Flask's url_for is aliased as _flask_url_for to avoid shadowing
|
||||
# this function. Decorators in this module that redirect (login_required etc.)
|
||||
# also use _flask_url_for directly since they only redirect to known bare names
|
||||
# that this shim already handles.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import flask.helpers as _flask_helpers
|
||||
# Capture Flask's original url_for BEFORE any shadowing
|
||||
_flask_url_for = _flask_helpers.url_for
|
||||
|
||||
|
||||
def url_for(endpoint, **values):
|
||||
"""
|
||||
Drop-in replacement for flask.url_for that resolves bare endpoint names
|
||||
across Blueprints. Qualified names (containing '.') pass through unchanged.
|
||||
"""
|
||||
from flask import current_app
|
||||
if '.' in endpoint:
|
||||
return _flask_url_for(endpoint, **values)
|
||||
try:
|
||||
return _flask_url_for(endpoint, **values)
|
||||
except Exception:
|
||||
pass
|
||||
for bp_name in sorted(current_app.blueprints.keys()):
|
||||
try:
|
||||
return _flask_url_for(f'{bp_name}.{endpoint}', **values)
|
||||
except Exception:
|
||||
pass
|
||||
return _flask_url_for(endpoint, **values) # raises Flask's normal BuildError
|
||||
|
||||
|
||||
"""
|
||||
utils/helpers.py
|
||||
================
|
||||
Shared utility functions, decorators, QR-code generation helpers,
|
||||
and role/permission helpers.
|
||||
|
||||
Extracted verbatim from app.py (lines 234-329, 910-969, 1274-1467).
|
||||
No logic changes — only import paths updated.
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
import os
|
||||
import base64
|
||||
from datetime import datetime, date, time, timedelta
|
||||
from functools import wraps
|
||||
|
||||
import qrcode
|
||||
from flask import session, redirect, flash, request
|
||||
from user_agents import parse
|
||||
|
||||
from extensions import logger_handler
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role constants
|
||||
# ---------------------------------------------------------------------------
|
||||
VALID_ROLES = ['admin', 'staff', 'payroll', 'project_manager', 'accounting']
|
||||
STAFF_LEVEL_ROLES = ['staff', 'payroll', 'project_manager', 'accounting']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_valid_role(role):
|
||||
"""Check if role is valid"""
|
||||
return role in VALID_ROLES
|
||||
|
||||
|
||||
def has_admin_privileges(role):
|
||||
"""Check if role has admin privileges"""
|
||||
return role == 'admin'
|
||||
|
||||
|
||||
def has_staff_level_access(role):
|
||||
"""Check if role has staff-level access (includes new roles)"""
|
||||
return role in STAFF_LEVEL_ROLES
|
||||
|
||||
|
||||
def get_role_permissions(role):
|
||||
"""Get permissions description for a role"""
|
||||
permissions = {
|
||||
'admin': {
|
||||
'title': 'Administrator Permissions',
|
||||
'permissions': [
|
||||
'Full QR code management (create, edit, delete)',
|
||||
'Complete user management capabilities',
|
||||
'System configuration access',
|
||||
'View all system analytics',
|
||||
'Bulk operations and data export',
|
||||
'Access to all admin features'
|
||||
],
|
||||
'restrictions': ['With great power comes great responsibility!']
|
||||
},
|
||||
'staff': {
|
||||
'title': 'Staff User Permissions',
|
||||
'permissions': [
|
||||
'Create and edit QR codes',
|
||||
'View all QR codes in the system',
|
||||
'Download QR code images',
|
||||
'Update personal profile information',
|
||||
],
|
||||
'restrictions': [
|
||||
'Cannot delete QR codes',
|
||||
'Cannot manage other users',
|
||||
'Cannot access admin settings'
|
||||
]
|
||||
},
|
||||
'payroll': {
|
||||
'title': 'Payroll Specialist Permissions',
|
||||
'permissions': [
|
||||
'Create and edit QR codes',
|
||||
'View all QR codes in the system',
|
||||
'Download QR code images',
|
||||
'Update personal profile information',
|
||||
'Access dashboard and reports',
|
||||
'Same permissions as Staff (additional features coming soon)'
|
||||
],
|
||||
'restrictions': [
|
||||
'Cannot delete QR codes',
|
||||
'Cannot manage other users',
|
||||
'Cannot access admin settings'
|
||||
]
|
||||
},
|
||||
'project_manager': {
|
||||
'title': 'Project Manager Permissions',
|
||||
'permissions': [
|
||||
'Create and edit QR codes',
|
||||
'View all QR codes in the system',
|
||||
'Download QR code images',
|
||||
'Update personal profile information',
|
||||
'Access dashboard and reports',
|
||||
'Same permissions as Staff (additional features coming soon)'
|
||||
],
|
||||
'restrictions': [
|
||||
'Cannot delete QR codes',
|
||||
'Cannot manage other users',
|
||||
'Cannot access admin settings'
|
||||
]
|
||||
},
|
||||
'accounting': {
|
||||
'title': 'Accounting Specialist Permissions',
|
||||
'permissions': [
|
||||
'View and modify employee records',
|
||||
'Access attendance reports and analytics',
|
||||
'View and manage time attendance data',
|
||||
'Export payroll and attendance data',
|
||||
'Access financial reports and statistics',
|
||||
'Update personal profile information',
|
||||
'Delete attendance records (same as payroll)'
|
||||
],
|
||||
'restrictions': [
|
||||
'Cannot create or delete QR codes',
|
||||
'Cannot manage other users',
|
||||
'Cannot access admin settings',
|
||||
'Cannot manage projects'
|
||||
]
|
||||
}
|
||||
}
|
||||
return permissions.get(role, {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth decorators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator to ensure user is logged in"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
flash('Please log in to access this page.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to ensure user has admin privileges"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'username' not in session:
|
||||
flash('Please log in to access this page.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
user_role = session.get('role')
|
||||
if not has_admin_privileges(user_role):
|
||||
flash('Administrator privileges required for this action.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def staff_or_admin_required(f):
|
||||
"""Decorator to ensure user has staff-level or admin privileges"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'username' not in session:
|
||||
flash('Please log in to access this page.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
user_role = session.get('role')
|
||||
if not (has_admin_privileges(user_role) or has_staff_level_access(user_role)):
|
||||
flash('Insufficient privileges to access this page.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def is_admin_user(user_id):
|
||||
"""Helper function to safely check if user is admin"""
|
||||
from extensions import db
|
||||
from models import set_db
|
||||
try:
|
||||
# User model is available through the app context
|
||||
from flask import current_app
|
||||
with current_app.app_context():
|
||||
# Access via db session to avoid circular import
|
||||
from sqlalchemy import text
|
||||
result = db.session.execute(
|
||||
text("SELECT role, active_status FROM users WHERE id = :uid"),
|
||||
{'uid': user_id}
|
||||
).fetchone()
|
||||
return result and result.active_status and result.role == 'admin'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_device_info(user_agent_string):
|
||||
"""Extract device information from user agent"""
|
||||
try:
|
||||
user_agent = parse(user_agent_string)
|
||||
device_info = f"{user_agent.device.family}"
|
||||
if user_agent.os.family:
|
||||
device_info += f" - {user_agent.os.family}"
|
||||
if user_agent.os.version_string:
|
||||
device_info += f" {user_agent.os.version_string}"
|
||||
if user_agent.browser.family:
|
||||
device_info += f" ({user_agent.browser.family})"
|
||||
return device_info[:200]
|
||||
except Exception:
|
||||
return "Unknown Device"
|
||||
|
||||
|
||||
def get_client_ip():
|
||||
"""Get client IP address"""
|
||||
if request.environ.get('HTTP_X_FORWARDED_FOR') is None:
|
||||
return request.environ['REMOTE_ADDR']
|
||||
else:
|
||||
return request.environ['HTTP_X_FORWARDED_FOR']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR code generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_qr_url(name, qr_id):
|
||||
"""Generate a unique URL for QR code destination"""
|
||||
clean_name = re.sub(r'[^a-zA-Z0-9\s-]', '', name)
|
||||
clean_name = re.sub(r'\s+', '-', clean_name.strip())
|
||||
clean_name = clean_name.lower()
|
||||
url_slug = f"qr-{qr_id}-{clean_name}"
|
||||
return url_slug[:200]
|
||||
|
||||
|
||||
def generate_qr_code(data, fill_color="black", back_color="white", box_size=10, border=4, error_correction='L'):
|
||||
"""Generate a QR code image and return as base64 string"""
|
||||
error_correction_map = {
|
||||
'L': qrcode.constants.ERROR_CORRECT_L,
|
||||
'M': qrcode.constants.ERROR_CORRECT_M,
|
||||
'Q': qrcode.constants.ERROR_CORRECT_Q,
|
||||
'H': qrcode.constants.ERROR_CORRECT_H
|
||||
}
|
||||
|
||||
try:
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=error_correction_map.get(error_correction, qrcode.constants.ERROR_CORRECT_L),
|
||||
box_size=int(box_size),
|
||||
border=int(border),
|
||||
)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color=fill_color, back_color=back_color)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
img_str = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
try:
|
||||
logger_handler.log_qr_code_generated(
|
||||
data_length=len(data),
|
||||
fill_color=fill_color,
|
||||
back_color=back_color,
|
||||
box_size=box_size,
|
||||
border=border,
|
||||
error_correction=error_correction
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return img_str
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('qr_code_generation', e)
|
||||
return generate_default_qr_code(data)
|
||||
|
||||
|
||||
def generate_default_qr_code(data):
|
||||
"""Fallback function for basic QR code generation"""
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
img_str = base64.b64encode(buffer.getvalue()).decode()
|
||||
return img_str
|
||||
|
||||
|
||||
def get_qr_styling(qr_code):
|
||||
"""Extract QR code styling parameters from database record"""
|
||||
return {
|
||||
'fill_color': getattr(qr_code, 'fill_color', '#000000') or '#000000',
|
||||
'back_color': getattr(qr_code, 'back_color', '#FFFFFF') or '#FFFFFF',
|
||||
'box_size': getattr(qr_code, 'box_size', 10) or 10,
|
||||
'border': getattr(qr_code, 'border', 4) or 4,
|
||||
'error_correction': getattr(qr_code, 'error_correction', 'L') or 'L'
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check-in history helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_employee_checkin_history(employee_id, qr_code_id, date_filter=None):
|
||||
"""Get check-in history for an employee at a specific location"""
|
||||
from extensions import db
|
||||
try:
|
||||
if date_filter is None:
|
||||
date_filter = date.today()
|
||||
# AttendanceData imported at call site to avoid circular import
|
||||
from flask import current_app
|
||||
AttendanceData = current_app.config.get('_models', {}).get('AttendanceData')
|
||||
if AttendanceData:
|
||||
checkins = AttendanceData.query.filter_by(
|
||||
employee_id=employee_id.upper(),
|
||||
qr_code_id=qr_code_id,
|
||||
check_in_date=date_filter
|
||||
).order_by(AttendanceData.check_in_time.asc()).all()
|
||||
return checkins
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"❌ Error retrieving checkin history: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def format_checkin_intervals(checkins):
|
||||
"""Format time intervals between check-ins for display"""
|
||||
if len(checkins) < 2:
|
||||
return []
|
||||
|
||||
intervals = []
|
||||
for i in range(1, len(checkins)):
|
||||
previous_time = datetime.combine(checkins[i - 1].check_in_date, checkins[i - 1].check_in_time)
|
||||
current_time = datetime.combine(checkins[i].check_in_date, checkins[i].check_in_time)
|
||||
interval = current_time - previous_time
|
||||
interval_minutes = int(interval.total_seconds() / 60)
|
||||
intervals.append({
|
||||
'from_time': checkins[i - 1].check_in_time.strftime('%H:%M'),
|
||||
'to_time': checkins[i].check_in_time.strftime('%H:%M'),
|
||||
'interval_minutes': interval_minutes,
|
||||
'interval_text': format_time_interval(interval_minutes)
|
||||
})
|
||||
return intervals
|
||||
|
||||
|
||||
def format_time_interval(minutes):
|
||||
"""Format minutes into human-readable time interval"""
|
||||
if minutes < 60:
|
||||
return f"{minutes} minutes"
|
||||
elif minutes < 1440:
|
||||
hours = minutes // 60
|
||||
remaining_minutes = minutes % 60
|
||||
if remaining_minutes == 0:
|
||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||
else:
|
||||
return f"{hours}h {remaining_minutes}m"
|
||||
else:
|
||||
days = minutes // 1440
|
||||
remaining_hours = (minutes % 1440) // 60
|
||||
if remaining_hours == 0:
|
||||
return f"{days} day{'s' if days != 1 else ''}"
|
||||
else:
|
||||
return f"{days}d {remaining_hours}h"
|
||||
Reference in New Issue
Block a user