1820 lines
70 KiB
Python
1820 lines
70 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
|
|
import qrcode
|
|
import io
|
|
import base64
|
|
import os
|
|
from sqlalchemy import text
|
|
import re
|
|
import uuid
|
|
from user_agents import parse
|
|
|
|
# Initialize Flask application
|
|
app = Flask(__name__)
|
|
app.config['SECRET_KEY'] = 'your-secret-key-change-in-production'
|
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://postgres:postgres1411@localhost/qr_management'
|
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
|
|
|
# Initialize database
|
|
db = SQLAlchemy(app)
|
|
|
|
# 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(128), 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'
|
|
|
|
# QR Code Model
|
|
class QRCode(db.Model):
|
|
"""
|
|
QR Code model to manage QR code records and metadata
|
|
"""
|
|
__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=False)
|
|
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)
|
|
|
|
class AttendanceData(db.Model):
|
|
"""
|
|
Attendance tracking model for QR code check-ins
|
|
"""
|
|
__tablename__ = 'attendance_data'
|
|
|
|
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)
|
|
|
|
# Relationships
|
|
qr_code = db.relationship('QRCode', backref=db.backref('attendance_records', lazy='dynamic'))
|
|
|
|
# Update your existing QRCode model to include the qr_url field
|
|
# Add this line to the QRCode model class:
|
|
# qr_url = db.Column(db.String(255), unique=True, nullable=True)
|
|
|
|
# Add these utility functions after your existing utility functions
|
|
|
|
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']
|
|
|
|
# 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):
|
|
@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'))
|
|
|
|
if session.get('role') != 'admin':
|
|
flash('Administrator privileges required for this action.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
return f(*args, **kwargs)
|
|
return decorated_function
|
|
|
|
# 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='%Y-%m-%d'):
|
|
"""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'])
|
|
def register():
|
|
"""User registration endpoint"""
|
|
if request.method == 'POST':
|
|
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()
|
|
|
|
flash('Registration successful! Please log in.', 'success')
|
|
return redirect(url_for('login'))
|
|
|
|
return render_template('register.html')
|
|
|
|
@app.route('/logout')
|
|
def logout():
|
|
"""User logout endpoint"""
|
|
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"""
|
|
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
|
|
try:
|
|
qr_codes = QRCode.query.order_by(QRCode.created_date.desc()).all() # ✅ Fixed: removed filter
|
|
except Exception as e:
|
|
print(f"Error fetching QR codes: {e}")
|
|
qr_codes = []
|
|
|
|
return render_template('dashboard.html', user=user, qr_codes=qr_codes)
|
|
|
|
@app.route('/profile', methods=['GET', 'POST'])
|
|
@login_required
|
|
def profile():
|
|
"""User profile management"""
|
|
user = User.query.get(session['user_id'])
|
|
|
|
if request.method == 'POST':
|
|
form_type = request.form.get('form_type')
|
|
|
|
if form_type == 'profile':
|
|
# Update profile information
|
|
user.full_name = request.form['full_name']
|
|
user.email = request.form['email']
|
|
|
|
db.session.commit()
|
|
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()
|
|
flash('Password updated successfully!', 'success')
|
|
else:
|
|
flash('Current password is incorrect.', 'error')
|
|
|
|
return redirect(url_for('profile'))
|
|
|
|
return render_template('profile.html', user=user)
|
|
|
|
@app.route('/users')
|
|
@admin_required
|
|
def users():
|
|
"""User management page (Admin only) - Enhanced with better data"""
|
|
try:
|
|
# Get all users with their QR code counts
|
|
all_users = db.session.query(User).all()
|
|
|
|
# Add QR code counts to each user
|
|
for user in all_users:
|
|
user.qr_code_count = user.created_qr_codes.count()
|
|
user.active_qr_count = user.created_qr_codes.filter_by(active_status=True).count()
|
|
|
|
print(f"Found {len(all_users)} users for admin view")
|
|
return render_template('users.html', users=all_users)
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching users: {e}")
|
|
flash('Error loading users. Please try again.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
@app.route('/users/create', methods=['GET', 'POST'])
|
|
@admin_required
|
|
def create_user():
|
|
"""Create new user (Admin only) - Fixed validation"""
|
|
if request.method == 'POST':
|
|
try:
|
|
full_name = request.form.get('full_name', '').strip()
|
|
email = request.form.get('email', '').strip().lower()
|
|
username = request.form.get('username', '').strip().lower()
|
|
password = request.form.get('password', '')
|
|
role = request.form.get('role', '')
|
|
|
|
# Validation
|
|
if not all([full_name, email, username, password, role]):
|
|
flash('All fields are required.', 'error')
|
|
return render_template('create_user.html')
|
|
|
|
if len(password) < 6:
|
|
flash('Password must be at least 6 characters long.', 'error')
|
|
return render_template('create_user.html')
|
|
|
|
if role not in ['staff', 'admin']:
|
|
flash('Invalid role specified.', 'error')
|
|
return render_template('create_user.html')
|
|
|
|
# Check if user already exists
|
|
if User.query.filter_by(username=username).first():
|
|
flash('Username already exists. Please choose a different username.', 'error')
|
|
return render_template('create_user.html')
|
|
|
|
if User.query.filter_by(email=email).first():
|
|
flash('Email already registered. Please use a different email.', 'error')
|
|
return render_template('create_user.html')
|
|
|
|
# Create user
|
|
new_user = User(
|
|
full_name=full_name,
|
|
email=email,
|
|
username=username,
|
|
role=role,
|
|
created_by=session['user_id'],
|
|
created_date=datetime.utcnow(),
|
|
active_status=True
|
|
)
|
|
new_user.set_password(password)
|
|
|
|
db.session.add(new_user)
|
|
db.session.commit()
|
|
|
|
flash(f'User "{full_name}" created successfully!', 'success')
|
|
print(f"Admin {session['username']} created user: {username} with role: {role}")
|
|
|
|
return redirect(url_for('users'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error creating user: {e}")
|
|
flash('Error creating user. Please try again.', 'error')
|
|
return render_template('create_user.html')
|
|
|
|
return render_template('create_user.html')
|
|
|
|
@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 user_to_demote.role == 'staff':
|
|
flash('User is already staff.', '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
|
|
def edit_user(user_id):
|
|
"""Edit user information (Admin only)"""
|
|
try:
|
|
user_to_edit = User.query.get(user_id)
|
|
current_user = User.query.get(session['user_id'])
|
|
|
|
if not user_to_edit:
|
|
flash('User not found.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
if request.method == 'POST':
|
|
full_name = request.form.get('full_name', '').strip()
|
|
email = request.form.get('email', '').strip().lower()
|
|
new_role = request.form.get('role', '')
|
|
new_password = request.form.get('new_password', '').strip()
|
|
|
|
# Validation
|
|
if not all([full_name, email, new_role]):
|
|
flash('Name, email, and role are required.', 'error')
|
|
return render_template('edit_user.html', user=user_to_edit)
|
|
|
|
if new_role not in ['staff', 'admin']:
|
|
flash('Invalid role specified.', 'error')
|
|
return render_template('edit_user.html', user=user_to_edit)
|
|
|
|
# Check for email conflicts (excluding current user)
|
|
existing_email_user = User.query.filter_by(email=email).first()
|
|
if existing_email_user and existing_email_user.id != user_to_edit.id:
|
|
flash('Email already in use by another user.', 'error')
|
|
return render_template('edit_user.html', user=user_to_edit)
|
|
|
|
# Prevent self-demotion
|
|
if (user_to_edit.id == current_user.id and
|
|
user_to_edit.role == 'admin' and new_role == 'staff'):
|
|
flash('You cannot demote yourself. Have another admin do this.', 'error')
|
|
return render_template('edit_user.html', user=user_to_edit)
|
|
|
|
# Check if demoting the last admin
|
|
if (user_to_edit.role == 'admin' and new_role == 'staff'):
|
|
active_admin_count = User.query.filter_by(role='admin', active_status=True).count()
|
|
if active_admin_count <= 1:
|
|
flash('Cannot demote the last admin user. Promote another user to admin first.', 'error')
|
|
return render_template('edit_user.html', user=user_to_edit)
|
|
|
|
# Update user information
|
|
user_to_edit.full_name = full_name
|
|
user_to_edit.email = email
|
|
user_to_edit.role = new_role
|
|
|
|
# Handle password change if provided
|
|
if new_password:
|
|
if len(new_password) < 6:
|
|
flash('Password must be at least 6 characters long.', 'error')
|
|
return render_template('edit_user.html', user=user_to_edit)
|
|
user_to_edit.set_password(new_password)
|
|
|
|
db.session.commit()
|
|
flash(f'User "{user_to_edit.full_name}" updated successfully.', 'success')
|
|
print(f"Admin {current_user.username} updated user: {user_to_edit.username}")
|
|
|
|
return redirect(url_for('users'))
|
|
|
|
return render_template('edit_user.html', user=user_to_edit)
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error editing user: {e}")
|
|
flash('Error updating 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 for user statistics"""
|
|
try:
|
|
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()
|
|
inactive_users = User.query.filter_by(active_status=False).count()
|
|
|
|
# Recent registrations (last 30 days)
|
|
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
|
|
recent_registrations = User.query.filter(User.created_date >= thirty_days_ago).count()
|
|
|
|
# Recent logins (last 7 days)
|
|
seven_days_ago = datetime.utcnow() - timedelta(days=7)
|
|
recent_logins = User.query.filter(
|
|
User.last_login_date >= seven_days_ago,
|
|
User.active_status == True
|
|
).count()
|
|
|
|
return jsonify({
|
|
'total_users': total_users,
|
|
'active_users': active_users,
|
|
'admin_users': admin_users,
|
|
'staff_users': staff_users,
|
|
'inactive_users': inactive_users,
|
|
'recent_registrations': recent_registrations,
|
|
'recent_logins': recent_logins
|
|
})
|
|
|
|
except Exception as e:
|
|
print(f"Error fetching user stats: {e}")
|
|
return jsonify({'error': 'Failed to fetch user statistics'}), 500
|
|
|
|
# Optional: Add a separate endpoint to get location data as JSON for AJAX requests
|
|
@app.route('/api/attendance-location-data')
|
|
@admin_required
|
|
def attendance_location_data():
|
|
"""API endpoint to get attendance data with location information"""
|
|
try:
|
|
# Get query parameters
|
|
limit = request.args.get('limit', 100, type=int)
|
|
offset = request.args.get('offset', 0, type=int)
|
|
|
|
query_sql = """
|
|
SELECT
|
|
ad.employee_id,
|
|
ad.check_in_date,
|
|
ad.check_in_time,
|
|
ad.latitude,
|
|
ad.longitude,
|
|
ad.location_accuracy,
|
|
ad.address,
|
|
ad.location_source,
|
|
ad.location_name,
|
|
qr.name as qr_name
|
|
FROM attendance_data ad
|
|
JOIN qr_codes qr ON ad.qr_code_id = qr.id
|
|
WHERE ad.latitude IS NOT NULL AND ad.longitude IS NOT NULL
|
|
ORDER BY ad.created_timestamp DESC
|
|
LIMIT :limit OFFSET :offset
|
|
"""
|
|
|
|
query = db.session.execute(text(query_sql), {
|
|
'limit': limit,
|
|
'offset': offset
|
|
})
|
|
|
|
records = []
|
|
for row in query.fetchall():
|
|
records.append({
|
|
'employee_id': row.employee_id,
|
|
'check_in_date': row.check_in_date.strftime('%Y-%m-%d') if row.check_in_date else '',
|
|
'check_in_time': row.check_in_time.strftime('%H:%M') if row.check_in_time else '',
|
|
'latitude': row.latitude,
|
|
'longitude': row.longitude,
|
|
'accuracy': row.location_accuracy,
|
|
'address': row.address,
|
|
'location_source': row.location_source,
|
|
'location_name': row.location_name,
|
|
'qr_name': row.qr_name
|
|
})
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'records': records,
|
|
'count': len(records)
|
|
})
|
|
|
|
except Exception as e:
|
|
print(f"Error getting location data: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': str(e)
|
|
}), 500
|
|
|
|
@app.route('/users/<int:user_id>/permanently-delete', methods=['GET', 'POST'])
|
|
@admin_required
|
|
def permanently_delete_user(user_id):
|
|
"""Permanently delete user and all associated data (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()
|
|
|
|
# Delete all QR codes created by this user
|
|
QRCode.query.filter_by(created_by=user_id).delete()
|
|
|
|
# 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
|
|
|
|
# Delete the user
|
|
db.session.delete(user_to_delete)
|
|
db.session.commit()
|
|
|
|
flash(f'User "{user_name}" and {user_qr_count} associated QR codes have been permanently deleted.', 'success')
|
|
print(f"Admin {current_user.username} permanently deleted user: {user_to_delete.username}")
|
|
|
|
return redirect(url_for('users'))
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error permanently deleting user: {e}")
|
|
flash('Error deleting user. Please try again.', 'error')
|
|
return redirect(url_for('users'))
|
|
|
|
# BULK USER OPERATIONS
|
|
@app.route('/users/bulk/deactivate', methods=['POST'])
|
|
@admin_required
|
|
def bulk_deactivate_users():
|
|
"""Bulk deactivate multiple users (Admin only)"""
|
|
try:
|
|
user_ids = request.json.get('user_ids', [])
|
|
current_user_id = session['user_id']
|
|
current_user = User.query.get(current_user_id)
|
|
|
|
if not user_ids:
|
|
return jsonify({'error': 'No users selected'}), 400
|
|
|
|
# Filter out current user and validate
|
|
valid_user_ids = []
|
|
admin_count = User.query.filter_by(role='admin', active_status=True).count()
|
|
admins_to_deactivate = 0
|
|
|
|
for user_id in user_ids:
|
|
if user_id == current_user_id:
|
|
continue # Skip current user
|
|
|
|
user = User.query.get(user_id)
|
|
if user and user.active_status:
|
|
if user.role == 'admin':
|
|
admins_to_deactivate += 1
|
|
valid_user_ids.append(user_id)
|
|
|
|
# Check if we're trying to deactivate all admins
|
|
if admin_count - admins_to_deactivate < 1:
|
|
return jsonify({'error': 'Cannot deactivate all admin users'}), 400
|
|
|
|
# Deactivate users
|
|
deactivated_count = 0
|
|
for user_id in valid_user_ids:
|
|
user = User.query.get(user_id)
|
|
if user:
|
|
user.active_status = False
|
|
deactivated_count += 1
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Successfully deactivated {deactivated_count} users',
|
|
'deactivated_count': deactivated_count
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error in bulk deactivate: {e}")
|
|
return jsonify({'error': 'Failed to deactivate users'}), 500
|
|
|
|
@app.route('/users/bulk/activate', methods=['POST'])
|
|
@admin_required
|
|
def bulk_activate_users():
|
|
"""Bulk activate multiple users (Admin only)"""
|
|
try:
|
|
user_ids = request.json.get('user_ids', [])
|
|
|
|
if not user_ids:
|
|
return jsonify({'error': 'No users selected'}), 400
|
|
|
|
# Activate users
|
|
activated_count = 0
|
|
for user_id in user_ids:
|
|
user = User.query.get(user_id)
|
|
if user and not user.active_status:
|
|
user.active_status = True
|
|
activated_count += 1
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': f'Successfully activated {activated_count} users',
|
|
'activated_count': activated_count
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error in bulk activate: {e}")
|
|
return jsonify({'error': 'Failed to activate users'}), 500
|
|
|
|
|
|
@app.route('/users/bulk/permanently-delete', methods=['POST'])
|
|
@admin_required
|
|
def bulk_permanently_delete_users():
|
|
"""Bulk permanently delete multiple users and all associated data (Admin only)"""
|
|
try:
|
|
user_ids = request.json.get('user_ids', [])
|
|
current_user_id = session['user_id']
|
|
current_user = User.query.get(current_user_id)
|
|
|
|
if not user_ids:
|
|
return jsonify({'error': 'No users selected'}), 400
|
|
|
|
# Convert string IDs to integers for safety
|
|
try:
|
|
user_ids = [int(uid) for uid in user_ids]
|
|
except (ValueError, TypeError):
|
|
return jsonify({'error': 'Invalid user IDs provided'}), 400
|
|
|
|
# Security validations
|
|
deleted_users = []
|
|
deleted_qr_count = 0
|
|
errors = []
|
|
|
|
for user_id in user_ids:
|
|
try:
|
|
# Skip current user
|
|
if user_id == current_user_id:
|
|
errors.append(f"Cannot delete your own account")
|
|
continue
|
|
|
|
user_to_delete = User.query.get(user_id)
|
|
if not user_to_delete:
|
|
errors.append(f"User with ID {user_id} not found")
|
|
continue
|
|
|
|
# Only allow deletion of inactive users for safety
|
|
if user_to_delete.active_status:
|
|
errors.append(f"User '{user_to_delete.full_name}' must be deactivated before permanent deletion")
|
|
continue
|
|
|
|
# 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:
|
|
errors.append(f"Cannot delete the last admin user '{user_to_delete.full_name}'")
|
|
continue
|
|
|
|
# Count QR codes before deletion for reporting
|
|
user_qr_count = user_to_delete.created_qr_codes.count()
|
|
deleted_qr_count += user_qr_count
|
|
|
|
# Delete all QR codes created by this user
|
|
QRCode.query.filter_by(created_by=user_id).delete()
|
|
|
|
# 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
|
|
|
|
# Delete the user
|
|
deleted_users.append({
|
|
'name': user_to_delete.full_name,
|
|
'username': user_to_delete.username,
|
|
'qr_count': user_qr_count
|
|
})
|
|
|
|
db.session.delete(user_to_delete)
|
|
|
|
except Exception as e:
|
|
print(f"Error processing user {user_id}: {e}")
|
|
errors.append(f"Error processing user ID {user_id}")
|
|
continue
|
|
|
|
# Commit all changes if we have deletions
|
|
if deleted_users:
|
|
db.session.commit()
|
|
|
|
# Log the bulk deletion
|
|
deleted_names = [user['name'] for user in deleted_users]
|
|
print(f"Admin {current_user.username} permanently deleted {len(deleted_users)} users: {', '.join(deleted_names)}")
|
|
|
|
# Prepare response message
|
|
if deleted_users and not errors:
|
|
message = f'Successfully deleted {len(deleted_users)} users and {deleted_qr_count} associated QR codes'
|
|
elif deleted_users and errors:
|
|
message = f'Deleted {len(deleted_users)} users and {deleted_qr_count} QR codes. {len(errors)} operations failed'
|
|
elif not deleted_users and errors:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'No users could be deleted',
|
|
'details': errors
|
|
}), 400
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'No valid users to delete'
|
|
}), 400
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': message,
|
|
'deleted_count': len(deleted_users),
|
|
'deleted_qr_count': deleted_qr_count,
|
|
'errors': errors if errors else None
|
|
})
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"Error in bulk permanently delete users: {e}")
|
|
return jsonify({
|
|
'success': False,
|
|
'error': 'Failed to delete users. Please try again.'
|
|
}), 500
|
|
|
|
# ENHANCED LOGIN WITH BETTER SESSION MANAGEMENT
|
|
@app.route('/login', methods=['GET', 'POST'])
|
|
def login():
|
|
"""Enhanced user authentication with better error handling"""
|
|
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
|
|
|
|
# Update last login date
|
|
user.last_login_date = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
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
|
|
flash('Invalid username or password.', 'error')
|
|
print(f"Failed login attempt for username: {username}")
|
|
|
|
except Exception as e:
|
|
print(f"Login error: {e}")
|
|
flash('Login error. Please try again.', 'error')
|
|
|
|
return render_template('login.html')
|
|
|
|
# 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
|
|
|
|
@app.route('/qr-codes/create', methods=['GET', 'POST'])
|
|
@login_required
|
|
def create_qr_code():
|
|
"""Create new QR code"""
|
|
if request.method == 'POST':
|
|
name = request.form['name']
|
|
location = request.form['location']
|
|
location_address = request.form['location_address']
|
|
location_event = request.form['location_event']
|
|
|
|
# Create QR code record first (without QR image and URL)
|
|
new_qr_code = QRCode(
|
|
name=name,
|
|
location=location,
|
|
location_address=location_address,
|
|
location_event=location_event,
|
|
qr_code_image='', # Temporary empty value
|
|
qr_url='', # Temporary empty value
|
|
created_by=session['user_id']
|
|
)
|
|
|
|
# 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 we can use the ID to generate the URL
|
|
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()
|
|
|
|
flash('QR code created successfully!', 'success')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
return render_template('create_qr_code.html')
|
|
|
|
@app.route('/qr-codes/<int:qr_id>/edit', methods=['GET', 'POST'])
|
|
@login_required
|
|
def edit_qr_code(qr_id):
|
|
"""Edit existing QR code"""
|
|
qr_code = QRCode.query.get_or_404(qr_id)
|
|
|
|
if request.method == 'POST':
|
|
# Store original name for comparison
|
|
original_name = qr_code.name
|
|
|
|
# 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['location_event']
|
|
|
|
# Check if name changed and handle URL regeneration
|
|
if original_name != new_name:
|
|
# Name changed, regenerate URL
|
|
new_qr_url = generate_qr_url(new_name, qr_code.id)
|
|
qr_code.qr_url = new_qr_url
|
|
|
|
# Update QR code data with new URL
|
|
qr_data = f"{request.url_root}qr/{new_qr_url}"
|
|
else:
|
|
# Name didn't change, use existing URL (if it exists)
|
|
if qr_code.qr_url:
|
|
qr_data = f"{request.url_root}qr/{qr_code.qr_url}"
|
|
else:
|
|
# Fallback: generate URL if it doesn't exist (for legacy QR codes)
|
|
new_qr_url = generate_qr_url(new_name, qr_code.id)
|
|
qr_code.qr_url = new_qr_url
|
|
qr_data = f"{request.url_root}qr/{new_qr_url}"
|
|
|
|
# Regenerate QR code with updated data (destination URL)
|
|
qr_code.qr_code_image = generate_qr_code(qr_data)
|
|
|
|
db.session.commit()
|
|
|
|
flash('QR code updated successfully!', 'success')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
return render_template('edit_qr_code.html', qr_code=qr_code)
|
|
|
|
@app.route('/qr-codes/<int:qr_id>/delete', methods=['GET', 'POST'])
|
|
@admin_required
|
|
def delete_qr_code(qr_id):
|
|
"""Permanently delete QR code (Admin only) - Hard delete"""
|
|
|
|
# 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
|
|
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}")
|
|
|
|
# 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()
|
|
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('/test-delete-simple/<int:qr_id>')
|
|
@admin_required
|
|
def test_delete_simple(qr_id):
|
|
print(f"🧪 TEST ROUTE CALLED FOR QR {qr_id}")
|
|
return f"Test route works! QR ID: {qr_id}, User: {session.get('username')}"
|
|
|
|
@app.route('/qr/<string:qr_url>')
|
|
def qr_destination(qr_url):
|
|
"""QR code destination page where staff check in"""
|
|
try:
|
|
# Find QR code by URL
|
|
qr_code = QRCode.query.filter_by(qr_url=qr_url, active_status=True).first()
|
|
|
|
if not qr_code:
|
|
flash('QR code not found or inactive.', 'error')
|
|
return render_template('qr_not_found.html'), 404
|
|
|
|
# Log the scan
|
|
print(f"QR Code scanned: {qr_code.name} at {datetime.now()}")
|
|
|
|
return render_template('qr_destination.html', qr_code=qr_code)
|
|
|
|
except Exception as e:
|
|
print(f"Error loading QR destination: {e}")
|
|
flash('Error loading QR code destination.', 'error')
|
|
return render_template('qr_not_found.html'), 500
|
|
|
|
@app.route('/qr/<string:qr_url>/checkin', methods=['POST'])
|
|
def qr_checkin(qr_url):
|
|
"""Handle staff check-in submission with geolocation support"""
|
|
try:
|
|
# Find QR code by URL
|
|
qr_code = QRCode.query.filter_by(qr_url=qr_url, active_status=True).first()
|
|
|
|
if not qr_code:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'QR code not found or inactive.'
|
|
}), 404
|
|
|
|
# Get form data
|
|
employee_id = request.form.get('employee_id', '').strip()
|
|
|
|
# NEW: Get location data from form
|
|
latitude = request.form.get('latitude', '').strip()
|
|
longitude = request.form.get('longitude', '').strip()
|
|
accuracy = request.form.get('accuracy', '').strip()
|
|
altitude = request.form.get('altitude', '').strip()
|
|
location_source = request.form.get('location_source', 'manual').strip()
|
|
address = request.form.get('address', '').strip()
|
|
|
|
if not employee_id:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Employee ID is required.'
|
|
}), 400
|
|
|
|
# Validate employee ID format
|
|
if not re.match(r'^[A-Za-z0-9]{3,20}$', employee_id):
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'Invalid employee ID format. Use 3-20 alphanumeric characters.'
|
|
}), 400
|
|
|
|
# Check for duplicate check-ins today
|
|
today = datetime.today()
|
|
existing_checkin = AttendanceData.query.filter_by(
|
|
qr_code_id=qr_code.id,
|
|
employee_id=employee_id.upper(),
|
|
check_in_date=today
|
|
).first()
|
|
|
|
if existing_checkin:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': f'You have already checked in today at {existing_checkin.check_in_time.strftime("%H:%M")}.'
|
|
}), 409
|
|
|
|
# Parse user agent
|
|
user_agent = request.headers.get('User-Agent', '')
|
|
parsed_agent = parse(user_agent)
|
|
device_info = f"{parsed_agent.browser.family} on {parsed_agent.os.family}"
|
|
|
|
# Get client IP
|
|
client_ip = request.environ.get('HTTP_X_FORWARDED_FOR', request.remote_addr)
|
|
if client_ip and ',' in client_ip:
|
|
client_ip = client_ip.split(',')[0].strip()
|
|
|
|
# NEW: Process location data safely
|
|
lat_value = None
|
|
lng_value = None
|
|
acc_value = None
|
|
alt_value = None
|
|
|
|
try:
|
|
if latitude and latitude != 'null' and latitude != '':
|
|
lat_value = float(latitude)
|
|
if longitude and longitude != 'null' and longitude != '':
|
|
lng_value = float(longitude)
|
|
if accuracy and accuracy != 'null' and accuracy != '':
|
|
acc_value = float(accuracy)
|
|
if altitude and altitude != 'null' and altitude != '':
|
|
alt_value = float(altitude)
|
|
except (ValueError, TypeError) as e:
|
|
print(f"⚠️ Invalid location data: {e}")
|
|
# Continue without location data
|
|
|
|
# Create 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,
|
|
ip_address=client_ip,
|
|
location_name=qr_code.location,
|
|
status='present'
|
|
)
|
|
|
|
# NEW: Add location data if available (safe attribute setting)
|
|
try:
|
|
if lat_value is not None:
|
|
attendance.latitude = lat_value
|
|
if lng_value is not None:
|
|
attendance.longitude = lng_value
|
|
if acc_value is not None:
|
|
attendance.accuracy = acc_value
|
|
if alt_value is not None:
|
|
attendance.altitude = alt_value
|
|
if location_source:
|
|
attendance.location_source = location_source
|
|
if address:
|
|
attendance.address = address
|
|
except AttributeError as e:
|
|
print(f"⚠️ Location columns not available: {e}")
|
|
# Continue without location data
|
|
|
|
db.session.add(attendance)
|
|
db.session.commit()
|
|
|
|
# Prepare response
|
|
response_data = {
|
|
'success': True,
|
|
'message': 'Check-in successful!',
|
|
'employee_id': employee_id.upper(),
|
|
'location': qr_code.location,
|
|
'event': qr_code.location_event,
|
|
'time': datetime.now().strftime('%H:%M'),
|
|
'date': today.strftime('%Y-%m-%d'),
|
|
'has_location': bool(lat_value and lng_value)
|
|
}
|
|
|
|
# NEW: Add location info to response
|
|
if lat_value and lng_value:
|
|
response_data.update({
|
|
'location_accuracy': acc_value,
|
|
'location_address': address
|
|
})
|
|
|
|
print(f"✅ Check-in successful: {employee_id.upper()} at {qr_code.location}")
|
|
if lat_value and lng_value:
|
|
print(f"📍 Location: {lat_value:.6f}, {lng_value:.6f} (±{acc_value}m) - {location_source}")
|
|
if address:
|
|
print(f"🏠 Address: {address}")
|
|
|
|
return jsonify(response_data)
|
|
|
|
except Exception as e:
|
|
db.session.rollback()
|
|
print(f"❌ Check-in error: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'System error occurred. Please try again.'
|
|
}), 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():
|
|
"""Enhanced attendance report page with location data"""
|
|
try:
|
|
# Get filter parameters
|
|
date_filter = request.args.get('date', '')
|
|
location_filter = request.args.get('location', '')
|
|
employee_filter = request.args.get('employee', '')
|
|
|
|
# Enhanced query to include location data
|
|
query_sql = """
|
|
SELECT
|
|
ad.id,
|
|
ad.employee_id,
|
|
ad.check_in_date,
|
|
ad.check_in_time,
|
|
ad.device_info,
|
|
ad.user_agent,
|
|
ad.ip_address,
|
|
ad.location_name,
|
|
ad.status,
|
|
ad.created_timestamp,
|
|
-- Location data columns
|
|
ad.latitude,
|
|
ad.longitude,
|
|
ad.location_accuracy,
|
|
ad.address,
|
|
ad.location_source,
|
|
ad.location_timestamp,
|
|
-- QR code info
|
|
qr.name as qr_name,
|
|
qr.location as qr_location,
|
|
qr.location_event,
|
|
qr.location_address as qr_address
|
|
FROM attendance_data ad
|
|
JOIN qr_codes qr ON ad.qr_code_id = qr.id
|
|
WHERE 1=1
|
|
"""
|
|
|
|
# Apply filters to SQL query
|
|
filter_params = {}
|
|
|
|
if date_filter:
|
|
query_sql += " AND ad.check_in_date = :date_filter"
|
|
filter_params['date_filter'] = date_filter
|
|
|
|
if location_filter:
|
|
query_sql += " AND ad.location_name ILIKE :location_filter"
|
|
filter_params['location_filter'] = f"%{location_filter}%"
|
|
|
|
if employee_filter:
|
|
query_sql += " AND ad.employee_id ILIKE :employee_filter"
|
|
filter_params['employee_filter'] = f"%{employee_filter}%"
|
|
|
|
# Order by latest first
|
|
query_sql += " ORDER BY ad.created_timestamp DESC"
|
|
|
|
# Execute query
|
|
query = db.session.execute(text(query_sql), filter_params)
|
|
attendance_records = query.fetchall()
|
|
|
|
# Get unique locations for filter dropdown
|
|
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()]
|
|
|
|
# Enhanced statistics including location data
|
|
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,
|
|
-- Location statistics
|
|
COUNT(CASE WHEN latitude IS NOT NULL AND longitude IS NOT NULL THEN 1 END) as checkins_with_location,
|
|
COUNT(CASE WHEN location_accuracy <= 50 THEN 1 END) as high_accuracy_checkins,
|
|
COUNT(CASE WHEN location_accuracy > 50 AND location_accuracy <= 100 THEN 1 END) as medium_accuracy_checkins,
|
|
COUNT(CASE WHEN location_accuracy > 100 THEN 1 END) as low_accuracy_checkins
|
|
FROM attendance_data
|
|
"""))
|
|
stats = stats_query.fetchone()
|
|
|
|
# Calculate location coverage percentage
|
|
location_coverage = 0
|
|
if stats.total_checkins > 0:
|
|
location_coverage = round((stats.checkins_with_location / stats.total_checkins) * 100, 1)
|
|
|
|
# Add today's date for template
|
|
today_date = datetime.now().strftime('%Y-%m-%d')
|
|
current_date_formatted = datetime.now().strftime('%B %d')
|
|
|
|
return render_template('attendance_report.html',
|
|
attendance_records=attendance_records,
|
|
locations=locations,
|
|
stats=stats,
|
|
location_coverage=location_coverage,
|
|
date_filter=date_filter,
|
|
location_filter=location_filter,
|
|
employee_filter=employee_filter,
|
|
today_date=today_date,
|
|
current_date_formatted=current_date_formatted)
|
|
|
|
except Exception as e:
|
|
print(f"Error loading attendance report: {e}")
|
|
import traceback
|
|
traceback.print_exc()
|
|
flash('Error loading attendance report.', 'error')
|
|
return redirect(url_for('dashboard'))
|
|
|
|
# 3. ADD NEW ROUTE FOR LOCATION STATISTICS (Optional)
|
|
@app.route('/admin/location-stats')
|
|
@admin_required
|
|
def location_stats():
|
|
"""View location statistics for admin"""
|
|
try:
|
|
# Get basic attendance stats
|
|
total_checkins = AttendanceData.query.count()
|
|
|
|
# Try to get location data (will work only if columns exist)
|
|
location_checkins = 0
|
|
recent_locations = []
|
|
location_accuracy_stats = {
|
|
'high': 0,
|
|
'medium': 0,
|
|
'low': 0,
|
|
'unknown': 0
|
|
}
|
|
|
|
try:
|
|
# Count check-ins with location data
|
|
location_checkins = db.session.execute(text("""
|
|
SELECT COUNT(*) FROM attendance_data
|
|
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
|
|
""")).fetchone()[0]
|
|
|
|
# Get recent locations
|
|
recent_locations_result = db.session.execute(text("""
|
|
SELECT employee_id, latitude, longitude, accuracy, address,
|
|
check_in_date, check_in_time, location_name, location_source
|
|
FROM attendance_data
|
|
WHERE latitude IS NOT NULL
|
|
ORDER BY created_timestamp DESC
|
|
LIMIT 20
|
|
""")).fetchall()
|
|
|
|
recent_locations = []
|
|
for row in recent_locations_result:
|
|
recent_locations.append({
|
|
'employee_id': row[0],
|
|
'latitude': row[1],
|
|
'longitude': row[2],
|
|
'accuracy': row[3],
|
|
'address': row[4],
|
|
'check_in_date': row[5].strftime('%Y-%m-%d') if row[5] else '',
|
|
'check_in_time': row[6].strftime('%H:%M') if row[6] else '',
|
|
'location_name': row[7],
|
|
'location_source': row[8]
|
|
})
|
|
|
|
# Count accuracy stats
|
|
accuracy = row[3]
|
|
if accuracy is None:
|
|
location_accuracy_stats['unknown'] += 1
|
|
elif accuracy <= 50:
|
|
location_accuracy_stats['high'] += 1
|
|
elif accuracy <= 100:
|
|
location_accuracy_stats['medium'] += 1
|
|
else:
|
|
location_accuracy_stats['low'] += 1
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ Location stats query failed: {e}")
|
|
|
|
return render_template('location_stats.html',
|
|
total_checkins=total_checkins,
|
|
location_checkins=location_checkins,
|
|
recent_locations=recent_locations,
|
|
accuracy_stats=location_accuracy_stats)
|
|
|
|
except Exception as e:
|
|
print(f"Error loading location stats: {e}")
|
|
flash('Error loading location statistics.', '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
|
|
def create_tables():
|
|
"""Create database tables and default admin user"""
|
|
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()
|
|
|
|
def update_existing_qr_codes():
|
|
"""Update existing QR codes with URLs and regenerate QR images"""
|
|
try:
|
|
qr_codes = QRCode.query.filter_by(active_status=True).all()
|
|
|
|
for qr_code in qr_codes:
|
|
if not qr_code.qr_url:
|
|
# Generate URL
|
|
qr_code.qr_url = generate_qr_url(qr_code.name, qr_code.id)
|
|
|
|
# Regenerate QR code with destination URL
|
|
qr_data = f"{request.url_root}qr/{qr_code.qr_url}"
|
|
qr_code.qr_code_image = generate_qr_code(qr_data)
|
|
|
|
db.session.commit()
|
|
print(f"Updated {len(qr_codes)} QR codes with destination URLs")
|
|
|
|
except Exception as e:
|
|
print(f"Error updating existing QR codes: {e}")
|
|
db.session.rollback()
|
|
|
|
def ensure_location_columns():
|
|
"""Ensure location columns exist in attendance_data table"""
|
|
try:
|
|
# Check if location columns exist
|
|
result = db.session.execute(text("""
|
|
SELECT column_name
|
|
FROM information_schema.columns
|
|
WHERE table_name = 'attendance_data'
|
|
AND column_name IN ('latitude', 'longitude', 'location_accuracy',
|
|
'address', 'location_source', 'location_timestamp')
|
|
""")).fetchall()
|
|
|
|
existing_columns = [row[0] for row in result]
|
|
required_columns = ['latitude', 'longitude', 'location_accuracy', 'address', 'location_source', 'location_timestamp']
|
|
missing_columns = [col for col in required_columns if col not in existing_columns]
|
|
|
|
if missing_columns:
|
|
print(f"⚠️ Missing location columns: {', '.join(missing_columns)}")
|
|
print(" Run the location migration script first!")
|
|
return False
|
|
else:
|
|
print(f"✅ All location columns exist")
|
|
return True
|
|
|
|
except Exception as e:
|
|
print(f"⚠️ Could not check location columns: {e}")
|
|
return False
|
|
|
|
if __name__ == '__main__':
|
|
with app.app_context():
|
|
create_tables()
|
|
update_existing_qr_codes()
|
|
|
|
# Check location columns
|
|
if not ensure_location_columns():
|
|
print("\n🚨 Location columns missing! Run location migration first:")
|
|
print("python location_migration.py")
|
|
app.run(debug=True, host="0.0.0.0")
|