Mar 20 2026: refactor app.py
This commit is contained in:
@@ -0,0 +1,414 @@
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# url_for compatibility shim
|
||||
# ---------------------------------------------------------------------------
|
||||
# Flask Blueprints prefix endpoint names (e.g. 'attendance.attendance_report').
|
||||
# The original codebase uses bare names (e.g. url_for('attendance_report')).
|
||||
# This wrapper resolves bare names by searching registered blueprints,
|
||||
# so zero url_for() calls in routes or templates need to change.
|
||||
#
|
||||
# IMPORTANT: Flask's url_for is aliased as _flask_url_for to avoid shadowing
|
||||
# this function. Decorators in this module that redirect (login_required etc.)
|
||||
# also use _flask_url_for directly since they only redirect to known bare names
|
||||
# that this shim already handles.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
import flask.helpers as _flask_helpers
|
||||
# Capture Flask's original url_for BEFORE any shadowing
|
||||
_flask_url_for = _flask_helpers.url_for
|
||||
|
||||
|
||||
def url_for(endpoint, **values):
|
||||
"""
|
||||
Drop-in replacement for flask.url_for that resolves bare endpoint names
|
||||
across Blueprints. Qualified names (containing '.') pass through unchanged.
|
||||
"""
|
||||
from flask import current_app
|
||||
if '.' in endpoint:
|
||||
return _flask_url_for(endpoint, **values)
|
||||
try:
|
||||
return _flask_url_for(endpoint, **values)
|
||||
except Exception:
|
||||
pass
|
||||
for bp_name in sorted(current_app.blueprints.keys()):
|
||||
try:
|
||||
return _flask_url_for(f'{bp_name}.{endpoint}', **values)
|
||||
except Exception:
|
||||
pass
|
||||
return _flask_url_for(endpoint, **values) # raises Flask's normal BuildError
|
||||
|
||||
|
||||
"""
|
||||
utils/helpers.py
|
||||
================
|
||||
Shared utility functions, decorators, QR-code generation helpers,
|
||||
and role/permission helpers.
|
||||
|
||||
Extracted verbatim from app.py (lines 234-329, 910-969, 1274-1467).
|
||||
No logic changes — only import paths updated.
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
import os
|
||||
import base64
|
||||
from datetime import datetime, date, time, timedelta
|
||||
from functools import wraps
|
||||
|
||||
import qrcode
|
||||
from flask import session, redirect, flash, request
|
||||
from user_agents import parse
|
||||
|
||||
from extensions import logger_handler
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role constants
|
||||
# ---------------------------------------------------------------------------
|
||||
VALID_ROLES = ['admin', 'staff', 'payroll', 'project_manager', 'accounting']
|
||||
STAFF_LEVEL_ROLES = ['staff', 'payroll', 'project_manager', 'accounting']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Role helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def is_valid_role(role):
|
||||
"""Check if role is valid"""
|
||||
return role in VALID_ROLES
|
||||
|
||||
|
||||
def has_admin_privileges(role):
|
||||
"""Check if role has admin privileges"""
|
||||
return role == 'admin'
|
||||
|
||||
|
||||
def has_staff_level_access(role):
|
||||
"""Check if role has staff-level access (includes new roles)"""
|
||||
return role in STAFF_LEVEL_ROLES
|
||||
|
||||
|
||||
def get_role_permissions(role):
|
||||
"""Get permissions description for a role"""
|
||||
permissions = {
|
||||
'admin': {
|
||||
'title': 'Administrator Permissions',
|
||||
'permissions': [
|
||||
'Full QR code management (create, edit, delete)',
|
||||
'Complete user management capabilities',
|
||||
'System configuration access',
|
||||
'View all system analytics',
|
||||
'Bulk operations and data export',
|
||||
'Access to all admin features'
|
||||
],
|
||||
'restrictions': ['With great power comes great responsibility!']
|
||||
},
|
||||
'staff': {
|
||||
'title': 'Staff User Permissions',
|
||||
'permissions': [
|
||||
'Create and edit QR codes',
|
||||
'View all QR codes in the system',
|
||||
'Download QR code images',
|
||||
'Update personal profile information',
|
||||
],
|
||||
'restrictions': [
|
||||
'Cannot delete QR codes',
|
||||
'Cannot manage other users',
|
||||
'Cannot access admin settings'
|
||||
]
|
||||
},
|
||||
'payroll': {
|
||||
'title': 'Payroll Specialist Permissions',
|
||||
'permissions': [
|
||||
'Create and edit QR codes',
|
||||
'View all QR codes in the system',
|
||||
'Download QR code images',
|
||||
'Update personal profile information',
|
||||
'Access dashboard and reports',
|
||||
'Same permissions as Staff (additional features coming soon)'
|
||||
],
|
||||
'restrictions': [
|
||||
'Cannot delete QR codes',
|
||||
'Cannot manage other users',
|
||||
'Cannot access admin settings'
|
||||
]
|
||||
},
|
||||
'project_manager': {
|
||||
'title': 'Project Manager Permissions',
|
||||
'permissions': [
|
||||
'Create and edit QR codes',
|
||||
'View all QR codes in the system',
|
||||
'Download QR code images',
|
||||
'Update personal profile information',
|
||||
'Access dashboard and reports',
|
||||
'Same permissions as Staff (additional features coming soon)'
|
||||
],
|
||||
'restrictions': [
|
||||
'Cannot delete QR codes',
|
||||
'Cannot manage other users',
|
||||
'Cannot access admin settings'
|
||||
]
|
||||
},
|
||||
'accounting': {
|
||||
'title': 'Accounting Specialist Permissions',
|
||||
'permissions': [
|
||||
'View and modify employee records',
|
||||
'Access attendance reports and analytics',
|
||||
'View and manage time attendance data',
|
||||
'Export payroll and attendance data',
|
||||
'Access financial reports and statistics',
|
||||
'Update personal profile information',
|
||||
'Delete attendance records (same as payroll)'
|
||||
],
|
||||
'restrictions': [
|
||||
'Cannot create or delete QR codes',
|
||||
'Cannot manage other users',
|
||||
'Cannot access admin settings',
|
||||
'Cannot manage projects'
|
||||
]
|
||||
}
|
||||
}
|
||||
return permissions.get(role, {})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth decorators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def login_required(f):
|
||||
"""Decorator to ensure user is logged in"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'user_id' not in session:
|
||||
flash('Please log in to access this page.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def admin_required(f):
|
||||
"""Decorator to ensure user has admin privileges"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'username' not in session:
|
||||
flash('Please log in to access this page.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
user_role = session.get('role')
|
||||
if not has_admin_privileges(user_role):
|
||||
flash('Administrator privileges required for this action.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def staff_or_admin_required(f):
|
||||
"""Decorator to ensure user has staff-level or admin privileges"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if 'username' not in session:
|
||||
flash('Please log in to access this page.', 'error')
|
||||
return redirect(url_for('login'))
|
||||
user_role = session.get('role')
|
||||
if not (has_admin_privileges(user_role) or has_staff_level_access(user_role)):
|
||||
flash('Insufficient privileges to access this page.', 'error')
|
||||
return redirect(url_for('dashboard'))
|
||||
return f(*args, **kwargs)
|
||||
return decorated_function
|
||||
|
||||
|
||||
def is_admin_user(user_id):
|
||||
"""Helper function to safely check if user is admin"""
|
||||
from extensions import db
|
||||
from models import set_db
|
||||
try:
|
||||
# User model is available through the app context
|
||||
from flask import current_app
|
||||
with current_app.app_context():
|
||||
# Access via db session to avoid circular import
|
||||
from sqlalchemy import text
|
||||
result = db.session.execute(
|
||||
text("SELECT role, active_status FROM users WHERE id = :uid"),
|
||||
{'uid': user_id}
|
||||
).fetchone()
|
||||
return result and result.active_status and result.role == 'admin'
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_device_info(user_agent_string):
|
||||
"""Extract device information from user agent"""
|
||||
try:
|
||||
user_agent = parse(user_agent_string)
|
||||
device_info = f"{user_agent.device.family}"
|
||||
if user_agent.os.family:
|
||||
device_info += f" - {user_agent.os.family}"
|
||||
if user_agent.os.version_string:
|
||||
device_info += f" {user_agent.os.version_string}"
|
||||
if user_agent.browser.family:
|
||||
device_info += f" ({user_agent.browser.family})"
|
||||
return device_info[:200]
|
||||
except Exception:
|
||||
return "Unknown Device"
|
||||
|
||||
|
||||
def get_client_ip():
|
||||
"""Get client IP address"""
|
||||
if request.environ.get('HTTP_X_FORWARDED_FOR') is None:
|
||||
return request.environ['REMOTE_ADDR']
|
||||
else:
|
||||
return request.environ['HTTP_X_FORWARDED_FOR']
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QR code generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_qr_url(name, qr_id):
|
||||
"""Generate a unique URL for QR code destination"""
|
||||
clean_name = re.sub(r'[^a-zA-Z0-9\s-]', '', name)
|
||||
clean_name = re.sub(r'\s+', '-', clean_name.strip())
|
||||
clean_name = clean_name.lower()
|
||||
url_slug = f"qr-{qr_id}-{clean_name}"
|
||||
return url_slug[:200]
|
||||
|
||||
|
||||
def generate_qr_code(data, fill_color="black", back_color="white", box_size=10, border=4, error_correction='L'):
|
||||
"""Generate a QR code image and return as base64 string"""
|
||||
error_correction_map = {
|
||||
'L': qrcode.constants.ERROR_CORRECT_L,
|
||||
'M': qrcode.constants.ERROR_CORRECT_M,
|
||||
'Q': qrcode.constants.ERROR_CORRECT_Q,
|
||||
'H': qrcode.constants.ERROR_CORRECT_H
|
||||
}
|
||||
|
||||
try:
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=error_correction_map.get(error_correction, qrcode.constants.ERROR_CORRECT_L),
|
||||
box_size=int(box_size),
|
||||
border=int(border),
|
||||
)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color=fill_color, back_color=back_color)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
img_str = base64.b64encode(buffer.getvalue()).decode()
|
||||
|
||||
try:
|
||||
logger_handler.log_qr_code_generated(
|
||||
data_length=len(data),
|
||||
fill_color=fill_color,
|
||||
back_color=back_color,
|
||||
box_size=box_size,
|
||||
border=border,
|
||||
error_correction=error_correction
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return img_str
|
||||
|
||||
except Exception as e:
|
||||
logger_handler.log_database_error('qr_code_generation', e)
|
||||
return generate_default_qr_code(data)
|
||||
|
||||
|
||||
def generate_default_qr_code(data):
|
||||
"""Fallback function for basic QR code generation"""
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
img_str = base64.b64encode(buffer.getvalue()).decode()
|
||||
return img_str
|
||||
|
||||
|
||||
def get_qr_styling(qr_code):
|
||||
"""Extract QR code styling parameters from database record"""
|
||||
return {
|
||||
'fill_color': getattr(qr_code, 'fill_color', '#000000') or '#000000',
|
||||
'back_color': getattr(qr_code, 'back_color', '#FFFFFF') or '#FFFFFF',
|
||||
'box_size': getattr(qr_code, 'box_size', 10) or 10,
|
||||
'border': getattr(qr_code, 'border', 4) or 4,
|
||||
'error_correction': getattr(qr_code, 'error_correction', 'L') or 'L'
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Check-in history helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_employee_checkin_history(employee_id, qr_code_id, date_filter=None):
|
||||
"""Get check-in history for an employee at a specific location"""
|
||||
from extensions import db
|
||||
try:
|
||||
if date_filter is None:
|
||||
date_filter = date.today()
|
||||
# AttendanceData imported at call site to avoid circular import
|
||||
from flask import current_app
|
||||
AttendanceData = current_app.config.get('_models', {}).get('AttendanceData')
|
||||
if AttendanceData:
|
||||
checkins = AttendanceData.query.filter_by(
|
||||
employee_id=employee_id.upper(),
|
||||
qr_code_id=qr_code_id,
|
||||
check_in_date=date_filter
|
||||
).order_by(AttendanceData.check_in_time.asc()).all()
|
||||
return checkins
|
||||
return []
|
||||
except Exception as e:
|
||||
print(f"❌ Error retrieving checkin history: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def format_checkin_intervals(checkins):
|
||||
"""Format time intervals between check-ins for display"""
|
||||
if len(checkins) < 2:
|
||||
return []
|
||||
|
||||
intervals = []
|
||||
for i in range(1, len(checkins)):
|
||||
previous_time = datetime.combine(checkins[i - 1].check_in_date, checkins[i - 1].check_in_time)
|
||||
current_time = datetime.combine(checkins[i].check_in_date, checkins[i].check_in_time)
|
||||
interval = current_time - previous_time
|
||||
interval_minutes = int(interval.total_seconds() / 60)
|
||||
intervals.append({
|
||||
'from_time': checkins[i - 1].check_in_time.strftime('%H:%M'),
|
||||
'to_time': checkins[i].check_in_time.strftime('%H:%M'),
|
||||
'interval_minutes': interval_minutes,
|
||||
'interval_text': format_time_interval(interval_minutes)
|
||||
})
|
||||
return intervals
|
||||
|
||||
|
||||
def format_time_interval(minutes):
|
||||
"""Format minutes into human-readable time interval"""
|
||||
if minutes < 60:
|
||||
return f"{minutes} minutes"
|
||||
elif minutes < 1440:
|
||||
hours = minutes // 60
|
||||
remaining_minutes = minutes % 60
|
||||
if remaining_minutes == 0:
|
||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||
else:
|
||||
return f"{hours}h {remaining_minutes}m"
|
||||
else:
|
||||
days = minutes // 1440
|
||||
remaining_hours = (minutes % 1440) // 60
|
||||
if remaining_hours == 0:
|
||||
return f"{days} day{'s' if days != 1 else ''}"
|
||||
else:
|
||||
return f"{days}d {remaining_hours}h"
|
||||
Reference in New Issue
Block a user