From 24d48f5d3479de65fb0e921f65ba78f0b7f53769 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Tue, 28 Jul 2026 21:25:23 -0400 Subject: [PATCH] Jul 28 - Update code for legacy database (Hieu's app) --- LEGACY_ATTENDANCE_DEPLOY.md | 101 ++++ app.py | 3 +- config.py | 9 + legacy_attendance_service.py | 328 +++++++++++++ routes/legacy_attendance.py | 129 ++++++ templates/base_authenticated.html | 434 ++++++++++-------- templates/legacy_attendance_dashboard.html | 90 ++++ templates/legacy_attendance_records.html | 262 +++++++++++ ...ration_legacy_attendance_remote_indexes.py | 94 ++++ 9 files changed, 1247 insertions(+), 203 deletions(-) create mode 100644 LEGACY_ATTENDANCE_DEPLOY.md create mode 100644 legacy_attendance_service.py create mode 100644 routes/legacy_attendance.py create mode 100644 templates/legacy_attendance_dashboard.html create mode 100644 templates/legacy_attendance_records.html create mode 100644 tools/migration_legacy_attendance_remote_indexes.py diff --git a/LEGACY_ATTENDANCE_DEPLOY.md b/LEGACY_ATTENDANCE_DEPLOY.md new file mode 100644 index 0000000..5661bf2 --- /dev/null +++ b/LEGACY_ATTENDANCE_DEPLOY.md @@ -0,0 +1,101 @@ +# Legacy Attendance — Deployment Package + +Adds a read-only "Legacy Attendance" sidebar item: dashboard + records list + +Excel export, sourced LIVE from the OLD remote MySQL server +(`contract` / `employee` / `locations` / `records` tables). No import, no +writes to the remote DB, nothing copied into the local database. + +Apply this same package to **both LT and GOV** (each server uses its own +`REMOTE_DB_*` values in its own `.env`). + +--- + +## 1. Files in this package + +### NEW files (just drop in — no existing file touched) +| File | Purpose | +|------|---------| +| `legacy_attendance_service.py` | Read-only pymysql data access to the legacy DB (queries, pagination, Excel builder) | +| `routes/legacy_attendance.py` | Blueprint `legacy_attendance` — dashboard, records, export routes | +| `templates/legacy_attendance_dashboard.html` | Dashboard page | +| `templates/legacy_attendance_records.html` | Records list (matches the Time Attendance table layout) | +| `tools/migration_legacy_attendance_remote_indexes.py` | One-time index migration for the REMOTE legacy DB | + +### MODIFIED files (replace existing) +| File | What changed | +|------|--------------| +| `config.py` | Added `REMOTE_DB_HOST/PORT/USERNAME/PASSWORD/NAME` to `Config` | +| `app.py` | Registered the `legacy_attendance` blueprint | +| `templates/base_authenticated.html` | Added "Legacy Attendance" sidebar link (admin + payroll/accounting sections) | + +Nothing was removed or renamed. Existing routes/functions/variables untouched. + +--- + +## 2. .env additions (BOTH servers) + +Add these to `.env` on each server, using that server's own legacy DB +credentials: + +``` +# Remote MySQL Server Configuration (Source — legacy attendance) +REMOTE_DB_HOST=xxx.xxx.xxx.xxx +REMOTE_DB_PORT=3306 +REMOTE_DB_USERNAME=xxx +REMOTE_DB_PASSWORD=xxx +REMOTE_DB_NAME=xxx +``` + +If these are left blank, the Legacy Attendance pages show a clean +"legacy database is not configured" flash message instead of erroring. + +--- + +## 3. One-time index migration (BOTH servers) + +The legacy schema ships with no useful indexes, which makes the live queries +full-scan and can trip the gunicorn worker timeout at scale. Run once per +server (safe to re-run — skips indexes that already exist; only adds indexes, +never touches data): + +``` +python3 tools/migration_legacy_attendance_remote_indexes.py +``` + +Expected first-run output: `[ADD]` lines for 6 indexes on +`records` / `employee` / `locations`, then `[DONE]`. + +> Note: on a fresh legacy DB dump these indexes may already be present +> (a recent dump already includes them). In that case every line prints +> `[SKIP]` — that's fine. + +--- + +## 4. Deploy steps (per server) + +1. Copy the NEW files into place. +2. Replace the 3 MODIFIED files. +3. Add the `REMOTE_DB_*` block to `.env`. +4. `python3 tools/migration_legacy_attendance_remote_indexes.py` +5. Restart gunicorn. +6. Log in, open **Legacy Attendance** in the sidebar, confirm the dashboard + stats populate and the records list shows Location Name + Event Description. + +No local DB migration is required — this feature reads the remote DB only. + +--- + +## 5. Key facts worth remembering + +- `records.locationId` holds the numeric **`locations.index`** (NOT the + location name). The join is + `locations.index = CAST(records.locationId AS UNSIGNED)`. +- `records.employeeId` is varchar; joined as + `employee.id = CAST(records.employeeId AS UNSIGNED)`. +- `records.type` values are `CHECK IN` / `CHECK OUT`. +- Records list column mapping: + ID=`employeeId`, Name=`Last, First`, Platform=`Manual`/`—` (from `isManual`), + Date/Time from `time`, Location Name=`locations.location`, + Action Description=`type` badge, Event Description=`locations.address`, + Recorded Address=`records.recordedAddress`. +- Read-only by design: no detail page, no delete (so no Actions column). diff --git a/app.py b/app.py index 85d92ce..5611045 100644 --- a/app.py +++ b/app.py @@ -91,10 +91,11 @@ def create_app() -> Flask: from routes.statistics import bp as statistics_bp from routes.employees import bp as employees_bp from routes.time_attendance import bp as time_attendance_bp + from routes.legacy_attendance import bp as legacy_attendance_bp for bp in (auth_bp, dashboard_bp, users_bp, admin_bp, projects_bp, qr_codes_bp, attendance_bp, statistics_bp, - employees_bp, time_attendance_bp): + employees_bp, time_attendance_bp, legacy_attendance_bp): app.register_blueprint(bp) # Register location-logging routes (from location_logging.py) diff --git a/config.py b/config.py index 58b4313..349f376 100644 --- a/config.py +++ b/config.py @@ -96,6 +96,15 @@ class Config: # ------------------------------------------------------------------ # DEFAULT_ADMIN_PASSWORD = os.environ.get('DEFAULT_ADMIN_PASSWORD', 'admin123') + # ------------------------------------------------------------------ # + # Remote (legacy) MySQL server — read-only source for Legacy Attendance + # ------------------------------------------------------------------ # + REMOTE_DB_HOST = os.environ.get('REMOTE_DB_HOST', '') + REMOTE_DB_PORT = int(os.environ.get('REMOTE_DB_PORT', '3306')) + REMOTE_DB_USERNAME = os.environ.get('REMOTE_DB_USERNAME', '') + REMOTE_DB_PASSWORD = os.environ.get('REMOTE_DB_PASSWORD', '') + REMOTE_DB_NAME = os.environ.get('REMOTE_DB_NAME', '') + class DevelopmentConfig(Config): DEBUG = True diff --git a/legacy_attendance_service.py b/legacy_attendance_service.py new file mode 100644 index 0000000..b3a4835 --- /dev/null +++ b/legacy_attendance_service.py @@ -0,0 +1,328 @@ +""" +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 diff --git a/routes/legacy_attendance.py b/routes/legacy_attendance.py new file mode 100644 index 0000000..0e460a3 --- /dev/null +++ b/routes/legacy_attendance.py @@ -0,0 +1,129 @@ +""" +routes/legacy_attendance.py +============================ +"Legacy Attendance" — same look/feel as Time Attendance (dashboard, +records list, Excel export) but sourced LIVE from the old remote MySQL +server (contract / employee / locations / records tables) instead of +Excel imports. Read-only: nothing is written to the remote server, and +nothing is copied into the local database. + +Routes: /legacy-attendance, /legacy-attendance/records, + /legacy-attendance/export +""" +from flask import Blueprint, render_template, request, redirect, flash, send_file, url_for +from datetime import datetime + +from extensions import logger_handler +from logger_handler import log_user_activity +from utils.helpers import login_required +from legacy_attendance_service import ( + LegacyDbUnavailable, + get_legacy_dashboard_stats, + get_legacy_unique_locations, + get_legacy_records, + get_legacy_records_for_export, + build_legacy_export_workbook, +) + +bp = Blueprint('legacy_attendance', __name__) + +# Fixed dropdown values — confirmed values stored in the legacy `records.type` column +LEGACY_RECORD_TYPES = ['CHECK IN', 'CHECK OUT'] + + +def _filters_from_request(): + return { + 'employee_search': request.args.get('employee_search', ''), + 'location': request.args.get('location', ''), + 'record_type': request.args.get('record_type', ''), + 'start_date': request.args.get('start_date', ''), + 'end_date': request.args.get('end_date', ''), + } + + +@bp.route('/legacy-attendance', endpoint='legacy_attendance_dashboard') +@login_required +@log_user_activity('legacy_attendance_view') +def legacy_attendance_dashboard(): + """Display legacy attendance dashboard with summary stats.""" + stats = { + 'total_records': 0, + 'unique_employees': 0, + 'unique_locations': 0, + 'earliest_record': None, + 'latest_record': None, + } + try: + stats = get_legacy_dashboard_stats() + except LegacyDbUnavailable as e: + flash(str(e), 'error') + except Exception as e: + logger_handler.logger.error(f"Error loading legacy attendance dashboard: {e}") + flash('Error loading legacy attendance dashboard. The legacy database may be unreachable.', 'error') + + return render_template('legacy_attendance_dashboard.html', stats=stats) + + +@bp.route('/legacy-attendance/records', endpoint='legacy_attendance_records') +@login_required +@log_user_activity('legacy_attendance_records_view') +def legacy_attendance_records(): + """Display legacy attendance records with filtering + pagination.""" + filters = _filters_from_request() + page = request.args.get('page', 1, type=int) + per_page = 50 + + records = None + unique_locations = [] + try: + unique_locations = get_legacy_unique_locations() + records = get_legacy_records(filters, page=page, per_page=per_page) + except LegacyDbUnavailable as e: + flash(str(e), 'error') + return redirect(url_for('legacy_attendance.legacy_attendance_dashboard')) + except Exception as e: + logger_handler.logger.error(f"Error loading legacy attendance records: {e}") + flash('Error loading legacy attendance records. The legacy database may be unreachable.', 'error') + return redirect(url_for('legacy_attendance.legacy_attendance_dashboard')) + + return render_template( + 'legacy_attendance_records.html', + records=records, + unique_locations=unique_locations, + record_types=LEGACY_RECORD_TYPES, + filters=filters, + ) + + +@bp.route('/legacy-attendance/export', endpoint='export_legacy_attendance') +@login_required +@log_user_activity('legacy_attendance_export') +def export_legacy_attendance(): + """Export the currently filtered legacy attendance records to Excel.""" + filters = _filters_from_request() + + try: + rows = get_legacy_records_for_export(filters) + except LegacyDbUnavailable as e: + flash(str(e), 'error') + return redirect(url_for('legacy_attendance.legacy_attendance_records', **filters)) + except Exception as e: + logger_handler.logger.error(f"Error exporting legacy attendance records: {e}") + flash('Error generating export file. Please try again.', 'error') + return redirect(url_for('legacy_attendance.legacy_attendance_records', **filters)) + + if not rows: + flash('No legacy records found to export.', 'warning') + return redirect(url_for('legacy_attendance.legacy_attendance_records', **filters)) + + logger_handler.logger.info(f"Exported {len(rows)} legacy attendance records") + + buffer = build_legacy_export_workbook(rows) + filename = f"legacy_attendance_{datetime.now().strftime('%m%d%Y_%H%M%S')}.xlsx" + + return send_file( + buffer, + as_attachment=True, + download_name=filename, + mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) diff --git a/templates/base_authenticated.html b/templates/base_authenticated.html index b5f8015..d2afc32 100644 --- a/templates/base_authenticated.html +++ b/templates/base_authenticated.html @@ -1,224 +1,254 @@ + + + + {% block title %}{{ COMPANY_NAME }}{% endblock %} - - - - {% block title %}{{ COMPANY_NAME }}{% endblock %} + + - - + + {% if THEME_NAME %} + + {% endif %} - - {% if THEME_NAME %} - - {% endif %} + + - - + + {% block extra_head %}{% endblock %} - - {% block extra_head %}{% endblock %} - - - - - - - -
- - + + + + + +
+ +
+
+ +

+ {% block page_title %}{{ COMPANY_NAME }}{% endblock %} +

+
+ +
+ +
+
+ + +
+ + {% with messages = get_flashed_messages(with_categories=true) %} {% if + messages %} +
+ {% for category, message in messages %} +
+ + {{ message }} + +
+ {% endfor %} +
+ {% endif %} {% endwith %} + + +
{% block content %}{% endblock %}
+
+ + + +
- - - + + - - {% block extra_scripts %}{% endblock %} - - - - + + + {% block extra_scripts %}{% endblock %} + \ No newline at end of file diff --git a/templates/legacy_attendance_dashboard.html b/templates/legacy_attendance_dashboard.html new file mode 100644 index 0000000..fce1915 --- /dev/null +++ b/templates/legacy_attendance_dashboard.html @@ -0,0 +1,90 @@ +{% extends "base_authenticated.html" %} +{% block title %}Legacy Attendance Dashboard - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ +
+
+

+ + Legacy Attendance Dashboard +

+

Live view of attendance records from the legacy database

+
+ + +
+ + +
+
+
+ +
+
+

{{ "{:,}".format(stats.total_records or 0) }}

+

Total Records

+
+
+ +
+
+ +
+
+

{{ "{:,}".format(stats.unique_employees or 0) }}

+

Employees

+
+
+ +
+
+ +
+
+

{{ "{:,}".format(stats.unique_locations or 0) }}

+

Locations

+
+
+ +
+
+ +
+
+

+ {% if stats.earliest_record and stats.latest_record %} + {{ stats.earliest_record.strftime('%m/%d/%Y') }} – {{ stats.latest_record.strftime('%m/%d/%Y') }} + {% else %} + — + {% endif %} +

+

Date Range

+
+
+
+ +
+
+ +
+

This page reads live from the legacy database

+

+ Records are queried directly from the old attendance system on each visit — + nothing is imported or stored locally. Use + View All Records + to search, filter, and export. +

+
+
+{% endblock %} diff --git a/templates/legacy_attendance_records.html b/templates/legacy_attendance_records.html new file mode 100644 index 0000000..2bf961f --- /dev/null +++ b/templates/legacy_attendance_records.html @@ -0,0 +1,262 @@ +{% extends "base_authenticated.html" %} +{% block title %}Legacy Attendance Records - {{ COMPANY_NAME }}{% endblock %} + +{% block extra_head %} + +{% endblock %} + +{% block content %} +
+ +
+
+

+ + Legacy Attendance Records +

+

Live results from the legacy database

+
+ + +
+ + +
+
+

Filters

+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + + Clear + +
+
+
+
+ + +
+
+

Attendance Records

+
+ {% if records and records.items %} + Showing {{ records.per_page * (records.page - 1) + 1 }} - + {{ records.per_page * (records.page - 1) + records.items|length }} + of {{ records.total }} records + {% else %} + No records found + {% endif %} +
+
+ + {% if records and records.items %} +
+ + + + + + + + + + + + + + + + {% for record in records.items %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endfor %} + +
IDNamePlatformDateTimeLocation NameAction DescriptionEvent DescriptionRecorded Address
+
+ {{ record.employee_id }} +
+
+
+ {{ record.resolved_employee_name }} +
+
+ {{ 'Manual' if record.is_manual else '—' }} + + {{ record.record_time.strftime('%Y-%m-%d') if record.record_time else '' }} + + {{ record.record_time.strftime('%H:%M:%S') if record.record_time else '' }} + +
+ + {{ record.location_name if record.location_name else '-' }} +
+
+ + {% if record.record_type.lower() == 'check in' %} + + {% elif record.record_type.lower() == 'check out' %} + + {% else %} + + {% endif %} + {{ record.record_type }} + + + {{ record.location_address if record.location_address else '-' }} + + {% if record.recorded_address %} + + {{ record.recorded_address[:40] }}{% if record.recorded_address|length > 40 %}...{% endif %} + + {% else %} + No address + {% endif %} +
+
+ + + {% if records.pages > 1 %} + + {% endif %} + + {% else %} +
+
+ +
+

No legacy attendance records found

+

Try adjusting your filters.

+
+ {% endif %} +
+
+{% endblock %} diff --git a/tools/migration_legacy_attendance_remote_indexes.py b/tools/migration_legacy_attendance_remote_indexes.py new file mode 100644 index 0000000..1226ce8 --- /dev/null +++ b/tools/migration_legacy_attendance_remote_indexes.py @@ -0,0 +1,94 @@ +""" +migration_legacy_attendance_remote_indexes.py +================================================ +Adds missing indexes to the REMOTE legacy database (the one described by +QrCodeLtServices.sql — contract / employee / locations / records) so the +Legacy Attendance feature's live queries don't full-table-scan on every +page load. + +As shipped, that schema has NO index on: + records.employeeId, records.locationId, records.time, records.contractId + employee.id (only the meaningless auto-increment `index` is indexed) + locations.location + +Adding an index does not change or risk any existing data — it only +speeds up reads. Safe to re-run: each ALTER is skipped if the index +already exists. + +Uses pymysql directly (never SQLAlchemy ORM), consistent with every other +migration script in tools/ — this connects to REMOTE_DB_* (the legacy +server), not the app's own local database. + +Run once per server (LT and GOV each point at their own legacy DB): + python3 tools/migration_legacy_attendance_remote_indexes.py +""" + +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from dotenv import load_dotenv +load_dotenv() + +import pymysql + +# (table, column, index_name) +INDEXES_TO_ADD = [ + ('records', 'employeeId', 'idx_records_employeeId'), + ('records', 'locationId', 'idx_records_locationId'), + ('records', 'time', 'idx_records_time'), + ('records', 'contractId', 'idx_records_contractId'), + ('employee', 'id', 'idx_employee_id'), + ('locations', 'location', 'idx_locations_location'), +] + + +def get_connection(): + host = os.environ.get('REMOTE_DB_HOST', '') + port = int(os.environ.get('REMOTE_DB_PORT', '3306')) + user = os.environ.get('REMOTE_DB_USERNAME', '') + password = os.environ.get('REMOTE_DB_PASSWORD', '') + database = os.environ.get('REMOTE_DB_NAME', '') + + if not host or not database: + print("[ERROR] REMOTE_DB_HOST / REMOTE_DB_NAME not set in .env — aborting.") + sys.exit(1) + + print(f"[INFO] Connecting to legacy DB {user}@{host}:{port}/{database} ...") + return pymysql.connect( + host=host, port=port, user=user, password=password, database=database, + charset='utf8mb4', connect_timeout=10 + ) + + +def index_exists(cursor, table, index_name): + cursor.execute(""" + SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s + """, (table, index_name)) + return cursor.fetchone()[0] > 0 + + +def main(): + conn = get_connection() + try: + with conn.cursor() as cur: + for table, column, index_name in INDEXES_TO_ADD: + if index_exists(cur, table, index_name): + print(f"[SKIP] {table}.{index_name} already exists") + continue + print(f"[ADD] {table}.{index_name} ON ({column}) ...") + cur.execute(f"ALTER TABLE `{table}` ADD INDEX `{index_name}` (`{column}`)") + conn.commit() + print(f"[OK] {table}.{index_name} created") + print("[DONE] Legacy database indexes are up to date.") + except pymysql.MySQLError as e: + print(f"[ERROR] {e}") + sys.exit(1) + finally: + conn.close() + + +if __name__ == '__main__': + main()