Jul 28 - Update code for legacy database (Hieu's app)
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user