""" legacy_attendance_service.py ============================= Read-only data access for the "Legacy Attendance" feature. This talks directly to the OLD remote MySQL server (the one described by QrCodeLtServices.sql: `contract`, `employee`, `locations`, `records` tables) using pymysql — never SQLAlchemy ORM — because that schema is completely different from the current app's models and is not, and should never be, mapped as a SQLAlchemy model. Connection is opened per-call and closed immediately after use (no pooling, no persistent connection kept on `g` or the app) because this data is only ever displayed, never written to. Nothing here performs INSERT/UPDATE/DELETE against the remote server. Env vars (already used by employee_table_sync.py): REMOTE_DB_HOST REMOTE_DB_PORT REMOTE_DB_USERNAME REMOTE_DB_PASSWORD REMOTE_DB_NAME """ import io import math from datetime import datetime, timedelta import pymysql import pymysql.cursors from config import Config try: import openpyxl from openpyxl.styles import Font, PatternFill, Alignment from openpyxl.utils import get_column_letter except ImportError: # pragma: no cover — openpyxl is already a hard requirement elsewhere openpyxl = None class LegacyDbUnavailable(Exception): """Raised when the remote legacy database cannot be reached or is not configured.""" pass def get_remote_connection(): """ Open a fresh, read-only connection to the legacy remote MySQL server. Caller is responsible for closing it (use as a context manager). """ if not Config.REMOTE_DB_HOST or not Config.REMOTE_DB_NAME: raise LegacyDbUnavailable( "Legacy database is not configured. Set REMOTE_DB_HOST, REMOTE_DB_PORT, " "REMOTE_DB_USERNAME, REMOTE_DB_PASSWORD, REMOTE_DB_NAME in .env" ) try: return pymysql.connect( host=Config.REMOTE_DB_HOST, port=Config.REMOTE_DB_PORT, user=Config.REMOTE_DB_USERNAME, password=Config.REMOTE_DB_PASSWORD, database=Config.REMOTE_DB_NAME, charset='utf8mb4', cursorclass=pymysql.cursors.DictCursor, connect_timeout=10, read_timeout=30, ) except pymysql.MySQLError as e: raise LegacyDbUnavailable(f"Could not connect to legacy database: {e}") class LegacyPagination: """ Minimal stand-in for Flask-SQLAlchemy's Pagination object, so templates can use the same `.items / .page / .pages / .has_prev / .iter_pages()` pattern already used by time_attendance_records.html. """ def __init__(self, items, page, per_page, total): self.items = items self.page = page self.per_page = per_page self.total = total self.pages = max(1, math.ceil(total / per_page)) if per_page else 1 @property def has_prev(self): return self.page > 1 @property def has_next(self): return self.page < self.pages @property def prev_num(self): return self.page - 1 @property def next_num(self): return self.page + 1 def iter_pages(self, left_edge=1, right_edge=1, left_current=1, right_current=2): last = 0 for num in range(1, self.pages + 1): if (num <= left_edge or (num > self.page - left_current - 1 and num < self.page + right_current) or num > self.pages - right_edge): if last + 1 != num: yield None yield num last = num # ---------------------------------------------------------------------- # # Shared filter -> WHERE clause builder # ---------------------------------------------------------------------- # def _build_where(filters): """ Build a WHERE clause + params list shared by count/list/export queries. filters: dict with optional keys: employee_search, location, record_type, start_date, end_date (all strings; dates as 'YYYY-MM-DD') """ clauses = [] params = [] employee_search = (filters.get('employee_search') or '').strip() if employee_search: clauses.append( "(r.employeeId LIKE %s OR CONCAT(e.firstName, ' ', e.lastName) LIKE %s)" ) like = f"%{employee_search}%" params.extend([like, like]) location = (filters.get('location') or '').strip() if location: clauses.append("l.location = %s") params.append(location) record_type = (filters.get('record_type') or '').strip() if record_type: clauses.append("r.type = %s") params.append(record_type) start_date = (filters.get('start_date') or '').strip() if start_date: try: start_dt = datetime.strptime(start_date, '%Y-%m-%d') clauses.append("r.time >= %s") params.append(start_dt) except ValueError: pass end_date = (filters.get('end_date') or '').strip() if end_date: try: end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) clauses.append("r.time < %s") params.append(end_dt) except ValueError: pass where_sql = (" WHERE " + " AND ".join(clauses)) if clauses else "" return where_sql, params _BASE_FROM = """ FROM records r LEFT JOIN employee e ON e.id = CAST(r.employeeId AS UNSIGNED) LEFT JOIN locations l ON l.`index` = CAST(r.locationId AS UNSIGNED) LEFT JOIN contract c ON c.id = r.contractId """ _SELECT_COLUMNS = """ r.`index` AS record_index, r.employeeId AS employee_id, r.time AS record_time, r.type AS record_type, r.recordedAddress AS recorded_address, r.locationId AS location_id_raw, r.jobCode AS job_code, r.isManual AS is_manual, r.contractId AS contract_id, e.firstName AS first_name, e.lastName AS last_name, l.location AS location_name, l.building AS building, l.address AS location_address, c.name AS contract_name, c.company AS contract_company """ def get_legacy_dashboard_stats(): """Summary stats for the Legacy Attendance dashboard.""" stats = { 'total_records': 0, 'unique_employees': 0, 'unique_locations': 0, 'earliest_record': None, 'latest_record': None, } with get_remote_connection() as conn: with conn.cursor() as cur: cur.execute(""" SELECT COUNT(*) AS total_records, COUNT(DISTINCT employeeId) AS unique_employees, COUNT(DISTINCT locationId) AS unique_locations, MIN(time) AS earliest_record, MAX(time) AS latest_record FROM records """) row = cur.fetchone() if row: stats.update(row) return stats def get_legacy_unique_locations(): """Distinct location names for the records filter dropdown.""" with get_remote_connection() as conn: with conn.cursor() as cur: cur.execute(""" SELECT DISTINCT location FROM locations WHERE location IS NOT NULL AND location != '' ORDER BY location """) return [row['location'] for row in cur.fetchall()] def get_legacy_records(filters, page=1, per_page=50): """ Fetch a filtered, paginated page of legacy attendance records, joined against employee/locations/contract for display. Returns a LegacyPagination instance. """ where_sql, params = _build_where(filters) with get_remote_connection() as conn: with conn.cursor() as cur: cur.execute(f"SELECT COUNT(*) AS total {_BASE_FROM}{where_sql}", params) total = cur.fetchone()['total'] offset = max(0, (page - 1) * per_page) cur.execute( f"SELECT {_SELECT_COLUMNS} {_BASE_FROM}{where_sql} " f"ORDER BY r.time DESC LIMIT %s OFFSET %s", params + [per_page, offset] ) rows = cur.fetchall() for row in rows: first = (row.get('first_name') or '').strip() last = (row.get('last_name') or '').strip() row['resolved_employee_name'] = f"{last}, {first}" if (first or last) else 'Unknown' row['location_display'] = row.get('location_name') or row.get('location_id_raw') or 'Unknown' return LegacyPagination(rows, page, per_page, total) def get_legacy_records_for_export(filters, max_rows=50000): """Fetch ALL matching rows (no pagination) for Excel export.""" where_sql, params = _build_where(filters) with get_remote_connection() as conn: with conn.cursor() as cur: cur.execute( f"SELECT {_SELECT_COLUMNS} {_BASE_FROM}{where_sql} " f"ORDER BY r.time DESC LIMIT %s", params + [max_rows] ) rows = cur.fetchall() for row in rows: first = (row.get('first_name') or '').strip() last = (row.get('last_name') or '').strip() row['resolved_employee_name'] = f"{last}, {first}" if (first or last) else 'Unknown' row['location_display'] = row.get('location_name') or row.get('location_id_raw') or 'Unknown' return rows def build_legacy_export_workbook(rows): """Build an openpyxl Workbook (in-memory) for the given legacy records.""" wb = openpyxl.Workbook() ws = wb.active ws.title = "Legacy Attendance" headers = [ 'Employee ID', 'Employee Name', 'Date', 'Time', 'Type', 'Location', 'Building', 'Location Address', 'Recorded Address', 'Contract', 'Company', 'Job Code', 'Manual Entry' ] header_fill = PatternFill(start_color='1F2937', end_color='1F2937', fill_type='solid') header_font = Font(color='FFFFFF', bold=True) for col_idx, header in enumerate(headers, start=1): cell = ws.cell(row=1, column=col_idx, value=header) cell.fill = header_fill cell.font = header_font cell.alignment = Alignment(horizontal='center', vertical='center') for row_idx, row in enumerate(rows, start=2): record_time = row.get('record_time') date_str = record_time.strftime('%Y-%m-%d') if record_time else '' time_str = record_time.strftime('%H:%M:%S') if record_time else '' ws.cell(row=row_idx, column=1, value=row.get('employee_id')) ws.cell(row=row_idx, column=2, value=row.get('resolved_employee_name')) ws.cell(row=row_idx, column=3, value=date_str) ws.cell(row=row_idx, column=4, value=time_str) ws.cell(row=row_idx, column=5, value=row.get('record_type')) ws.cell(row=row_idx, column=6, value=row.get('location_display')) ws.cell(row=row_idx, column=7, value=row.get('building')) ws.cell(row=row_idx, column=8, value=row.get('location_address')) ws.cell(row=row_idx, column=9, value=row.get('recorded_address')) ws.cell(row=row_idx, column=10, value=row.get('contract_name')) ws.cell(row=row_idx, column=11, value=row.get('contract_company')) ws.cell(row=row_idx, column=12, value=row.get('job_code')) ws.cell(row=row_idx, column=13, value='Yes' if row.get('is_manual') else 'No') for col_idx in range(1, len(headers) + 1): ws.column_dimensions[get_column_letter(col_idx)].width = 20 ws.freeze_panes = 'A2' buffer = io.BytesIO() wb.save(buffer) buffer.seek(0) return buffer