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
+311 -12128
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -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
View File
@@ -25,7 +25,7 @@ import os
import traceback import traceback
from datetime import datetime, date, timedelta from datetime import datetime, date, timedelta
from functools import wraps 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 import text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
import uuid import uuid
@@ -201,8 +201,14 @@ class AppLogger:
return render_template('errors/404.html'), 404 return render_template('errors/404.html'), 404
def _get_request_context(self): def _get_request_context(self):
"""Get current request context information""" """Get current request context information.
if not request: 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 {}
return { return {
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
View File
+814
View File
@@ -0,0 +1,814 @@
"""
utils/geocoding.py
==================
All geocoding, distance calculation, and location-accuracy helpers.
Extracted verbatim from app.py (lines 1881317).
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 []
+414
View File
@@ -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"