Jul 28 - Update code for legacy database (Hieu's app)
This commit is contained in:
@@ -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).
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
)
|
||||
+232
-202
@@ -1,224 +1,254 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}{{ COMPANY_NAME }}{% endblock %}</title>
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{% block title %}{{ COMPANY_NAME }}{% endblock %}</title>
|
||||
<!-- Main CSS -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', filename='css/style.css') }}"
|
||||
/>
|
||||
|
||||
<!-- Main CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}" />
|
||||
<!-- Theme Override (set THEME_NAME in .env to activate, e.g. THEME_NAME=gov) -->
|
||||
{% if THEME_NAME %}
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="{{ url_for('static', filename='css/theme-' + THEME_NAME + '.css') }}"
|
||||
/>
|
||||
{% endif %}
|
||||
|
||||
<!-- Theme Override (set THEME_NAME in .env to activate, e.g. THEME_NAME=gov) -->
|
||||
{% if THEME_NAME %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme-' + THEME_NAME + '.css') }}" />
|
||||
{% endif %}
|
||||
<!-- Font Awesome -->
|
||||
<link
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet" />
|
||||
<!-- Page-specific CSS -->
|
||||
{% block extra_head %}{% endblock %}
|
||||
|
||||
<!-- Page-specific CSS -->
|
||||
{% block extra_head %}{% endblock %}
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}" />
|
||||
</head>
|
||||
|
||||
<body class="has-sidebar">
|
||||
<!-- Authenticated Layout with Sidebar -->
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<!-- Brand Section -->
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-content">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
<span class="brand-text">{{ COMPANY_NAME }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Menu -->
|
||||
<div class="sidebar-menu">
|
||||
<div class="menu-section">
|
||||
<div class="menu-items">
|
||||
<a href="{{ url_for('dashboard.dashboard') }}" class="menu-item">
|
||||
<i class="fas fa-tachometer-alt"></i>
|
||||
<span class="menu-text">Dashboard</span>
|
||||
</a>
|
||||
|
||||
{% if session.role == 'admin' %}
|
||||
<a href="{{ url_for('qr_codes.create_qr_code') }}" class="menu-item">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span class="menu-text">Create QR</span>
|
||||
</a>
|
||||
<a href="{{ url_for('projects.projects') }}"
|
||||
class="menu-item {% if request.endpoint in ['projects', 'create_project', 'edit_project'] %}active{% endif %}">
|
||||
<i class="fas fa-folder"></i>
|
||||
<span class="menu-text">Projects</span>
|
||||
</a>
|
||||
<a href="{{ url_for('users.users') }}" class="menu-item">
|
||||
<i class="fas fa-user-cog"></i>
|
||||
<span class="menu-text">Users</span>
|
||||
</a>
|
||||
<a href="{{ url_for('employees.employees') }}" class="menu-item">
|
||||
<i class="fas fa-user"></i>
|
||||
<span class="menu-text">Employees</span>
|
||||
</a>
|
||||
<a href="{{ url_for('attendance.attendance_report') }}" class="menu-item">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<span class="menu-text">Reports</span>
|
||||
</a>
|
||||
<a href="{{ url_for('attendance.verification_review') }}"
|
||||
class="menu-item {% if request.endpoint == 'verification_review' %}active{% endif %}">
|
||||
<i class="fas fa-camera-retro"></i>
|
||||
<span class="menu-text">Verification Review</span>
|
||||
</a>
|
||||
<a href="{{ url_for('time_attendance.time_attendance_dashboard') }}"
|
||||
class="menu-item {% if request.endpoint and (request.endpoint.startswith('time_attendance') or request.endpoint.startswith('import_time_attendance')) %}active{% endif %}">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span class="menu-text">Time Attendance</span>
|
||||
</a>
|
||||
<a href="{{ url_for('statistics.qr_statistics') }}"
|
||||
class="menu-item {% if request.endpoint == 'qr_statistics' %}active{% endif %}">
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
<span class="menu-text">Statistics</span>
|
||||
</a>
|
||||
<a href="{{ url_for('admin.admin_logs') }}"
|
||||
class="menu-item {% if request.endpoint == 'admin_logs' %}active{% endif %}">
|
||||
<i class="fas fa-clipboard-list"></i>
|
||||
<span class="menu-text">System Logs</span>
|
||||
</a>
|
||||
{% elif session.role in ['payroll', 'accounting'] %}
|
||||
<a href="{{ url_for('employees.employees') }}" class="menu-item">
|
||||
<i class="fas fa-user"></i>
|
||||
<span class="menu-text">Employees</span>
|
||||
</a>
|
||||
<a href="{{ url_for('attendance.attendance_report') }}" class="menu-item">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<span class="menu-text">Reports</span>
|
||||
</a>
|
||||
<a href="{{ url_for('attendance.verification_review') }}"
|
||||
class="menu-item {% if request.endpoint == 'verification_review' %}active{% endif %}">
|
||||
<i class="fas fa-camera-retro"></i>
|
||||
<span class="menu-text">Verification Review</span>
|
||||
</a>
|
||||
<a href="{{ url_for('time_attendance.time_attendance_dashboard') }}"
|
||||
class="menu-item {% if request.endpoint and (request.endpoint.startswith('time_attendance') or request.endpoint.startswith('import_time_attendance')) %}active{% endif %}">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span class="menu-text">Time Attendance</span>
|
||||
</a>
|
||||
|
||||
{% elif session.role in ['project_manager'] %}
|
||||
<a href="{{ url_for('attendance.attendance_report') }}" class="menu-item">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<span class="menu-text">Reports</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('auth.profile') }}" class="menu-item">
|
||||
<i class="fas fa-user"></i>
|
||||
<span class="menu-text">Profile</span>
|
||||
</a>
|
||||
<!-- Favicon -->
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="{{ url_for('static', filename='favicon.ico') }}"
|
||||
/>
|
||||
</head>
|
||||
<body class="has-sidebar">
|
||||
<!-- Authenticated Layout with Sidebar -->
|
||||
<div class="app-layout">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<!-- Brand Section -->
|
||||
<div class="sidebar-brand">
|
||||
<div class="brand-content">
|
||||
<i class="fas fa-qrcode"></i>
|
||||
<span class="brand-text">{{ COMPANY_NAME }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Section -->
|
||||
<div class="sidebar-bottom">
|
||||
<div class="menu-items">
|
||||
<a href="{{ url_for('auth.logout') }}" class="menu-item logout">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span class="menu-text">Logout</span>
|
||||
</a>
|
||||
<!-- Navigation Menu -->
|
||||
<div class="sidebar-menu">
|
||||
<div class="menu-section">
|
||||
<div class="menu-items">
|
||||
<a href="{{ url_for('dashboard.dashboard') }}" class="menu-item">
|
||||
<i class="fas fa-tachometer-alt"></i>
|
||||
<span class="menu-text">Dashboard</span>
|
||||
</a>
|
||||
|
||||
{% if session.role == 'admin' %}
|
||||
<a href="{{ url_for('qr_codes.create_qr_code') }}" class="menu-item">
|
||||
<i class="fas fa-plus"></i>
|
||||
<span class="menu-text">Create QR</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('projects.projects') }}"
|
||||
class="menu-item {% if request.endpoint in ['projects', 'create_project', 'edit_project'] %}active{% endif %}"
|
||||
>
|
||||
<i class="fas fa-folder"></i>
|
||||
<span class="menu-text">Projects</span>
|
||||
</a>
|
||||
<a href="{{ url_for('users.users') }}" class="menu-item">
|
||||
<i class="fas fa-user-cog"></i>
|
||||
<span class="menu-text">Users</span>
|
||||
</a>
|
||||
<a href="{{ url_for('employees.employees') }}" class="menu-item">
|
||||
<i class="fas fa-user"></i>
|
||||
<span class="menu-text">Employees</span>
|
||||
</a>
|
||||
<a href="{{ url_for('attendance.attendance_report') }}" class="menu-item">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<span class="menu-text">Reports</span>
|
||||
</a>
|
||||
<a href="{{ url_for('attendance.verification_review') }}"
|
||||
class="menu-item {% if request.endpoint == 'verification_review' %}active{% endif %}">
|
||||
<i class="fas fa-camera-retro"></i>
|
||||
<span class="menu-text">Verification Review</span>
|
||||
</a>
|
||||
<a href="{{ url_for('time_attendance.time_attendance_dashboard') }}"
|
||||
class="menu-item {% if request.endpoint and (request.endpoint.startswith('time_attendance') or request.endpoint.startswith('import_time_attendance')) %}active{% endif %}">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span class="menu-text">Time Attendance</span>
|
||||
</a>
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_dashboard') }}"
|
||||
class="menu-item {% if request.endpoint and request.endpoint.startswith('legacy_attendance') %}active{% endif %}">
|
||||
<i class="fas fa-history"></i>
|
||||
<span class="menu-text">Legacy Attendance</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('statistics.qr_statistics') }}"
|
||||
class="menu-item {% if request.endpoint == 'qr_statistics' %}active{% endif %}"
|
||||
>
|
||||
<i class="fas fa-chart-pie"></i>
|
||||
<span class="menu-text">Statistics</span>
|
||||
</a>
|
||||
<a
|
||||
href="{{ url_for('admin.admin_logs') }}"
|
||||
class="menu-item {% if request.endpoint == 'admin_logs' %}active{% endif %}"
|
||||
>
|
||||
<i class="fas fa-clipboard-list"></i>
|
||||
<span class="menu-text">System Logs</span>
|
||||
</a>
|
||||
{% elif session.role in ['payroll', 'accounting'] %}
|
||||
<a href="{{ url_for('employees.employees') }}" class="menu-item">
|
||||
<i class="fas fa-user"></i>
|
||||
<span class="menu-text">Employees</span>
|
||||
</a>
|
||||
<a href="{{ url_for('attendance.attendance_report') }}" class="menu-item">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<span class="menu-text">Reports</span>
|
||||
</a>
|
||||
<a href="{{ url_for('attendance.verification_review') }}"
|
||||
class="menu-item {% if request.endpoint == 'verification_review' %}active{% endif %}">
|
||||
<i class="fas fa-camera-retro"></i>
|
||||
<span class="menu-text">Verification Review</span>
|
||||
</a>
|
||||
<a href="{{ url_for('time_attendance.time_attendance_dashboard') }}"
|
||||
class="menu-item {% if request.endpoint and (request.endpoint.startswith('time_attendance') or request.endpoint.startswith('import_time_attendance')) %}active{% endif %}">
|
||||
<i class="fas fa-clock"></i>
|
||||
<span class="menu-text">Time Attendance</span>
|
||||
</a>
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_dashboard') }}"
|
||||
class="menu-item {% if request.endpoint and request.endpoint.startswith('legacy_attendance') %}active{% endif %}">
|
||||
<i class="fas fa-history"></i>
|
||||
<span class="menu-text">Legacy Attendance</span>
|
||||
</a>
|
||||
|
||||
{% elif session.role in ['project_manager'] %}
|
||||
<a href="{{ url_for('attendance.attendance_report') }}" class="menu-item">
|
||||
<i class="fas fa-chart-line"></i>
|
||||
<span class="menu-text">Reports</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('auth.profile') }}" class="menu-item">
|
||||
<i class="fas fa-user"></i>
|
||||
<span class="menu-text">Profile</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Toggle Button -->
|
||||
<button class="sidebar-toggle" id="sidebarToggle">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Overlay -->
|
||||
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-wrapper">
|
||||
<!-- Top Header Bar -->
|
||||
<header class="top-header">
|
||||
<div class="header-left">
|
||||
<button class="mobile-menu-btn" id="mobileMenuBtn">
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
<h1 class="page-title">
|
||||
{% block page_title %}{{ COMPANY_NAME }}{% endblock %}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ session.full_name or 'User' }}</span>
|
||||
<span class="user-role">{{ session.role|title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %} {% if
|
||||
messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">
|
||||
<i
|
||||
class="fas {% if category == 'success' %}fa-check-circle{% elif category == 'error' %}fa-exclamation-circle{% elif category == 'info' %}fa-info-circle{% else %}fa-exclamation-triangle{% endif %}"></i>
|
||||
{{ message }}
|
||||
<button class="alert-close" onclick="this.parentElement.style.display='none'">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %} {% endwith %}
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="container">{% block content %}{% endblock %}</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<p>© {{ CURRENT_YEAR }} {{ COMPANY_NAME }}. All rights reserved.</p>
|
||||
<div class="footer-links">
|
||||
<a href="#" class="footer-link">Privacy Policy</a>
|
||||
<a href="#" class="footer-link">Terms of Service</a>
|
||||
<a href="#" class="footer-link">Support</a>
|
||||
<!-- Bottom Section -->
|
||||
<div class="sidebar-bottom">
|
||||
<div class="menu-items">
|
||||
<a href="{{ url_for('auth.logout') }}" class="menu-item logout">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
<span class="menu-text">Logout</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<!-- Sidebar Toggle Button -->
|
||||
<button class="sidebar-toggle" id="sidebarToggle">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Overlay -->
|
||||
<div class="sidebar-overlay" id="sidebarOverlay"></div>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<div class="main-wrapper">
|
||||
<!-- Top Header Bar -->
|
||||
<header class="top-header">
|
||||
<div class="header-left">
|
||||
<button class="mobile-menu-btn" id="mobileMenuBtn">
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
<h1 class="page-title">
|
||||
{% block page_title %}{{ COMPANY_NAME }}{% endblock %}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="user-info">
|
||||
<span class="user-name">{{ session.full_name or 'User' }}</span>
|
||||
<span class="user-role">{{ session.role|title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %} {% if
|
||||
messages %}
|
||||
<div class="flash-messages">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">
|
||||
<i
|
||||
class="fas {% if category == 'success' %}fa-check-circle{% elif category == 'error' %}fa-exclamation-circle{% elif category == 'info' %}fa-info-circle{% else %}fa-exclamation-triangle{% endif %}"
|
||||
></i>
|
||||
{{ message }}
|
||||
<button
|
||||
class="alert-close"
|
||||
onclick="this.parentElement.style.display='none'"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %} {% endwith %}
|
||||
|
||||
<!-- Page Content -->
|
||||
<div class="container">{% block content %}{% endblock %}</div>
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<p>© {{ CURRENT_YEAR }} {{ COMPANY_NAME }}. All rights reserved.</p>
|
||||
<div class="footer-links">
|
||||
<a href="#" class="footer-link">Privacy Policy</a>
|
||||
<a href="#" class="footer-link">Terms of Service</a>
|
||||
<a href="#" class="footer-link">Support</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main JavaScript -->
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
<!-- Main JavaScript -->
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
|
||||
<!-- Page-specific JavaScript -->
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
|
||||
<!-- Global JavaScript Variables -->
|
||||
<script>
|
||||
// Pass Flask session data to JavaScript
|
||||
window.qrConfig = {
|
||||
currentUserId: {{ session.user_id |default ('null') }},
|
||||
currentUserRole: '{{ session.role|default('') }}',
|
||||
currentUserName: '{{ session.full_name|default('') }}',
|
||||
<!-- Global JavaScript Variables — must come BEFORE extra_scripts so csrfToken is available -->
|
||||
<script>
|
||||
// Pass Flask session data to JavaScript
|
||||
window.qrConfig = {
|
||||
currentUserId: {{ session.user_id|default('null') }},
|
||||
currentUserRole: '{{ session.role|default('') }}',
|
||||
currentUserName: '{{ session.full_name|default('') }}',
|
||||
csrfToken: '{{ csrf_token() if csrf_token else '' }}'
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</script>
|
||||
|
||||
<!-- Page-specific JavaScript -->
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,90 @@
|
||||
{% extends "base_authenticated.html" %}
|
||||
{% block title %}Legacy Attendance Dashboard - {{ COMPANY_NAME }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="time-attendance-page">
|
||||
<!-- Page Header -->
|
||||
<div class="time-attendance-header">
|
||||
<div class="header-content">
|
||||
<h1>
|
||||
<i class="fas fa-history"></i>
|
||||
Legacy Attendance Dashboard
|
||||
</h1>
|
||||
<p class="header-description">Live view of attendance records from the legacy database</p>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_records') }}" class="btn btn-primary">
|
||||
<i class="fas fa-search"></i>
|
||||
View All Records
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Section -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon records">
|
||||
<i class="fas fa-database"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ "{:,}".format(stats.total_records or 0) }}</h3>
|
||||
<p>Total Records</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon employees">
|
||||
<i class="fas fa-users"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ "{:,}".format(stats.unique_employees or 0) }}</h3>
|
||||
<p>Employees</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon locations">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>{{ "{:,}".format(stats.unique_locations or 0) }}</h3>
|
||||
<p>Locations</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card">
|
||||
<div class="stat-icon imports">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
</div>
|
||||
<div class="stat-info">
|
||||
<h3>
|
||||
{% if stats.earliest_record and stats.latest_record %}
|
||||
{{ stats.earliest_record.strftime('%m/%d/%Y') }} – {{ stats.latest_record.strftime('%m/%d/%Y') }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</h3>
|
||||
<p>Date Range</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</div>
|
||||
<h3>This page reads live from the legacy database</h3>
|
||||
<p>
|
||||
Records are queried directly from the old attendance system on each visit —
|
||||
nothing is imported or stored locally. Use
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_records') }}">View All Records</a>
|
||||
to search, filter, and export.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,262 @@
|
||||
{% extends "base_authenticated.html" %}
|
||||
{% block title %}Legacy Attendance Records - {{ COMPANY_NAME }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="time-attendance-page">
|
||||
<!-- Page Header -->
|
||||
<div class="time-attendance-header">
|
||||
<div class="header-content">
|
||||
<h1>
|
||||
<i class="fas fa-history"></i>
|
||||
Legacy Attendance Records
|
||||
</h1>
|
||||
<p class="header-description">Live results from the legacy database</p>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('legacy_attendance.export_legacy_attendance', **filters) }}" class="btn btn-secondary">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
Export to Excel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-header">
|
||||
<h3><i class="fas fa-filter"></i> Filters</h3>
|
||||
</div>
|
||||
<div class="filter-body">
|
||||
<form method="GET" class="filter-form">
|
||||
<div class="form-group">
|
||||
<label for="employee_search">
|
||||
<i class="fas fa-user-search"></i>
|
||||
Employee (ID or Name)
|
||||
</label>
|
||||
<input type="text"
|
||||
id="employee_search"
|
||||
name="employee_search"
|
||||
class="form-input"
|
||||
placeholder="Search by ID or name..."
|
||||
value="{{ filters.employee_search }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
Location
|
||||
</label>
|
||||
<select id="location" name="location" class="form-select">
|
||||
<option value="">All Locations</option>
|
||||
{% for loc in unique_locations %}
|
||||
<option value="{{ loc }}" {% if filters.location == loc %}selected{% endif %}>{{ loc }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="record_type">
|
||||
<i class="fas fa-tag"></i>
|
||||
Type
|
||||
</label>
|
||||
<select id="record_type" name="record_type" class="form-select">
|
||||
<option value="">All Types</option>
|
||||
{% for rt in record_types %}
|
||||
<option value="{{ rt }}" {% if filters.record_type == rt %}selected{% endif %}>{{ rt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="start_date">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
Start Date
|
||||
</label>
|
||||
<input type="date" id="start_date" name="start_date" class="form-input" value="{{ filters.start_date }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="end_date">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
End Date
|
||||
</label>
|
||||
<input type="date" id="end_date" name="end_date" class="form-input" value="{{ filters.end_date }}">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search"></i>
|
||||
Apply Filters
|
||||
</button>
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_records') }}" class="btn btn-outline">
|
||||
<i class="fas fa-times"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Records Table -->
|
||||
<div class="records-section">
|
||||
<div class="records-header">
|
||||
<h2><i class="fas fa-table"></i> Attendance Records</h2>
|
||||
<div class="records-meta">
|
||||
{% 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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if records and records.items %}
|
||||
<div class="records-table-container">
|
||||
<table class="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Platform</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Location Name</th>
|
||||
<th>Action Description</th>
|
||||
<th>Event Description</th>
|
||||
<th>Recorded Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in records.items %}
|
||||
<tr>
|
||||
<!-- 1. ID (Employee ID) -->
|
||||
<td>
|
||||
<div class="employee-id-cell">
|
||||
<span class="employee-id">{{ record.employee_id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- 2. Name -->
|
||||
<td>
|
||||
<div class="employee-name-cell">
|
||||
<span>{{ record.resolved_employee_name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- 3. Platform -->
|
||||
<td class="platform-cell">
|
||||
{{ 'Manual' if record.is_manual else '—' }}
|
||||
</td>
|
||||
|
||||
<!-- 4. Date -->
|
||||
<td class="date-cell">
|
||||
{{ record.record_time.strftime('%Y-%m-%d') if record.record_time else '' }}
|
||||
</td>
|
||||
|
||||
<!-- 5. Time -->
|
||||
<td class="time-cell">
|
||||
{{ record.record_time.strftime('%H:%M:%S') if record.record_time else '' }}
|
||||
</td>
|
||||
|
||||
<!-- 6. Location Name -->
|
||||
<td class="location-cell">
|
||||
<div class="location-info">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
{{ record.location_name if record.location_name else '-' }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- 7. Action Description -->
|
||||
<td>
|
||||
<span class="action-badge {{ record.record_type.lower().replace(' ', '-') }}">
|
||||
{% if record.record_type.lower() == 'check in' %}
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
{% elif record.record_type.lower() == 'check out' %}
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-clock"></i>
|
||||
{% endif %}
|
||||
{{ record.record_type }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- 8. Event Description -->
|
||||
<td class="event-cell">
|
||||
{{ record.location_address if record.location_address else '-' }}
|
||||
</td>
|
||||
|
||||
<!-- 9. Recorded Address -->
|
||||
<td class="address-cell">
|
||||
{% if record.recorded_address %}
|
||||
<span title="{{ record.recorded_address }}">
|
||||
{{ record.recorded_address[:40] }}{% if record.recorded_address|length > 40 %}...{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">No address</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if records.pages > 1 %}
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
Page {{ records.page }} of {{ records.pages }}
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
{% if records.has_prev %}
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_records', page=records.prev_num, **filters) }}"
|
||||
class="pagination-btn">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in records.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != records.page %}
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_records', page=page_num, **filters) }}"
|
||||
class="pagination-btn">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="pagination-btn active">{{ page_num }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="pagination-ellipsis">...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if records.has_next %}
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_records', page=records.next_num, **filters) }}"
|
||||
class="pagination-btn">
|
||||
Next
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</div>
|
||||
<h3>No legacy attendance records found</h3>
|
||||
<p>Try adjusting your filters.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user