""" routes/time_attendance.py ========================= Time attendance dashboard, import pipeline, export (Excel / by-building), and records management routes. Routes: /time-attendance, /time-attendance/import/*, /time-attendance/export*, /time-attendance/records, /time-attendance/record/, /time-attendance/delete/, /api/time-attendance/* """ from flask import Blueprint, render_template, request, redirect, flash, session, jsonify, send_file, Response, g, current_app, url_for from datetime import datetime, date, timedelta, time import io, os, json, re, uuid, traceback import time as _time from extensions import db, logger_handler from models.employee import Employee from models.project import Project from models.qrcode import QRCode from models.time_attendance import TimeAttendance from models.user import User from sqlalchemy import text from werkzeug.utils import secure_filename from logger_handler import log_user_activity, log_database_operations from utils.helpers import ( admin_required, has_admin_privileges, has_staff_level_access, login_required, staff_or_admin_required) from utils.geocoding import calculate_location_accuracy_enhanced from working_hours_calculator import WorkingHoursCalculator, round_time_to_quarter_hour, convert_minutes_to_base100, round_base100_hours from time_attendance_import_service import TimeAttendanceImportService import openpyxl from openpyxl.styles import Font, PatternFill, Alignment, Border, Side, numbers from openpyxl.utils import get_column_letter import openpyxl.cell.cell bp = Blueprint('time_attendance', __name__) from routes.time_attendance_export import ( calculate_possible_violation, _overnight_aware_sort_key, _qtr, export_time_attendance_excel, export_time_attendance_by_building_excel, ) @bp.route('/time-attendance', endpoint='time_attendance_dashboard') @login_required @log_user_activity('time_attendance_view') def time_attendance_dashboard(): """Display time attendance dashboard with table layout""" try: # Initialize default values total_records = 0 unique_employees = 0 unique_locations = 0 recent_imports = [] recent_records = [] employees = [] locations = [] # Try to get data from TimeAttendance model if it exists try: # Get summary statistics total_records = TimeAttendance.query.count() if total_records > 0: unique_employees = db.session.query(TimeAttendance.employee_id).distinct().count() unique_locations = db.session.query(TimeAttendance.location_name).distinct().count() # Get recent records (last 20 records for table display) recent_records = TimeAttendance.query.order_by( TimeAttendance.attendance_date.desc(), TimeAttendance.attendance_time.desc() ).limit(20).all() # Get recent imports (last 10 import batches) recent_imports = db.session.query( TimeAttendance.import_batch_id, TimeAttendance.import_date, TimeAttendance.import_source, db.func.count(TimeAttendance.id).label('record_count') ).filter( TimeAttendance.import_batch_id.isnot(None) ).group_by( TimeAttendance.import_batch_id, TimeAttendance.import_date, TimeAttendance.import_source ).order_by( TimeAttendance.import_date.desc() ).limit(10).all() # Get filter options employees = TimeAttendance.get_unique_employees() locations = TimeAttendance.get_unique_locations() except ImportError: # TimeAttendance model doesn't exist yet - use defaults pass except Exception as e: # Database table doesn't exist yet or other error - use defaults logger_handler.logger.error(f"TimeAttendance query error: {e}", exc_info=True) pass return render_template('time_attendance_dashboard.html', total_records=total_records, unique_employees=unique_employees, unique_locations=unique_locations, recent_imports=recent_imports, recent_records=recent_records, employees=employees, locations=locations) except Exception as e: logger_handler.logger.error(f"Error in time attendance dashboard: {e}") flash('Error loading time attendance dashboard.', 'error') return redirect(url_for('dashboard.dashboard')) @bp.route('/time-attendance/import', methods=['GET', 'POST'], endpoint='import_time_attendance') @login_required @log_database_operations('time_attendance_import') def import_time_attendance(): """Enhanced import with duplicate review""" if request.method == 'GET': # Load active projects for dropdown projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() return render_template('time_attendance_import.html', projects=projects) if request.method == 'POST': try: # Check if this is coming from invalid review (file is already in session) coming_from_invalid_review = request.form.get('from_invalid_review', 'false').lower() == 'true' coming_from_duplicate_review = request.form.get('from_duplicate_review', 'false').lower() == 'true' logger_handler.logger.debug( f"Import flow: coming_from_invalid={coming_from_invalid_review}, " f"coming_from_duplicate={coming_from_duplicate_review}" ) if coming_from_invalid_review or coming_from_duplicate_review: # Retrieve file from session if 'pending_import_file' not in session or 'pending_import_filename' not in session: flash('Session expired. Please upload the file again.', 'error') return redirect(url_for('time_attendance.import_time_attendance')) temp_path = session['pending_import_file'] filename = session['pending_import_filename'] # Verify file still exists if not os.path.exists(temp_path): flash('Temporary file not found. Please upload the file again.', 'error') session.pop('pending_import_file', None) session.pop('pending_import_filename', None) return redirect(url_for('time_attendance.import_time_attendance')) logger_handler.logger.debug(f"Retrieved file from session: {filename}, exists={os.path.exists(temp_path)}") else: # Normal file upload flow - now supports multiple files if 'files' not in request.files: flash('No files uploaded.', 'error') return redirect(request.url) files = request.files.getlist('files') if not files or len(files) == 0: flash('No files selected.', 'error') return redirect(request.url) # Validate all files and save them temporarily temp_paths = [] filenames = [] for file in files: if file.filename == '': continue # Validate file extension if not file.filename.lower().endswith(('.xlsx', '.xls')): flash(f'Invalid file format: {file.filename}. Please upload only Excel files (.xlsx or .xls).', 'error') # Clean up already saved files for saved_path in temp_paths: if os.path.exists(saved_path): os.remove(saved_path) return redirect(request.url) # Save uploaded file temporarily filename = secure_filename(file.filename) temp_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/tmp'), f"temp_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}") os.makedirs(os.path.dirname(temp_path), exist_ok=True) file.save(temp_path) temp_paths.append(temp_path) filenames.append(filename) logger_handler.logger.debug(f"Uploaded file {len(temp_paths)}: {filename} saved to {temp_path}") if len(temp_paths) == 0: flash('No valid files selected.', 'error') return redirect(request.url) # Store file paths in session for duplicate/invalid review session['pending_import_file'] = temp_paths[0] if len(temp_paths) == 1 else temp_paths session['pending_import_filename'] = filenames[0] if len(filenames) == 1 else filenames session['pending_import_files_multiple'] = len(temp_paths) > 1 temp_path = temp_paths[0] if len(temp_paths) == 1 else temp_paths filename = filenames[0] if len(filenames) == 1 else ', '.join(filenames) logger_handler.logger.debug(f"Total files uploaded: {len(temp_paths)}") # Determine if we're processing multiple files is_multiple_files = session.get('pending_import_files_multiple', False) files_to_process = [] if is_multiple_files: # Multiple files mode if isinstance(temp_path, list): files_to_process = list(zip(temp_path, filename.split(', ') if isinstance(filename, str) else filename)) else: files_to_process = [(temp_path, filename)] else: # Single file mode (existing behavior) files_to_process = [(temp_path, filename)] logger_handler.logger.info(f"Processing {len(files_to_process)} file(s) for time attendance import") try: import_service = TimeAttendanceImportService(db, logger_handler) # Get import options skip_duplicates = request.form.get('skip_duplicates', 'true').lower() == 'true' validate_only = request.form.get('validate_only', 'false').lower() == 'true' analyze_duplicates = request.form.get('analyze_duplicates', 'false').lower() == 'true' analyze_invalid = request.form.get('analyze_invalid', 'false').lower() == 'true' logger_handler.logger.debug( f"Import options: skip_duplicates={skip_duplicates}, validate_only={validate_only}, " f"analyze_duplicates={analyze_duplicates}, analyze_invalid={analyze_invalid}, " f"coming_from_invalid={coming_from_invalid_review}" ) # Store combined results for multiple files all_results = { 'total_files': len(files_to_process), 'successful_files': 0, 'failed_files': 0, 'total_imported': 0, 'total_duplicates': 0, 'total_failed': 0, 'file_results': [], 'errors': [], 'warnings': [] } # Process each file for file_index, (current_temp_path, current_filename) in enumerate(files_to_process, 1): logger_handler.logger.info(f"Processing file {file_index}/{len(files_to_process)}: {current_filename}") import_result = None # Initialize to prevent reference errors try: # For multiple files, skip review screens and import directly if is_multiple_files: logger_handler.logger.debug("Batch mode: processing directly without review screens") # Validate the file first validation_result = import_service.validate_excel_file(current_temp_path) if not validation_result['valid']: raise Exception(f"Validation failed: {'; '.join(validation_result['errors'])}") # Get import settings project_id = request.form.get('project_id') project_id = int(project_id) if project_id and project_id != '' else None import_source = request.form.get('import_source', f"Batch Import - {current_filename}") # Import the file (always skip duplicates in batch mode) import_result = import_service.import_from_excel( current_temp_path, created_by=session['user_id'], import_source=import_source, skip_duplicates=True, # Always skip duplicates in batch mode force_import_hashes=set(), project_id=project_id ) else: # Single file - use existing review workflow logic below # This continues to the existing code after the loop pass # Accumulate results if import was performed if import_result and import_result.get('success'): all_results['successful_files'] += 1 all_results['total_imported'] += import_result.get('imported_records', 0) all_results['total_duplicates'] += import_result.get('duplicate_records', 0) all_results['file_results'].append({ 'filename': current_filename, 'status': 'success', 'imported': import_result.get('imported_records', 0), 'batch_id': import_result.get('batch_id', '') }) logger_handler.logger.info(f"Imported {import_result.get('imported_records', 0)} records from {current_filename}") elif import_result: # Import ran but failed all_results['failed_files'] += 1 all_results['total_failed'] += import_result.get('failed_records', 0) all_results['errors'].append(f"{current_filename}: Import failed") all_results['file_results'].append({ 'filename': current_filename, 'status': 'failed', 'error': 'Import returned unsuccessful status' }) except Exception as file_error: logger_handler.logger.error(f"Error processing file {current_filename}: {file_error}", exc_info=True) logger_handler.logger.error(f"Error processing file {current_filename}: {file_error}") all_results['failed_files'] += 1 all_results['errors'].append(f"{current_filename}: {str(file_error)}") all_results['file_results'].append({ 'filename': current_filename, 'status': 'failed', 'error': str(file_error) }) continue finally: # Cleanup individual file (only for multiple file mode, single file cleanup happens later) if is_multiple_files and os.path.exists(current_temp_path): try: os.remove(current_temp_path) logger_handler.logger.debug(f"Cleaned up temp file: {current_temp_path}") except Exception as cleanup_error: logger_handler.logger.warning(f"Failed to cleanup temp file: {cleanup_error}") # After processing all files if is_multiple_files: # Log the batch import activity logger_handler.logger.info( f"Batch Import: User {session.get('username', 'unknown')} imported time attendance data from {len(files_to_process)} files - " f"Successful: {all_results['successful_files']}/{all_results['total_files']}, " f"Total imported: {all_results['total_imported']}, " f"Duplicates: {all_results['total_duplicates']}" ) # Show combined results if all_results['successful_files'] > 0: flash(f"✅ Successfully imported {all_results['total_imported']} records from {all_results['successful_files']}/{all_results['total_files']} files.", 'success') if all_results['total_duplicates'] > 0: flash(f"ℹ️ Skipped {all_results['total_duplicates']} duplicate records across all files.", 'info') if all_results['failed_files'] > 0: flash(f"❌ {all_results['failed_files']} file(s) failed to import.", 'error') # Show first few error details for error in all_results['errors'][:3]: flash(f"Error: {error}", 'error') if len(all_results['errors']) > 3: flash(f"...and {len(all_results['errors']) - 3} more errors", 'error') # Clear session session.pop('pending_import_file', None) session.pop('pending_import_filename', None) session.pop('pending_import_files_multiple', None) logger_handler.logger.info( f"Batch import summary: files={all_results['total_files']}, " f"successful={all_results['successful_files']}, failed={all_results['failed_files']}, " f"imported={all_results['total_imported']}, duplicates={all_results['total_duplicates']}" ) return redirect(url_for('time_attendance.time_attendance_dashboard')) # Check if this is coming from duplicate review force_import_hashes = request.form.getlist('force_import_hashes[]') # If analyzing for duplicates, show review page (but not if coming from invalid/duplicate review) if analyze_duplicates and not force_import_hashes and not coming_from_invalid_review and not coming_from_duplicate_review: logger_handler.logger.debug("Analyzing for duplicates") project_id_for_analysis = request.form.get('project_id') project_id_for_analysis = int(project_id_for_analysis) if project_id_for_analysis and project_id_for_analysis != '' else None duplicate_analysis = import_service.analyze_for_duplicates(temp_path, project_id=project_id_for_analysis) # Surface project-mismatch errors immediately if duplicate_analysis.get('errors'): for err in duplicate_analysis['errors']: flash(err, 'error') projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() return render_template('time_attendance_import.html', projects=projects) if duplicate_analysis['duplicate_records'] > 0: logger_handler.logger.info(f"Found {duplicate_analysis['duplicate_records']} duplicates") # Show duplicate review page return render_template('time_attendance_duplicate_review.html', analysis=duplicate_analysis, filename=filename, project_id=project_id_for_analysis) else: logger_handler.logger.debug("No duplicates found") flash('No duplicates found. Proceeding with import.', 'info') # Check for invalid rows and show review if any (but not if coming from invalid review) if analyze_invalid and not coming_from_invalid_review: logger_handler.logger.debug("Analyzing for invalid rows") project_id_for_analysis = request.form.get('project_id') project_id_for_analysis = int(project_id_for_analysis) if project_id_for_analysis and project_id_for_analysis != '' else None invalid_analysis = import_service.analyze_for_invalid_rows(temp_path, project_id=project_id_for_analysis) # Surface project-mismatch errors immediately if invalid_analysis.get('errors'): for err in invalid_analysis['errors']: flash(err, 'error') projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() return render_template('time_attendance_import.html', projects=projects) if invalid_analysis['invalid_rows'] > 0: logger_handler.logger.info(f"Found {invalid_analysis['invalid_rows']} invalid rows") # Show invalid row review page return render_template('time_attendance_invalid_review.html', analysis=invalid_analysis, filename=filename, project_id=project_id_for_analysis) else: logger_handler.logger.debug("All rows are valid") flash('All rows are valid. Proceeding with import.', 'info') # If coming from invalid review, skip validation (already done) if not coming_from_invalid_review: logger_handler.logger.debug("Validating file") # Validate file validation_result = import_service.validate_excel_file(temp_path) if not validation_result['valid']: logger_handler.logger.warning(f"Validation failed: {validation_result['errors']}") flash(f"File validation failed: {'; '.join(validation_result['errors'])}", 'error') projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() return render_template('time_attendance_import.html', projects=projects, validation_result=validation_result) if validation_result['warnings']: for warning in validation_result['warnings']: flash(warning, 'warning') if validate_only: logger_handler.logger.info(f"Validation successful: {validation_result['valid_rows']} valid records") flash(f"File validation successful! Found {validation_result['valid_rows']} valid records.", 'success') projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() return render_template('time_attendance_import.html', projects=projects, validation_result=validation_result) else: logger_handler.logger.debug("Skipping validation (already validated)") # Proceed with import logger_handler.logger.info("Starting import process") import_source = request.form.get('import_source', f"Manual Import - {filename}") project_id = request.form.get('project_id') project_id = int(project_id) if project_id and project_id != '' else None import_result = import_service.import_from_excel( temp_path, created_by=session['user_id'], import_source=import_source, skip_duplicates=skip_duplicates, force_import_hashes=force_import_hashes, project_id=project_id ) if import_result['success']: logger_handler.logger.info( f"Import successful: batch_id={import_result['batch_id']}, " f"imported={import_result['imported_records']}/{import_result['total_records']}, " f"duplicates={import_result['duplicate_records']}, failed={import_result['failed_records']}" ) logger_handler.logger.info( f"User {session['username']} successfully imported time attendance data - " f"Batch: {import_result['batch_id']}, " f"Records: {import_result['imported_records']}/{import_result['total_records']}, " f"Duplicates: {import_result['duplicate_records']}, " f"Forced: {import_result['forced_duplicates']}, " f"Failed: {import_result['failed_records']}" ) flash(f"Import successful! Imported {import_result['imported_records']} records " f"out of {import_result['total_records']} total records.", 'success') if import_result['duplicate_records'] > 0: flash(f"Skipped {import_result['duplicate_records']} duplicate records.", 'info') if import_result['forced_duplicates'] > 0: flash(f"Imported {import_result['forced_duplicates']} duplicate records as requested.", 'info') if import_result['failed_records'] > 0: flash(f"Note: {import_result['failed_records']} records failed to import. " f"Check the error details below.", 'warning') # Clean up temp file after successful import if os.path.exists(temp_path): try: os.remove(temp_path) session.pop('pending_import_file', None) session.pop('pending_import_filename', None) logger_handler.logger.debug(f"Cleaned up temp file: {temp_path}") except Exception as cleanup_error: logger_handler.logger.warning(f"Failed to cleanup temp file: {cleanup_error}") return render_template('time_attendance_import_result.html', import_result=import_result) else: logger_handler.logger.error(f"Import failed: {import_result['errors']}") flash(f"Import failed: {'; '.join(import_result['errors'][:3])}", 'error') if len(import_result['errors']) > 3: flash(f"...and {len(import_result['errors']) - 3} more errors", 'warning') projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() return render_template('time_attendance_import.html', projects=projects, import_result=import_result) except Exception as import_error: logger_handler.logger.error(f"Import exception: {import_error}", exc_info=True) raise except Exception as e: logger_handler.log_database_error('time_attendance_import', e) logger_handler.logger.error(f"Top-level import exception: {e}", exc_info=True) flash('Import failed due to an unexpected error.', 'error') projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() return render_template('time_attendance_import.html', projects=projects) # GET request projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() return render_template('time_attendance_import.html', projects=projects) @bp.route('/time-attendance/import/analyze-duplicates', methods=['POST'], endpoint='analyze_import_duplicates') @login_required def analyze_import_duplicates(): """AJAX endpoint to analyze file for duplicates""" try: if 'file' not in request.files: return jsonify({'success': False, 'message': 'No file provided'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'success': False, 'message': 'No file selected'}), 400 if not file.filename.lower().endswith(('.xlsx', '.xls')): return jsonify({'success': False, 'message': 'Invalid file format'}), 400 # Save temporarily filename = secure_filename(file.filename) temp_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/tmp'), f"analyze_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}") os.makedirs(os.path.dirname(temp_path), exist_ok=True) file.save(temp_path) # Store in session session['pending_import_file'] = temp_path session['pending_import_filename'] = filename try: import_service = TimeAttendanceImportService(db, logger_handler) analysis = import_service.analyze_for_duplicates(temp_path) # Convert datetime objects to strings for JSON for duplicate in analysis.get('duplicates', []): if 'new_record' in duplicate: if duplicate['new_record'].get('attendance_date'): duplicate['new_record']['attendance_date'] = str(duplicate['new_record']['attendance_date']) if duplicate['new_record'].get('attendance_time'): duplicate['new_record']['attendance_time'] = str(duplicate['new_record']['attendance_time']) if 'existing_record' in duplicate: if duplicate['existing_record'].get('attendance_date'): duplicate['existing_record']['attendance_date'] = str(duplicate['existing_record']['attendance_date']) if duplicate['existing_record'].get('attendance_time'): duplicate['existing_record']['attendance_time'] = str(duplicate['existing_record']['attendance_time']) if duplicate['existing_record'].get('import_date'): duplicate['existing_record']['import_date'] = str(duplicate['existing_record']['import_date']) return jsonify({ 'success': True, 'analysis': analysis }) except Exception as e: # Cleanup on error if os.path.exists(temp_path): os.remove(temp_path) raise e except Exception as e: logger_handler.logger.error(f"Duplicate analysis error: {e}") return jsonify({ 'success': False, 'message': f'Analysis failed: {str(e)}' }), 500 @bp.route('/time-attendance/import/analyze-invalid', methods=['POST'], endpoint='analyze_import_invalid') @login_required def analyze_import_invalid(): """AJAX endpoint to analyze file for invalid rows""" try: if 'file' not in request.files: return jsonify({'success': False, 'message': 'No file provided'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'success': False, 'message': 'No file selected'}), 400 if not file.filename.lower().endswith(('.xlsx', '.xls')): return jsonify({'success': False, 'message': 'Invalid file format'}), 400 # Save temporarily filename = secure_filename(file.filename) temp_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/tmp'), f"analyze_invalid_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}") os.makedirs(os.path.dirname(temp_path), exist_ok=True) file.save(temp_path) # Store in session session['pending_import_file'] = temp_path session['pending_import_filename'] = filename try: import_service = TimeAttendanceImportService(db, logger_handler) analysis = import_service.analyze_for_invalid_rows(temp_path) # Convert datetime objects to strings for JSON for invalid in analysis.get('invalid_details', []): if 'row_data' in invalid: if invalid['row_data'].get('attendance_date'): invalid['row_data']['attendance_date'] = str(invalid['row_data']['attendance_date']) if invalid['row_data'].get('attendance_time'): invalid['row_data']['attendance_time'] = str(invalid['row_data']['attendance_time']) return jsonify({ 'success': True, 'analysis': analysis }) except Exception as e: logger_handler.logger.error(f"Invalid row analysis error: {e}") return jsonify({ 'success': False, 'message': f'Analysis failed: {str(e)}' }), 500 except Exception as e: logger_handler.logger.error(f"Invalid row analysis error: {e}") return jsonify({ 'success': False, 'message': f'Analysis failed: {str(e)}' }), 500 # --------------------------------------------------------------------------- # Time Attendance Import — SSE progress streaming (disk-based, multi-worker safe) # # Design: progress state is written to a small JSON file on disk so that any # gunicorn worker process can read it. No shared in-memory state is required. # The /stream endpoint runs the import itself (synchronously inside the SSE # generator) while writing progress to the file and yielding events to the # browser — compatible with gunicorn gevent workers. # --------------------------------------------------------------------------- def _progress_file_path(job_id: str, upload_dir: str = '/tmp') -> str: """Return the path for the on-disk progress file for a given job_id.""" os.makedirs(upload_dir, exist_ok=True) return os.path.join(upload_dir, f"import_progress_{job_id}.json") def _write_progress(job_id: str, event: dict, upload_dir: str = '/tmp') -> None: """Atomically write the latest progress event to disk.""" path = _progress_file_path(job_id, upload_dir) try: tmp = path + '.tmp' with open(tmp, 'w') as f: json.dump(event, f) os.replace(tmp, path) except Exception: pass # Best-effort; import will continue regardless @bp.route('/time-attendance/import/start', methods=['POST'], endpoint='start_import_job') @login_required def start_import_job(): """ Validates the uploaded file, saves it to disk, stores import options in a progress file, then returns a job_id. The actual import runs inside the SSE stream endpoint so no background thread or shared memory is needed. """ try: if 'files' not in request.files: return jsonify({'success': False, 'error': 'No file uploaded.'}), 400 files = request.files.getlist('files') if not files or files[0].filename == '': return jsonify({'success': False, 'error': 'No file selected.'}), 400 file = files[0] if not file.filename.lower().endswith(('.xlsx', '.xls')): return jsonify({'success': False, 'error': 'Invalid file format.'}), 400 filename = secure_filename(file.filename) upload_dir = current_app.config.get('UPLOAD_FOLDER', '/tmp') os.makedirs(upload_dir, exist_ok=True) job_id = str(uuid.uuid4()) temp_path = os.path.join(upload_dir, f"stream_{job_id}_{filename}") file.save(temp_path) # Store import options alongside the file so the stream endpoint can # read them without depending on session or shared memory. job_meta = { 'type': 'pending', 'temp_path': temp_path, 'filename': filename, 'skip_duplicates': request.form.get('skip_duplicates', 'true').lower() == 'true', 'project_id': int(request.form.get('project_id')) if request.form.get('project_id') else None, 'import_source': request.form.get('import_source', f"Manual Import - {filename}"), 'created_by': session['user_id'], 'username': session.get('username', 'unknown'), } _write_progress(job_id, job_meta, upload_dir) logger_handler.logger.info( f"User {job_meta['username']} queued time attendance import job {job_id} for file {filename}" ) return jsonify({'success': True, 'job_id': job_id}) except Exception as e: logger_handler.logger.error(f"Error queuing import job: {e}") return jsonify({'success': False, 'error': str(e)}), 500 @bp.route('/time-attendance/import/stream/', endpoint='stream_import_progress') @login_required def stream_import_progress(job_id): """ SSE endpoint — runs the import synchronously while streaming progress to the browser. Works across multiple gunicorn workers because all state is stored on disk (no in-memory job store). """ # Capture upload_dir HERE in the request context — current_app is NOT # available inside the background thread (_run) or after context teardown. upload_dir = current_app.config.get('UPLOAD_FOLDER', '/tmp') progress_path = _progress_file_path(job_id, upload_dir) # Capture real app object in request context — safe to use in background thread _real_app = current_app._get_current_object() def generate(): import time as _time # ── Read the job metadata written by /start ──────────────────────── deadline = _time.time() + 15 # Wait up to 15 s for the file to appear meta = None while _time.time() < deadline: if os.path.exists(progress_path): try: with open(progress_path) as f: meta = json.load(f) break except Exception: pass yield "data: " + json.dumps({'type': 'heartbeat'}) + "\n\n" _time.sleep(0.3) if not meta or meta.get('type') != 'pending': yield "data: " + json.dumps({ 'type': 'error', 'message': 'Job metadata not found. Please try importing again.' }) + "\n\n" return temp_path = meta['temp_path'] skip_dupes = meta['skip_duplicates'] project_id = meta['project_id'] import_source= meta['import_source'] created_by = meta['created_by'] username = meta['username'] if not os.path.exists(temp_path): yield "data: " + json.dumps({ 'type': 'error', 'message': 'Uploaded file not found. Please try importing again.' }) + "\n\n" return yield "data: " + json.dumps({'type': 'status', 'message': 'Reading and validating file...'}) + "\n\n" # ── Run the import with a progress callback ──────────────────────── try: svc = TimeAttendanceImportService(db, logger_handler) # progress_callback writes to disk AND yields an SSE event. # We collect events in a list so the generator can yield them. _pending_events = [] def on_progress(current, total, message): pct = int(current / total * 100) if total else 0 event = { 'type': 'progress', 'current': current, 'total': total, 'percent': pct, 'message': message, } _write_progress(job_id, event, upload_dir) _pending_events.append(event) # We need to interleave yielding with the synchronous import loop. # Strategy: run import_from_excel; the callback appends to # _pending_events; after every DB commit batch (50 records) we # flush pending events to the SSE stream. import threading as _threading result_holder = [None] error_holder = [None] done_event = _threading.Event() def _run(): # Push an application context so the thread can access # Flask-SQLAlchemy, Employee.query, etc. with _real_app.app_context(): try: result_holder[0] = svc.import_from_excel( temp_path, created_by=created_by, import_source=import_source, skip_duplicates=skip_dupes, force_import_hashes=[], project_id=project_id, progress_callback=on_progress) except Exception as exc: error_holder[0] = exc finally: done_event.set() t = _threading.Thread(target=_run, daemon=True) t.start() # Yield progress events as they arrive while the import thread runs while not done_event.is_set(): while _pending_events: yield "data: " + json.dumps(_pending_events.pop(0)) + "\n\n" yield "data: " + json.dumps({'type': 'heartbeat'}) + "\n\n" _time.sleep(0.4) # Drain any remaining events after the thread finishes while _pending_events: yield "data: " + json.dumps(_pending_events.pop(0)) + "\n\n" if error_holder[0]: raise error_holder[0] result = result_holder[0] if result and result['success']: logger_handler.logger.info( f"User {username} imported {result['imported_records']} time attendance records " f"via stream (batch: {result['batch_id']})" ) # Sanitize result dict for JSON serialization — convert any # datetime objects (e.g. import_date) to ISO-format strings. if result and isinstance(result.get('import_date'), datetime): result['import_date'] = result['import_date'].isoformat() done_event_data = {'type': 'done', 'result': result} _write_progress(job_id, done_event_data, upload_dir) yield "data: " + json.dumps(done_event_data) + "\n\n" except Exception as e: logger_handler.logger.error(f"Import stream error for job {job_id}: {e}") error_event = {'type': 'error', 'message': str(e)} _write_progress(job_id, error_event, upload_dir) yield "data: " + json.dumps(error_event) + "\n\n" finally: # Clean up temp files for path in (temp_path, progress_path): try: if os.path.exists(path): os.remove(path) except Exception: pass return Response( generate(), mimetype='text/event-stream', headers={ 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no', # Disable nginx buffering for SSE } ) @bp.route('/time-attendance/import/cancel-pending', endpoint='cancel_pending_import') @login_required def cancel_pending_import(): """Cancel pending import and cleanup temp file""" try: if 'pending_import_file' in session: temp_path = session['pending_import_file'] if os.path.exists(temp_path): os.remove(temp_path) session.pop('pending_import_file') if 'pending_import_filename' in session: session.pop('pending_import_filename') flash('Import cancelled.', 'info') except Exception as e: logger_handler.logger.error(f"Error cancelling import: {e}") return redirect(url_for('time_attendance.import_time_attendance')) @bp.route('/time-attendance/import/validate', methods=['POST'], endpoint='validate_import_file') @login_required def validate_import_file(): """AJAX endpoint to validate Excel file before import""" try: if 'file' not in request.files: return jsonify({'success': False, 'message': 'No file provided'}), 400 file = request.files['file'] if file.filename == '': return jsonify({'success': False, 'message': 'No file selected'}), 400 # Validate file extension if not file.filename.lower().endswith(('.xlsx', '.xls')): return jsonify({'success': False, 'message': 'Invalid file format'}), 400 # Save temporarily filename = secure_filename(file.filename) temp_path = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/tmp'), f"validate_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{filename}") os.makedirs(os.path.dirname(temp_path), exist_ok=True) file.save(temp_path) try: # Validate file import_service = TimeAttendanceImportService(db, logger_handler) validation_result = import_service.validate_excel_file(temp_path) return jsonify({ 'success': True, 'validation': validation_result }) finally: # Cleanup if os.path.exists(temp_path): os.remove(temp_path) except Exception as e: logger_handler.logger.error(f"Validation error: {e}") return jsonify({ 'success': False, 'message': f'Validation failed: {str(e)}' }), 500 @bp.route('/time-attendance/import/batch/', endpoint='view_import_batch') @login_required @log_user_activity('view_import_batch') def view_import_batch(batch_id): """View details of a specific import batch""" try: import_service = TimeAttendanceImportService(db, logger_handler) batch_summary = import_service.get_import_summary(batch_id) if not batch_summary: flash('Import batch not found.', 'error') return redirect(url_for('time_attendance.time_attendance_dashboard')) return render_template('time_attendance_batch_detail.html', batch_summary=batch_summary) except Exception as e: logger_handler.logger.error(f"Error viewing batch {batch_id}: {e}") flash('Error loading batch details.', 'error') return redirect(url_for('time_attendance.time_attendance_dashboard')) @bp.route('/time-attendance/import/batch//delete', methods=['POST'], endpoint='delete_import_batch') @admin_required @log_database_operations('delete_import_batch') def delete_import_batch(batch_id): """Delete an entire import batch""" try: import_service = TimeAttendanceImportService(db, logger_handler) result = import_service.delete_import_batch(batch_id, deleted_by=session['user_id']) if result['success']: flash(result['message'], 'success') logger_handler.logger.info( f"User {session['username']} deleted import batch {batch_id} - " f"{result['deleted_count']} records removed" ) else: flash(result['message'], 'error') return redirect(url_for('time_attendance.time_attendance_dashboard')) except Exception as e: logger_handler.logger.error(f"Error deleting batch {batch_id}: {e}") flash('Error deleting import batch.', 'error') return redirect(url_for('time_attendance.time_attendance_dashboard')) @bp.route('/time-attendance/import/download-template', endpoint='download_import_template') @login_required def download_import_template(): """Download Excel template for time attendance import""" try: import io from openpyxl import Workbook from openpyxl.styles import Font, PatternFill, Alignment from flask import send_file # Create workbook wb = Workbook() ws = wb.active ws.title = "Time Attendance Template" # Define headers headers = ['ID', 'Name', 'Platform', 'Date', 'Time', 'Location Name', 'Action Description', 'Event Description', 'Recorded Address', 'Distance'] # Style headers header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") header_font = Font(bold=True, color="FFFFFF") for col_num, header in enumerate(headers, 1): cell = ws.cell(row=1, column=col_num) cell.value = header cell.fill = header_fill cell.font = header_font cell.alignment = Alignment(horizontal='center') # Add sample data rows sample_data = [ ['12345', 'John Doe', 'iPhone - iOS', '2025-10-06', '09:00:00', 'HQ Suite 210', 'Check In', 'Main Office', '123 Main St', '0.125'], ['67890', 'Jane Smith', 'Android', '2025-10-06', '08:45:00', 'Branch Office', 'Check In', 'Morning Entry', '456 Oak Avenue', '0.250'], ] for row_num, row_data in enumerate(sample_data, 2): for col_num, value in enumerate(row_data, 1): ws.cell(row=row_num, column=col_num, value=value) # Adjust column widths for col in ws.columns: max_length = 0 col_letter = col[0].column_letter for cell in col: try: if len(str(cell.value)) > max_length: max_length = len(str(cell.value)) except: pass adjusted_width = min(max_length + 2, 50) ws.column_dimensions[col_letter].width = adjusted_width # Add instructions sheet ws_instructions = wb.create_sheet("Instructions") instructions = [ ["Time Attendance Import Template - Instructions"], [""], ["Required Columns:"], ["- ID: Employee ID (required)"], ["- Name: Employee full name (required)"], ["- Date: Attendance date in YYYY-MM-DD format (required)"], ["- Time: Attendance time in HH:MM:SS format (required)"], ["- Location Name: Location where attendance was recorded (required)"], ["- Action Description: Type of action (e.g., Check In, Check Out) (required)"], [""], ["Optional Columns:"], ["- Platform: Device platform (e.g., iPhone - iOS, Android)"], ["- Event Description: Additional event details"], ["- Recorded Address: Physical address where attendance was recorded"], ["- Distance: Distance in miles between Building and Recorded Address (optional)"], [""], ["Important Notes:"], ["- Do not modify the header row"], ["- Ensure all required fields have values"], ["- Date format must be YYYY-MM-DD (e.g., 2025-10-06)"], ["- Time format must be HH:MM:SS (e.g., 09:00:00)"], ["- Remove the sample data rows before importing your actual data"], ["- Duplicate records will be automatically detected and skipped"], ] for row_num, instruction in enumerate(instructions, 1): ws_instructions.cell(row=row_num, column=1, value=instruction[0]) ws_instructions.column_dimensions['A'].width = 80 # Save to bytes output = io.BytesIO() wb.save(output) output.seek(0) # Log download logger_handler.logger.info(f"User {session['username']} downloaded import template") return send_file( output, mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', as_attachment=True, download_name=f'time_attendance_template_{datetime.now().strftime("%Y%m%d")}.xlsx' ) except Exception as e: logger_handler.logger.error(f"Error generating template: {e}") flash('Error generating template file.', 'error') return redirect(url_for('time_attendance.import_time_attendance')) @bp.route('/time-attendance/export', endpoint='export_time_attendance') @login_required @log_user_activity('time_attendance_export') def export_time_attendance(): """Export time attendance records to CSV or Excel""" try: export_format = request.args.get('format', 'excel').lower() # Get filter parameters (same as records page) employee_filter = request.args.get('employee_id') location_filter = request.args.get('location_name') start_date = request.args.get('start_date') end_date = request.args.get('end_date') import_batch = request.args.get('import_batch') project_filter = request.args.get('project_id') # unlimited=true skips the 14-day cap; default is capped (2-week) mode unlimited = request.args.get('unlimited', 'false').lower() == 'true' # Build query with same filters as the view query = TimeAttendance.query # Apply filters — employee_id supports comma-separated multi-employee values if employee_filter: employee_ids_export = [e.strip() for e in employee_filter.split(',') if e.strip()] from working_hours_calculator import parse_employee_id_for_work_type as _parse_wt all_variants = [] for eid in employee_ids_export: _base_emp_id, _ = _parse_wt(str(eid)) all_variants += [ _base_emp_id, f"{_base_emp_id} SP", f"{_base_emp_id}SP", f"SP {_base_emp_id}", f"SP{_base_emp_id}", f"{_base_emp_id} PW", f"{_base_emp_id}PW", f"PW {_base_emp_id}", f"PW{_base_emp_id}", f"{_base_emp_id} PT", f"{_base_emp_id}PT", f"PT {_base_emp_id}", f"PT{_base_emp_id}", ] query = query.filter(TimeAttendance.employee_id.in_(all_variants)) if location_filter: query = query.filter(TimeAttendance.location_name == location_filter) if start_date: try: start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() query = query.filter(TimeAttendance.attendance_date >= start_date_obj) except ValueError: flash('Invalid start date format.', 'error') return redirect(url_for('time_attendance.time_attendance_records')) if end_date: try: end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() # Fetch one extra calendar day beyond the requested end date so that # early-morning check-out records stored on Day N+1 (overnight shifts # ending after midnight on the last report day) are available for the # overnight pairing detection inside export_time_attendance_excel. # The displayed date range is controlled by start_date_filter / # end_date_filter inside that function and is not affected. query = query.filter(TimeAttendance.attendance_date <= end_date_obj + timedelta(days=1)) except ValueError: flash('Invalid end date format.', 'error') return redirect(url_for('time_attendance.time_attendance_records')) if import_batch: query = query.filter(TimeAttendance.import_batch_id == import_batch) if project_filter: query = query.filter(TimeAttendance.project_id == project_filter) # Order by date and time (most recent first) records = query.order_by( TimeAttendance.attendance_date.desc(), TimeAttendance.attendance_time.desc() ).all() if not records: flash('No records found to export.', 'warning') return redirect(url_for('time_attendance.time_attendance_records')) # Get project name if project filter exists project_name_for_filename = '' if project_filter: try: project = db.session.get(Project, int(project_filter)) if project: # Replace spaces and special characters with underscores project_name_safe = project.name.replace(' ', '_').replace('/', '_').replace('\\', '_') project_name_for_filename = f"{project_name_safe}_" except Exception as e: logger_handler.logger.warning(f"Error getting project name for filename: {e}") # Log export logger_handler.logger.info( f"User {session['username']} exported {len(records)} time attendance records " f"in {export_format.upper()} format (unlimited={unlimited})" ) # Format dates for filename (MMDDYYYY format) date_from_formatted = '' date_to_formatted = '' if start_date: try: date_obj = datetime.strptime(start_date, '%Y-%m-%d') date_from_formatted = date_obj.strftime('%m%d%Y') except ValueError: pass if end_date: try: date_obj = datetime.strptime(end_date, '%Y-%m-%d') date_to_formatted = date_obj.strftime('%m%d%Y') except ValueError: pass # Build filename with date range # Format: [project_name_]time_attendance_[fromdate_todate].xlsx/csv date_range_str = '' if date_from_formatted and date_to_formatted: date_range_str = f"{date_from_formatted}_{date_to_formatted}" elif date_from_formatted: date_range_str = f"from_{date_from_formatted}" elif date_to_formatted: date_range_str = f"to_{date_to_formatted}" # Keep the filter_str for backward compatibility (but not in filename anymore) filter_desc = [] if employee_filter: filter_desc.append(f"emp_{employee_filter}") if location_filter: filter_desc.append(f"loc_{location_filter[:10]}") filter_str = "_".join(filter_desc) if filter_desc else "all" return export_time_attendance_excel(records, project_name_for_filename, date_range_str, filter_str, start_date, end_date, unlimited=unlimited) except Exception as e: logger_handler.logger.error(f"Error exporting time attendance records: {e}") flash('Error generating export file. Please try again.', 'error') return redirect(url_for('time_attendance.time_attendance_records')) @bp.route('/time-attendance/export/excel', endpoint='excel_export_time_attendance') @login_required @log_user_activity('time_attendance_excel_export') def excel_export_time_attendance(): """Excel export with current page filters""" # Redirect to main export with Excel format return redirect(url_for('time_attendance.export_time_attendance', format='excel', **request.args)) @bp.route('/time-attendance/export-by-building', endpoint='export_time_attendance_by_building') @login_required @log_user_activity('time_attendance_export_by_building') def export_time_attendance_by_building(): """Export time attendance records grouped by building/location to Excel""" try: # Get filter parameters (same as records page) employee_filter = request.args.get('employee_id') location_filter = request.args.get('location_name') start_date = request.args.get('start_date') end_date = request.args.get('end_date') import_batch = request.args.get('import_batch') project_filter = request.args.get('project_id') # unlimited=true skips the 14-day cap; default is capped (2-week) mode unlimited = request.args.get('unlimited', 'false').lower() == 'true' # Build query with same filters as the view query = TimeAttendance.query # Apply filters — employee_id supports comma-separated multi-employee values if employee_filter: employee_ids_export = [e.strip() for e in employee_filter.split(',') if e.strip()] from working_hours_calculator import parse_employee_id_for_work_type as _parse_wt all_variants = [] for eid in employee_ids_export: _base_emp_id, _ = _parse_wt(str(eid)) all_variants += [ _base_emp_id, f"{_base_emp_id} SP", f"{_base_emp_id}SP", f"SP {_base_emp_id}", f"SP{_base_emp_id}", f"{_base_emp_id} PW", f"{_base_emp_id}PW", f"PW {_base_emp_id}", f"PW{_base_emp_id}", f"{_base_emp_id} PT", f"{_base_emp_id}PT", f"PT {_base_emp_id}", f"PT{_base_emp_id}", ] query = query.filter(TimeAttendance.employee_id.in_(all_variants)) if location_filter: query = query.filter(TimeAttendance.location_name == location_filter) try: start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() query = query.filter(TimeAttendance.attendance_date >= start_date_obj) except ValueError: flash('Invalid start date format.', 'error') return redirect(url_for('time_attendance.time_attendance_records')) if end_date: try: end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() # Fetch one extra calendar day so that early-morning check-out records # stored on Day N+1 (overnight shifts ending after midnight on the last # report day) are included for overnight pairing detection. # The display range remains controlled by start_date_filter/end_date_filter # inside export_time_attendance_by_building_excel and is not affected. query = query.filter(TimeAttendance.attendance_date <= end_date_obj + timedelta(days=1)) except ValueError: flash('Invalid end date format.', 'error') return redirect(url_for('time_attendance.time_attendance_records')) if import_batch: query = query.filter(TimeAttendance.import_batch_id == import_batch) if project_filter: query = query.filter(TimeAttendance.project_id == project_filter) # Order by location, date, and time records = query.order_by( TimeAttendance.location_name, TimeAttendance.attendance_date.desc(), TimeAttendance.attendance_time.desc() ).all() if not records: flash('No records found to export.', 'warning') return redirect(url_for('time_attendance.time_attendance_records')) # Get project name if project filter exists project_name_for_filename = '' if project_filter: try: project = db.session.get(Project, int(project_filter)) if project: # Replace spaces and special characters with underscores project_name_safe = project.name.replace(' ', '_').replace('/', '_').replace('\\', '_') project_name_for_filename = f"{project_name_safe}_" except Exception as e: logger_handler.logger.warning(f"Error getting project name for filename: {e}") # Log export logger_handler.logger.info( f"User {session['username']} exported {len(records)} time attendance records " f"by building in Excel format (unlimited={unlimited})" ) # Format dates for filename (MMDDYYYY format) date_from_formatted = '' date_to_formatted = '' if start_date: try: date_obj = datetime.strptime(start_date, '%Y-%m-%d') date_from_formatted = date_obj.strftime('%m%d%Y') except ValueError: pass if end_date: try: date_obj = datetime.strptime(end_date, '%Y-%m-%d') date_to_formatted = date_obj.strftime('%m%d%Y') except ValueError: pass # Build filename with date range date_range_str = '' if date_from_formatted and date_to_formatted: date_range_str = f"{date_from_formatted}_{date_to_formatted}" elif date_from_formatted: date_range_str = f"from_{date_from_formatted}" elif date_to_formatted: date_range_str = f"to_{date_to_formatted}" return export_time_attendance_by_building_excel(records, project_name_for_filename, date_range_str, start_date, end_date, unlimited=unlimited) except Exception as e: logger_handler.logger.error(f"Error exporting time attendance records by building: {e}") flash('Error generating export file. Please try again.', 'error') return redirect(url_for('time_attendance.time_attendance_records')) @bp.route('/time-attendance/records', endpoint='time_attendance_records') @login_required @log_user_activity('time_attendance_records_view') def time_attendance_records(): """Display time attendance records with filtering options""" try: # Get filter parameters employee_filter = request.args.get('employee_id', '') location_filter = request.args.get('location_name') start_date = request.args.get('start_date') end_date = request.args.get('end_date') project_filter = request.args.get('project_id') page = request.args.get('page', 1, type=int) per_page = 50 # Records per page # Build list of selected employee IDs (comma-separated multi-employee support) employee_ids = [e.strip() for e in employee_filter.split(',') if e.strip()] if employee_filter else [] # Build display names for each selected employee import re as _re employee_display_names = [] for eid in employee_ids: try: numeric_only = _re.search(r'\d+', str(eid)) if numeric_only: emp = Employee.query.filter_by(id=int(numeric_only.group(0))).first() if emp: employee_display_names.append({'id': eid, 'name': f"{emp.lastName}, {emp.firstName}"}) else: employee_display_names.append({'id': eid, 'name': f"ID: {eid}"}) else: employee_display_names.append({'id': eid, 'name': eid}) except (ValueError, TypeError): employee_display_names.append({'id': eid, 'name': eid}) employee_display_name = ', '.join([e['name'] for e in employee_display_names]) # Build query query = TimeAttendance.query # Apply filters if employee_ids: # Expand each base ID to include all SP/PW/PT work-type variants so that # cross-type pairs are included in results and exports. from working_hours_calculator import parse_employee_id_for_work_type as _parse_wt all_variants = [] for eid in employee_ids: _base_emp_id, _ = _parse_wt(str(eid)) all_variants += [ _base_emp_id, f"{_base_emp_id} SP", f"{_base_emp_id}SP", f"SP {_base_emp_id}", f"SP{_base_emp_id}", f"{_base_emp_id} PW", f"{_base_emp_id}PW", f"PW {_base_emp_id}", f"PW{_base_emp_id}", f"{_base_emp_id} PT", f"{_base_emp_id}PT", f"PT {_base_emp_id}", f"PT{_base_emp_id}", ] query = query.filter(TimeAttendance.employee_id.in_(all_variants)) logger_handler.logger.info( f"Time attendance records filtered by employee IDs: {employee_ids} " f"by user {session.get('username', 'unknown')}" ) if location_filter: query = query.filter(TimeAttendance.location_name == location_filter) if project_filter: query = query.filter(TimeAttendance.project_id == project_filter) if start_date: try: start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() query = query.filter(TimeAttendance.attendance_date >= start_date_obj) except ValueError: flash('Invalid start date format.', 'error') if end_date: try: end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() query = query.filter(TimeAttendance.attendance_date <= end_date_obj) except ValueError: flash('Invalid end date format.', 'error') # Order by date and time (most recent first) query = query.order_by( TimeAttendance.attendance_date.desc(), TimeAttendance.attendance_time.desc() ) # Paginate results records = query.paginate(page=page, per_page=per_page, error_out=False) # Enhance records with QR address and location accuracy for record in records.items: # Find matching QR code by location name qr_code = QRCode.query.filter_by(location=record.location_name).first() if qr_code: record.qr_address = qr_code.location_address # Calculate location accuracy if coordinates are available if record.recorded_address and qr_code.location_address: try: # Try to calculate location accuracy location_accuracy = calculate_location_accuracy_enhanced( qr_address=qr_code.location_address, checkin_address=record.recorded_address, checkin_lat=None, # TimeAttendance doesn't have GPS coords checkin_lng=None ) record.location_accuracy = location_accuracy except Exception as e: logger_handler.logger.warning(f"Could not calculate location accuracy for record {record.id}: {e}") record.location_accuracy = None else: record.location_accuracy = None else: record.qr_address = None record.location_accuracy = None # Resolve employee name by stripping work type prefix/suffix (SP, PW, PT) # from employee_id ONLY for the lookup. The original employee_id is kept intact. # e.g. '3937SP', 'SP3937', 'PW3937' -> lookup by numeric '3937' try: import re as _re numeric_only = _re.search(r'\d+', str(record.employee_id or '')) if numeric_only: emp = Employee.query.filter_by(id=int(numeric_only.group(0))).first() record.resolved_employee_name = f"{emp.lastName}, {emp.firstName}" if emp else record.employee_name else: record.resolved_employee_name = record.employee_name except Exception as e: logger_handler.logger.warning(f"Could not resolve employee name for ID {record.employee_id}: {e}") record.resolved_employee_name = record.employee_name # Get unique employees and locations for filters unique_employees = TimeAttendance.get_unique_employees() unique_locations = TimeAttendance.get_unique_locations() projects = Project.query.filter_by(active_status=True).order_by(Project.name).all() return render_template( 'time_attendance_records.html', records=records, unique_employees=unique_employees, unique_locations=unique_locations, projects=projects, employee_display_name=employee_display_name, employee_display_names=employee_display_names, employee_filter=employee_filter, employee_ids=employee_ids ) except Exception as e: logger_handler.logger.error(f"Error displaying time attendance records: {e}") flash('Error loading attendance records.', 'error') return redirect(url_for('time_attendance.time_attendance_dashboard')) @bp.route('/time-attendance/record/', endpoint='time_attendance_record_detail') @login_required @log_user_activity('time_attendance_record_detail') def time_attendance_record_detail(record_id): """Display detailed view of a time attendance record""" try: record = TimeAttendance.query.get_or_404(record_id) return render_template('time_attendance_record_detail.html', record=record) except Exception as e: logger_handler.logger.error(f"Error viewing time attendance record {record_id}: {e}") flash('Error loading record details.', 'error') return redirect(url_for('time_attendance.time_attendance_records')) @bp.route('/time-attendance/delete/', methods=['POST'], endpoint='delete_time_attendance_record') @admin_required @log_database_operations('time_attendance_delete') def delete_time_attendance_record(record_id): """Delete a time attendance record""" try: record = TimeAttendance.query.get_or_404(record_id) # Store record info for logging employee_info = f"{record.employee_name} (ID: {record.employee_id})" location_info = record.location_name date_info = record.attendance_date # Delete the record db.session.delete(record) db.session.commit() # Log deletion logger_handler.logger.info( f"User {session['username']} deleted time attendance record {record_id} - " f"Employee: {employee_info}, Location: {location_info}, Date: {date_info}" ) flash(f'Time attendance record for {employee_info} deleted successfully.', 'success') except Exception as e: db.session.rollback() logger_handler.log_database_error('time_attendance_delete', e) flash('Failed to delete time attendance record.', 'error') # Get filter parameters from BOTH request.form (POST) and request.args (GET query params) # This handles both the records list page and the detail page filter_params = {} # List of possible filter parameters filter_keys = ['employee_id', 'location_name', 'project_id', 'start_date', 'end_date', 'page'] for key in filter_keys: # Try to get from form data first (records list page) value = request.form.get(key) # If not in form, try query parameters (detail page) if not value: value = request.args.get(key) # Only include if value exists and is not empty if value: filter_params[key] = value # Redirect back with filters preserved return redirect(url_for('time_attendance.time_attendance_records', **filter_params)) @bp.route('/api/time-attendance/employee/', endpoint='api_time_attendance_by_employee') @login_required def api_time_attendance_by_employee(employee_id): """API endpoint to get time attendance records for a specific employee""" try: start_date = request.args.get('start_date') end_date = request.args.get('end_date') start_date_obj = None end_date_obj = None if start_date: start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() if end_date: end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() records = TimeAttendance.get_by_employee_id(employee_id, start_date_obj, end_date_obj) return jsonify({ 'success': True, 'employee_id': employee_id, 'total_records': len(records), 'records': [record.to_dict() for record in records] }) except Exception as e: logger_handler.logger.error(f"API error getting time attendance for employee {employee_id}: {e}") return jsonify({ 'success': False, 'error': 'Failed to retrieve time attendance records' }), 500 @bp.route('/api/time-attendance/location/', endpoint='api_time_attendance_by_location') @login_required def api_time_attendance_by_location(location_name): """API endpoint to get time attendance records for a specific location""" try: start_date = request.args.get('start_date') end_date = request.args.get('end_date') start_date_obj = None end_date_obj = None if start_date: start_date_obj = datetime.strptime(start_date, '%Y-%m-%d').date() if end_date: end_date_obj = datetime.strptime(end_date, '%Y-%m-%d').date() records = TimeAttendance.get_by_location(location_name, start_date_obj, end_date_obj) return jsonify({ 'success': True, 'location_name': location_name, 'total_records': len(records), 'records': [record.to_dict() for record in records] }) except Exception as e: logger_handler.logger.error(f"API error getting time attendance for location {location_name}: {e}") return jsonify({ 'success': False, 'error': 'Failed to retrieve time attendance records' }), 500 # Jinja2 filters for better template functionality