Sep 16 - Optimize code, part 2
This commit is contained in:
+98
-38
@@ -24,6 +24,7 @@ from sqlalchemy import text, or_, and_
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import (
|
||||
admin_required,
|
||||
load_project_manager_scope,
|
||||
expand_employee_id_filter,
|
||||
get_base_employee_id,
|
||||
get_client_ip,
|
||||
@@ -837,27 +838,38 @@ def time_attendance_locations_api():
|
||||
Used by the time attendance records page to dynamically scope the location dropdown."""
|
||||
try:
|
||||
project_id = request.args.get('project_id', '').strip()
|
||||
conditions = ["location_name IS NOT NULL"]
|
||||
params = {}
|
||||
|
||||
if project_id:
|
||||
try:
|
||||
project_id_int = int(project_id)
|
||||
params['project_id'] = int(project_id)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'success': False, 'error': 'Invalid project_id'}), 400
|
||||
conditions.append("project_id = :project_id")
|
||||
|
||||
result = db.session.execute(text("""
|
||||
SELECT DISTINCT location_name
|
||||
FROM time_attendance
|
||||
WHERE project_id = :project_id
|
||||
AND location_name IS NOT NULL
|
||||
ORDER BY location_name
|
||||
"""), {'project_id': project_id_int})
|
||||
else:
|
||||
result = db.session.execute(text("""
|
||||
SELECT DISTINCT location_name
|
||||
FROM time_attendance
|
||||
WHERE location_name IS NOT NULL
|
||||
ORDER BY location_name
|
||||
"""))
|
||||
# Project Managers see only their assigned projects / locations (§4)
|
||||
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
|
||||
if is_pm:
|
||||
if not (allowed_project_ids or allowed_location_names):
|
||||
return jsonify({'success': True, 'locations': []})
|
||||
scope = []
|
||||
if allowed_project_ids:
|
||||
placeholders = ', '.join(f':pm_project_{i}' for i in range(len(allowed_project_ids)))
|
||||
scope.append(f"project_id IN ({placeholders})")
|
||||
params.update({f'pm_project_{i}': pid for i, pid in enumerate(allowed_project_ids)})
|
||||
if allowed_location_names:
|
||||
placeholders = ', '.join(f':pm_location_{i}' for i in range(len(allowed_location_names)))
|
||||
scope.append(f"location_name IN ({placeholders})")
|
||||
params.update({f'pm_location_{i}': loc for i, loc in enumerate(allowed_location_names)})
|
||||
conditions.append('(' + ' OR '.join(scope) + ')')
|
||||
|
||||
result = db.session.execute(text(f"""
|
||||
SELECT DISTINCT location_name
|
||||
FROM time_attendance
|
||||
WHERE {' AND '.join(conditions)}
|
||||
ORDER BY location_name
|
||||
"""), params)
|
||||
|
||||
locations = [row[0] for row in result.fetchall()]
|
||||
logger_handler.logger.info(
|
||||
@@ -878,28 +890,39 @@ def attendance_locations_api():
|
||||
Used by the attendance report page to dynamically scope the location dropdown when a project is selected."""
|
||||
try:
|
||||
project_id = request.args.get('project_id', '').strip()
|
||||
conditions = ["ad.location_name IS NOT NULL"]
|
||||
params = {}
|
||||
|
||||
if project_id:
|
||||
try:
|
||||
project_id_int = int(project_id)
|
||||
params['project_id'] = int(project_id)
|
||||
except (ValueError, TypeError):
|
||||
return jsonify({'success': False, 'error': 'Invalid project_id'}), 400
|
||||
conditions.append("qc.project_id = :project_id")
|
||||
|
||||
result = db.session.execute(text("""
|
||||
SELECT DISTINCT ad.location_name
|
||||
FROM attendance_data ad
|
||||
INNER JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||
WHERE qc.project_id = :project_id
|
||||
AND ad.location_name IS NOT NULL
|
||||
ORDER BY ad.location_name
|
||||
"""), {'project_id': project_id_int})
|
||||
else:
|
||||
result = db.session.execute(text("""
|
||||
SELECT DISTINCT location_name
|
||||
FROM attendance_data
|
||||
WHERE location_name IS NOT NULL
|
||||
ORDER BY location_name
|
||||
"""))
|
||||
# Project Managers see only their assigned projects / locations (§4)
|
||||
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
|
||||
if is_pm:
|
||||
if not (allowed_project_ids or allowed_location_names):
|
||||
return jsonify({'success': True, 'locations': []})
|
||||
scope = []
|
||||
if allowed_project_ids:
|
||||
placeholders = ', '.join(f':pm_project_{i}' for i in range(len(allowed_project_ids)))
|
||||
scope.append(f"qc.project_id IN ({placeholders})")
|
||||
params.update({f'pm_project_{i}': pid for i, pid in enumerate(allowed_project_ids)})
|
||||
if allowed_location_names:
|
||||
placeholders = ', '.join(f':pm_location_{i}' for i in range(len(allowed_location_names)))
|
||||
scope.append(f"ad.location_name IN ({placeholders})")
|
||||
params.update({f'pm_location_{i}': loc for i, loc in enumerate(allowed_location_names)})
|
||||
conditions.append('(' + ' OR '.join(scope) + ')')
|
||||
|
||||
result = db.session.execute(text(f"""
|
||||
SELECT DISTINCT ad.location_name
|
||||
FROM attendance_data ad
|
||||
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||
WHERE {' AND '.join(conditions)}
|
||||
ORDER BY ad.location_name
|
||||
"""), params)
|
||||
|
||||
locations = [row[0] for row in result.fetchall()]
|
||||
logger_handler.logger.info(
|
||||
@@ -930,15 +953,35 @@ def search_employees_api():
|
||||
|
||||
search_pattern = f"%{search_query}%"
|
||||
|
||||
# Project Managers may only search within their own projects (§4). A PM
|
||||
# scoped by locations is resolved to the projects those locations belong
|
||||
# to, so their report filter still works without exposing other projects.
|
||||
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
|
||||
pm_project_ids = []
|
||||
if is_pm:
|
||||
pm_project_ids = list(allowed_project_ids)
|
||||
if allowed_location_names:
|
||||
location_projects = db.session.query(QRCode.project_id).filter(
|
||||
QRCode.location.in_(allowed_location_names),
|
||||
QRCode.project_id.isnot(None)
|
||||
).distinct().all()
|
||||
pm_project_ids.extend(row[0] for row in location_projects)
|
||||
pm_project_ids = sorted(set(pm_project_ids))
|
||||
if not pm_project_ids:
|
||||
return jsonify({'employees': []})
|
||||
|
||||
# 1. Registered employees — search by ID or name
|
||||
employees = Employee.query.filter(
|
||||
employee_query = Employee.query.filter(
|
||||
db.or_(
|
||||
Employee.id.like(search_pattern),
|
||||
Employee.firstName.like(search_pattern),
|
||||
Employee.lastName.like(search_pattern),
|
||||
db.func.concat(Employee.firstName, ' ', Employee.lastName).like(search_pattern)
|
||||
)
|
||||
).limit(10).all()
|
||||
)
|
||||
if is_pm:
|
||||
employee_query = employee_query.filter(Employee.contractId.in_(pm_project_ids))
|
||||
employees = employee_query.limit(10).all()
|
||||
|
||||
employee_list = [{
|
||||
'id': emp.id,
|
||||
@@ -955,17 +998,25 @@ def search_employees_api():
|
||||
if len(employee_list) < 10:
|
||||
remaining_slots = 10 - len(employee_list)
|
||||
try:
|
||||
unregistered_conditions = ["e.id IS NULL", "ad.employee_id LIKE :pattern"]
|
||||
unregistered_params = {'pattern': search_pattern, 'lim': remaining_slots}
|
||||
if is_pm:
|
||||
placeholders = ', '.join(f':pm_project_{i}' for i in range(len(pm_project_ids)))
|
||||
unregistered_conditions.append(f"qc.project_id IN ({placeholders})")
|
||||
unregistered_params.update(
|
||||
{f'pm_project_{i}': pid for i, pid in enumerate(pm_project_ids)})
|
||||
|
||||
unregistered_rows = db.session.execute(
|
||||
text("""
|
||||
text(f"""
|
||||
SELECT DISTINCT ad.employee_id
|
||||
FROM attendance_data ad
|
||||
LEFT JOIN employee e ON CAST(ad.employee_id AS UNSIGNED) = e.id
|
||||
WHERE e.id IS NULL
|
||||
AND ad.employee_id LIKE :pattern
|
||||
LEFT JOIN qr_codes qc ON ad.qr_code_id = qc.id
|
||||
WHERE {' AND '.join(unregistered_conditions)}
|
||||
ORDER BY ad.employee_id
|
||||
LIMIT :lim
|
||||
"""),
|
||||
{'pattern': search_pattern, 'lim': remaining_slots}
|
||||
unregistered_params
|
||||
).fetchall()
|
||||
|
||||
for row in unregistered_rows:
|
||||
@@ -999,7 +1050,16 @@ def get_project_locations_api():
|
||||
|
||||
if not project_id:
|
||||
return jsonify({'success': False, 'locations': [], 'error': 'Project ID required'})
|
||||
|
||||
|
||||
# Project Managers may only ask about their own projects (§4)
|
||||
is_pm, allowed_project_ids, _ = load_project_manager_scope()
|
||||
if is_pm and int(project_id) not in allowed_project_ids:
|
||||
logger_handler.logger.warning(
|
||||
f"Project Manager {session.get('username')} requested locations for "
|
||||
f"project {project_id}, which is not assigned to them"
|
||||
)
|
||||
return jsonify({'success': True, 'locations': []})
|
||||
|
||||
# Get active QR codes for this project
|
||||
qr_codes = QRCode.query.filter_by(
|
||||
project_id=int(project_id),
|
||||
|
||||
+62
-12
@@ -35,6 +35,38 @@ from openpyxl.utils import get_column_letter
|
||||
|
||||
from routes.attendance import bp # shared blueprint — do not redefine
|
||||
|
||||
# Employee IDs typed on the edit / manual-add forms follow the same rule as a
|
||||
# check-in: 1 to 4 digits, optionally carrying a work-type code.
|
||||
MANUAL_EMPLOYEE_ID_MAX_DIGITS = 4
|
||||
|
||||
|
||||
def _normalize_manual_employee_id(raw_employee_id):
|
||||
"""
|
||||
(canonical_id, base_id, work_type) for an ID typed on a form, or
|
||||
(None, None, None) when it is not a valid employee ID.
|
||||
|
||||
"1234" -> ("1234", "1234", "regular")
|
||||
"1234sp" -> ("1234SP", "1234", "SP")
|
||||
"1234 . PW"-> ("1234PW", "1234", "PW")
|
||||
"12345", "abc", "" -> (None, None, None)
|
||||
|
||||
Manual entry used to do int(employee_id), so an extra-work record such as
|
||||
"1234SP" could not be added by hand at all, and edit accepted any text,
|
||||
which created pseudo-employees in the exports (§13).
|
||||
"""
|
||||
from working_hours_calculator import parse_employee_id_for_work_type
|
||||
|
||||
raw = str(raw_employee_id or '').strip()
|
||||
if not raw:
|
||||
return None, None, None
|
||||
|
||||
base_id, work_type = parse_employee_id_for_work_type(raw)
|
||||
if not re.fullmatch(r'[0-9]+', base_id) or len(base_id) > MANUAL_EMPLOYEE_ID_MAX_DIGITS:
|
||||
return None, None, None
|
||||
|
||||
canonical = base_id if work_type == 'regular' else f"{base_id}{work_type}"
|
||||
return canonical, base_id, work_type
|
||||
|
||||
|
||||
@bp.route('/attendance/<int:record_id>/edit', methods=['GET', 'POST'], endpoint='edit_attendance')
|
||||
@login_required
|
||||
@@ -74,7 +106,16 @@ def edit_attendance(record_id):
|
||||
}
|
||||
|
||||
# Update attendance record fields
|
||||
new_employee_id = request.form['employee_id'].strip().upper()
|
||||
new_employee_id, _base_id, _work_type = _normalize_manual_employee_id(
|
||||
request.form.get('employee_id', ''))
|
||||
if not new_employee_id:
|
||||
flash('Employee ID must be 1 to 4 digits, optionally with a work type '
|
||||
'(for example 1234 or 1234SP).', 'error')
|
||||
projects = Project.query.filter_by(active_status=True).order_by(Project.name).all()
|
||||
return render_template('edit_attendance.html',
|
||||
attendance_record=attendance_record,
|
||||
projects=projects,
|
||||
qr_codes=QRCode.query.filter_by(active_status=True).all())
|
||||
new_check_in_date = datetime.strptime(request.form['check_in_date'], '%Y-%m-%d').date()
|
||||
new_check_in_time = datetime.strptime(request.form['check_in_time'], '%H:%M').time()
|
||||
new_location_name = request.form['location_name'].strip()
|
||||
@@ -120,10 +161,12 @@ def edit_attendance(record_id):
|
||||
attendance_record.check_in_time = new_check_in_time
|
||||
attendance_record.location_name = new_location_name
|
||||
attendance_record.qr_code_id = int(new_qr_code_id)
|
||||
attendance_record.updated_timestamp = datetime.utcnow()
|
||||
|
||||
# Local time throughout (§18): check-in dates and times are local, so
|
||||
# a UTC audit stamp read hours apart from the record it describes.
|
||||
attendance_record.updated_timestamp = datetime.now()
|
||||
|
||||
# Store the audit note with timestamp and user info
|
||||
timestamp = datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')
|
||||
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
username = session.get('username', 'Unknown')
|
||||
role = session.get('role', 'unknown')
|
||||
|
||||
@@ -262,10 +305,17 @@ def save_manual_attendance():
|
||||
flash('All fields are required.', 'error')
|
||||
return redirect(url_for('attendance.add_manual_attendance'))
|
||||
|
||||
# Validate employee exists
|
||||
employee = Employee.query.filter_by(id=int(employee_id)).first()
|
||||
# Validate the ID, and keep any work-type code ("1234SP") on the stored value
|
||||
canonical_employee_id, base_employee_id, work_type = _normalize_manual_employee_id(employee_id)
|
||||
if not canonical_employee_id:
|
||||
flash('Employee ID must be 1 to 4 digits, optionally with a work type '
|
||||
'(for example 1234 or 1234SP).', 'error')
|
||||
return redirect(url_for('attendance.add_manual_attendance'))
|
||||
|
||||
# Validate employee exists (by the numeric base ID)
|
||||
employee = Employee.query.filter_by(id=int(base_employee_id)).first()
|
||||
if not employee:
|
||||
flash(f'Employee with ID {employee_id} not found.', 'error')
|
||||
flash(f'Employee with ID {base_employee_id} not found.', 'error')
|
||||
return redirect(url_for('attendance.add_manual_attendance'))
|
||||
|
||||
# Get QR code (location)
|
||||
@@ -285,7 +335,7 @@ def save_manual_attendance():
|
||||
|
||||
# Check if record already exists for this employee, location, date, and time
|
||||
existing_record = AttendanceData.query.filter_by(
|
||||
employee_id=str(employee_id),
|
||||
employee_id=canonical_employee_id,
|
||||
qr_code_id=qr_code.id,
|
||||
check_in_date=check_date_obj,
|
||||
check_in_time=check_time_obj
|
||||
@@ -300,7 +350,7 @@ def save_manual_attendance():
|
||||
# Set fixed distance of 0.010 miles
|
||||
new_attendance = AttendanceData(
|
||||
qr_code_id=qr_code.id,
|
||||
employee_id=str(employee_id),
|
||||
employee_id=canonical_employee_id,
|
||||
check_in_date=check_date_obj,
|
||||
check_in_time=check_time_obj,
|
||||
location_name=qr_code.location,
|
||||
@@ -320,8 +370,8 @@ def save_manual_attendance():
|
||||
status='present',
|
||||
verification_required=False,
|
||||
verification_status='approved',
|
||||
created_timestamp=datetime.utcnow(),
|
||||
updated_timestamp=datetime.utcnow()
|
||||
created_timestamp=datetime.now(),
|
||||
updated_timestamp=datetime.now()
|
||||
)
|
||||
|
||||
db.session.add(new_attendance)
|
||||
@@ -330,7 +380,7 @@ def save_manual_attendance():
|
||||
# Log the manual entry
|
||||
logger_handler.logger.info(
|
||||
f"Manual attendance record created by {session.get('username')} ({user_role}): "
|
||||
f"Employee {employee.firstName} {employee.lastName} (ID: {employee_id}), "
|
||||
f"Employee {employee.firstName} {employee.lastName} (ID: {canonical_employee_id}), "
|
||||
f"Location: {qr_code.location}, Event: {qr_code.location_event}, "
|
||||
f"Date: {check_date}, Time: {check_time}"
|
||||
)
|
||||
|
||||
+106
-24
@@ -8,6 +8,7 @@ Routes: /dashboard, /project/<id>/qr-codes, /dashboard/search,
|
||||
"""
|
||||
from flask import abort, Blueprint, render_template, request, redirect, flash, session, jsonify, url_for
|
||||
from datetime import datetime, timedelta, date, time
|
||||
from sqlalchemy import or_
|
||||
|
||||
from extensions import db, logger_handler
|
||||
from models.attendance import AttendanceData
|
||||
@@ -15,7 +16,27 @@ from models.project import Project
|
||||
from models.qrcode import QRCode
|
||||
from models.user import User
|
||||
from logger_handler import log_user_activity, log_database_operations
|
||||
from utils.helpers import login_required
|
||||
from utils.helpers import login_required, load_project_manager_scope
|
||||
|
||||
|
||||
def _project_manager_qr_filter():
|
||||
"""
|
||||
(is_pm, qr_filter) for the session user — the QR codes a Project Manager may
|
||||
see (§4). qr_filter is None for everyone else, and a never-true condition for
|
||||
a PM with no assignments.
|
||||
"""
|
||||
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
|
||||
if not is_pm:
|
||||
return False, None
|
||||
|
||||
scope = []
|
||||
if allowed_project_ids:
|
||||
scope.append(QRCode.project_id.in_(allowed_project_ids))
|
||||
if allowed_location_names:
|
||||
scope.append(QRCode.location.in_(allowed_location_names))
|
||||
if not scope:
|
||||
return True, QRCode.id.is_(None) # assigned nothing → sees nothing
|
||||
return True, or_(*scope)
|
||||
|
||||
bp = Blueprint('dashboard', __name__)
|
||||
|
||||
@@ -34,7 +55,12 @@ def dashboard():
|
||||
|
||||
# Build QR codes query with filters
|
||||
qr_query = QRCode.query
|
||||
|
||||
|
||||
# Project Managers only see QR codes in their assigned projects/locations
|
||||
is_pm, pm_qr_filter = _project_manager_qr_filter()
|
||||
if pm_qr_filter is not None:
|
||||
qr_query = qr_query.filter(pm_qr_filter)
|
||||
|
||||
# Apply name filter if provided
|
||||
if search_name:
|
||||
qr_query = qr_query.filter(QRCode.name.ilike(f'%{search_name}%'))
|
||||
@@ -47,7 +73,16 @@ def dashboard():
|
||||
|
||||
# Execute query
|
||||
qr_codes = qr_query.order_by(QRCode.created_date.desc()).all()
|
||||
projects = Project.query.order_by(Project.name.asc()).all()
|
||||
|
||||
project_query = Project.query
|
||||
if is_pm:
|
||||
# Only the projects the PM is assigned to, plus those behind their locations
|
||||
visible_project_ids = {qr.project_id for qr in qr_codes if qr.project_id}
|
||||
_, allowed_project_ids, _ = load_project_manager_scope()
|
||||
visible_project_ids.update(allowed_project_ids)
|
||||
project_query = (project_query.filter(Project.id.in_(sorted(visible_project_ids)))
|
||||
if visible_project_ids else project_query.filter(Project.id.is_(None)))
|
||||
projects = project_query.order_by(Project.name.asc()).all()
|
||||
|
||||
# Log dashboard access with filter info
|
||||
filter_info = []
|
||||
@@ -87,7 +122,16 @@ def project_qr_codes(project_id):
|
||||
project = db.session.get(Project, project_id)
|
||||
if project is None:
|
||||
abort(404)
|
||||
|
||||
|
||||
# Project Managers may only open their own projects (§4)
|
||||
is_pm, allowed_project_ids, _ = load_project_manager_scope()
|
||||
if is_pm and project_id not in allowed_project_ids:
|
||||
logger_handler.logger.warning(
|
||||
f"Project Manager {session.get('username')} tried to open project {project_id}"
|
||||
)
|
||||
flash('You do not have permission to view that project.', 'error')
|
||||
return redirect(url_for('dashboard.dashboard'))
|
||||
|
||||
# Get search parameters from URL
|
||||
search_name = request.args.get('search_name', '').strip()
|
||||
search_status = request.args.get('search_status', '').strip()
|
||||
@@ -154,36 +198,59 @@ def search_qr_codes():
|
||||
def dashboard_stats_api():
|
||||
"""API endpoint for dashboard statistics"""
|
||||
try:
|
||||
# Project Manager scope (§4): every count below is restricted to the QR
|
||||
# codes / locations they are assigned to.
|
||||
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
|
||||
_, pm_qr_filter = _project_manager_qr_filter()
|
||||
|
||||
qr_base_query = QRCode.query.filter_by(active_status=True)
|
||||
attendance_base_query = AttendanceData.query
|
||||
location_base_query = db.session.query(AttendanceData.location_name)
|
||||
project_base_query = Project.query.filter_by(active_status=True)
|
||||
|
||||
if is_pm:
|
||||
qr_base_query = qr_base_query.filter(pm_qr_filter)
|
||||
attendance_scope = []
|
||||
if allowed_project_ids:
|
||||
attendance_scope.append(AttendanceData.qr_code_id.in_(
|
||||
db.session.query(QRCode.id).filter(QRCode.project_id.in_(allowed_project_ids))
|
||||
))
|
||||
project_base_query = project_base_query.filter(Project.id.in_(allowed_project_ids))
|
||||
else:
|
||||
project_base_query = project_base_query.filter(Project.id.is_(None))
|
||||
if allowed_location_names:
|
||||
attendance_scope.append(AttendanceData.location_name.in_(allowed_location_names))
|
||||
attendance_condition = or_(*attendance_scope) if attendance_scope else AttendanceData.id.is_(None)
|
||||
attendance_base_query = attendance_base_query.filter(attendance_condition)
|
||||
location_base_query = location_base_query.filter(attendance_condition)
|
||||
|
||||
# Get current stats
|
||||
total_qr_codes = QRCode.query.filter_by(active_status=True).count()
|
||||
|
||||
# Today's check-ins
|
||||
today = datetime.utcnow().date()
|
||||
today_checkins = AttendanceData.query.filter(
|
||||
total_qr_codes = qr_base_query.count()
|
||||
|
||||
# Today's check-ins — local date, matching how check-ins are stored
|
||||
today = datetime.now().date()
|
||||
today_checkins = attendance_base_query.filter(
|
||||
AttendanceData.check_in_date == today
|
||||
).count()
|
||||
|
||||
|
||||
# Active projects
|
||||
active_projects = Project.query.filter_by(active_status=True).count()
|
||||
|
||||
active_projects = project_base_query.count()
|
||||
|
||||
# Unique locations
|
||||
unique_locations = db.session.query(
|
||||
AttendanceData.location_name
|
||||
).distinct().count()
|
||||
|
||||
unique_locations = location_base_query.distinct().count()
|
||||
|
||||
# Calculate trends (compared to last month)
|
||||
last_month = datetime.utcnow() - timedelta(days=30)
|
||||
|
||||
last_month = datetime.now() - timedelta(days=30)
|
||||
|
||||
# QR codes trend
|
||||
old_qr_count = QRCode.query.filter(
|
||||
QRCode.created_date <= last_month,
|
||||
QRCode.active_status == True
|
||||
old_qr_count = qr_base_query.filter(
|
||||
QRCode.created_date <= last_month
|
||||
).count()
|
||||
qr_change = ((total_qr_codes - old_qr_count) / max(old_qr_count, 1)) * 100
|
||||
|
||||
# Check-ins trend (yesterday)
|
||||
yesterday = today - timedelta(days=1)
|
||||
yesterday_checkins = AttendanceData.query.filter(
|
||||
yesterday_checkins = attendance_base_query.filter(
|
||||
AttendanceData.check_in_date == yesterday
|
||||
).count()
|
||||
checkin_change = ((today_checkins - yesterday_checkins) / max(yesterday_checkins, 1)) * 100
|
||||
@@ -214,12 +281,27 @@ def dashboard_realtime_api():
|
||||
"""API endpoint for real-time dashboard data"""
|
||||
try:
|
||||
# Get recent activity (last 10 check-ins)
|
||||
recent_activity = db.session.query(
|
||||
recent_query = db.session.query(
|
||||
AttendanceData.employee_id,
|
||||
AttendanceData.location_name,
|
||||
AttendanceData.check_in_time,
|
||||
AttendanceData.check_in_date
|
||||
).order_by(
|
||||
)
|
||||
|
||||
# Project Managers only see activity at their own projects/locations (§4)
|
||||
is_pm, allowed_project_ids, allowed_location_names = load_project_manager_scope()
|
||||
if is_pm:
|
||||
recent_scope = []
|
||||
if allowed_project_ids:
|
||||
recent_scope.append(AttendanceData.qr_code_id.in_(
|
||||
db.session.query(QRCode.id).filter(QRCode.project_id.in_(allowed_project_ids))
|
||||
))
|
||||
if allowed_location_names:
|
||||
recent_scope.append(AttendanceData.location_name.in_(allowed_location_names))
|
||||
recent_query = recent_query.filter(or_(*recent_scope) if recent_scope
|
||||
else AttendanceData.id.is_(None))
|
||||
|
||||
recent_activity = recent_query.order_by(
|
||||
AttendanceData.check_in_date.desc(),
|
||||
AttendanceData.check_in_time.desc()
|
||||
).limit(10).all()
|
||||
|
||||
@@ -85,6 +85,20 @@ def _overnight_aware_sort_key(record):
|
||||
seconds_total += 24 * 3600
|
||||
return seconds_total
|
||||
|
||||
def _pair_is_regular(check_in_record, check_out_record) -> bool:
|
||||
"""
|
||||
True when a completed pair counts toward the 40-hour overtime rule.
|
||||
|
||||
SP / PW / PT / C hours are paid but never build toward overtime (§13). The
|
||||
OUT record's work type wins, mirroring effective_work_type elsewhere, so a
|
||||
Regular IN paired with an SP OUT counts as SP.
|
||||
"""
|
||||
in_work_type = getattr(check_in_record, 'work_type', None)
|
||||
out_work_type = getattr(check_out_record, 'work_type', None)
|
||||
effective = out_work_type or in_work_type
|
||||
return effective not in ('SP', 'PW', 'PT', 'C')
|
||||
|
||||
|
||||
def _qtr(decimal_hours: float) -> float:
|
||||
"""
|
||||
Round a decimal-hours value to the nearest quarter hour (.00/.25/.50/.75).
|
||||
@@ -596,6 +610,10 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
||||
current_week_start = None
|
||||
grand_regular_hours = 0
|
||||
grand_ot_hours = 0
|
||||
# Regular-only hours feeding the 40-hour overtime rule. SP/PW/PT/C hours
|
||||
# are worked and paid, but they do NOT build toward overtime (§13), so
|
||||
# they are excluded here while column H still shows every hour worked.
|
||||
weekly_regular_hours = 0
|
||||
# Accumulate SP/PW/PT/C hours from cross-type pairs (where the calculator
|
||||
# could not detect them because it processes each work-type stream independently).
|
||||
cross_type_sp_hours = 0.0
|
||||
@@ -624,20 +642,22 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
||||
_report_start = start_date
|
||||
week_start = (_report_start + timedelta(days=((date_obj.date() - _report_start).days // 7) * 7))
|
||||
if current_week_start is not None and week_start != current_week_start:
|
||||
# Write weekly total row
|
||||
week_regular = min(weekly_total_hours, 40.0)
|
||||
week_overtime = max(0, weekly_total_hours - 40.0)
|
||||
|
||||
# Write weekly total row. Column H = every hour worked; Regular
|
||||
# and OT are computed from regular hours only (SP/PW/PT/C excluded).
|
||||
week_regular = min(weekly_regular_hours, 40.0)
|
||||
week_overtime = max(0, weekly_regular_hours - 40.0)
|
||||
|
||||
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
|
||||
ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font
|
||||
ws.cell(row=current_row, column=9, value=_qtr(week_regular)).font = bold_font
|
||||
ws.cell(row=current_row, column=10, value=_qtr(week_overtime)).font = bold_font
|
||||
|
||||
|
||||
grand_regular_hours += week_regular
|
||||
grand_ot_hours += week_overtime
|
||||
current_row += 1
|
||||
|
||||
|
||||
weekly_total_hours = 0
|
||||
weekly_regular_hours = 0
|
||||
|
||||
current_week_start = week_start
|
||||
|
||||
@@ -666,6 +686,8 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
||||
# group, and sum only complete pairs. This ensures the daily total in
|
||||
# column H matches exactly the pairs rendered in the export rows.
|
||||
_day_total_hours = 0.0
|
||||
# Same pairs, minus SP/PW/PT/C: the overtime base (§13)
|
||||
_day_regular_hours = 0.0
|
||||
# Track which records are consumed by same-building pairing so the
|
||||
# cross-building pass only considers true orphans.
|
||||
_same_building_used_ids = set()
|
||||
@@ -704,6 +726,8 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
||||
if _duration > 24:
|
||||
continue
|
||||
_day_total_hours += _duration
|
||||
if _pair_is_regular(_in_r, _out_r):
|
||||
_day_regular_hours += _duration
|
||||
_out_used[_oi2] = True
|
||||
_same_building_used_ids.add(id(_in_r))
|
||||
_same_building_used_ids.add(id(_out_r))
|
||||
@@ -768,6 +792,8 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
||||
'hours': _cb_dur,
|
||||
})
|
||||
_day_total_hours += _cb_dur
|
||||
if _pair_is_regular(_cb_in, _cb_out):
|
||||
_day_regular_hours += _cb_dur
|
||||
logger_handler.logger.info(
|
||||
f"[TA Export] Cross-building pair for employee {employee_id} on {date_str}: "
|
||||
f"IN {_cb_in.location_name} @ {_cb_in.check_in_time} → "
|
||||
@@ -788,6 +814,7 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
||||
|
||||
total_hours = _qtr(_day_total_hours)
|
||||
weekly_total_hours += total_hours
|
||||
weekly_regular_hours += _qtr(_day_regular_hours)
|
||||
|
||||
# Daily total display (only shown on last location's last row)
|
||||
daily_total_display = _qtr(total_hours) if total_hours > 0 else ''
|
||||
@@ -1282,10 +1309,10 @@ def export_time_attendance_excel(records, project_name_for_filename, date_range_
|
||||
# END CROSS-BUILDING PAIR ROW WRITING
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
# Write final weekly total for this employee
|
||||
# Write final weekly total for this employee (Regular/OT from regular hours only)
|
||||
if weekly_total_hours > 0:
|
||||
week_regular = min(weekly_total_hours, 40.0)
|
||||
week_overtime = max(0, weekly_total_hours - 40.0)
|
||||
week_regular = min(weekly_regular_hours, 40.0)
|
||||
week_overtime = max(0, weekly_regular_hours - 40.0)
|
||||
|
||||
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
|
||||
ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font
|
||||
@@ -1757,7 +1784,21 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
|
||||
date_cell.font = Font(name='Aptos Narrow', size=11)
|
||||
date_cell.alignment = Alignment(horizontal='left')
|
||||
current_row += 1
|
||||
|
||||
|
||||
# Row 5: how the totals in this sheet are calculated. Overtime here is per
|
||||
# BUILDING (each block counts only that building's hours), so an employee
|
||||
# split across two buildings shows no overtime even past 40 hours — the main
|
||||
# Time Attendance export totals the week across all buildings.
|
||||
ws.merge_cells(f'A{current_row}:N{current_row}')
|
||||
ot_note_cell = ws.cell(
|
||||
row=current_row, column=1,
|
||||
value=('Note: Weekly Total / Regular / OT below are per building, and overtime excludes '
|
||||
'SP / PW / PT / C hours. Use the main Time Attendance export for payroll totals.')
|
||||
)
|
||||
ot_note_cell.font = Font(name='Aptos Narrow', size=10, italic=True)
|
||||
ot_note_cell.alignment = Alignment(horizontal='left')
|
||||
current_row += 1
|
||||
|
||||
# Empty rows before first building
|
||||
current_row += 2
|
||||
|
||||
@@ -1967,6 +2008,9 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
|
||||
current_week_start = None
|
||||
grand_regular_hours = 0
|
||||
grand_ot_hours = 0
|
||||
# Regular-only hours for the 40-hour rule (SP/PW/PT/C excluded, §13).
|
||||
# Note: per BUILDING in this sheet — see the note under the date range.
|
||||
weekly_regular_hours = 0
|
||||
# Accumulate raw regular-only (non-SP/PW/PT) pair hours.
|
||||
# Used for the "Regular" summary row when the employee also has
|
||||
# special work-type hours.
|
||||
@@ -1993,9 +2037,10 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
|
||||
_report_start = start_date
|
||||
week_start = (_report_start + timedelta(days=((date_obj.date() - _report_start).days // 7) * 7))
|
||||
if current_week_start is not None and week_start != current_week_start:
|
||||
# Write weekly total row
|
||||
week_regular = min(weekly_total_hours, 40.0)
|
||||
week_overtime = max(0, weekly_total_hours - 40.0)
|
||||
# Write weekly total row. Column H = every hour worked at this
|
||||
# building; Regular and OT come from regular hours only.
|
||||
week_regular = min(weekly_regular_hours, 40.0)
|
||||
week_overtime = max(0, weekly_regular_hours - 40.0)
|
||||
|
||||
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
|
||||
ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font
|
||||
@@ -2005,8 +2050,9 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
|
||||
grand_regular_hours += week_regular
|
||||
grand_ot_hours += week_overtime
|
||||
current_row += 1
|
||||
|
||||
|
||||
weekly_total_hours = 0
|
||||
weekly_regular_hours = 0
|
||||
|
||||
current_week_start = week_start
|
||||
|
||||
@@ -2145,6 +2191,7 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
|
||||
# Calculate daily hours
|
||||
daily_hours = 0
|
||||
_bb_day_non_sp_hours = 0.0 # weekly summary sheet excludes SP time
|
||||
_bb_day_regular_hours = 0.0 # overtime base: excludes SP/PW/PT/C
|
||||
for pair in pairs:
|
||||
if pair['check_in'] and pair['check_out'] and not pair['is_miss_punch']:
|
||||
pair_in = datetime.combine(date_obj, pair['check_in'].check_in_time)
|
||||
@@ -2165,11 +2212,13 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
|
||||
_bb_eff_wt = _bb_out_wt or _bb_in_wt # prefer OUT's type (mirrors main export)
|
||||
if _bb_eff_wt not in ('SP', 'PW', 'PT', 'C'):
|
||||
regular_only_hours += _bb_dur
|
||||
_bb_day_regular_hours += _bb_dur
|
||||
if _bb_eff_wt != 'SP':
|
||||
_bb_day_non_sp_hours += _bb_dur
|
||||
|
||||
daily_hours = _qtr(daily_hours)
|
||||
weekly_total_hours += daily_hours
|
||||
weekly_regular_hours += _qtr(_bb_day_regular_hours)
|
||||
|
||||
# Same week anchoring as the Weekly Total rows (report start date)
|
||||
_bb_week_idx = (date_obj.date() - start_date).days // 7
|
||||
@@ -2250,10 +2299,10 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
|
||||
|
||||
current_row += 1
|
||||
|
||||
# Write final weekly total
|
||||
# Write final weekly total (Regular/OT from regular hours only)
|
||||
if weekly_total_hours > 0:
|
||||
week_regular = min(weekly_total_hours, 40.0)
|
||||
week_overtime = max(0, weekly_total_hours - 40.0)
|
||||
week_regular = min(weekly_regular_hours, 40.0)
|
||||
week_overtime = max(0, weekly_regular_hours - 40.0)
|
||||
|
||||
ws.cell(row=current_row, column=7, value='Weekly Total: ').font = bold_font
|
||||
ws.cell(row=current_row, column=8, value=_qtr(weekly_total_hours)).font = bold_font
|
||||
|
||||
Reference in New Issue
Block a user