Mar 20 2026: refactor app.py

This commit is contained in:
2026-03-20 15:04:10 -04:00
parent 63b40353f3
commit 7caf12a760
18 changed files with 12527 additions and 12273 deletions
View File
+591
View File
@@ -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
+2370
View File
File diff suppressed because it is too large Load Diff
+272
View File
@@ -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'))
+251
View File
@@ -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
+390
View File
@@ -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'))
+709
View File
@@ -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
}
+173
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+315
View File
@@ -0,0 +1,315 @@
"""
routes/statistics.py
====================
Statistics dashboard and export routes.
Routes: /statistics, /api/statistics/export
"""
from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, make_response, current_app
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
View File
File diff suppressed because it is too large Load Diff