3413 lines
136 KiB
Python
3413 lines
136 KiB
Python
from flask import Flask, render_template, request, redirect, url_for, flash, session, jsonify
|
|
from flask_sqlalchemy import SQLAlchemy
|
|
from werkzeug.security import generate_password_hash, check_password_hash
|
|
from functools import wraps
|
|
from datetime import datetime, date, time, timedelta
|
|
from sqlalchemy import text
|
|
from user_agents import parse
|
|
from math import radians, cos, sin, asin, sqrt
|
|
import io, os, base64, re, uuid, requests, json, qrcode
|
|
from dotenv import load_dotenv
|
|
# Import the logging handler
|
|
from logger_handler import AppLogger, log_user_activity, log_database_operations
|
|
|
|
# Load environment variables in .env
|
|
load_dotenv()
|
|
|
|
# Initialize Flask application
|
|
app = Flask(__name__)
|
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY')
|
|
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL')
|
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = os.environ.get('SQLALCHEMY_TRACK_MODIFICATIONS')
|
|
|
|
# Initialize database
|
|
db = SQLAlchemy(app)
|
|
|
|
# Valid user roles with new additions
|
|
VALID_ROLES = ['admin', 'staff', 'payroll', 'project_manager']
|
|
|
|
# Roles that have staff-level permissions (non-admin roles)
|
|
STAFF_LEVEL_ROLES = ['staff', 'payroll', 'project_manager']
|
|
|
|
# Initialize the logging system
|
|
logger_handler = AppLogger(app, db)
|
|
|
|
# User Model
|
|
class User(db.Model):
|
|
"""
|
|
User model to manage system users with role-based access control
|
|
"""
|
|
__tablename__ = 'users'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
full_name = db.Column(db.String(100), nullable=False)
|
|
email = db.Column(db.String(120), unique=True, nullable=False)
|
|
username = db.Column(db.String(80), unique=True, nullable=False)
|
|
password_hash = db.Column(db.String(255), nullable=False)
|
|
role = db.Column(db.String(20), nullable=False, default='staff') # admin or staff
|
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
|
created_date = db.Column(db.DateTime, default=datetime.utcnow)
|
|
active_status = db.Column(db.Boolean, default=True)
|
|
last_login_date = db.Column(db.DateTime, nullable=True)
|
|
|
|
# Relationships
|
|
created_users = db.relationship('User', backref=db.backref('creator', remote_side=[id]))
|
|
created_qr_codes = db.relationship('QRCode', backref='creator', lazy='dynamic')
|
|
|
|
def set_password(self, password):
|
|
"""Hash and set user password"""
|
|
self.password_hash = generate_password_hash(password)
|
|
|
|
def check_password(self, password):
|
|
"""Verify user password"""
|
|
return check_password_hash(self.password_hash, password)
|
|
|
|
def is_admin(self):
|
|
"""Check if user has admin privileges"""
|
|
return self.role == 'admin'
|
|
|
|
def has_staff_permissions(self):
|
|
"""Check if user has staff-level permissions (includes new roles)"""
|
|
return self.role in STAFF_LEVEL_ROLES
|
|
|
|
def get_role_display_name(self):
|
|
"""Get user-friendly role name"""
|
|
role_names = {
|
|
'admin': 'Administrator',
|
|
'staff': 'Staff User',
|
|
'payroll': 'Payroll Specialist',
|
|
'project_manager': 'Project Manager'
|
|
}
|
|
return role_names.get(self.role, self.role.title())
|
|
|
|
# QR Code Model
|
|
class QRCode(db.Model):
|
|
"""
|
|
Enhanced QR Code model to manage QR code records and metadata with address coordinates
|
|
"""
|
|
__tablename__ = 'qr_codes'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(100), nullable=False)
|
|
location = db.Column(db.String(100), nullable=False)
|
|
location_address = db.Column(db.Text, nullable=False)
|
|
location_event = db.Column(db.String(200), nullable=False)
|
|
qr_code_image = db.Column(db.Text, nullable=False) # Base64 encoded image
|
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
|
created_date = db.Column(db.DateTime, default=datetime.utcnow)
|
|
active_status = db.Column(db.Boolean, default=True)
|
|
qr_url = db.Column(db.String(255), unique=True, nullable=True)
|
|
# Address Coordinates Fields
|
|
address_latitude = db.Column(db.Float, nullable=True)
|
|
address_longitude = db.Column(db.Float, nullable=True)
|
|
coordinate_accuracy = db.Column(db.String(50), nullable=True, default='geocoded')
|
|
coordinates_updated_date = db.Column(db.DateTime, nullable=True)
|
|
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True)
|
|
# The project_id field:
|
|
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True)
|
|
|
|
@property
|
|
def has_coordinates(self):
|
|
"""Check if this QR code has address coordinates"""
|
|
return self.address_latitude is not None and self.address_longitude is not None
|
|
|
|
@property
|
|
def coordinates_display(self):
|
|
"""Get formatted coordinates for display"""
|
|
if self.has_coordinates:
|
|
return f"{self.address_latitude:.10f}, {self.address_longitude:.10f}"
|
|
return "Coordinates not available"
|
|
|
|
def update_coordinates(self, latitude, longitude, accuracy='geocoded'):
|
|
"""Update the address coordinates for this QR code"""
|
|
self.address_latitude = latitude
|
|
self.address_longitude = longitude
|
|
self.coordinate_accuracy = accuracy
|
|
self.coordinates_updated_date = datetime.utcnow()
|
|
|
|
class Project(db.Model):
|
|
"""
|
|
Project model to organize QR codes by projects
|
|
"""
|
|
__tablename__ = 'projects'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
name = db.Column(db.String(100), nullable=False)
|
|
description = db.Column(db.Text, nullable=True)
|
|
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
|
|
created_date = db.Column(db.DateTime, default=datetime.utcnow)
|
|
active_status = db.Column(db.Boolean, default=True)
|
|
|
|
# Relationships
|
|
qr_codes = db.relationship('QRCode', backref='project', lazy='dynamic')
|
|
creator = db.relationship('User', backref='created_projects')
|
|
|
|
def __repr__(self):
|
|
return f'<Project {self.name}>'
|
|
|
|
@property
|
|
def qr_count(self):
|
|
"""Get count of QR codes in this project"""
|
|
return self.qr_codes.filter_by(active_status=True).count()
|
|
|
|
@property
|
|
def total_qr_count(self):
|
|
"""Get total count of QR codes (including inactive) in this project"""
|
|
return self.qr_codes.count()
|
|
|
|
# Attendance Data Model
|
|
class AttendanceData(db.Model):
|
|
"""Enhanced attendance tracking model with location support"""
|
|
__tablename__ = 'attendance_data'
|
|
|
|
# Existing fields
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
qr_code_id = db.Column(db.Integer, db.ForeignKey('qr_codes.id', ondelete='CASCADE'), nullable=False)
|
|
employee_id = db.Column(db.String(50), nullable=False)
|
|
check_in_date = db.Column(db.Date, nullable=False, default=datetime.today)
|
|
check_in_time = db.Column(db.Time, nullable=False, default=datetime.now().time)
|
|
device_info = db.Column(db.String(200))
|
|
user_agent = db.Column(db.Text)
|
|
ip_address = db.Column(db.String(45))
|
|
location_name = db.Column(db.String(100), nullable=False)
|
|
status = db.Column(db.String(20), default='present')
|
|
created_timestamp = db.Column(db.DateTime, default=datetime.utcnow)
|
|
updated_timestamp = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
latitude = db.Column(db.Float, nullable=True)
|
|
longitude = db.Column(db.Float, nullable=True)
|
|
accuracy = db.Column(db.Float, nullable=True)
|
|
location_accuracy = db.Column(db.Float, nullable=True)
|
|
altitude = db.Column(db.Float, nullable=True)
|
|
location_source = db.Column(db.String(50), default='manual')
|
|
address = db.Column(db.String(500), nullable=True)
|
|
|
|
# Relationships
|
|
qr_code = db.relationship('QRCode', backref=db.backref('attendance_records', lazy='dynamic'))
|
|
|
|
def __repr__(self):
|
|
return f'<AttendanceData {self.employee_id} at {self.location_name} on {self.check_in_date}>'
|
|
|
|
@property
|
|
def has_location_data(self):
|
|
"""Check if this record has GPS coordinates"""
|
|
return self.latitude is not None and self.longitude is not None
|
|
|
|
@property
|
|
def location_accuracy_level(self):
|
|
"""Get human-readable accuracy level"""
|
|
if not self.accuracy:
|
|
return 'unknown'
|
|
elif self.accuracy <= 50:
|
|
return 'high'
|
|
elif self.accuracy <= 100:
|
|
return 'medium'
|
|
else:
|
|
return 'low'
|
|
|
|
@property
|
|
def coordinates_display(self):
|
|
"""Get formatted coordinates for display"""
|
|
if self.has_location_data:
|
|
return f"{self.latitude:.10f}, {self.longitude:.10f}"
|
|
return "No GPS data"
|
|
|
|
def to_dict(self):
|
|
"""Convert to dictionary for JSON responses"""
|
|
return {
|
|
'id': self.id,
|
|
'employee_id': self.employee_id,
|
|
'check_in_date': self.check_in_date.isoformat(),
|
|
'check_in_time': self.check_in_time.isoformat(),
|
|
'location_name': self.location_name,
|
|
'status': self.status,
|
|
'has_location': self.has_location_data,
|
|
'coordinates': self.coordinates_display,
|
|
'accuracy': self.accuracy,
|
|
'address': self.address,
|
|
'location_source': self.location_source
|
|
}
|
|
|
|
# Utility functions
|
|
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'
|
|
]
|
|
}
|
|
}
|
|
return permissions.get(role, {})
|
|
|
|
def get_coordinates_from_address(address):
|
|
"""
|
|
Get latitude and longitude from address using geocoding service
|
|
Returns (lat, lng) tuple or (None, None) if failed
|
|
"""
|
|
if not address or address.strip() == '':
|
|
return None, None
|
|
|
|
try:
|
|
# Using a free geocoding service (Nominatim/OpenStreetMap)
|
|
# In production, consider using Google Maps Geocoding API for better accuracy
|
|
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"✅ Geocoded address '{address[:50]}...' to coordinates: {lat}, {lng}")
|
|
return lat, lng
|
|
|
|
print(f"⚠️ Could not geocode address: {address}")
|
|
return None, None
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error geocoding address '{address}': {e}")
|
|
return None, None
|
|
|
|
def get_coordinates_from_address_enhanced(address):
|
|
"""
|
|
Enhanced geocoding function with better error handling
|
|
Returns (latitude, longitude, accuracy_level)
|
|
"""
|
|
if not address or address.strip() == "":
|
|
print("⚠️ Empty address provided for geocoding")
|
|
return None, None, None
|
|
|
|
address = address.strip()
|
|
print(f"🌍 Enhanced geocoding for: {address}")
|
|
|
|
try:
|
|
# Primary geocoding using Nominatim (OpenStreetMap)
|
|
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'])
|
|
|
|
# Enhanced accuracy assessment
|
|
place_type = result.get('type', 'unknown')
|
|
osm_type = result.get('osm_type', 'unknown')
|
|
importance = float(result.get('importance', 0))
|
|
|
|
# More sophisticated accuracy determination
|
|
if place_type in ['house', 'building', 'shop', 'office'] or osm_type == 'way':
|
|
accuracy = 'excellent'
|
|
elif place_type in ['neighbourhood', 'suburb', 'quarter', 'residential']:
|
|
accuracy = 'good'
|
|
elif place_type in ['city', 'town', 'village'] and importance > 0.5:
|
|
accuracy = 'fair'
|
|
else:
|
|
accuracy = 'poor'
|
|
|
|
print(f"✅ Enhanced geocoding successful:")
|
|
print(f" Coordinates: {lat:.10f}, {lng:.10f}")
|
|
print(f" Accuracy: {accuracy}")
|
|
|
|
return lat, lng, accuracy
|
|
|
|
print(f"⚠️ No results from enhanced geocoding for: {address}")
|
|
return None, None, None
|
|
|
|
except Exception as e:
|
|
print(f"❌ Enhanced geocoding error: {e}")
|
|
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:
|
|
# Nominatim API endpoint
|
|
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'])
|
|
|
|
# Determine accuracy based on result type
|
|
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
|
|
|
|
def calculate_distance_miles(lat1, lng1, lat2, lng2):
|
|
"""
|
|
Enhanced Haversine formula to calculate distance between two points in miles
|
|
Improved with better precision and error handling
|
|
"""
|
|
if any(coord is None for coord in [lat1, lng1, lat2, lng2]):
|
|
print("⚠️ Missing coordinates for distance calculation")
|
|
return None
|
|
|
|
try:
|
|
# Validate coordinate ranges
|
|
if not (-90 <= lat1 <= 90) or not (-90 <= lat2 <= 90):
|
|
print(f"⚠️ Invalid latitude values: {lat1}, {lat2}")
|
|
return None
|
|
|
|
if not (-180 <= lng1 <= 180) or not (-180 <= lng2 <= 180):
|
|
print(f"⚠️ Invalid longitude values: {lng1}, {lng2}")
|
|
return None
|
|
|
|
# Convert decimal degrees to radians
|
|
lat1, lng1, lat2, lng2 = map(radians, [float(lat1), float(lng1), float(lat2), float(lng2)])
|
|
|
|
# Enhanced Haversine formula for better precision
|
|
dlng = lng2 - lng1
|
|
dlat = lat2 - lat1
|
|
|
|
# Haversine calculation
|
|
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlng/2)**2
|
|
c = 2 * asin(sqrt(a))
|
|
|
|
# Earth's radius in miles (more precise value)
|
|
r_miles = 3959.87433
|
|
|
|
# Calculate distance with enhanced precision
|
|
distance = c * r_miles
|
|
|
|
# Round to 4 decimal places for better precision
|
|
distance = round(distance, 4)
|
|
|
|
print(f"📏 Enhanced distance calculation:")
|
|
print(f" Point 1: {lat1*180/3.14159:.10f}, {lng1*180/3.14159:.10f}")
|
|
print(f" Point 2: {lat2*180/3.14159:.10f}, {lng2*180/3.14159:.10f}")
|
|
print(f" Distance: {distance:.4f} miles")
|
|
|
|
return distance
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error in enhanced distance calculation: {e}")
|
|
return None
|
|
|
|
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}")
|
|
|
|
# Get QR code coordinates from address
|
|
qr_lat, qr_lng = get_coordinates_from_address(qr_address)
|
|
|
|
if qr_lat is None or qr_lng is None:
|
|
print(f"⚠️ Could not geocode QR address, cannot calculate accuracy")
|
|
return None
|
|
|
|
# Use check-in coordinates if available, otherwise geocode check-in address
|
|
if checkin_lat is not None and checkin_lng is not None:
|
|
checkin_coords_lat, checkin_coords_lng = checkin_lat, checkin_lng
|
|
print(f"✅ 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(f"⚠️ Could not geocode check-in address, cannot calculate accuracy")
|
|
return None
|
|
print(f"✅ Using geocoded coordinates for check-in address")
|
|
|
|
# Calculate distance
|
|
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
|
|
This function provides improved precision and better error handling
|
|
|
|
Parameters:
|
|
- qr_address: Address associated with the QR code
|
|
- checkin_address: Address where user checked in (from reverse geocoding)
|
|
- checkin_lat: GPS latitude from check-in (if available)
|
|
- checkin_lng: GPS longitude from check-in (if available)
|
|
|
|
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()}")
|
|
|
|
# Validate input parameters
|
|
if not qr_address or qr_address.strip() == "":
|
|
print(f"❌ QR address is empty or invalid")
|
|
return None
|
|
|
|
# Step 1: Get coordinates for QR address using enhanced geocoding
|
|
print(f"\n📍 Step 1: Geocoding QR address...")
|
|
qr_lat, qr_lng, qr_accuracy = get_coordinates_from_address_enhanced(qr_address)
|
|
|
|
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})")
|
|
|
|
# Step 2: Determine check-in coordinates
|
|
print(f"\n📱 Step 2: Determining check-in coordinates...")
|
|
|
|
checkin_coords_lat = None
|
|
checkin_coords_lng = None
|
|
checkin_source = "unknown"
|
|
|
|
# Priority 1: Use GPS coordinates if available and valid
|
|
if checkin_lat is not None and checkin_lng is not None:
|
|
try:
|
|
lat_val = float(checkin_lat)
|
|
lng_val = float(checkin_lng)
|
|
|
|
# Validate GPS coordinates
|
|
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):
|
|
print(f"⚠️ Could not parse GPS coordinates")
|
|
|
|
# Priority 2: Fallback to geocoding check-in address
|
|
if checkin_coords_lat is None and checkin_address:
|
|
print(f"🌍 Falling back to geocoding check-in address...")
|
|
checkin_coords_lat, checkin_coords_lng, checkin_accuracy = get_coordinates_from_address_enhanced(checkin_address)
|
|
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})")
|
|
|
|
# Check if we have valid coordinates for both locations
|
|
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
|
|
|
|
# Step 3: Calculate enhanced distance
|
|
print(f"\n📏 Step 3: Calculating enhanced distance...")
|
|
distance = calculate_distance_miles(qr_lat, qr_lng, checkin_coords_lat, checkin_coords_lng)
|
|
|
|
if distance is not None:
|
|
print(f"✅ Enhanced location accuracy calculated successfully!")
|
|
print(f" QR Location: {qr_lat:.10f}, {qr_lng:.10f}")
|
|
print(f" Check-in Location: {checkin_coords_lat:.10f}, {checkin_coords_lng:.10f}")
|
|
print(f" Source: {checkin_source}")
|
|
print(f" Distance: {distance:.4f} miles")
|
|
print(f" Accuracy Level: {get_location_accuracy_level_enhanced(distance)}")
|
|
else:
|
|
print(f"❌ Failed to calculate distance")
|
|
|
|
return distance
|
|
|
|
def generate_qr_url(name, qr_id):
|
|
"""Generate a unique URL for QR code destination"""
|
|
# Clean the name for URL use
|
|
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()
|
|
|
|
# Create unique URL
|
|
url_slug = f"qr-{qr_id}-{clean_name}"
|
|
return url_slug[:200] # Limit length
|
|
|
|
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] # Limit length
|
|
except:
|
|
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']
|
|
|
|
def calculate_distance_miles(lat1, lng1, lat2, lng2):
|
|
"""
|
|
Calculate the great circle distance between two points on Earth in miles
|
|
Using the Haversine formula
|
|
"""
|
|
if any(coord is None for coord in [lat1, lng1, lat2, lng2]):
|
|
return None
|
|
|
|
try:
|
|
# Convert decimal degrees to radians
|
|
lat1, lng1, lat2, lng2 = map(radians, [lat1, lng1, lat2, lng2])
|
|
|
|
# Haversine formula
|
|
dlng = lng2 - lng1
|
|
dlat = lat2 - lat1
|
|
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlng/2)**2
|
|
c = 2 * asin(sqrt(a))
|
|
|
|
# Radius of Earth in miles
|
|
r_miles = 3959
|
|
|
|
# Calculate the result
|
|
distance = c * r_miles
|
|
|
|
print(f"📏 Calculated distance: {distance:.3f} miles")
|
|
return round(distance, 3)
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error calculating distance: {e}")
|
|
return None
|
|
|
|
def get_location_accuracy_level(location_accuracy):
|
|
"""Get human-readable location accuracy level based on distance"""
|
|
if not location_accuracy:
|
|
return 'unknown'
|
|
elif location_accuracy <= 0.1: # Within 0.1 mile (528 feet)
|
|
return 'excellent'
|
|
elif location_accuracy <= 0.5: # Within 0.5 mile
|
|
return 'good'
|
|
elif location_accuracy <= 1.0: # Within 1 mile
|
|
return 'fair'
|
|
else:
|
|
return 'poor'
|
|
|
|
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'
|
|
|
|
# More precise accuracy thresholds
|
|
if location_accuracy <= 0.05: # Within 264 feet (50 meters)
|
|
return 'excellent'
|
|
elif location_accuracy <= 0.1: # Within 528 feet (100 meters)
|
|
return 'very_good'
|
|
elif location_accuracy <= 0.25: # Within 0.25 mile (1320 feet)
|
|
return 'good'
|
|
elif location_accuracy <= 0.5: # Within 0.5 mile
|
|
return 'fair'
|
|
elif location_accuracy <= 1.0: # Within 1 mile
|
|
return 'poor'
|
|
else: # Greater than 1 mile
|
|
return 'very_poor'
|
|
|
|
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:
|
|
# Process latitude
|
|
if location_data.get('latitude') and location_data['latitude'] not in ['null', '']:
|
|
lat = float(location_data['latitude'])
|
|
if -90 <= lat <= 90: # Valid latitude range
|
|
processed['latitude'] = lat
|
|
else:
|
|
print(f"⚠️ Invalid latitude: {lat}")
|
|
|
|
# Process longitude
|
|
if location_data.get('longitude') and location_data['longitude'] not in ['null', '']:
|
|
lng = float(location_data['longitude'])
|
|
if -180 <= lng <= 180: # Valid longitude range
|
|
processed['longitude'] = lng
|
|
else:
|
|
print(f"⚠️ Invalid longitude: {lng}")
|
|
|
|
# Process accuracy
|
|
if location_data.get('accuracy') and location_data['accuracy'] not in ['null', '']:
|
|
acc = float(location_data['accuracy'])
|
|
if acc >= 0: # Accuracy should be positive
|
|
processed['accuracy'] = acc
|
|
else:
|
|
print(f"⚠️ Invalid accuracy: {acc}")
|
|
|
|
# Process altitude
|
|
if location_data.get('altitude') and location_data['altitude'] not in ['null', '']:
|
|
alt = float(location_data['altitude'])
|
|
# Altitude can be negative (below sea level)
|
|
processed['altitude'] = alt
|
|
|
|
except (ValueError, TypeError) as e:
|
|
print(f"⚠️ Error processing location data: {e}")
|
|
|
|
return processed
|
|
|
|
def reverse_geocode_coordinates(latitude, longitude):
|
|
"""
|
|
Convert GPS coordinates to human-readable address using reverse geocoding
|
|
Returns address string or None if failed
|
|
"""
|
|
if not latitude or not longitude:
|
|
return None
|
|
|
|
try:
|
|
print(f"🌍 Reverse geocoding coordinates: {latitude}, {longitude}")
|
|
|
|
# Using Nominatim (OpenStreetMap) reverse geocoding service
|
|
url = "https://nominatim.openstreetmap.org/reverse"
|
|
params = {
|
|
'lat': latitude,
|
|
'lon': longitude,
|
|
'format': 'json',
|
|
'addressdetails': 1,
|
|
'zoom': 18 # High detail level
|
|
}
|
|
|
|
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"✅ Reverse geocoded address: {address}")
|
|
return address
|
|
else:
|
|
print(f"⚠️ 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}")
|
|
return None
|
|
|
|
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:
|
|
# Process latitude
|
|
if form_data.get('latitude') and form_data['latitude'] not in ['null', '', 'undefined']:
|
|
lat = float(form_data['latitude'])
|
|
if -90 <= lat <= 90: # Valid latitude range
|
|
processed['latitude'] = lat
|
|
else:
|
|
print(f"⚠️ Invalid latitude: {lat}")
|
|
|
|
# Process longitude
|
|
if form_data.get('longitude') and form_data['longitude'] not in ['null', '', 'undefined']:
|
|
lng = float(form_data['longitude'])
|
|
if -180 <= lng <= 180: # Valid longitude range
|
|
processed['longitude'] = lng
|
|
else:
|
|
print(f"⚠️ Invalid longitude: {lng}")
|
|
|
|
# Process GPS accuracy
|
|
if form_data.get('accuracy') and form_data['accuracy'] not in ['null', '', 'undefined']:
|
|
acc = float(form_data['accuracy'])
|
|
if acc >= 0: # Accuracy should be positive
|
|
processed['accuracy'] = acc
|
|
else:
|
|
print(f"⚠️ Invalid GPS accuracy: {acc}")
|
|
|
|
# Process altitude
|
|
if form_data.get('altitude') and form_data['altitude'] not in ['null', '', 'undefined']:
|
|
alt = float(form_data['altitude'])
|
|
processed['altitude'] = alt
|
|
|
|
# Process address - First check if address was provided
|
|
if form_data.get('address'):
|
|
address = form_data['address'].strip()
|
|
if address and address not in ['null', '', 'undefined']:
|
|
# Check if the address is just coordinates (like "38.8104192000, -77.1850240000")
|
|
if re.match(r'^-?\d+\.\d+,?\s*-?\d+\.\d+$', address.replace(' ', '')):
|
|
print(f"🔍 Detected coordinate-format address: {address}")
|
|
# This is just coordinates, we need to reverse geocode
|
|
processed['address'] = None # Reset so reverse geocoding will trigger
|
|
else:
|
|
# This is a real address
|
|
processed['address'] = address[:500] # Limit to 500 characters
|
|
print(f"✅ Using provided address: {processed['address'][:100]}...")
|
|
|
|
# CRITICAL: If we have coordinates but no real address, perform reverse geocoding
|
|
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(f"⚠️ Could not reverse geocode coordinates, keeping coordinates as fallback")
|
|
processed['address'] = f"{processed['latitude']:.10f}, {processed['longitude']:.10f}"
|
|
|
|
print(f"📍 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
|
|
"""
|
|
try:
|
|
print("🔄 Starting enhanced location accuracy migration...")
|
|
|
|
# Get all records that need recalculation
|
|
records = db.session.execute(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:
|
|
# Calculate enhanced location accuracy
|
|
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:
|
|
# Update the record
|
|
db.session.execute(text("""
|
|
UPDATE attendance_data
|
|
SET location_accuracy = :accuracy
|
|
WHERE id = :record_id
|
|
"""), {
|
|
'accuracy': new_accuracy,
|
|
'record_id': record.id
|
|
})
|
|
|
|
updated_count += 1
|
|
|
|
# Check if this is an improvement
|
|
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}")
|
|
|
|
# Commit all changes
|
|
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)
|
|
"""
|
|
try:
|
|
# MySQL-compatible query for checking column existence
|
|
result = db.session.execute(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
|
|
|
|
def get_employee_checkin_history(employee_id, qr_code_id, date_filter=None):
|
|
"""
|
|
Get check-in history for an employee at a specific location
|
|
"""
|
|
try:
|
|
if date_filter is None:
|
|
date_filter = date.today()
|
|
|
|
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
|
|
|
|
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: # Less than 24 hours
|
|
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"
|
|
|
|
# Authentication decorator
|
|
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
|
|
|
|
# Add this helper function to check admin requirements more safely
|
|
def is_admin_user(user_id):
|
|
"""Helper function to safely check if user is admin"""
|
|
try:
|
|
user = User.Query.get(user_id)
|
|
return user and user.active_status and user.role == 'admin'
|
|
except:
|
|
return False
|
|
|
|
# Utility function to generate QR code
|
|
def generate_qr_code(data):
|
|
"""Generate QR code image and return as base64 string"""
|
|
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")
|
|
|
|
# Convert to base64
|
|
buffer = io.BytesIO()
|
|
img.save(buffer, format='PNG')
|
|
img_str = base64.b64encode(buffer.getvalue()).decode()
|
|
|
|
return img_str
|
|
|
|
@app.template_filter('strftime')
|
|
def strftime_filter(value, format='%m/%d/%Y'):
|
|
"""Format datetime/date/string as strftime"""
|
|
if isinstance(value, str):
|
|
if value.lower() == 'now':
|
|
return datetime.now().strftime(format)
|
|
try:
|
|
# Try to parse string as datetime
|
|
dt = datetime.fromisoformat(value)
|
|
return dt.strftime(format)
|
|
except (ValueError, TypeError):
|
|
return value
|
|
|
|
if hasattr(value, 'strftime'):
|
|
return value.strftime(format)
|
|
|
|
return str(value)
|
|
|
|
# Routes
|
|
@app.route('/')
|
|
def index():
|
|
"""Home page - redirect to login if not authenticated"""
|
|
if 'user_id' in session:
|
|
return redirect(url_for('dashboard'))
|
|
return redirect(url_for('login'))
|
|
|
|
@app.route('/register', methods=['GET', 'POST'])
|
|
@log_user_activity('user_registration')
|
|
def register():
|
|
"""User registration endpoint"""
|
|
if request.method == 'POST':
|
|
try:
|
|
full_name = request.form['full_name']
|
|
email = request.form['email']
|
|
username = request.form['username']
|
|
password = request.form['password']
|
|
|
|
# Check if user already exists
|
|
if User.query.filter_by(username=username).first():
|
|
flash('Username already exists.', 'error')
|
|
return render_template('register.html')
|
|
|
|
if User.query.filter_by(email=email).first():
|
|
flash('Email already registered.', 'error')
|
|
return render_template('register.html')
|
|
|
|
# Create new user (default role: staff)
|
|
new_user = User(
|
|
full_name=full_name,
|
|
email=email,
|
|
username=username,
|
|
role='staff'
|
|
)
|
|
new_user.set_password(password)
|
|
|
|
db.session.add(new_user)
|
|
db.session.commit()
|
|
|
|
# Log successful user registration
|
|
logger_handler.logger.info(f"New user registered: {username} ({email})")
|
|
|
|
flash('Registration successful! Please log in.', 'success')
|
|
return redirect(url_for('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')
|
|
|
|
@app.route('/login', methods=['GET', 'POST'])
|
|
def login():
|
|
"""Enhanced user authentication with comprehensive logging"""
|
|
if request.method == 'POST':
|
|
username = request.form.get('username', '').strip()
|
|
password = request.form.get('password', '')
|
|
|
|
if not username or not password:
|
|
flash('Please enter both username and password.', 'error')
|
|
return render_template('login.html')
|
|
|
|
try:
|
|
# Find user (case-insensitive username)
|
|
user = User.query.filter(
|
|
User.username.ilike(username),
|
|
User.active_status == True
|
|
).first()
|
|
|
|
if user and user.check_password(password):
|
|
# 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
|
|
logger_handler.log_user_login(
|
|
user_id=user.id,
|
|
username=user.username,
|
|
success=True
|
|
)
|
|
|
|
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('dashboard'))
|
|
|
|
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')
|
|
|
|
@app.route('/logout')
|
|
def logout():
|
|
"""User logout endpoint with session duration logging"""
|
|
user_id = session.get('user_id')
|
|
username = session.get('username')
|
|
login_time_str = session.get('login_time')
|
|
|
|
# Calculate session duration
|
|
session_duration = None
|
|
if login_time_str:
|
|
try:
|
|
login_time = datetime.fromisoformat(login_time_str)
|
|
session_duration = (datetime.now() - login_time).total_seconds() / 60 # minutes
|
|
except:
|
|
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'))
|
|
|
|
@app.route('/dashboard')
|
|
@login_required
|
|
def dashboard():
|
|
"""Main dashboard after login - Fixed to show all QR codes"""
|
|
try:
|
|
user = User.query.get(session['user_id'])
|
|
|
|
# Get ALL QR codes (both active and inactive) with proper error handling
|
|
# The frontend filtering will handle display logic
|
|
qr_codes = QRCode.query.order_by(QRCode.created_date.desc()).all()
|
|
|
|
return render_template('dashboard.html', user=user, qr_codes=qr_codes)
|
|
|
|
except Exception as e:
|
|
logger_handler.log_database_error('dashboard_load', e)
|
|
print(f"Error fetching QR codes: {e}")
|
|
flash('Error loading dashboard. Please try again.', 'error')
|
|
qr_codes = []
|
|
user = None
|
|
return render_template('dashboard.html', user=user, qr_codes=qr_codes)
|
|
|
|
# User management routes
|
|
@app.route('/profile', methods=['GET', 'POST'])
|
|
@login_required
|
|
@log_user_activity('profile_update')
|
|
def profile():
|
|
"""User profile management with logging"""
|
|
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'))
|
|
|
|
@app.route('/users')
|
|
@admin_required
|
|
def users():
|
|
"""Display all users (Admin only)"""
|
|
try:
|
|
users = User.query.order_by(User.created_date.desc()).all()
|
|
return render_template('users.html', users=users)
|
|
except Exception as e:
|
|
logger_handler.log_database_error('users_list', e)
|
|
flash('Error loading users list.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
@app.route('/users/create', methods=['GET', 'POST'])
|
|
@admin_required
|
|
@log_database_operations('user_creation')
|
|
def create_user():
|
|
"""Create new user (Admin only)"""
|
|
if request.method == 'POST':
|
|
try:
|
|
full_name = request.form['full_name']
|
|
email = request.form['email']
|
|
username = request.form['username']
|
|
password = request.form['password']
|
|
role = request.form['role']
|
|
|
|
# Validate role
|
|
if role not in VALID_ROLES:
|
|
flash(f'Invalid role selected. Valid roles: {", ".join(VALID_ROLES)}', 'error')
|
|
return render_template('create_user.html')
|
|
|
|
# Check if user already exists
|
|
if User.query.filter_by(username=username).first():
|
|
flash('Username already exists.', 'error')
|
|
return render_template('create_user.html')
|
|
|
|
if User.query.filter_by(email=email).first():
|
|
flash('Email already registered.', 'error')
|
|
return render_template('create_user.html')
|
|
|
|
# Create new user
|
|
new_user = User(
|
|
full_name=full_name,
|
|
email=email,
|
|
username=username,
|
|
role=role,
|
|
created_by=session['user_id']
|
|
)
|
|
new_user.set_password(password)
|
|
|
|
db.session.add(new_user)
|
|
db.session.commit()
|
|
|
|
# Log user creation
|
|
logger_handler.logger.info(f"Admin user {session['username']} created new user: {username} with role {role}")
|
|
|
|
flash(f'User "{full_name}" created successfully with role "{role}".', 'success')
|
|
return redirect(url_for('users'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger_handler.log_database_error('user_creation', e)
|
|
flash('Failed to create user. Please try again.', 'error')
|
|
|
|
return render_template('create_user.html', valid_roles=VALID_ROLES)
|
|
|
|
@app.route('/users/<int:user_id>/delete', methods=['GET', 'POST'])
|
|
@admin_required
|
|
def delete_user(user_id):
|
|
"""Deactivate user (Admin only) - Fixed with proper validation"""
|
|
try:
|
|
user_to_delete = User.query.get(user_id)
|
|
current_user = User.query.get(session['user_id'])
|
|
|
|
if not user_to_delete:
|
|
flash('User not found.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# Prevent self-deletion
|
|
if user_to_delete.id == current_user.id:
|
|
flash('You cannot deactivate your own account. Ask another admin to do this.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# Check if trying to delete the last admin
|
|
if user_to_delete.role == 'admin':
|
|
active_admin_count = User.query.filter_by(role='admin', active_status=True).count()
|
|
if active_admin_count <= 1:
|
|
flash('Cannot deactivate the last admin user. Promote another user to admin first.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# Deactivate the user instead of deleting
|
|
user_to_delete.active_status = False
|
|
db.session.commit()
|
|
|
|
flash(f'User "{user_to_delete.full_name}" has been deactivated successfully.', 'success')
|
|
print(f"Admin {current_user.username} deactivated user: {user_to_delete.username}")
|
|
|
|
return redirect(url_for('users'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error deactivating user: {e}")
|
|
flash('Error deactivating user. Please try again.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
@app.route('/users/<int:user_id>/reactivate', methods=['GET', 'POST'])
|
|
@admin_required
|
|
def reactivate_user(user_id):
|
|
"""Reactivate a deactivated user (Admin only)"""
|
|
try:
|
|
user_to_reactivate = User.query.get(user_id)
|
|
current_user = User.query.get(session['user_id'])
|
|
|
|
if not user_to_reactivate:
|
|
flash('User not found.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
if user_to_reactivate.active_status:
|
|
flash('User is already active.', 'info')
|
|
else:
|
|
user_to_reactivate.active_status = True
|
|
db.session.commit()
|
|
flash(f'User "{user_to_reactivate.full_name}" has been reactivated successfully.', 'success')
|
|
print(f"Admin {current_user.username} reactivated user: {user_to_reactivate.username}")
|
|
|
|
return redirect(url_for('users'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error reactivating user: {e}")
|
|
flash('Error reactivating user. Please try again.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
@app.route('/users/<int:user_id>/promote', methods=['GET', 'POST'])
|
|
@admin_required
|
|
def promote_user(user_id):
|
|
"""Promote a staff user to admin (Admin only)"""
|
|
try:
|
|
user_to_promote = User.query.get(user_id)
|
|
current_user = User.query.get(session['user_id'])
|
|
|
|
if not user_to_promote:
|
|
flash('User not found.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
if user_to_promote.role == 'admin':
|
|
flash('User is already an admin.', 'info')
|
|
else:
|
|
user_to_promote.role = 'admin'
|
|
db.session.commit()
|
|
flash(f'"{user_to_promote.full_name}" has been promoted to admin.', 'success')
|
|
print(f"Admin {current_user.username} promoted user {user_to_promote.username} to admin")
|
|
|
|
return redirect(url_for('users'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error promoting user: {e}")
|
|
flash('Error promoting user. Please try again.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
@app.route('/users/<int:user_id>/demote', methods=['GET', 'POST'])
|
|
@admin_required
|
|
def demote_user(user_id):
|
|
"""Demote an admin user to staff (Admin only)"""
|
|
try:
|
|
user_to_demote = User.query.get(user_id)
|
|
current_user = User.query.get(session['user_id'])
|
|
|
|
if not user_to_demote:
|
|
flash('User not found.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# Prevent self-demotion
|
|
if user_to_demote.id == current_user.id:
|
|
flash('You cannot demote yourself. Have another admin do this.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# Check if this is the last admin
|
|
active_admin_count = User.query.filter_by(role='admin', active_status=True).count()
|
|
if active_admin_count <= 1 and user_to_demote.role == 'admin':
|
|
flash('Cannot demote the last admin user. Promote another user to admin first.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
if has_staff_level_access(user_to_demote.role):
|
|
flash('User already has staff-level permissions.', 'info')
|
|
else:
|
|
user_to_demote.role = 'staff'
|
|
db.session.commit()
|
|
flash(f'"{user_to_demote.full_name}" has been demoted to staff.', 'success')
|
|
print(f"Admin {current_user.username} demoted user {user_to_demote.username} to staff")
|
|
|
|
return redirect(url_for('users'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error demoting user: {e}")
|
|
flash('Error demoting user. Please try again.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
@app.route('/users/<int:user_id>/edit', methods=['GET', 'POST'])
|
|
@admin_required
|
|
@log_database_operations('user_update')
|
|
def edit_user(user_id):
|
|
"""Edit user details (Admin only)"""
|
|
try:
|
|
user_to_edit = User.query.get_or_404(user_id)
|
|
|
|
if request.method == 'POST':
|
|
# Track changes
|
|
changes = {}
|
|
old_values = {
|
|
'full_name': user_to_edit.full_name,
|
|
'email': user_to_edit.email,
|
|
'role': user_to_edit.role
|
|
}
|
|
|
|
# Update user details
|
|
user_to_edit.full_name = request.form['full_name']
|
|
user_to_edit.email = request.form['email']
|
|
new_role = request.form['role']
|
|
|
|
# Validate role
|
|
if new_role not in VALID_ROLES:
|
|
flash(f'Invalid role selected. Valid roles: {", ".join(VALID_ROLES)}', 'error')
|
|
return render_template('edit_user.html', user=user_to_edit, valid_roles=VALID_ROLES)
|
|
|
|
user_to_edit.role = new_role
|
|
|
|
# Handle password update if provided
|
|
new_password = request.form.get('password')
|
|
if new_password and new_password.strip():
|
|
user_to_edit.set_password(new_password)
|
|
changes['password'] = 'Password updated'
|
|
|
|
# Track changes
|
|
for field, old_value in old_values.items():
|
|
new_value = getattr(user_to_edit, field)
|
|
if old_value != new_value:
|
|
changes[field] = {'old': old_value, 'new': new_value}
|
|
|
|
db.session.commit()
|
|
|
|
# Log user update
|
|
if changes:
|
|
logger_handler.logger.info(f"Admin user {session['username']} updated user {user_to_edit.username}: {json.dumps(changes)}")
|
|
|
|
flash(f'User "{user_to_edit.full_name}" updated successfully.', 'success')
|
|
return redirect(url_for('users'))
|
|
|
|
return render_template('edit_user.html', user=user_to_edit, valid_roles=VALID_ROLES)
|
|
|
|
except Exception as e:
|
|
logger_handler.log_database_error('user_update', e)
|
|
flash('Error updating user. Please try again.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
@app.route('/users/<int:user_id>/toggle-status', methods=['POST'])
|
|
@admin_required
|
|
def toggle_user_status(user_id):
|
|
"""Toggle user active status via AJAX (Admin only)"""
|
|
try:
|
|
user_to_toggle = User.query.get(user_id)
|
|
current_user = User.query.get(session['user_id'])
|
|
|
|
if not user_to_toggle:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'User not found.'
|
|
}), 404
|
|
|
|
# Prevent self-deactivation
|
|
if user_to_toggle.id == current_user.id:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'You cannot deactivate yourself.'
|
|
}), 400
|
|
|
|
# Check if trying to deactivate the last admin
|
|
if (user_to_toggle.role == 'admin' and
|
|
user_to_toggle.active_status and
|
|
User.query.filter_by(role='admin', active_status=True).count() <= 1):
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Cannot deactivate the last admin user.'
|
|
}), 400
|
|
|
|
# Toggle the status
|
|
new_status = not user_to_toggle.active_status
|
|
user_to_toggle.active_status = new_status
|
|
db.session.commit()
|
|
|
|
action = 'activated' if new_status else 'deactivated'
|
|
message = f'"{user_to_toggle.full_name}" has been {action} successfully.'
|
|
|
|
# Log status change
|
|
logger_handler.logger.info(f"Admin {current_user.username} {action} user {user_to_toggle.username}")
|
|
|
|
print(f"Admin {current_user.username} {action} user {user_to_toggle.username}")
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': message,
|
|
'new_status': new_status,
|
|
'user_id': user_id
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger_handler.log_database_error('user_status_toggle', e)
|
|
print(f"Error toggling user status: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Error updating user status. Please try again.'
|
|
}), 500
|
|
|
|
@app.route('/users/<int:user_id>/activate', methods=['GET', 'POST'])
|
|
@admin_required
|
|
def activate_user(user_id):
|
|
"""Activate a user (Admin only) - Alternative route"""
|
|
try:
|
|
user_to_activate = User.query.get(user_id)
|
|
current_user = User.query.get(session['user_id'])
|
|
|
|
if not user_to_activate:
|
|
flash('User not found.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
if user_to_activate.active_status:
|
|
flash('User is already active.', 'info')
|
|
else:
|
|
user_to_activate.active_status = True
|
|
db.session.commit()
|
|
|
|
# Log activation
|
|
logger_handler.logger.info(f"Admin {current_user.username} activated user {user_to_activate.username}")
|
|
|
|
flash(f'"{user_to_activate.full_name}" has been activated.', 'success')
|
|
print(f"Admin {current_user.username} activated user {user_to_activate.username}")
|
|
|
|
return redirect(url_for('users'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger_handler.log_database_error('user_activation', e)
|
|
print(f"Error activating user: {e}")
|
|
flash('Error activating user. Please try again.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
@app.route('/users/<int:user_id>/deactivate', methods=['GET', 'POST'])
|
|
@admin_required
|
|
def deactivate_user(user_id):
|
|
"""Deactivate a user (Admin only) - Alternative route"""
|
|
try:
|
|
user_to_deactivate = User.query.get(user_id)
|
|
current_user = User.query.get(session['user_id'])
|
|
|
|
if not user_to_deactivate:
|
|
flash('User not found.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# Prevent self-deactivation
|
|
if user_to_deactivate.id == current_user.id:
|
|
flash('You cannot deactivate yourself.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# Check if this is the last admin
|
|
if user_to_deactivate.role == 'admin' and user_to_deactivate.active_status:
|
|
active_admin_count = User.query.filter_by(role='admin', active_status=True).count()
|
|
if active_admin_count <= 1:
|
|
flash('Cannot deactivate the last admin user.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
if not user_to_deactivate.active_status:
|
|
flash('User is already inactive.', 'info')
|
|
else:
|
|
user_to_deactivate.active_status = False
|
|
db.session.commit()
|
|
|
|
# Log deactivation
|
|
logger_handler.logger.info(f"Admin {current_user.username} deactivated user {user_to_deactivate.username}")
|
|
|
|
flash(f'"{user_to_deactivate.full_name}" has been deactivated.', 'success')
|
|
print(f"Admin {current_user.username} deactivated user {user_to_deactivate.username}")
|
|
|
|
return redirect(url_for('users'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger_handler.log_database_error('user_deactivation', e)
|
|
print(f"Error deactivating user: {e}")
|
|
flash('Error deactivating user. Please try again.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# ENHANCED USER STATISTICS API
|
|
@app.route('/api/users/stats')
|
|
@admin_required
|
|
def user_stats_api():
|
|
"""API endpoint to get user statistics for dashboard"""
|
|
try:
|
|
# Get current date for recent activity calculations
|
|
one_week_ago = datetime.now() - timedelta(days=7)
|
|
|
|
total_users = User.query.count()
|
|
active_users = User.query.filter_by(active_status=True).count()
|
|
admin_users = User.query.filter_by(role='admin', active_status=True).count()
|
|
staff_users = User.query.filter_by(role='staff', active_status=True).count()
|
|
payroll_users = User.query.filter_by(role='payroll', active_status=True).count()
|
|
project_manager_users = User.query.filter_by(role='project_manager', active_status=True).count()
|
|
inactive_users = User.query.filter_by(active_status=False).count()
|
|
|
|
recent_registrations = User.query.filter(
|
|
User.created_date >= one_week_ago
|
|
).count()
|
|
|
|
recent_logins = User.query.filter(
|
|
User.last_login_date >= one_week_ago
|
|
).count()
|
|
|
|
return jsonify({
|
|
'total_users': total_users,
|
|
'active_users': active_users,
|
|
'admin_users': admin_users,
|
|
'staff_users': staff_users,
|
|
'payroll_users': payroll_users,
|
|
'project_manager_users': project_manager_users,
|
|
'inactive_users': inactive_users,
|
|
'recent_registrations': recent_registrations,
|
|
'recent_logins': recent_logins
|
|
})
|
|
|
|
except Exception as e:
|
|
logger_handler.log_database_error('user_stats_api', e)
|
|
print(f"Error fetching user stats: {e}")
|
|
return jsonify({'error': 'Failed to fetch user statistics'}), 500
|
|
|
|
@app.route('/api/roles/permissions')
|
|
@admin_required
|
|
def role_permissions_api():
|
|
"""NEW: API endpoint to get role permissions data"""
|
|
try:
|
|
permissions_data = {}
|
|
for role in VALID_ROLES:
|
|
permissions_data[role] = get_role_permissions(role)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'roles': permissions_data,
|
|
'valid_roles': VALID_ROLES,
|
|
'staff_level_roles': STAFF_LEVEL_ROLES
|
|
})
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching role permissions: {e}")
|
|
return jsonify({'error': 'Failed to fetch role permissions'}), 500
|
|
|
|
@app.route('/api/geocode', methods=['POST'])
|
|
@login_required
|
|
def geocode_address():
|
|
"""API endpoint to geocode an address and return coordinates"""
|
|
try:
|
|
data = request.get_json()
|
|
address = data.get('address', '').strip()
|
|
|
|
if not address:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Address is required'
|
|
}), 400
|
|
|
|
# Use the enhanced function that returns 3 values
|
|
lat, lng, accuracy = geocode_address_enhanced(address)
|
|
|
|
if lat is not None and lng is not None:
|
|
return jsonify({
|
|
'success': True,
|
|
'data': {
|
|
'latitude': lat,
|
|
'longitude': lng,
|
|
'accuracy': accuracy,
|
|
'coordinates_display': f"{lat:.10f}, {lng:.10f}"
|
|
},
|
|
'message': f'Address geocoded successfully with {accuracy} accuracy'
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Unable to geocode the provided address. Please verify the address is complete and accurate.'
|
|
}), 400
|
|
|
|
except Exception as e:
|
|
logger_handler.log_flask_error('geocode_api_error', str(e))
|
|
print(f"❌ Geocoding API error: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'An error occurred while geocoding the address'
|
|
}), 500
|
|
|
|
@app.route('/users/<int:user_id>/permanently-delete', methods=['GET', 'POST'])
|
|
@admin_required
|
|
def permanently_delete_user(user_id):
|
|
"""Permanently delete user but preserve associated QR codes (Admin only)"""
|
|
try:
|
|
user_to_delete = User.query.get_or_404(user_id)
|
|
current_user = User.query.get(session['user_id'])
|
|
|
|
# Security checks
|
|
if user_to_delete.id == current_user.id:
|
|
flash('You cannot delete your own account.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# Only allow deletion of inactive users for safety
|
|
if user_to_delete.active_status:
|
|
flash('User must be deactivated before permanent deletion.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# If deleting an admin, ensure at least one admin remains
|
|
if user_to_delete.role == 'admin':
|
|
active_admin_count = User.query.filter_by(role='admin', active_status=True).count()
|
|
if active_admin_count <= 1:
|
|
flash('Cannot delete the last admin user in the system.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
user_name = user_to_delete.full_name
|
|
user_qr_count = user_to_delete.created_qr_codes.count()
|
|
username = user_to_delete.username
|
|
|
|
# MODIFIED: Preserve QR codes by setting created_by to NULL instead of deleting them
|
|
orphaned_qr_codes = QRCode.query.filter_by(created_by=user_id).all()
|
|
for qr_code in orphaned_qr_codes:
|
|
qr_code.created_by = None
|
|
|
|
# Update any users that were created by this user (set created_by to None)
|
|
created_users = User.query.filter_by(created_by=user_id).all()
|
|
for created_user in created_users:
|
|
created_user.created_by = None
|
|
|
|
# Log user deletion before actual deletion
|
|
logger_handler.log_security_event(
|
|
event_type="user_permanent_deletion",
|
|
description=f"Admin {current_user.username} permanently deleted user {username}",
|
|
severity="HIGH",
|
|
additional_data={'deleted_user': username, 'qr_codes_orphaned': user_qr_count}
|
|
)
|
|
|
|
# Delete the user
|
|
db.session.delete(user_to_delete)
|
|
db.session.commit()
|
|
|
|
# Updated flash message to reflect QR codes are preserved
|
|
flash(f'User "{user_name}" has been permanently deleted. {user_qr_count} QR codes created by this user are now orphaned but preserved.', 'success')
|
|
print(f"Admin {current_user.username} permanently deleted user: {username}, preserved {user_qr_count} QR codes")
|
|
|
|
return redirect(url_for('users'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger_handler.log_database_error('user_permanent_deletion', e)
|
|
print(f"Error permanently deleting user: {e}")
|
|
flash('Error deleting user. Please try again.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# Admin logging routes
|
|
@app.route('/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'))
|
|
|
|
# API endpoints for logging data (admin only)
|
|
@app.route('/api/logs/recent')
|
|
@admin_required
|
|
def api_recent_logs():
|
|
"""API endpoint to get recent log entries"""
|
|
try:
|
|
days = request.args.get('days', 1, type=int)
|
|
limit = request.args.get('limit', 50, type=int)
|
|
|
|
cutoff_date = datetime.now() - timedelta(days=days)
|
|
|
|
logs_sql = """
|
|
SELECT event_type, event_category, event_description,
|
|
severity_level, created_timestamp, username, ip_address
|
|
FROM log_events
|
|
WHERE created_timestamp >= :cutoff_date
|
|
ORDER BY created_timestamp DESC
|
|
LIMIT :limit
|
|
"""
|
|
|
|
result = db.session.execute(text(logs_sql), {
|
|
'cutoff_date': cutoff_date,
|
|
'limit': limit
|
|
}).fetchall()
|
|
|
|
logs = []
|
|
for row in result:
|
|
logs.append({
|
|
'event_type': row.event_type,
|
|
'event_category': row.event_category,
|
|
'description': row.event_description,
|
|
'severity': row.severity_level,
|
|
'timestamp': row.created_timestamp.isoformat(),
|
|
'username': row.username,
|
|
'ip_address': row.ip_address
|
|
})
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'logs': logs,
|
|
'total': len(logs)
|
|
})
|
|
|
|
except Exception as e:
|
|
logger_handler.log_database_error('api_recent_logs', e)
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Failed to fetch recent logs'
|
|
}), 500
|
|
|
|
@app.route('/api/logs/stats')
|
|
@admin_required
|
|
def api_log_stats():
|
|
"""API endpoint to get logging statistics"""
|
|
try:
|
|
days = request.args.get('days', 7, type=int)
|
|
stats = logger_handler.get_log_statistics(days=days)
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'stats': stats,
|
|
'days': days
|
|
})
|
|
|
|
except Exception as e:
|
|
logger_handler.log_database_error('api_log_stats', e)
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Failed to fetch log statistics'
|
|
}), 500
|
|
|
|
@app.route('/api/logs/cleanup', methods=['POST'])
|
|
@admin_required
|
|
def api_cleanup_logs():
|
|
"""API endpoint to cleanup old log entries"""
|
|
try:
|
|
days_to_keep = request.json.get('days_to_keep', 90)
|
|
deleted_count = logger_handler.cleanup_old_logs(days_to_keep=days_to_keep)
|
|
|
|
# Log the cleanup operation
|
|
logger_handler.logger.info(f"Log cleanup completed: {deleted_count} entries removed")
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'deleted_count': deleted_count,
|
|
'message': f'Successfully cleaned up {deleted_count} old log entries'
|
|
})
|
|
|
|
except Exception as e:
|
|
logger_handler.log_database_error('api_cleanup_logs', e)
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Failed to cleanup old logs'
|
|
}), 500
|
|
|
|
# PROJECT MANAGEMENT ROUTES
|
|
@app.route('/projects')
|
|
@admin_required
|
|
def projects():
|
|
"""Display all projects"""
|
|
try:
|
|
projects = Project.query.order_by(Project.created_date.desc()).all()
|
|
return render_template('projects.html', projects=projects)
|
|
except Exception as e:
|
|
logger_handler.log_database_error('projects_list', e)
|
|
flash('Error loading projects list.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
@app.route('/projects/create', methods=['GET', 'POST'])
|
|
@admin_required
|
|
@log_database_operations('project_creation')
|
|
def create_project():
|
|
"""Create new project"""
|
|
if request.method == 'POST':
|
|
try:
|
|
name = request.form['name']
|
|
description = request.form.get('description', '')
|
|
|
|
# Check if project name already exists
|
|
if Project.query.filter_by(name=name).first():
|
|
flash('Project name already exists.', 'error')
|
|
return render_template('create_project.html')
|
|
|
|
# Create new project
|
|
new_project = Project(
|
|
name=name,
|
|
description=description,
|
|
created_by=session['user_id']
|
|
)
|
|
|
|
db.session.add(new_project)
|
|
db.session.commit()
|
|
|
|
# Log project creation
|
|
logger_handler.logger.info(f"User {session['username']} created new project: {name}")
|
|
|
|
flash(f'Project "{name}" created successfully.', 'success')
|
|
return redirect(url_for('projects'))
|
|
|
|
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')
|
|
|
|
@app.route('/projects/<int:project_id>/edit', methods=['GET', 'POST'])
|
|
@admin_required
|
|
@log_database_operations('project_edit')
|
|
def edit_project(project_id):
|
|
"""Edit existing project"""
|
|
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'))
|
|
|
|
@app.route('/projects/<int:project_id>/toggle', methods=['POST'])
|
|
@admin_required
|
|
@log_database_operations('project_toggle')
|
|
def toggle_project(project_id):
|
|
"""Toggle project active status"""
|
|
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
|
|
@app.route('/api/projects/active')
|
|
@login_required
|
|
def api_active_projects():
|
|
"""Get active projects for dropdown"""
|
|
try:
|
|
projects = Project.query.filter_by(active_status=True).order_by(Project.name.asc()).all()
|
|
|
|
projects_data = [
|
|
{
|
|
'id': project.id,
|
|
'name': project.name,
|
|
'description': project.description,
|
|
'qr_count': project.qr_count
|
|
}
|
|
for project in projects
|
|
]
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'projects': projects_data
|
|
})
|
|
|
|
except Exception as e:
|
|
logger_handler.log_database_error('api_active_projects', e)
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Failed to fetch projects'
|
|
}), 500
|
|
|
|
# QR code management routes
|
|
@app.route('/qr-codes/create', methods=['GET', 'POST'])
|
|
@login_required
|
|
@log_database_operations('qr_code_creation')
|
|
def create_qr_code():
|
|
"""Enhanced create QR code with readable URL format and coordinates saving"""
|
|
if request.method == 'POST':
|
|
try:
|
|
name = request.form['name']
|
|
location = request.form['location']
|
|
location_address = request.form['location_address']
|
|
location_event = request.form.get('location_event', '')
|
|
project_id = request.form.get('project_id')
|
|
|
|
# Extract coordinates data from form
|
|
latitude = request.form.get('latitude')
|
|
longitude = request.form.get('longitude')
|
|
coordinate_accuracy = request.form.get('coordinate_accuracy', 'geocoded')
|
|
|
|
# Convert coordinates to float if they exist
|
|
address_latitude = None
|
|
address_longitude = None
|
|
has_coordinates = False
|
|
|
|
if latitude and longitude:
|
|
try:
|
|
address_latitude = float(latitude)
|
|
address_longitude = float(longitude)
|
|
has_coordinates = True
|
|
print(f"✓ Coordinates received: {address_latitude}, {address_longitude}")
|
|
except (ValueError, TypeError) as e:
|
|
print(f"⚠️ Invalid coordinates format: {e}")
|
|
address_latitude = None
|
|
address_longitude = None
|
|
has_coordinates = False
|
|
|
|
# Validate project_id if provided
|
|
project = None
|
|
if project_id:
|
|
project_id = int(project_id)
|
|
project = Project.query.get(project_id)
|
|
if not project or not project.active_status:
|
|
flash('Selected project is not valid or inactive.', 'error')
|
|
return render_template('create_qr_code.html')
|
|
|
|
# Create new QR code record first (without URL and image)
|
|
new_qr_code = QRCode(
|
|
name=name,
|
|
location=location,
|
|
location_address=location_address,
|
|
location_event=location_event,
|
|
qr_code_image="", # Will be updated after URL generation
|
|
qr_url="", # Will be updated after ID is assigned
|
|
created_by=session['user_id'],
|
|
project_id=project_id,
|
|
address_latitude=address_latitude,
|
|
address_longitude=address_longitude,
|
|
coordinate_accuracy=coordinate_accuracy if has_coordinates else None,
|
|
coordinates_updated_date=datetime.utcnow() if has_coordinates else None
|
|
)
|
|
|
|
# Add to session and flush to get the ID
|
|
db.session.add(new_qr_code)
|
|
db.session.flush() # This assigns the ID without committing
|
|
|
|
# Now generate the readable URL using the ID
|
|
qr_url = generate_qr_url(name, new_qr_code.id)
|
|
|
|
# Generate QR code data with the destination URL
|
|
qr_data = f"{request.url_root}qr/{qr_url}"
|
|
qr_image = generate_qr_code(qr_data)
|
|
|
|
# Update the QR code with the URL and image
|
|
new_qr_code.qr_url = qr_url
|
|
new_qr_code.qr_code_image = qr_image
|
|
|
|
# Now commit all changes
|
|
db.session.commit()
|
|
|
|
# Enhanced logging with project and coordinates information
|
|
log_data = {
|
|
'qr_code_id': new_qr_code.id,
|
|
'name': name,
|
|
'location': location,
|
|
'qr_url': qr_url, # Log the readable URL
|
|
'project_id': project_id,
|
|
'project_name': project.name if project else None,
|
|
'has_coordinates': has_coordinates,
|
|
'latitude': address_latitude,
|
|
'longitude': address_longitude,
|
|
'coordinate_accuracy': coordinate_accuracy
|
|
}
|
|
logger_handler.logger.info(f"User {session['username']} created QR code: {json.dumps(log_data)}")
|
|
|
|
# Success message with coordinates info
|
|
project_info = f" in project '{project.name}'" if project else ""
|
|
coord_info = f" with coordinates ({new_qr_code.coordinates_display})" if has_coordinates else ""
|
|
|
|
flash(f'QR Code "{name}" created successfully{project_info}{coord_info}! URL: {qr_url}', 'success')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger_handler.log_database_error('qr_code_creation', e)
|
|
flash('QR Code creation failed. Please try again.', 'error')
|
|
print(f"❌ QR Code creation error: {e}")
|
|
|
|
# Get active projects for dropdown
|
|
projects = Project.query.filter_by(active_status=True).order_by(Project.name.asc()).all()
|
|
return render_template('create_qr_code.html', projects=projects)
|
|
|
|
@app.route('/qr-codes/<int:qr_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
@log_database_operations('qr_code_edit')
|
|
def edit_qr_code(qr_id):
|
|
"""Enhanced edit QR code with project association and URL regeneration"""
|
|
try:
|
|
qr_code = QRCode.query.get_or_404(qr_id)
|
|
|
|
if request.method == 'POST':
|
|
# Track changes for logging
|
|
old_data = {
|
|
'name': qr_code.name,
|
|
'location': qr_code.location,
|
|
'location_address': qr_code.location_address,
|
|
'location_event': qr_code.location_event,
|
|
'project_id': qr_code.project_id,
|
|
'qr_url': qr_code.qr_url
|
|
}
|
|
|
|
# Update QR code fields
|
|
new_name = request.form['name']
|
|
qr_code.name = new_name
|
|
qr_code.location = request.form['location']
|
|
qr_code.location_address = request.form['location_address']
|
|
qr_code.location_event = request.form.get('location_event', '')
|
|
|
|
# Handle coordinates update
|
|
latitude = request.form.get('latitude')
|
|
longitude = request.form.get('longitude')
|
|
coordinate_accuracy = request.form.get('coordinate_accuracy', 'geocoded')
|
|
|
|
if latitude and longitude:
|
|
try:
|
|
qr_code.address_latitude = float(latitude)
|
|
qr_code.address_longitude = float(longitude)
|
|
qr_code.coordinate_accuracy = coordinate_accuracy
|
|
qr_code.coordinates_updated_date = datetime.utcnow()
|
|
except (ValueError, TypeError):
|
|
pass # Keep existing coordinates if invalid
|
|
|
|
# Handle project association
|
|
new_project_id = request.form.get('project_id')
|
|
if new_project_id:
|
|
new_project_id = int(new_project_id)
|
|
project = Project.query.get(new_project_id)
|
|
if project and project.active_status:
|
|
qr_code.project_id = new_project_id
|
|
else:
|
|
flash('Selected project is not valid or inactive.', 'error')
|
|
return render_template('edit_qr_code.html', qr_code=qr_code, projects=Project.query.filter_by(active_status=True).all())
|
|
else:
|
|
qr_code.project_id = None
|
|
|
|
# Regenerate URL if name changed
|
|
name_changed = old_data['name'] != new_name
|
|
if name_changed:
|
|
new_qr_url = generate_qr_url(new_name, qr_code.id)
|
|
|
|
# Regenerate QR code image with new URL
|
|
qr_data = f"{request.url_root}qr/{new_qr_url}"
|
|
new_qr_image = generate_qr_code(qr_data)
|
|
|
|
qr_code.qr_url = new_qr_url
|
|
qr_code.qr_code_image = new_qr_image
|
|
|
|
db.session.commit()
|
|
|
|
# Log changes
|
|
new_data = {
|
|
'name': qr_code.name,
|
|
'location': qr_code.location,
|
|
'location_address': qr_code.location_address,
|
|
'location_event': qr_code.location_event,
|
|
'project_id': qr_code.project_id,
|
|
'qr_url': qr_code.qr_url
|
|
}
|
|
|
|
changes = {}
|
|
for key in old_data:
|
|
if old_data[key] != new_data[key]:
|
|
changes[key] = {'old': old_data[key], 'new': new_data[key]}
|
|
|
|
if changes:
|
|
logger_handler.logger.info(f"User {session['username']} updated QR code {qr_id}: {json.dumps(changes)}")
|
|
|
|
url_message = f" URL updated to: {qr_code.qr_url}" if name_changed else ""
|
|
flash(f'QR Code "{qr_code.name}" updated successfully!{url_message}', 'success')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
# Get active projects for dropdown
|
|
projects = Project.query.filter_by(active_status=True).order_by(Project.name.asc()).all()
|
|
return render_template('edit_qr_code.html', qr_code=qr_code, projects=projects)
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger_handler.log_database_error('qr_code_edit', e)
|
|
flash('QR Code update failed. Please try again.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
@app.route('/qr-codes/<int:qr_id>/delete', methods=['GET', 'POST'])
|
|
@admin_required
|
|
@log_database_operations('qr_code_deletion')
|
|
def delete_qr_code(qr_id):
|
|
"""Permanently delete QR code (Admin only) - Hard delete - PRESERVING EXACT ROUTE"""
|
|
|
|
# OBVIOUS DEBUGGING - You MUST see this in console
|
|
print("\n" + "="*60)
|
|
print("🔥 DELETE ROUTE WAS CALLED! 🔥")
|
|
print(f"🔥 QR ID: {qr_id}")
|
|
print(f"🔥 Method: {request.method}")
|
|
print(f"🔥 User: {session.get('username', 'NO_USER')}")
|
|
print(f"🔥 Role: {session.get('role', 'NO_ROLE')}")
|
|
print("="*60 + "\n")
|
|
|
|
try:
|
|
qr_code = QRCode.query.get_or_404(qr_id)
|
|
print(f"✅ Found QR Code: {qr_code.name}")
|
|
|
|
if request.method == 'POST':
|
|
qr_name = qr_code.name
|
|
qr_code_id = qr_code.id
|
|
print(f"🗑️ ATTEMPTING TO DELETE: {qr_name}")
|
|
|
|
# Check if QR exists before delete
|
|
before_count = QRCode.query.count()
|
|
print(f"📊 QR count before delete: {before_count}")
|
|
|
|
# Log QR code deletion before actual deletion
|
|
logger_handler.log_qr_code_deleted(
|
|
qr_code_id=qr_code_id,
|
|
qr_code_name=qr_name,
|
|
deleted_by_user_id=session['user_id']
|
|
)
|
|
|
|
# Delete the QR code
|
|
db.session.delete(qr_code)
|
|
print("💾 Called db.session.delete()")
|
|
|
|
db.session.commit()
|
|
print("💾 Called db.session.commit()")
|
|
|
|
# Check count after delete
|
|
after_count = QRCode.query.count()
|
|
print(f"📊 QR count after delete: {after_count}")
|
|
print(f"✅ DELETE SUCCESS! Removed {before_count - after_count} records")
|
|
|
|
flash(f'QR code "{qr_name}" has been permanently deleted!', 'success')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
# GET request - show confirmation page
|
|
print("📄 Showing confirmation page")
|
|
return render_template('confirm_delete_qr.html', qr_code=qr_code)
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger_handler.log_database_error('qr_code_deletion', e)
|
|
print(f"❌ ERROR in delete route: {e}")
|
|
print(f"❌ Exception type: {type(e)}")
|
|
import traceback
|
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
|
flash('Error deleting QR code. Please try again.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
@app.route('/qr/<string:qr_url>')
|
|
def qr_destination(qr_url):
|
|
"""QR code destination page where staff check in - PRESERVING EXACT ROUTE"""
|
|
try:
|
|
# Find QR code by URL
|
|
qr_code = QRCode.query.filter_by(qr_url=qr_url, active_status=True).first()
|
|
|
|
if not qr_code:
|
|
# Log invalid QR code access attempt
|
|
logger_handler.log_security_event(
|
|
event_type="invalid_qr_access",
|
|
description=f"Attempt to access invalid QR code URL: {qr_url}",
|
|
severity="MEDIUM"
|
|
)
|
|
flash('QR code not found or inactive.', 'error')
|
|
return redirect(url_for('index'))
|
|
|
|
# Log QR code access
|
|
logger_handler.log_qr_code_accessed(
|
|
qr_code_id=qr_code.id,
|
|
qr_code_name=qr_code.name,
|
|
access_method='scan'
|
|
)
|
|
|
|
return render_template('qr_destination.html', qr_code=qr_code)
|
|
|
|
except Exception as e:
|
|
logger_handler.log_database_error('qr_code_scan', e)
|
|
flash('Error processing QR code scan.', 'error')
|
|
return redirect(url_for('index'))
|
|
|
|
@app.route('/qr/<string:qr_url>/checkin', methods=['POST'])
|
|
def qr_checkin(qr_url):
|
|
"""
|
|
Enhanced staff check-in with location accuracy calculation
|
|
Allows multiple check-ins with minimum interval between them
|
|
PRESERVES coordinate-to-address conversion functionality
|
|
"""
|
|
try:
|
|
print(f"\n🚀 STARTING ENHANCED CHECK-IN PROCESS")
|
|
print(f" QR URL: {qr_url}")
|
|
print(f" Timestamp: {datetime.now()}")
|
|
|
|
# Find QR code by URL
|
|
qr_code = QRCode.query.filter_by(qr_url=qr_url, active_status=True).first()
|
|
|
|
if not qr_code:
|
|
print(f"❌ QR code not found or inactive: {qr_url}")
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'QR code not found or inactive.'
|
|
}), 404
|
|
|
|
print(f"✅ Found QR code: {qr_code.name} (ID: {qr_code.id})")
|
|
print(f" Location: {qr_code.location}")
|
|
print(f" QR Address: {qr_code.location_address}")
|
|
|
|
# Get and validate employee ID
|
|
employee_id = request.form.get('employee_id', '').strip()
|
|
|
|
if not employee_id:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Employee ID is required.'
|
|
}), 400
|
|
|
|
# Check for recent check-ins with 30-minute interval validation
|
|
today = date.today()
|
|
current_time = datetime.now()
|
|
time_interval = int(os.environ.get('TIME_INTERVAL'))
|
|
the_last_checkin_time = current_time - timedelta(minutes=time_interval)
|
|
|
|
# Find the most recent check-in for this employee at this location today
|
|
recent_checkin = AttendanceData.query.filter_by(
|
|
qr_code_id=qr_code.id,
|
|
employee_id=employee_id.upper(),
|
|
check_in_date=today
|
|
).order_by(AttendanceData.check_in_time.desc()).first()
|
|
|
|
if recent_checkin:
|
|
# Convert check_in_time (time) to datetime for comparison
|
|
recent_checkin_datetime = datetime.combine(today, recent_checkin.check_in_time)
|
|
|
|
# Check if 30 minutes have passed since the last check-in
|
|
if recent_checkin_datetime > the_last_checkin_time:
|
|
minutes_remaining = time_interval - int((current_time - recent_checkin_datetime).total_seconds() / 60)
|
|
print(f"⚠️ Too soon for another check-in for {employee_id}")
|
|
print(f" Last check-in: {recent_checkin.check_in_time.strftime('%H:%M')}")
|
|
print(f" Minutes remaining: {minutes_remaining}")
|
|
|
|
return jsonify({
|
|
'success': False,
|
|
'message': f"You can check in again in {minutes_remaining} minutes. Last check-in was at {recent_checkin.check_in_time.strftime("%H:%M")}. \n"
|
|
f"Puedes volver a registrarte en {minutes_remaining} minutos. El último registro fue a las {recent_checkin.check_in_time.strftime("%H:%M")}."
|
|
}), 400
|
|
else:
|
|
print(f"✅ 30-minute interval satisfied. Allowing new check-in for {employee_id}")
|
|
else:
|
|
print(f"✅ First check-in today for {employee_id}")
|
|
|
|
# PRESERVED: Process location data with coordinate-to-address conversion
|
|
location_data = process_location_data_enhanced(request.form)
|
|
|
|
# PRESERVED: Get device and network info
|
|
user_agent_string = request.headers.get('User-Agent', '')
|
|
device_info = detect_device_info(user_agent_string)
|
|
client_ip = get_client_ip()
|
|
|
|
print(f"📱 Device Info: {device_info}")
|
|
print(f"🌐 IP Address: {client_ip}")
|
|
print(f"📍 Location Data: {location_data}")
|
|
|
|
# PRESERVED: Create attendance record
|
|
print(f"\n💾 CREATING ATTENDANCE RECORD:")
|
|
|
|
attendance = AttendanceData(
|
|
qr_code_id=qr_code.id,
|
|
employee_id=employee_id.upper(),
|
|
check_in_date=today,
|
|
check_in_time=datetime.now().time(),
|
|
device_info=device_info,
|
|
user_agent=user_agent_string,
|
|
ip_address=client_ip,
|
|
location_name=qr_code.location,
|
|
latitude=location_data['latitude'],
|
|
longitude=location_data['longitude'],
|
|
accuracy=location_data['accuracy'],
|
|
altitude=location_data['altitude'],
|
|
location_source=location_data['source'],
|
|
address=location_data['address'], # This now includes converted address
|
|
status='present'
|
|
)
|
|
|
|
print(f"✅ Created base attendance record")
|
|
|
|
# PRESERVED: Calculate location accuracy
|
|
print(f"\n🎯 CALCULATING LOCATION ACCURACY...")
|
|
location_accuracy = None
|
|
|
|
try:
|
|
location_accuracy = calculate_location_accuracy_enhanced(
|
|
qr_address=qr_code.location_address,
|
|
checkin_address=location_data['address'],
|
|
checkin_lat=location_data['latitude'],
|
|
checkin_lng=location_data['longitude']
|
|
)
|
|
|
|
if location_accuracy is not None:
|
|
attendance.location_accuracy = location_accuracy
|
|
accuracy_level = get_location_accuracy_level_enhanced(location_accuracy)
|
|
print(f"✅ Location accuracy set: {location_accuracy:.4f} miles ({accuracy_level})")
|
|
else:
|
|
print(f"⚠️ Could not calculate location accuracy")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error in location accuracy calculation: {e}")
|
|
|
|
# PRESERVED: Save to database
|
|
try:
|
|
db.session.add(attendance)
|
|
db.session.commit()
|
|
print(f"✅ Successfully saved attendance record with ID: {attendance.id}")
|
|
|
|
# NEW: Count total check-ins for today for this employee at this location
|
|
today_checkin_count = AttendanceData.query.filter_by(
|
|
qr_code_id=qr_code.id,
|
|
employee_id=employee_id.upper(),
|
|
check_in_date=today
|
|
).count()
|
|
|
|
checkin_sequence_text = f"{qr_code.location_event} details"
|
|
|
|
except Exception as e:
|
|
print(f"❌ Database error: {e}")
|
|
db.session.rollback()
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Database error occurred.'
|
|
}), 500
|
|
|
|
# ENHANCED: Return success response with sequence information
|
|
response_data = {
|
|
'success': True,
|
|
'message': f'Check-in successful! {checkin_sequence_text} for today.',
|
|
'data': {
|
|
'employee_id': attendance.employee_id,
|
|
'location': attendance.location_name,
|
|
'location_event': qr_code.location_event,
|
|
'check_in_time': attendance.check_in_time.strftime('%H:%M:%S'),
|
|
'check_in_date': attendance.check_in_date.strftime('%m/%d/%Y'),
|
|
'device_info': attendance.device_info,
|
|
'ip_address': attendance.ip_address,
|
|
'location_accuracy': location_accuracy,
|
|
'checkin_count_today': today_checkin_count,
|
|
'checkin_sequence': checkin_sequence_text
|
|
}
|
|
}
|
|
|
|
if location_data['address']:
|
|
response_data['data']['address'] = location_data['address']
|
|
|
|
if location_data['latitude'] and location_data['longitude']:
|
|
response_data['data']['coordinates'] = f"{location_data['latitude']:.10f}, {location_data['longitude']:.10f}"
|
|
|
|
print(f"✅ Check-in completed successfully")
|
|
print(f" Employee: {attendance.employee_id}")
|
|
print(f" Time: {attendance.check_in_time}")
|
|
print(f" Location: {attendance.location_name}")
|
|
print(f" Address: {attendance.address}")
|
|
print(f" Today's count: {today_checkin_count}")
|
|
|
|
return jsonify(response_data), 200
|
|
|
|
except Exception as e:
|
|
print(f"❌ Unexpected error in check-in process: {e}")
|
|
import traceback
|
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
|
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'An unexpected error occurred during check-in.'
|
|
}), 500
|
|
|
|
@app.route('/qr-codes/<int:qr_id>/toggle-status', methods=['POST'])
|
|
@login_required
|
|
def toggle_qr_status(qr_id):
|
|
"""Toggle QR code active/inactive status"""
|
|
try:
|
|
qr_code = QRCode.query.get_or_404(qr_id)
|
|
|
|
# Toggle the status
|
|
qr_code.active_status = not qr_code.active_status
|
|
db.session.commit()
|
|
|
|
status_text = "activated" if qr_code.active_status else "deactivated"
|
|
flash(f'QR code "{qr_code.name}" has been {status_text} successfully!', 'success')
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'new_status': qr_code.active_status,
|
|
'status_text': 'Active' if qr_code.active_status else 'Inactive',
|
|
'message': f'QR code {status_text} successfully!'
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error toggling QR status: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Error updating QR code status. Please try again.'
|
|
}), 500
|
|
|
|
@app.route('/qr-codes/<int:qr_id>/activate', methods=['POST'])
|
|
@login_required
|
|
def activate_qr_code(qr_id):
|
|
"""Activate a QR code"""
|
|
try:
|
|
qr_code = QRCode.query.get_or_404(qr_id)
|
|
qr_code.active_status = True
|
|
db.session.commit()
|
|
|
|
flash(f'QR code "{qr_code.name}" has been activated successfully!', 'success')
|
|
return jsonify({
|
|
'success': True,
|
|
'new_status': True,
|
|
'status_text': 'Active',
|
|
'message': 'QR code activated successfully!'
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error activating QR code: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Error activating QR code. Please try again.'
|
|
}), 500
|
|
|
|
@app.route('/qr-codes/<int:qr_id>/deactivate', methods=['POST'])
|
|
@login_required
|
|
def deactivate_qr_code(qr_id):
|
|
"""Deactivate a QR code"""
|
|
try:
|
|
qr_code = QRCode.query.get_or_404(qr_id)
|
|
qr_code.active_status = False
|
|
db.session.commit()
|
|
|
|
flash(f'QR code "{qr_code.name}" has been deactivated successfully!', 'success')
|
|
return jsonify({
|
|
'success': True,
|
|
'new_status': False,
|
|
'status_text': 'Inactive',
|
|
'message': 'QR code deactivated successfully!'
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error deactivating QR code: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Error deactivating QR code. Please try again.'
|
|
}), 500
|
|
|
|
@app.route('/qr-codes/<int:qr_id>/toggle-status', methods=['POST'])
|
|
@admin_required
|
|
def toggle_qr_status_api(qr_id):
|
|
"""Toggle QR code active/inactive status - Enhanced JSON API"""
|
|
try:
|
|
qr_code = QRCode.query.get_or_404(qr_id)
|
|
qr_code.active_status = not qr_code.active_status
|
|
db.session.commit()
|
|
|
|
status_text = "activated" if qr_code.active_status else "deactivated"
|
|
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
return jsonify({
|
|
'success': True,
|
|
'new_status': qr_code.active_status,
|
|
'status_text': 'Active' if qr_code.active_status else 'Inactive',
|
|
'message': f'QR code "{qr_code.name}" has been {status_text} successfully!'
|
|
})
|
|
else:
|
|
flash(f'QR code "{qr_code.name}" has been {status_text} successfully!', 'success')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
|
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Error updating QR code status. Please try again.'
|
|
}), 500
|
|
else:
|
|
flash('Error updating QR code status. Please try again.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
@app.route('/attendance')
|
|
# @admin_required
|
|
def attendance_report():
|
|
"""Safe attendance report with backward compatibility for location_accuracy and fixed datetime handling"""
|
|
try:
|
|
print("📊 Loading attendance report...")
|
|
|
|
# Check if location_accuracy column exists
|
|
has_location_accuracy = check_location_accuracy_column_exists()
|
|
print(f"🔍 Location accuracy column exists: {has_location_accuracy}")
|
|
|
|
# Get filter parameters
|
|
date_from = request.args.get('date_from', '')
|
|
date_to = request.args.get('date_to', '')
|
|
location_filter = request.args.get('location', '')
|
|
employee_filter = request.args.get('employee', '')
|
|
|
|
# Build base query - conditional based on column existence
|
|
if has_location_accuracy:
|
|
# New query with location accuracy
|
|
base_query = """
|
|
SELECT
|
|
ad.id,
|
|
ad.employee_id,
|
|
ad.check_in_date,
|
|
ad.check_in_time,
|
|
ad.location_name,
|
|
qc.location_event,
|
|
qc.location_address as qr_address,
|
|
ad.address as checked_in_address,
|
|
ad.latitude,
|
|
ad.longitude,
|
|
ad.location_accuracy,
|
|
ad.accuracy as gps_accuracy,
|
|
ad.device_info
|
|
FROM attendance_data ad
|
|
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
|
WHERE 1=1
|
|
"""
|
|
else:
|
|
# Fallback query without location accuracy
|
|
base_query = """
|
|
SELECT
|
|
ad.id,
|
|
ad.employee_id,
|
|
ad.check_in_date,
|
|
ad.check_in_time,
|
|
ad.location_name,
|
|
qc.location_event,
|
|
qc.location_address as qr_address,
|
|
ad.address as checked_in_address,
|
|
ad.latitude,
|
|
ad.longitude,
|
|
NULL as location_accuracy,
|
|
ad.accuracy as gps_accuracy,
|
|
ad.device_info
|
|
FROM attendance_data ad
|
|
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
|
WHERE 1=1
|
|
"""
|
|
|
|
conditions = []
|
|
params = {}
|
|
|
|
# Apply date range filter
|
|
if date_from:
|
|
conditions.append("ad.check_in_date >= :date_from")
|
|
params['date_from'] = date_from
|
|
|
|
if date_to:
|
|
conditions.append("ad.check_in_date <= :date_to")
|
|
params['date_to'] = date_to
|
|
|
|
# Apply location filter
|
|
if location_filter:
|
|
conditions.append("ad.location_name ILIKE :location")
|
|
params['location'] = f"%{location_filter}%"
|
|
|
|
# Apply employee filter
|
|
if employee_filter:
|
|
conditions.append("ad.employee_id ILIKE :employee")
|
|
params['employee'] = f"%{employee_filter}%"
|
|
|
|
# Add conditions to query
|
|
if conditions:
|
|
base_query += " AND " + " AND ".join(conditions)
|
|
|
|
# Add ordering
|
|
base_query += " ORDER BY ad.check_in_date DESC, ad.check_in_time DESC"
|
|
|
|
print(f"🔍 Executing query with {len(params)} parameters")
|
|
|
|
# Execute query
|
|
query_result = db.session.execute(text(base_query), params)
|
|
attendance_records = query_result.fetchall()
|
|
|
|
print(f"✅ Found {len(attendance_records)} attendance records")
|
|
|
|
# FIXED: Process records to add calculated fields with proper datetime handling
|
|
processed_records = []
|
|
for record in attendance_records:
|
|
# Safe attribute access with fallbacks
|
|
location_accuracy = getattr(record, 'location_accuracy', None)
|
|
gps_accuracy = getattr(record, 'gps_accuracy', None)
|
|
qr_address = getattr(record, 'qr_address', None)
|
|
|
|
# Handle location accuracy for address display logic
|
|
if location_accuracy is not None and location_accuracy != "None":
|
|
try:
|
|
accuracy_value = float(location_accuracy) if isinstance(location_accuracy, str) else location_accuracy
|
|
checked_in_address = qr_address if (accuracy_value <= 0.5) else getattr(record, 'checked_in_address', None)
|
|
except (ValueError, TypeError):
|
|
checked_in_address = getattr(record, 'checked_in_address', None)
|
|
else:
|
|
checked_in_address = getattr(record, 'checked_in_address', None)
|
|
|
|
# CRITICAL FIX: Properly handle check_in_time formatting
|
|
check_in_time_value = record.check_in_time
|
|
|
|
# Handle different possible types for check_in_time
|
|
if isinstance(check_in_time_value, timedelta):
|
|
# Convert timedelta to time object
|
|
total_seconds = int(check_in_time_value.total_seconds())
|
|
hours = total_seconds // 3600
|
|
minutes = (total_seconds % 3600) // 60
|
|
seconds = total_seconds % 60
|
|
formatted_time = time(hours % 24, minutes, seconds)
|
|
print(f"⚠️ Converted timedelta to time: {check_in_time_value} -> {formatted_time}")
|
|
elif isinstance(check_in_time_value, time):
|
|
# Already a time object, use as-is
|
|
formatted_time = check_in_time_value
|
|
elif isinstance(check_in_time_value, datetime):
|
|
# Extract time component from datetime
|
|
formatted_time = check_in_time_value.time()
|
|
elif isinstance(check_in_time_value, str):
|
|
# Try to parse string to time
|
|
try:
|
|
formatted_time = datetime.strptime(check_in_time_value, '%H:%M:%S').time()
|
|
except ValueError:
|
|
try:
|
|
formatted_time = datetime.strptime(check_in_time_value, '%H:%M').time()
|
|
except ValueError:
|
|
# Fallback to current time if parsing fails
|
|
formatted_time = datetime.now().time()
|
|
print(f"⚠️ Could not parse time string: {check_in_time_value}, using current time")
|
|
else:
|
|
# Fallback to current time for any other type
|
|
formatted_time = datetime.now().time()
|
|
print(f"⚠️ Unexpected check_in_time type: {type(check_in_time_value)}, using current time")
|
|
|
|
# Create the record dictionary with properly formatted time
|
|
record_dict = {
|
|
'id': record.id,
|
|
'employee_id': record.employee_id,
|
|
'check_in_date': record.check_in_date,
|
|
'check_in_time': formatted_time, # Now guaranteed to be a time object
|
|
'location_name': record.location_name,
|
|
'location_event': getattr(record, 'location_event', ''),
|
|
'qr_address': qr_address or 'Not available',
|
|
'checked_in_address': checked_in_address or 'Location not captured',
|
|
'device_info': getattr(record, 'device_info', ''),
|
|
'location_accuracy': location_accuracy,
|
|
'gps_accuracy': gps_accuracy,
|
|
'accuracy_level': get_location_accuracy_level(location_accuracy) if location_accuracy else 'unknown',
|
|
'has_location_data': record.latitude is not None and record.longitude is not None,
|
|
'coordinates': f"{record.latitude:.10f}, {record.longitude:.10f}" if record.latitude and record.longitude else "No GPS data",
|
|
'has_location_accuracy_feature': has_location_accuracy
|
|
}
|
|
processed_records.append(record_dict)
|
|
|
|
print(f"✅ Processed {len(processed_records)} records")
|
|
|
|
# Get unique locations for filter dropdown
|
|
try:
|
|
locations_query = db.session.execute(text("""
|
|
SELECT DISTINCT location_name
|
|
FROM attendance_data
|
|
WHERE location_name IS NOT NULL
|
|
ORDER BY location_name
|
|
"""))
|
|
locations = [row[0] for row in locations_query.fetchall()]
|
|
print(f"✅ Found {len(locations)} unique locations")
|
|
except Exception as e:
|
|
print(f"⚠️ Error loading locations: {e}")
|
|
locations = []
|
|
|
|
# Get attendance statistics
|
|
try:
|
|
if has_location_accuracy:
|
|
stats_query = db.session.execute(text("""
|
|
SELECT
|
|
COUNT(*) as total_checkins,
|
|
COUNT(DISTINCT employee_id) as unique_employees,
|
|
COUNT(DISTINCT qr_code_id) as active_locations,
|
|
COUNT(CASE WHEN check_in_date = CURRENT_DATE THEN 1 END) as today_checkins,
|
|
COUNT(CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL THEN 1 END) as records_with_gps,
|
|
COUNT(CASE WHEN location_accuracy IS NOT NULL THEN 1 END) as records_with_accuracy,
|
|
AVG(location_accuracy) as avg_location_accuracy
|
|
FROM attendance_data
|
|
"""))
|
|
else:
|
|
stats_query = db.session.execute(text("""
|
|
SELECT
|
|
COUNT(*) as total_checkins,
|
|
COUNT(DISTINCT employee_id) as unique_employees,
|
|
COUNT(DISTINCT qr_code_id) as active_locations,
|
|
COUNT(CASE WHEN check_in_date = CURRENT_DATE THEN 1 END) as today_checkins,
|
|
COUNT(CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL THEN 1 END) as records_with_gps,
|
|
0 as records_with_accuracy,
|
|
0 as avg_location_accuracy
|
|
FROM attendance_data
|
|
"""))
|
|
|
|
stats = stats_query.fetchone()
|
|
print(f"✅ Loaded statistics: {stats.total_checkins} total check-ins")
|
|
except Exception as e:
|
|
print(f"⚠️ Error loading statistics: {e}")
|
|
# Fallback stats
|
|
stats = type('Stats', (), {
|
|
'total_checkins': 0,
|
|
'unique_employees': 0,
|
|
'active_locations': 0,
|
|
'today_checkins': 0,
|
|
'records_with_gps': 0,
|
|
'records_with_accuracy': 0,
|
|
'avg_location_accuracy': 0
|
|
})()
|
|
|
|
# Add today's date for template
|
|
today_date = datetime.now().strftime('%m/%d/%Y')
|
|
current_date_formatted = datetime.now().strftime('%B %d')
|
|
|
|
print("✅ Rendering attendance report template")
|
|
|
|
return render_template('attendance_report.html',
|
|
attendance_records=processed_records,
|
|
locations=locations,
|
|
stats=stats,
|
|
date_from=date_from,
|
|
date_to=date_to,
|
|
location_filter=location_filter,
|
|
employee_filter=employee_filter,
|
|
today_date=today_date,
|
|
current_date_formatted=current_date_formatted,
|
|
has_location_accuracy_feature=has_location_accuracy)
|
|
|
|
except Exception as e:
|
|
print(f"❌ Error loading attendance report: {e}")
|
|
print(f"❌ Exception type: {type(e)}")
|
|
import traceback
|
|
print(f"❌ Traceback: {traceback.format_exc()}")
|
|
|
|
flash('Error loading attendance report. Please check the server logs for details.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
@app.route('/api/attendance/stats')
|
|
@admin_required
|
|
def attendance_stats_api():
|
|
"""API endpoint for attendance statistics"""
|
|
try:
|
|
# Daily stats for the last 7 days
|
|
daily_stats = db.session.execute(text("""
|
|
SELECT
|
|
check_in_date,
|
|
COUNT(*) as checkins,
|
|
COUNT(DISTINCT employee_id) as unique_employees
|
|
FROM attendance_data
|
|
WHERE check_in_date >= CURRENT_DATE - INTERVAL '7 days'
|
|
GROUP BY check_in_date
|
|
ORDER BY check_in_date DESC
|
|
""")).fetchall()
|
|
|
|
# Location stats
|
|
location_stats = db.session.execute(text("""
|
|
SELECT
|
|
location_name,
|
|
COUNT(*) as total_checkins,
|
|
COUNT(DISTINCT employee_id) as unique_employees
|
|
FROM attendance_data
|
|
GROUP BY location_name
|
|
ORDER BY total_checkins DESC
|
|
LIMIT 10
|
|
""")).fetchall()
|
|
|
|
# Peak hours
|
|
hourly_stats = db.session.execute(text("""
|
|
SELECT
|
|
EXTRACT(hour FROM check_in_time) as hour,
|
|
COUNT(*) as checkins
|
|
FROM attendance_data
|
|
WHERE check_in_date >= CURRENT_DATE - INTERVAL '30 days'
|
|
GROUP BY EXTRACT(hour FROM check_in_time)
|
|
ORDER BY hour
|
|
""")).fetchall()
|
|
|
|
return jsonify({
|
|
'daily_stats': [{'date': str(row[0]), 'checkins': row[1], 'employees': row[2]} for row in daily_stats],
|
|
'location_stats': [{'location': row[0], 'checkins': row[1], 'employees': row[2]} for row in location_stats],
|
|
'hourly_stats': [{'hour': int(row[0]), 'checkins': row[1]} for row in hourly_stats]
|
|
})
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching attendance stats: {e}")
|
|
return jsonify({'error': 'Failed to fetch attendance statistics'}), 500
|
|
|
|
# Jinja2 filters for better template functionality
|
|
@app.template_filter('days_since')
|
|
def days_since_filter(date):
|
|
"""Calculate days since a given date"""
|
|
if not date:
|
|
return 0
|
|
from datetime import datetime
|
|
now = datetime.utcnow()
|
|
return (now - date).days
|
|
|
|
@app.template_filter('time_ago')
|
|
def time_ago_filter(date):
|
|
"""Human readable time ago"""
|
|
if not date:
|
|
return 'Never'
|
|
from datetime import datetime
|
|
now = datetime.utcnow()
|
|
diff = now - date
|
|
|
|
if diff.days > 365:
|
|
years = diff.days // 365
|
|
return f"{years} year{'s' if years != 1 else ''} ago"
|
|
elif diff.days > 30:
|
|
months = diff.days // 30
|
|
return f"{months} month{'s' if months != 1 else ''} ago"
|
|
elif diff.days > 0:
|
|
return f"{diff.days} day{'s' if diff.days != 1 else ''} ago"
|
|
elif diff.seconds > 3600:
|
|
hours = diff.seconds // 3600
|
|
return f"{hours} hour{'s' if hours != 1 else ''} ago"
|
|
elif diff.seconds > 60:
|
|
minutes = diff.seconds // 60
|
|
return f"{minutes} minute{'s' if minutes != 1 else ''} ago"
|
|
else:
|
|
return "Just now"
|
|
|
|
# Error handlers
|
|
@app.errorhandler(500)
|
|
def internal_error(error):
|
|
"""Handle internal server errors with user-friendly page"""
|
|
if app.debug:
|
|
# Let Flask handle debug errors naturally
|
|
return None
|
|
|
|
return '''
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Server Error</title></head>
|
|
<body style="font-family: Arial; text-align: center; margin-top: 100px;">
|
|
<h1>🔧 Something went wrong</h1>
|
|
<p>We're working to fix this issue. Please try again later.</p>
|
|
<a href="/" style="color: #2563eb;">← Back to Home</a>
|
|
</body>
|
|
</html>
|
|
''', 500
|
|
|
|
@app.errorhandler(404)
|
|
def not_found(error):
|
|
"""Handle page not found errors"""
|
|
return '''
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Page Not Found</title></head>
|
|
<body style="font-family: Arial; text-align: center; margin-top: 100px;">
|
|
<h1>🔍 Page Not Found</h1>
|
|
<p>The page you're looking for doesn't exist.</p>
|
|
<a href="/" style="color: #2563eb;">← Back to Home</a>
|
|
</body>
|
|
</html>
|
|
''', 404
|
|
|
|
# Initialize database tables
|
|
@log_database_operations('database_initialization')
|
|
def create_tables():
|
|
"""Create database tables and default admin user with logging"""
|
|
try:
|
|
db.create_all()
|
|
|
|
# Create default admin user if not exists
|
|
admin = User.query.filter_by(username='admin').first()
|
|
if not admin:
|
|
admin = User(
|
|
full_name='System Administrator',
|
|
email='admin@example.com',
|
|
username='admin',
|
|
role='admin'
|
|
)
|
|
admin.set_password('admin123') # Change this in production
|
|
db.session.add(admin)
|
|
db.session.commit()
|
|
|
|
# Log admin user creation
|
|
logger_handler.logger.info("Default admin user created during initialization")
|
|
|
|
# Initialize logging table
|
|
logger_handler._create_log_table()
|
|
|
|
except Exception as e:
|
|
logger_handler.log_database_error('database_initialization', e)
|
|
raise
|
|
|
|
def update_existing_qr_codes():
|
|
"""Update existing QR codes with URLs and regenerate QR images with logging"""
|
|
try:
|
|
qr_codes = QRCode.query.filter_by(active_status=True).all()
|
|
updated_count = 0
|
|
|
|
for qr_code in qr_codes:
|
|
if not qr_code.qr_url or not qr_code.qr_code_image:
|
|
try:
|
|
# Generate URL if missing
|
|
if not qr_code.qr_url:
|
|
qr_code.qr_url = generate_qr_url(qr_code.name, qr_code.id)
|
|
|
|
# Generate QR image if missing
|
|
if not qr_code.qr_code_image:
|
|
qr_data = f"{request.url_root}qr/{qr_code.qr_url}"
|
|
qr_code.qr_code_image = generate_qr_code(qr_data)
|
|
|
|
updated_count += 1
|
|
|
|
except Exception as e:
|
|
logger_handler.log_flask_error(
|
|
error_type="qr_code_update_error",
|
|
error_message=f"Failed to update QR code {qr_code.id}: {str(e)}"
|
|
)
|
|
continue
|
|
|
|
if updated_count > 0:
|
|
db.session.commit()
|
|
logger_handler.logger.info(f"Updated {updated_count} existing QR codes with missing URLs/images")
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
logger_handler.log_database_error('update_existing_qr_codes', e)
|
|
|
|
def add_coordinate_columns():
|
|
"""Add coordinate columns to existing qr_codes table (MySQL compatible)"""
|
|
try:
|
|
# Check if columns already exist - MySQL compatible query
|
|
result = db.session.execute(text("""
|
|
SELECT COLUMN_NAME
|
|
FROM INFORMATION_SCHEMA.COLUMNS
|
|
WHERE TABLE_SCHEMA = DATABASE()
|
|
AND TABLE_NAME = 'qr_codes'
|
|
AND COLUMN_NAME IN ('address_latitude', 'address_longitude', 'coordinate_accuracy', 'coordinates_updated_date')
|
|
"""))
|
|
|
|
existing_columns = [row.COLUMN_NAME for row in result.fetchall()]
|
|
|
|
# Add missing columns
|
|
if 'address_latitude' not in existing_columns:
|
|
db.session.execute(text("""
|
|
ALTER TABLE qr_codes ADD COLUMN address_latitude FLOAT
|
|
"""))
|
|
print("✅ Added address_latitude column")
|
|
|
|
if 'address_longitude' not in existing_columns:
|
|
db.session.execute(text("""
|
|
ALTER TABLE qr_codes ADD COLUMN address_longitude FLOAT
|
|
"""))
|
|
print("✅ Added address_longitude column")
|
|
|
|
if 'coordinate_accuracy' not in existing_columns:
|
|
db.session.execute(text("""
|
|
ALTER TABLE qr_codes ADD COLUMN coordinate_accuracy VARCHAR(50) DEFAULT 'geocoded'
|
|
"""))
|
|
print("✅ Added coordinate_accuracy column")
|
|
|
|
if 'coordinates_updated_date' not in existing_columns:
|
|
db.session.execute(text("""
|
|
ALTER TABLE qr_codes ADD COLUMN coordinates_updated_date TIMESTAMP
|
|
"""))
|
|
print("✅ Added coordinates_updated_date column")
|
|
|
|
db.session.commit()
|
|
print("✅ Database migration completed successfully")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Database migration error: {e}")
|
|
db.session.rollback()
|
|
|
|
# Application context processor for logging status
|
|
@app.context_processor
|
|
def inject_logging_status():
|
|
"""Inject logging status into all templates"""
|
|
return {
|
|
'logging_enabled': hasattr(app, 'logger_handler'),
|
|
'is_admin': has_admin_privileges(session.get('role', ''))
|
|
}
|
|
|
|
# Before request handler for request logging
|
|
@app.before_request
|
|
def log_request_info():
|
|
"""Log request information for security monitoring"""
|
|
# Skip logging for static files and API calls
|
|
if (request.endpoint and
|
|
(request.endpoint.startswith('static') or
|
|
request.path.startswith('/api/logs'))):
|
|
return
|
|
|
|
# Log suspicious activity
|
|
user_agent = request.headers.get('User-Agent', '')
|
|
ip_address = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr)
|
|
|
|
# Check for potential security threats
|
|
suspicious_patterns = [
|
|
'sqlmap', 'nikto', 'nmap', 'dirb', 'dirbuster',
|
|
'wget', 'curl.*bot', 'scanner', 'exploit'
|
|
]
|
|
|
|
if any(pattern in user_agent.lower() for pattern in suspicious_patterns):
|
|
logger_handler.log_security_event(
|
|
event_type="suspicious_user_agent",
|
|
description=f"Suspicious user agent detected: {user_agent[:200]}",
|
|
severity="HIGH",
|
|
additional_data={'user_agent': user_agent, 'ip_address': ip_address}
|
|
)
|
|
|
|
# After request handler for performance monitoring
|
|
@app.after_request
|
|
def log_response_info(response):
|
|
"""Log response information for performance monitoring"""
|
|
# Skip logging for static files
|
|
if request.endpoint and request.endpoint.startswith('static'):
|
|
return response
|
|
|
|
# Log slow requests (over 5 seconds)
|
|
if hasattr(request, 'start_time'):
|
|
duration = time.time() - request.start_time
|
|
if duration > 5.0:
|
|
logger_handler.logger.warning(f"Slow request: {request.path} took {duration:.2f} seconds")
|
|
|
|
# Log error responses
|
|
if response.status_code >= 400:
|
|
logger_handler.logger.warning(
|
|
f"Error response: {response.status_code} for {request.path} "
|
|
f"by user {session.get('username', 'anonymous')}"
|
|
)
|
|
|
|
return response
|
|
|
|
if __name__ == '__main__':
|
|
with app.app_context():
|
|
try:
|
|
# Initialize database and logging
|
|
create_tables()
|
|
update_existing_qr_codes()
|
|
|
|
# Log application startup
|
|
logger_handler.logger.info("QR Attendance Management System started successfully")
|
|
|
|
print("🚀 QR Attendance Management System")
|
|
print("="*50)
|
|
print("✅ Database initialized")
|
|
print("✅ Logging system enabled")
|
|
print("✅ Application ready")
|
|
print("\n📋 Logging Features:")
|
|
print(" • User login/logout tracking")
|
|
print(" • QR code creation/modification/deletion")
|
|
print(" • Database error monitoring")
|
|
print(" • Flask application error tracking")
|
|
print(" • Security event logging")
|
|
print("\n📁 Log Files Location: ./logs/")
|
|
print(" • application.log - General application events")
|
|
print(" • errors.log - Error events")
|
|
print(" • security.log - Security-related events")
|
|
print("\n💾 Database Logging: log_events table")
|
|
|
|
except Exception as e:
|
|
print(f"❌ Application startup failed: {e}")
|
|
if hasattr(app, 'logger_handler'):
|
|
logger_handler.log_flask_error(
|
|
error_type="application_startup_error",
|
|
error_message=str(e)
|
|
)
|
|
raise
|
|
|
|
app.run(debug=os.environ.get('DEBUG'),
|
|
host=os.environ.get('FLASK_HOST'),
|
|
port=os.environ.get('FLASK_PORT'))
|