Compare commits

..
12 Commits
20 changed files with 3220 additions and 323 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(grep -vE \"^[+-]\\\\s*$\")",
"Bash(python -c \"import ast;ast.parse\\(open\\('app.py'\\).read\\(\\)\\);ast.parse\\(open\\('config.py'\\).read\\(\\)\\);print\\('AST OK'\\)\")",
"Bash(python -c ' *)"
]
}
}
-2
View File
@@ -207,5 +207,3 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/
README.md
Claude.md
+974
View File
@@ -0,0 +1,974 @@
# CLAUDE.md — QR Code Attendance Management System
# Unified reference — LT Services, Inc. & GOV Services, Inc.
This file is the authoritative reference for Claude across all sessions.
Every session must treat this file as ground truth for architecture, conventions,
domain rules, and developer preferences. Read it fully before making any changes.
---
## 1. Project Overview
A Flask/Python web application for **employee attendance tracking via QR codes** with
GPS validation, Excel import/export, payroll calculations, role-based access control,
and per-QR photo verification toggle.
**Deployed for two companies on separate servers:**
- **LT Services, Inc.** — `THEME_NAME` empty (blue theme). Production: `test.ltservicesinc.com`. Gitea: `gitea.ngodanguyen.tech/nngo/LT_QR_Codes_Management`.
- **GOV Services, Inc.** — `THEME_NAME=gov` (green theme). Production: `qr.govservicesinc.com`. Key `.env` differences: `COMPANY_NAME=GOV. Services, Inc`, `THEME_NAME=gov`, `QR_BASE_URL=https://qr.govservicesinc.com`.
**Both codebases are structurally identical** in all Python files, routes, models, and templates.
They differ only in `.env` values and `static/css/theme-gov.css`.
**Every code fix must be applied to both instances.**
- **Stack:** Flask 3.1, SQLAlchemy 2.0 / MySQL (PyMySQL only — `mysql-connector-python` removed), openpyxl 3.1, pandas 2.2, Python 3.12
- **Frontend:** Jinja2 templates + vanilla JS + Font Awesome 6 icons
- **Auth guard:** Cloudflare Turnstile (optional, toggled via `.env`)
- **Production server:** Gunicorn + gevent workers, Nginx reverse proxy, Ubuntu Server
---
## 2. Folder Structure
```
QR_Code_Management/
├── app.py # Application factory (create_app) + startup entry point
├── config.py # All env-var reads — single source of truth
├── extensions.py # Shared singletons: db (SQLAlchemy) + logger_handler (AppLogger)
├── logger_handler.py # AppLogger class + log_user_activity / log_database_operations decorators
├── location_logging.py # Android GPS debug routes (/api/log-location-action, /api/location-debug-info)
├── turnstile_utils.py # Cloudflare Turnstile verification helper (TurnstileUtils class)
├── advanced_security_middleware.py # SecurityManager (CSRF token gen, rate limiting, suspicious IP tracking)
│ # Wired into create_app() — do not import separately in blueprints
├── working_hours_calculator.py # CANONICAL calculator for Excel exports (WorkingHoursCalculator)
├── single_checkin_calculator.py # Legacy calculator — NOT used for exports; never switch to this
├── time_attendance_import_service.py # Excel import pipeline with duplicate detection
├── qr_code_import_service.py # Bulk QR code import from Excel
├── address_normalization_fix.py # normalize_address() + addresses_are_similar() helpers
├── app_performance_middleware.py # PerformanceMonitor — dev-mode only; lazy-imported inside __main__
├── db_audit_tables.py # DB audit table helpers (standalone operational script)
├── db_health_check.py # DB connectivity health check (standalone)
├── db_maintenance.py # DB maintenance utilities (standalone)
├── db_performance_optimization.py # Index/query optimization helpers
├── employee_data_merger.py # Merge duplicate employee records (standalone)
├── employee_duplicate_remove.py # Remove employee duplicates (standalone)
├── employee_sync_scheduler.py # Scheduled employee sync (schedule library)
├── employee_table_sync.py # Employee table sync logic
├── requirements.txt # All pinned Python dependencies (single MySQL driver: PyMySQL)
├── models/
│ ├── __init__.py # set_db(db) → unpacks and returns all model classes
│ ├── base.py # Shared db reference (base.db); all models import from here
│ ├── user.py # User model (table: users)
│ ├── employee.py # Employee model (table: employee)
│ ├── attendance.py # AttendanceData model (table: attendance_data)
│ ├── time_attendance.py # TimeAttendance model (table: time_attendance)
│ ├── qrcode.py # QRCode, QRCodeStyle, QRCodeLocation models
│ ├── project.py # Project model (table: projects)
│ └── permissions.py # UserProjectPermission, UserLocationPermission models
├── routes/
│ ├── __init__.py # Empty (intentional)
│ ├── auth.py # Blueprint 'auth': /, /register (admin-only), /login, /logout, /profile
│ ├── dashboard.py # Blueprint 'dashboard': /dashboard, project QR views, stats APIs
│ ├── users.py # Blueprint 'users': /users/*, user management APIs
│ ├── admin.py # Blueprint 'admin': /admin/logs, /api/logs/*
│ ├── projects.py # Blueprint 'projects': /projects/*
│ ├── qr_codes.py # Blueprint 'qr_codes': /qr-codes/*, /qr/<url> check-in flow
│ ├── attendance.py # Blueprint 'attendance': /attendance report + API endpoints
│ ├── attendance_edit.py # Side-effect module: edit, add manual, save, delete routes
│ │ # Imports bp FROM attendance.py — does NOT define its own blueprint
│ ├── verification.py # Side-effect module: /verification-review/* routes
│ │ # Imports bp FROM attendance.py — does NOT define its own blueprint
│ ├── attendance_export.py # Side-effect module: /export-configuration, /generate-excel-export
│ │ # Imports bp FROM attendance.py — does NOT define its own blueprint
│ ├── statistics.py # Blueprint 'statistics': /statistics
│ ├── employees.py # Blueprint 'employees': /employees/*
│ ├── time_attendance.py # Blueprint 'time_attendance': /time-attendance/* (all TA routes)
│ └── time_attendance_export.py # Plain module (NO Blueprint): export helper functions only
├── utils/
│ ├── __init__.py
│ ├── helpers.py # Role decorators, QR generation, role/permission helpers
│ ├── geocoding.py # Haversine distance, Google Maps client, reverse geocode
│ └── template_helpers.py # Context processors: get_employee_name, format_hours, etc.
├── static/
│ ├── css/
│ │ ├── style.css # MASTER stylesheet — all CSS variables and layout
│ │ ├── theme-gov.css # GOV Services brand theme overrides (loaded when THEME_NAME=gov)
│ │ └── *.css # Page-specific stylesheets
│ └── js/
│ ├── script.js # Global JS — includes CSRF fetch wrapper
│ ├── qr_destination.js # Check-in page: GPS, bilingual UI, camera — CSRF-EXEMPT
│ ├── attendance_report.js # Attendance report: pagination, sorting, charts
│ ├── dashboard.js
│ ├── export_configuration.js
│ ├── users.js
│ ├── attendance_fullscreen.js
│ └── android_location_handler.js # Android-specific GPS workaround
├── templates/
│ ├── base.html # Base for unauthenticated pages — loads theme CSS conditionally
│ ├── base_authenticated.html # Base for protected pages — loads theme CSS conditionally
│ │ # Brand text uses {{ COMPANY_NAME }} — no hardcoded strings
│ ├── [all other page templates] # All POST forms include {{ csrf_token() }} hidden field
│ └── errors/
│ ├── 403.html / 404.html / 500.html
└── tools/
├── migration_photo_verification_toggle.py # Adds photo_verification_enabled to qr_codes
├── migration_PM_permissions.py # One-time: user_project/location_permissions tables
├── migration_dynamic_qr_locations.py # One-time: qr_type column + qr_code_locations table
└── optimize_time_attendance_db.py # Standalone DB index optimization script
```
---
## 3. Database Tables
| Table | Model | Purpose |
|---|---|---|
| `users` | `User` | System user accounts with RBAC |
| `employee` | `Employee` | Employee master data (firstName, lastName, title, contractId) |
| `attendance_data` | `AttendanceData` | QR check-in records with GPS, photo verification |
| `time_attendance` | `TimeAttendance` | Imported time-clock records from Excel |
| `qr_codes` | `QRCode` | QR code definitions (standard and dynamic types) |
| `qr_code_styles` | `QRCodeStyle` | Reusable QR code visual styles |
| `qr_code_locations` | `QRCodeLocation` | Selectable locations for dynamic QR codes |
| `projects` | `Project` | Projects that group QR codes and employees |
| `user_project_permissions` | `UserProjectPermission` | Project-level access for Project Managers |
| `user_location_permissions` | `UserLocationPermission` | Location-level access for Project Managers |
| `log_events` | (raw SQL) | Application event log (created by AppLogger) |
### `qr_codes` — key columns
| Column | Type | Notes |
|---|---|---|
| `qr_type` | VARCHAR(20) | `'standard'` or `'dynamic'` |
| `photo_verification_enabled` | TINYINT(1) DEFAULT 1 | Per-QR photo verification toggle (added May 2026) |
| `active_status` | BOOLEAN | |
| `project_id` | FK → projects | |
### `attendance_data` — key columns
| Column | Type | Notes |
|---|---|---|
| `employee_id` | VARCHAR(50) | Always stored uppercased |
| `location_name` | VARCHAR(100) | Resolved location — never stores `'Dynamic'` |
| `location_accuracy` | FLOAT | Haversine distance in miles |
| `is_dynamic_qr` | BOOLEAN | True when checked in via dynamic QR |
| `verification_photo` | TEXT | Base64 encoded image |
| `verification_required` | BOOLEAN | |
| `verification_status` | VARCHAR(20) | `pending` / `approved` / `rejected` |
**Key relationships:**
- `Employee.contractId``Project.id`
- `QRCode.project_id``Project.id`
- `AttendanceData.qr_code_id``QRCode.id` (CASCADE DELETE)
- `TimeAttendance.project_id``Project.id`
---
## 4. User Roles & Access Control
```python
VALID_ROLES = ['admin', 'staff', 'payroll', 'project_manager', 'accounting']
STAFF_LEVEL_ROLES = ['staff', 'payroll', 'project_manager', 'accounting']
```
| Role | Key Access |
|---|---|
| `admin` | Full access; only role that sees System Logs, Users, all export tools |
| `staff` | Create/edit QR codes; view dashboard and reports; no delete, no admin |
| `payroll` | Same as staff + Time Attendance section |
| `accounting` | Same as payroll (identical menu items) |
| `project_manager` | Reports only; scoped to assigned projects and locations via permission tables |
**Auth decorators** (in `utils/helpers.py`):
- `@login_required` — redirects to `/login` if no session
- `@admin_required` — 403 if role is not `admin`
- `@staff_or_admin_required` — 403 if not admin or staff-level
**`/register` is restricted to `@admin_required`** — public self-registration is disabled.
---
## 5. Application Factory & Initialization Order
```
1. load_dotenv()
2. from extensions import db, init_logger
3. create_app():
a. app.config.from_object(get_config())
b. SECRET_KEY guard — sys.exit(1) if default value in non-debug mode
c. db.init_app(app)
d. set_db(db) → unpacks all model classes
e. init_logger(app, db) → binds logger_handler in extensions.py
f. register_blueprints() — in canonical order (see §6)
+ side-effect import of attendance_edit, verification, attendance_export
g. create_location_logging_routes() ← from location_logging.py
h. SecurityManager.init_app() ← wires CSRF before_request + rate limiting
i. inject_csrf_token() context processor
j. inject_company_name() context processor (COMPANY_NAME, THEME_NAME, CURRENT_YEAR)
k. inject_logging_status(), inject_turnstile() context processors
l. template_filters (strftime, days_since, time_ago)
m. before_request: adjust_session_lifetime + g.start_time + suspicious UA scan
n. after_request: slow-query detection + error response logging
o. Error handlers: 403, 404, 500
p. Startup init: create_tables() + update_existing_qr_codes() ← runs under Gunicorn too
4. if __name__ == '__main__':
a. lazy import PerformanceMonitor (dev-mode only)
b. initialize_performance_optimizations()
c. app.run()
```
**Critical notes:**
- `create_tables()` and `update_existing_qr_codes()` run inside `with app.app_context()` inside `create_app()` — they execute under gunicorn, not only under `__main__`.
- `PerformanceMonitor` is imported lazily inside `__main__` only — gunicorn workers never load it.
- `update_existing_qr_codes()` prefers `QR_BASE_URL` from `.env`. Falls back to `FLASK_HOST`/`FLASK_PORT`. Never uses `request.url_root`.
---
## 6. Blueprint Registration Order
```python
auth_bp, dashboard_bp, users_bp, admin_bp, projects_bp,
qr_codes_bp, attendance_bp, statistics_bp,
employees_bp, time_attendance_bp
```
**Attendance blueprint split:**
`attendance.py` defines `bp = Blueprint('attendance', __name__)` once.
`attendance_edit.py`, `verification.py`, and `attendance_export.py` each do:
```python
from routes.attendance import bp # shared blueprint — do not redefine
```
`app.py` registers only `attendance_bp` once. Sub-modules are loaded as side-effect imports:
```python
import routes.attendance_edit # noqa: F401
import routes.verification # noqa: F401
import routes.attendance_export # noqa: F401
```
`time_attendance_export.py` is **not a Blueprint**. Plain module providing export helper functions. Never register it separately.
---
## 7. All Routes
### auth (Blueprint: `auth`)
| URL | Endpoint | Notes |
|---|---|---|
| `/` | `auth.index` | |
| `/register` | `auth.register` | `@admin_required` — not public |
| `/login` | `auth.login` | Rate-limited via SecurityManager |
| `/logout` | `auth.logout` | |
| `/profile` | `auth.profile` | |
### dashboard (Blueprint: `dashboard`)
| URL | Endpoint |
|---|---|
| `/dashboard` | `dashboard.dashboard` |
| `/project/<id>/qr-codes` | `dashboard.project_qr_codes` |
| `/dashboard/search` | `dashboard.search_qr_codes` |
| `/api/dashboard/stats` | `dashboard.dashboard_stats_api` |
| `/api/dashboard/realtime` | `dashboard.dashboard_realtime_api` |
### users (Blueprint: `users`)
| URL | Endpoint |
|---|---|
| `/users` | `users.users` |
| `/users/create` | `users.create_user` |
| `/users/<id>/edit` | `users.edit_user` |
| `/users/<id>/delete` | `users.delete_user` |
| `/users/<id>/reactivate` | `users.reactivate_user` |
| `/users/<id>/promote` | `users.promote_user` |
| `/users/<id>/demote` | `users.demote_user` |
| `/users/<id>/toggle-status` | `users.toggle_user_status` |
| `/users/<id>/activate` | `users.activate_user` |
| `/users/<id>/deactivate` | `users.deactivate_user` |
| `/users/<id>/permanently-delete` | `users.permanently_delete_user` |
| `/api/users/stats` | `users.user_stats_api` |
| `/api/locations-by-projects` | `users.get_locations_by_projects` |
| `/api/roles/permissions` | `users.role_permissions_api` |
| `/api/geocode` | `users.geocode_address_api` |
| `/api/reverse-geocode` | `users.reverse_geocode_api` |
### admin (Blueprint: `admin`)
| URL | Endpoint |
|---|---|
| `/admin/logs` | `admin.admin_logs` |
| `/admin/health/google-maps` | `admin.google_maps_health` |
| `/api/logs/recent` | `admin.api_recent_logs` |
| `/api/logs/stats` | `admin.api_log_stats` |
| `/api/logs/cleanup` | `admin.api_cleanup_logs` |
| `/api/logs/clear` | `admin.api_clear_logs` |
| `/api/logs/clear-old` | `admin.api_clear_old_logs` |
| `/api/logs/export` | `admin.api_export_logs` |
### projects (Blueprint: `projects`)
| URL | Endpoint |
|---|---|
| `/projects` | `projects.projects` |
| `/projects/create` | `projects.create_project` |
| `/projects/<id>/edit` | `projects.edit_project` |
| `/projects/<id>/toggle` | `projects.toggle_project` |
| `/api/projects/active` | `projects.api_active_projects` |
### qr_codes (Blueprint: `qr_codes`)
| URL | Endpoint | Notes |
|---|---|---|
| `/qr-codes/create` | `qr_codes.create_qr_code` | |
| `/qr-codes/bulk-import` | `qr_codes.import_bulk_qr_codes` | |
| `/qr-codes/bulk-import/template` | `qr_codes.download_qr_import_template` | |
| `/qr-codes/<id>/edit` | `qr_codes.edit_qr_code` | |
| `/qr-codes/<id>/delete` | `qr_codes.delete_qr_code` | |
| `/qr-codes/<id>/toggle-status` | `qr_codes.toggle_qr_status` | |
| `/qr-codes/<id>/activate` | `qr_codes.activate_qr_code` | |
| `/qr-codes/<id>/deactivate` | `qr_codes.deactivate_qr_code` | |
| `/qr-codes/<id>/copy-url` | `qr_codes.copy_qr_url` | |
| `/qr-codes/<id>/open-link` | `qr_codes.open_qr_link` | |
| `/qr/<url>` | `qr_codes.qr_destination` | |
| `/qr/<url>/checkin` | `qr_codes.qr_checkin` | **CSRF-exempt** — public unauthenticated |
| `/qr/<url>/locations` | `qr_codes.qr_get_locations` | |
### attendance (Blueprint: `attendance` — split across 4 files)
| URL | Endpoint | File |
|---|---|---|
| `/attendance` | `attendance.attendance_report` | `attendance.py` |
| `/api/attendance/locations` | `attendance.attendance_locations_api` | `attendance.py` |
| `/api/attendance/stats` | `attendance.attendance_stats_api` | `attendance.py` |
| `/api/search_employees` | `attendance.search_employees_api` | `attendance.py` |
| `/api/get_project_locations` | `attendance.get_project_locations_api` | `attendance.py` |
| `/api/time-attendance/locations` | `attendance.time_attendance_locations_api` | `attendance.py` |
| `/attendance/<id>/edit` | `attendance.edit_attendance` | `attendance_edit.py` |
| `/attendance/<id>/delete` | `attendance.delete_attendance` | `attendance_edit.py` |
| `/attendance/add` | `attendance.add_manual_attendance` | `attendance_edit.py` |
| `/attendance/save_manual` | `attendance.save_manual_attendance` | `attendance_edit.py` |
| `/verification-review` | `attendance.verification_review` | `verification.py` |
| `/verification-review/<id>` | `attendance.verification_review_detail` | `verification.py` |
| `/verification-review/<id>/update` | `attendance.update_verification_status` | `verification.py` |
| `/api/attendance/<id>/verification-details` | `attendance.get_verification_details` | `verification.py` |
| `/export-configuration` | `attendance.export_configuration` | `attendance_export.py` |
| `/generate-excel-export` | `attendance.generate_excel_export` | `attendance_export.py` |
### statistics (Blueprint: `statistics`)
| URL | Endpoint |
|---|---|
| `/statistics` | `statistics.qr_statistics` |
| `/api/statistics/export` | `statistics.export_statistics` |
### employees (Blueprint: `employees`)
| URL | Endpoint |
|---|---|
| `/employees` | `employees.employees` |
| `/employees/create` | `employees.create_employee` |
| `/employees/<idx>/edit` | `employees.edit_employee` |
| `/employees/<idx>/delete` | `employees.delete_employee` |
| `/employees/<idx>` | `employees.employee_detail` |
| `/api/employees/search` | `employees.api_employees_search` |
### time_attendance (Blueprint: `time_attendance`)
| URL | Endpoint |
|---|---|
| `/time-attendance` | `time_attendance.time_attendance_dashboard` |
| `/time-attendance/import` | `time_attendance.import_time_attendance` |
| `/time-attendance/import/analyze-duplicates` | `time_attendance.analyze_import_duplicates` |
| `/time-attendance/import/analyze-invalid` | `time_attendance.analyze_import_invalid` |
| `/time-attendance/import/start` | `time_attendance.start_import_job` |
| `/time-attendance/import/stream/<job_id>` | `time_attendance.stream_import_progress` |
| `/time-attendance/import/cancel-pending` | `time_attendance.cancel_pending_import` |
| `/time-attendance/import/validate` | `time_attendance.validate_import_file` |
| `/time-attendance/import/batch/<batch_id>` | `time_attendance.view_import_batch` |
| `/time-attendance/import/batch/<batch_id>/delete` | `time_attendance.delete_import_batch` |
| `/time-attendance/import/download-template` | `time_attendance.download_import_template` |
| `/time-attendance/export` | `time_attendance.export_time_attendance` |
| `/time-attendance/export/excel` | `time_attendance.excel_export_time_attendance` |
| `/time-attendance/export-by-building` | `time_attendance.export_time_attendance_by_building` |
| `/time-attendance/records` | `time_attendance.time_attendance_records` |
| `/time-attendance/record/<id>` | `time_attendance.time_attendance_record_detail` |
| `/time-attendance/delete/<id>` | `time_attendance.delete_time_attendance_record` |
| `/api/time-attendance/employee/<id>` | `time_attendance.api_time_attendance_by_employee` |
| `/api/time-attendance/location/<n>` | `time_attendance.api_time_attendance_by_location` |
### Location logging (registered directly on `app`, not a Blueprint)
| URL | Purpose |
|---|---|
| `/api/log-location-action` | Android GPS debug logging |
| `/api/location-debug-info` | Return GPS debug info |
### Security API (registered by SecurityManager on `app`)
| URL | Purpose |
|---|---|
| `/api/security/status` | Admin-only security dashboard stats |
| `/api/security/clear-blocks` | Admin-only: clear rate-limit blocks |
---
## 8. Template System
### Two Base Templates
- **`base.html`** — unauthenticated pages (login, register, QR scan, errors). Body class: `login-layout`.
- **`base_authenticated.html`** — all protected pages. Body class: `has-sidebar`. Fixed collapsible sidebar, top header with user/role badge, flash message rendering.
### Template Block Names
| Block | Purpose |
|---|---|
| `{% block title %}` | Page `<title>` text only — **no CSS or JS here** |
| `{% block page_title %}` | Top header `<h1>` (authenticated only) |
| `{% block extra_head %}` | Page-specific CSS — inside `<head>` |
| `{% block content %}` | Main page body |
| `{% block extra_scripts %}` | Page-specific JS — before `</body>` |
**Rule:** CSS always goes in `extra_head`. JS always goes in `extra_scripts`. **Never inject either into `{% block title %}`.**
### Theme System
Both `base.html` and `base_authenticated.html` load the theme override after `style.css`:
```html
<!-- 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 %}
```
Loaded **before** `{% block extra_head %}` so page-specific CSS loads after and can override.
**`theme-gov.css` overrides:** `--primary-color: #16a34a` / `--primary-hover: #15803d` (institutional green). No CSS text hacks — brand names handled via `{{ COMPANY_NAME }}`.
**Adding a third company theme:** Create `static/css/theme-{name}.css` and set `THEME_NAME={name}` in `.env`. No code changes required.
### Global Context Variables
| Variable | Source |
|---|---|
| `COMPANY_NAME` | `.env` (`COMPANY_NAME`) |
| `THEME_NAME` | `.env` (`THEME_NAME`) — empty string when not set |
| `CURRENT_YEAR` | `datetime.now().year` |
| `csrf_token` | `generate_csrf_token()` from `advanced_security_middleware` |
| `is_admin` | `bool` from session role |
| `turnstile_enabled`, `turnstile_site_key` | Turnstile config |
### CSRF in Templates
Every POST form:
```html
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
```
Every AJAX POST:
```js
'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || ''
```
Exception: `/qr/<url>/checkin` is CSRF-exempt (public, unauthenticated).
---
## 9. Design System
### CSS Custom Properties (defined in `style.css`)
```css
--primary-color: #2563eb /* Blue — LT default */
--primary-hover: #1d4ed8
--secondary-color: #64748b
--success-color: #10b981
--warning-color: #f59e0b
--danger-color: #ef4444
--info-color: #0891b2
--gray-50: #f8fafc /* Page background */
--gray-700: #334155 /* Body text */
--gray-900: #0f172a /* Headings */
```
**Light mode only**`color-scheme: light only !important` enforced globally.
### Layout
- Sidebar: `280px` expanded, `64px` collapsed. Gradient `#2563eb → #1d4ed8` (LT). GOV theme: `#16a34a → #15803d`.
- Header height: `64px`.
- Z-index: dropdown 1000, modal 1050, sidebar 1100, overlay 1200.
### Excel Export Styling
- **Header rows:** white bold text on solid black fill (`000000`)
- **Miss-punch / amber:** `FFC000` fill
- **Font:** Aptos Narrow 11pt everywhere; 14pt report title, 12pt section summary
---
## 10. Security Architecture
### CSRF Protection
- `SecurityManager` from `advanced_security_middleware.py` wired via `init_app()` in `create_app()`
- `before_request` validates `csrf_token` form field or `X-CSRF-Token` header on every `POST/PUT/PATCH/DELETE`
- Token stored in `session['csrf_token']`; compared with `hmac.compare_digest()`
- **CSRF-exempt:** `auth.login`, `auth.register`, `qr_codes.qr_checkin`, `static`
### Rate Limiting
- 5 failed login attempts within 15 minutes → IP blocked 15 minutes (in-memory per worker)
- `SecurityManager.create_secure_session()` called on login success (clears failed-attempt counter)
### Session Security
- `session.clear()` before setting new keys on login (prevents session fixation)
- `before_request` hook `adjust_session_lifetime()`: `remember_me` → 30 days, default → 10 hours
- `SESSION_COOKIE_SECURE=True` requires HTTPS — HTTP-only deployments must set `false` or login loops
### validate_session_security() — DO NOT USE in before_request
In-memory dict per worker — breaks under multi-worker gunicorn (login worker A, next request hits worker B with empty dict → 401). Intentionally not called. Flask signed cookie + CSRF handles integrity.
### SQL Injection Prevention
All dynamic SQL uses SQLAlchemy parameterized queries:
```python
conditions, params = [], {}
conditions.append("ad.check_in_date >= :date_from")
params["date_from"] = date_from
db.session.execute(text("... WHERE 1=1 " + filter_clause), params)
```
---
## 11. QR Code System
### QR Types
- **`standard`** (default) — fixed single location
- **`dynamic`** — employee selects location at scan time from list auto-generated from all active standard QR codes. No manual management UI — always queried live via `SELECT DISTINCT location, location_address FROM qr_codes WHERE qr_type='standard'`.
### Photo Verification — Two-Layer Toggle
Both must be `True` for photo verification to trigger:
1. **Global:** `PHOTO_VERIFICATION_ENABLED` in `.env`
2. **Per-QR:** `qr_codes.photo_verification_enabled` (TINYINT(1) DEFAULT 1)
```python
qr_photo_verification = getattr(qr_code, 'photo_verification_enabled', True)
if qr_photo_verification and current_app.config.get('PHOTO_VERIFICATION_ENABLED', True) \
and location_accuracy > threshold:
# require photo
```
Toggle UI in `create_qr_code.html` and `edit_qr_code.html` — uses `addEventListener('change', ...)` in `extra_scripts` block.
### Check-In Flow
1. Employee scans QR → `qr_destination.html`
2. Enters ID; GPS captured by browser
3. 30-min interval guard (configurable via `TIME_INTERVAL`)
4. Dynamic QR: server rejects if `selected_location_name` is empty; `location_name` in record is always the resolved name, never `'Dynamic'`
5. Haversine distance calculated; photo required if beyond threshold (and both toggles enabled)
6. Server-side photo size check: rejects > `VERIFICATION_PHOTO_MAX_SIZE` with HTTP 413
7. Record saved to `attendance_data`
### Check-In Page Features
- Bilingual: English/Vietnamese toggle (`qr_destination.js`)
- Staff ID persistence: `localStorage` remembers last employee ID
- Android GPS: special handler (`android_location_handler.js`)
- QR URLs must be full `https://` absolute URLs — relative URLs parsed as search queries by phones
- Base URL constructed from `QR_BASE_URL` env var — never from `request.url_root`
---
## 12. Time Attendance Import Pipeline
### Excel File Requirements
- **Required columns:** `ID`, `Date`, `Time`, `Location Name`, `Action Description`
- **Optional columns:** `Name`, `Platform`, `Event Description`, `Recorded Address`, `Distance`
### Import Flow
1. Upload → `validate_import_file`
2. Duplicate analysis → `analyze_import_duplicates` → user reviews
3. Invalid record analysis → `analyze_import_invalid`
4. Start job → `start_import_job` (SSE streaming progress via `stream_import_progress`)
5. Result in `time_attendance_import_result.html`
### Special Handling
- `Recorded Address`: read via openpyxl directly (not pandas) to preserve HYPERLINK formulas
- Duplicate detection: hash of `employee_id + date + time + action_description`
- Import tracked by `import_batch_id` (UUID)
- `self.db` not `db` — in `TimeAttendanceImportService`, always access SQLAlchemy via `self.db`
- Validation `except` blocks must not silently swallow exceptions (fail-open prevention)
---
## 13. Time Attendance Excel Export
### Calculator — CRITICAL
**Always use `WorkingHoursCalculator`** from `working_hours_calculator.py`.
**Never switch to `SingleCheckInCalculator`** — legacy, causes silent calculation errors.
### Rounding Rules
- **Daily / Weekly / Grand Total + SP/PW/PT summary columns:** `_qtr()` — quarter-hour rounding
- **Individual Hours/Building entry cells:** raw `round(..., 2)` — no quarter rounding
- `_qtr()` pipeline: decimal hours → minutes → `round_time_to_quarter_hour()``convert_minutes_to_base100()``round_base100_hours()`
### Work Type Codes
- **SP** = Special Project, **PW** = Periodic Work, **PT** = Project Team (Part-Time)
- Parsed from `employee_id` via `parse_employee_id_for_work_type()`
### Overnight Shift Handling
**Rule 1 — Sort key:** early-morning OUTs (`hour <= 3`) use `_overnight_aware_sort_key()` which adds 86400 seconds — pushes them past midnight so they sort after same-day evening INs.
**Rule 2 — Detection threshold:** `hour >= 12` (noon). Any IN at or after 12:00 PM is an overnight IN candidate if no matching OUT exists same day AND early-morning OUT (`hour <= 3`) exists next calendar day.
**Orphan guard:** early-morning OUTs with `check_in_date <= current_day` are orphans — skip. Only OUTs with `check_in_date > current_day` (moved by overnight detection) pair with evening INs.
### Cross-Type SP Pairs
`is_cross_type = True` when SP IN pairs with non-SP OUT (or vice versa). Accumulates separately in `cross_type_sp/pw/pt_hours` to prevent double-counting in summary rows.
### Summary Rows (SP / PW / PT + Regular)
```
col8='SP' col9=_qtr(sp_hours) ← only if sp_hours > 0
col8='PW' col9=_qtr(pw_hours) ← only if pw_hours > 0
col8='PT' col9=_qtr(pt_hours) ← only if pt_hours > 0
col8='Regular' col9=_qtr(regular_only_hours) ← always when any special type exists
col7='GRAND TOTAL:' col9=_qtr(grand_regular) col10=_qtr(grand_ot)
```
### Export Date Range
`_resolve_date_range()` enforces a **14-day cap by default**. Both export functions accept `unlimited=False`; pass `unlimited=True` to bypass.
**UI:** "Unlimited" checkbox in `time_attendance_records.html` before export buttons.
### Employee Name Format
`"Lastname, Firstname"``f"{emp.lastName}, {emp.firstName}"`
### Export Date Iteration
Cap `sorted_dates` to `<= end_date` — prevents overnight buffer day from rendering as a display row.
### Cross-Building Pairing
Same-day IN at Building A + OUT at Building B → pair and label `"IN: Building A → OUT: Building B"`. Suppress spurious Missed Punch rows.
---
## 14. Logging System (`logger_handler.py`)
Single `AppLogger` instance in `extensions.py`. Import everywhere as:
```python
from extensions import logger_handler
logger_handler.logger.info("...")
logger_handler.logger.error("...", exc_info=True) # always pass exc_info=True in except blocks
```
### Log Destinations
- `logs/application.log` — rotating 10MB/5 backups
- `logs/errors.log` — rotating 5MB/10 backups
- `logs/security.log` — rotating 2MB/20 backups
- `log_events` DB table — admin dashboard at `/admin/logs`
### Log Every Action
All create, edit, and delete operations must include a log entry:
```python
logger_handler.logger.info(f"User {session['username']} created employee {new_employee.id}")
```
### traceback Convention
- Use `exc_info=True` on `logger.error()` — never `import traceback` inline
- `traceback.format_exc()` only acceptable when passing `stack_trace=` to `log_flask_error()`
---
## 15. Configuration (`config.py` + `.env`)
All env-var reads centralized in `config.py`. Blueprints use `current_app.config['KEY']`.
### Key `.env` Variables
```
DATABASE_URL # mysql+pymysql://user:pass@host/db — special chars in password OK
SECRET_KEY # MUST differ from default
COMPANY_NAME / CONTRACT_NAME
FLASK_HOST / FLASK_PORT / FLASK_ENV / DEBUG
SESSION_COOKIE_SECURE # MUST be 'false' for HTTP-only deployments
SESSION_COOKIE_HTTPONLY / SESSION_COOKIE_SAMESITE
TIME_INTERVAL # Check-in cooldown in minutes (default 30)
GOOGLE_MAPS_API_KEY # Optional; falls back to Haversine-only
TURNSTILE_ENABLED / TURNSTILE_SITE_KEY / TURNSTILE_SECRET_KEY
ENABLE_PHOTO_VERIFICATION # Global toggle (default 'true')
PHOTO_VERIFICATION_DISTANCE_THRESHOLD # Miles (default 0.3)
VERIFICATION_PHOTO_MAX_SIZE # Bytes (default 5MB)
UPLOAD_FOLDER # Temp path (default /tmp)
DEFAULT_ADMIN_PASSWORD # CHANGE IN PRODUCTION
QR_BASE_URL # Public-facing domain for QR links — required behind reverse proxy
THEME_NAME # Activates static/css/theme-{THEME_NAME}.css
SYNC_INTERVAL_MINUTES
SQLALCHEMY_ENGINE_OPTIONS_POOL_SIZE / POOL_TIMEOUT / POOL_RECYCLE / MAX_OVERFLOW
```
---
## 16. Distance Calculation
Uses **Haversine formula** only (straight-line geodesic distance). Google Maps Distance Matrix API removed.
`utils/geocoding.py` initializes `gmaps_client` for geocoding (address → coordinates) if `GOOGLE_MAPS_API_KEY` is set.
### Google Maps Guard — ALWAYS USE
```python
from utils.geocoding import is_gmaps_available, gmaps_client
if is_gmaps_available():
result = gmaps_client.geocode(address)
else:
# fall back to OpenStreetMap / Haversine
```
Never call `gmaps_client.method()` without a `None` guard.
### Address Normalization
Multi-strategy matching: core street extraction → component matching → fuzzy fallback. Handles geocoding drift for near-identical addresses.
---
## 17. Migration Scripts
All in `tools/`. Always use **`pymysql` directly** — never import the Flask app or SQLAlchemy ORM.
ORM loads models at import time; if target column doesn't exist yet, it crashes on startup.
**Pattern:**
```python
from dotenv import load_dotenv
load_dotenv()
import pymysql, re
def parse_db_url(url):
"""Use regex — urlparse breaks on special chars (@, :) in passwords."""
url = re.sub(r'^mysql\+pymysql://', '', url)
m = re.match(
r'^(?P<user>[^:]+):(?P<password>.+)@(?P<host>[^@:/]+)(?::(?P<port>\d+))?/(?P<db>[^?]+)',
url
)
return {'host': m.group('host'), 'port': int(m.group('port') or 3306),
'user': m.group('user'), 'password': m.group('password'), 'database': m.group('db')}
def run():
conn = pymysql.connect(**parse_db_url(os.environ['DATABASE_URL']), charset='utf8mb4', autocommit=False)
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM information_schema.COLUMNS WHERE ...")
if cur.fetchone()[0] > 0:
print("[SKIP] already exists"); return
cur.execute("ALTER TABLE `table` ADD COLUMN `col` TINYINT(1) NOT NULL DEFAULT 1")
conn.commit()
```
### Completed Migrations
| Script | What it adds |
|---|---|
| `migration_photo_verification_toggle.py` | `qr_codes.photo_verification_enabled` TINYINT(1) DEFAULT 1 |
| `migration_dynamic_qr_locations.py` | `qr_type` column + `qr_code_locations` table |
| `migration_PM_permissions.py` | `user_project_permissions` + `user_location_permissions` tables |
---
## 18. Coding Conventions & Developer Preferences
### Change Philosophy
- **Minimal, additive changes only** — preserve all route names, function names, endpoint names, variable names, URL patterns
- New functionality added alongside existing code, not replacing it
- Never rename routes, functions, endpoints, or variables
- **Every code fix applies to both LT and GOV instances**
### File Delivery
- **≤ 4 files changed** → present each file individually
- **≥ 5 files changed** → deliver as a single zip archive
### File Editing Approach
- Pull latest code at start of each session
- CRLF normalization first if needed: `content.replace('\r\n', '\n')`
- Use surgical `str_replace` edits — never rewrite large blocks wholesale
- AST parse check after every Python edit: `python3 -c "import ast; ast.parse(open(f).read())"`
- Simulate logic before and after any calculator or pairing logic changes
### SQLAlchemy Patterns
```python
# Correct (SQLAlchemy 2.0):
record = db.session.get(Model, record_id)
if record is None:
abort(404)
# Deprecated — do not use:
record = Model.query.get_or_404(record_id)
record = Model.query.get(record_id)
# Raw SQL requires text():
db.session.execute(text("SELECT ..."), params)
# Never pass raw strings to conn.execute() — ObjectNotExecutableError in SQLAlchemy 2.0
```
### JavaScript Patterns
- Use `createElement` + `addEventListener` — never inline `onchange`, `onclick`, etc.
- Wrap in `DOMContentLoaded`
- CSS → `{% block extra_head %}`, JS → `{% block extra_scripts %}`, **never into `{% block title %}`**
- All AJAX POST: `'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || ''`
- No `localStorage` / `sessionStorage` in artifacts
### Error Handling Pattern
Every route `except` block must:
1. `db.session.rollback()`
2. `logger_handler.logger.error(f"...: {e}", exc_info=True)`
3. `flash(...)` a user-facing message
4. Return redirect or error response
No bare `except:` — always `except Exception as e:`.
### QR Code Name — Locked After Creation
The QR code name **cannot be changed** after creation because it is used to generate the `qr_url` slug.
Changing the name would break all existing printed/distributed QR codes.
- **Template (`edit_qr_code.html`):** name `<input>` has `readonly` attribute + `cursor: not-allowed` style + lock icon in label
- **Route (`routes/qr_codes.py`):** edit POST ignores `request.form['name']` — sets `new_name = qr_code.name` (original value). Do NOT change this back to reading from the form.
```python
# CORRECT — name locked after creation:
new_name = qr_code.name # always use existing name, never request.form['name']
# WRONG — never do this in the edit route:
# new_name = request.form['name']
```
### Employee Autocomplete (attendance and time-attendance)
- Visible text input + hidden `employee_id` field synced on numeric input
- Fetches `/api/search_employees` (includes unregistered IDs from `attendance_data`, not only Employee table)
- CSS inlined in `extra_head`; JS uses `createElement` + `addEventListener`
### Timestamp Convention
Use `datetime.now()` (local time) throughout — **not** `datetime.utcnow()`.
### Context Safety
- Use `has_request_context()` (not `if not request:`) to check Flask request context
- Capture `current_app._get_current_object()` in route body, not inside lazy generators
---
## 19. Attendance Report
Query fetches **1,001 rows**, trims to 1,000 if extra row present, sets `records_truncated = True`.
Template displays yellow banner when truncated:
```
Showing the most recent 1,000 records. Narrow the date range or apply additional filters.
```
---
## 20. Known Bugs Fixed — Do Not Reintroduce
### Set 1 — Critical
| File | Fix |
|---|---|
| `routes/time_attendance_export.py` | Removed stray second docstring |
| `app.py` | Error handlers wired to `render_template('errors/*.html')`; 403 added |
| `templates/errors/*.html` | `url_for('dashboard')``url_for('dashboard.dashboard')` |
| `routes/payroll.py` | Removed duplicate `get_employee_name`, `get_qr_code_checkin_count`, `@bp.context_processor` |
| `models/attendance.py` | `check_in_time` default: `datetime.now().time``lambda: datetime.now().time()` |
| `models/user.py` | Added `@staticmethod` to `has_export_permissions()` |
### Set 2 — Moderate / Minor
| File | Fix |
|---|---|
| `app.py` | Merged duplicate before/after request hooks |
| `app.py` | `update_existing_qr_codes()` uses `QR_BASE_URL` first, not `request.url_root` |
| `config.py` | `SQLALCHEMY_ENGINE_OPTIONS` pool wiring |
| `utils/geocoding.py` | `is_gmaps_available()` helper |
| `working_hours_calculator.py` | CRLF → LF |
### Set 3 — Structural
| File | Fix |
|---|---|
| `routes/time_attendance_export.py` | Removed 14 unused imports |
| `routes/dashboard.py`, `statistics.py`, `payroll.py` | `db.session.rollback()` in all except blocks |
| `models/employee.py` | `cls.id.like()` on BigInteger → `cast(cls.id, String).like()` |
### Set 4 — Export Pipeline
| File | Fix |
|---|---|
| `routes/time_attendance_export.py` | Overnight IN threshold: `>= 19``>= 12` |
| `routes/time_attendance_export.py` | SP/PW/PT summary rows + `Regular` row |
| `routes/time_attendance_export.py` | `unlimited=False` parameter throughout |
| `templates/time_attendance_records.html` | "Unlimited" checkbox |
### Set 5 — GOV Deployment
| File | Fix |
|---|---|
| `routes/qr_codes.py` | `_get_qr_base_url()` helper; replaced all `request.url_root` |
| `config.py` | `QR_BASE_URL` env var |
| nginx (GOV) | Removed duplicate `proxy_set_header Host` (doubled hostname in QR links) |
### Set 6 — Theme System
| File | Fix |
|---|---|
| `static/css/theme-gov.css` | New file — GOV brand green overrides |
| `app.py` | `inject_company_name()` returns `THEME_NAME` and `CURRENT_YEAR` |
| `templates/base.html` + `base_authenticated.html` | Conditional theme CSS; `{{ COMPANY_NAME }}` for brand text; `{{ CURRENT_YEAR }}` in footer |
### Set 7 — Security Hardening
| File | Fix |
|---|---|
| `routes/statistics.py`, `routes/payroll.py` | SQL injection: f-string filters → parameterized queries |
| `routes/auth.py` | Open redirect: `_is_safe_url()` on `next` param |
| `app.py` | `SECRET_KEY` default guard: `sys.exit(1)` |
| `routes/auth.py` | Session fixation: `session.clear()` before new keys |
| `routes/auth.py` | `@login_required` + `@admin_required` on `/register` |
| `app.py` | CSRF `SecurityManager` wired; `before_request` validator |
| All POST forms (22 templates) | `{{ csrf_token() }}` hidden field |
| All AJAX POST calls | `X-CSRF-Token` header |
| `advanced_security_middleware.py` | `request.json` guarded with content-type check (fixes 415) |
| `advanced_security_middleware.py` | Stable key from `SECRET_KEY` via SHA-256 (fixes per-worker warning) |
| `advanced_security_middleware.py` | `validate_session_security()` removed from `security_check()` (fixes 401 under multi-worker) |
| All route files | Bare `except:``except Exception` (11 locations) |
| `routes/qr_codes.py` | Server-side photo size enforcement; HTTP 413 on oversized payload |
### Set 8 — Code Quality
| File | Fix |
|---|---|
| 7 route files | `Model.query.get_or_404()``db.session.get()` + `abort(404)` (21 call sites) |
| `config.py` | `PERMANENT_SESSION_LIFETIME``timedelta(hours=10)` |
| `routes/attendance.py` | Split into 4 files sharing one blueprint |
| `requirements.txt` | `mysql-connector-python` removed |
| `routes/attendance.py` | LIMIT 1001 + `records_truncated` flag + yellow banner |
### Set 9 — Template Sync (May 2026)
| File | Fix |
|---|---|
| `templates/base.html` (GOV) | Footer hardcoded `2025 QR Code Management System``{{ CURRENT_YEAR }} {{ COMPANY_NAME }}` |
| `templates/base.html` (GOV) | Theme CSS block moved to after `style.css`, before Font Awesome (matches LT order) |
| `templates/base_authenticated.html` (GOV) | Indentation sync with LT |
| `templates/projects.html` (GOV) | Confirm dialog on project toggle + loading state on Edit buttons (sync with LT) |
### Set 10 — Per-QR Photo Verification Toggle (May 2026)
| File | Change |
|---|---|
| `models/qrcode.py` | Added `photo_verification_enabled` TINYINT(1) DEFAULT 1 |
| `routes/qr_codes.py` | Create: reads toggle from form, passes to constructor + logs |
| `routes/qr_codes.py` | Edit: reads toggle from form, assigns to record + logs |
| `routes/qr_codes.py` | Checkin: checks per-QR AND global flag (both must be True) |
| `templates/create_qr_code.html` | Photo Verification toggle section added |
| `templates/edit_qr_code.html` | Photo Verification toggle section added (CSS in `extra_head`, JS in `extra_scripts` with `addEventListener`) |
| `tools/migration_photo_verification_toggle.py` | pymysql migration — adds column safely |
### Set 11 — QR Name Lock Restored + CSRF Fix (May 2026)
| File | Change |
|---|---|
| `templates/edit_qr_code.html` | Name field restored to `readonly` — lock icon in label, `cursor: not-allowed`, help text explaining why |
| `routes/qr_codes.py` | Edit route: `new_name = qr_code.name` (never reads from form) — prevents name change server-side |
| `templates/create_qr_code.html` | `window.qrConfig` with `csrfToken` injected in `<head>` — fixes 403 on `/api/geocode` (standalone template, doesn't extend base) |
| `templates/base_authenticated.html` | `window.qrConfig` block moved before `{% block extra_scripts %}` — fixes token availability for all pages extending base |
---
## 21. Infrastructure & Deployment
- **Deploy user:** `qrcode` on both servers
- **Restart:** `sudo supervisorctl restart qrcode`
- **Template-only changes:** no restart needed
- **Python/model changes:** always run migration first, then deploy files, then restart
- **Gunicorn:** gevent workers — avoid threading-unsafe patterns
### Deploy Order for DB Column Additions
1. Run `python3 tools/migration_<name>.py` (pymysql, safe to re-run)
2. Deploy updated Python files
3. Restart service
---
## 22. Session Changelog
### Sessions 18 — Foundational Work
See §20 Sets 18 for detailed bug fix history. Covers: initial critical fixes, security hardening (CSRF, SQL injection, session fixation, rate limiting, open redirect), export pipeline enhancements, GOV deployment, theme system, code quality sprint.
### Session (May 2026) — Template Sync + Photo Verification Toggle
- Diffed LT and GOV codebases — confirmed only 3 template files differed (whitespace + minor logic)
- Synced `base.html`, `base_authenticated.html`, `projects.html` — GOV brought to parity with LT
- Added per-QR `photo_verification_enabled` toggle (DB column + model + routes + UI + migration)
- Fixed migration script: replaced `urlparse` with regex parser (handles special chars in DB passwords), replaced SQLAlchemy `conn.execute(str)` with `pymysql` direct (avoids ORM loading model before column exists)
- Fixed `edit_qr_code.html` toggle bug: JS was injected into `{% block title %}` (corrupted by earlier injection) — moved CSS to `extra_head`, JS to `extra_scripts` using `addEventListener`, removed inline `onchange` attribute
- Restored QR code name lock on edit page: field is `readonly` in template; route ignores submitted name (`new_name = qr_code.name`)
- Fixed `/api/geocode` 403: `create_qr_code.html` is standalone HTML (no `{% extends %}`), so `window.qrConfig` was never injected — added direct injection in `<head>`; also fixed ordering in `base_authenticated.html` for pages that do extend it
+101
View File
@@ -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).
+65 -9
View File
@@ -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)
@@ -225,6 +226,19 @@ def create_app() -> Flask:
# Request / response hooks
# ------------------------------------------------------------------
@app.before_request
def adjust_session_lifetime():
"""
Dynamically set session lifetime based on the 'remember_me' flag stored
in the session. When the user chose 'Remember Me' at login, their
permanent session lives for 30 days; otherwise the default 10-hour
lifetime from Config.PERMANENT_SESSION_LIFETIME applies.
"""
if session.get('remember_me'):
app.permanent_session_lifetime = timedelta(days=30)
else:
app.permanent_session_lifetime = timedelta(hours=10)
@app.before_request
def log_request_info():
"""Record request start time and scan for suspicious user agents"""
@@ -300,18 +314,60 @@ def create_app() -> Flask:
# ------------------------------------------------------------------
# Startup initialization (runs under gunicorn and flask run alike)
# ------------------------------------------------------------------
with app.app_context():
try:
create_tables()
update_existing_qr_codes()
except Exception as e:
from extensions import logger_handler as _startup_lh
_startup_lh.logger.error(f"Startup initialization failed: {e}", exc_info=True)
raise
_run_startup_initialization(app)
return app
def _run_startup_initialization(app) -> bool:
"""Run create_tables() + update_existing_qr_codes(), tolerating a DB that
is not up yet.
After a server reboot gunicorn and mysqld start in parallel, so the first
connection attempt can be refused. Retry for a short bounded window, then
boot the app anyway instead of re-raising: a worker that refuses to start
takes the whole site down permanently (supervisor exhausts its start
retries within seconds and gives up), while a booted worker recovers on its
own once MySQL accepts connections — pool_pre_ping discards the dead
connections. Startup work that was skipped is idempotent and runs on the
next successful restart.
"""
from extensions import logger_handler as _startup_lh
attempts = app.config.get('DB_STARTUP_RETRY_ATTEMPTS', 5)
delay = app.config.get('DB_STARTUP_RETRY_DELAY', 3)
last_error = None
for attempt in range(1, attempts + 1):
with app.app_context():
try:
create_tables()
update_existing_qr_codes()
if attempt > 1:
_startup_lh.logger.info(
f"Startup initialization succeeded on attempt {attempt}/{attempts}"
)
return True
except Exception as e:
last_error = e
try:
db.session.rollback()
except Exception:
pass
_startup_lh.logger.warning(
f"Startup initialization attempt {attempt}/{attempts} failed: {e}"
)
if attempt < attempts:
_time.sleep(delay)
_startup_lh.logger.error(
f"Startup initialization failed after {attempts} attempts; starting anyway "
f"so workers can serve once the database recovers: {last_error}",
exc_info=True
)
return False
# ---------------------------------------------------------------------------
# Database initialization helpers (called at startup)
# ---------------------------------------------------------------------------
+23 -1
View File
@@ -41,12 +41,25 @@ class Config:
'pool_timeout': int(os.environ.get('SQLALCHEMY_ENGINE_OPTIONS_POOL_TIMEOUT', '20')),
'pool_recycle': int(os.environ.get('SQLALCHEMY_ENGINE_OPTIONS_POOL_RECYCLE', '3600')),
'max_overflow': int(os.environ.get('SQLALCHEMY_ENGINE_OPTIONS_MAX_OVERFLOW', '20')),
# Validate a pooled connection before handing it out. Without this, every
# connection opened while mysqld was down/restarting stays in the pool as a
# dead socket and keeps failing requests long after MySQL has recovered.
'pool_pre_ping': True,
}
# ------------------------------------------------------------------ #
# Startup DB-connect retry (server reboot: gunicorn may start before mysqld)
# ------------------------------------------------------------------ #
# Observed worst case: an unattended-upgrades restart of mysql-server left
# mysqld down for ~15s. 8 attempts x 3s covers ~21s of downtime while
# staying inside gunicorn's 30s worker-boot timeout.
DB_STARTUP_RETRY_ATTEMPTS = int(os.environ.get('DB_STARTUP_RETRY_ATTEMPTS', '8'))
DB_STARTUP_RETRY_DELAY = int(os.environ.get('DB_STARTUP_RETRY_DELAY', '3'))
# ------------------------------------------------------------------ #
# Session / cookies
# ------------------------------------------------------------------ #
PERMANENT_SESSION_LIFETIME = timedelta(hours=10) # Reduced from 30 days — payroll data sensitivity
PERMANENT_SESSION_LIFETIME = timedelta(days=30) # Reduced from 30 days — payroll data sensitivity
SESSION_COOKIE_SECURE = (
os.environ.get('SESSION_COOKIE_SECURE', 'false').lower() == 'true'
)
@@ -96,6 +109,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
+328
View File
@@ -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
+3 -1
View File
@@ -43,6 +43,8 @@ class QRCode(base.db.Model):
# 'standard' = fixed single location (existing behavior, default)
# 'dynamic' = employee selects location from a list at scan time
qr_type = base.db.Column(base.db.String(20), nullable=False, default='standard')
# Per-QR photo verification toggle (default: enabled)
photo_verification_enabled = base.db.Column(base.db.Boolean, nullable=False, default=True)
# Relationship to style
style = base.db.relationship('QRCodeStyle', backref='qr_codes')
@@ -113,4 +115,4 @@ class QRCodeLocation(base.db.Model):
qr_code = base.db.relationship('QRCode', backref='locations')
def __repr__(self):
return f'<QRCodeLocation "{self.location_name}" (QR #{self.qr_code_id})>'
return f'<QRCodeLocation "{self.location_name}" (QR #{self.qr_code_id})>'
+129
View File
@@ -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'
)
+18 -5
View File
@@ -160,6 +160,8 @@ def create_qr_code():
styles=QRCodeStyle.query.all())
# Create new QR code record first (without URL and image)
photo_verification_enabled = request.form.get('photo_verification_enabled', '1') != '0'
new_qr_code = QRCode(
name=name,
location=location,
@@ -174,6 +176,7 @@ def create_qr_code():
coordinate_accuracy=coordinate_accuracy if has_coordinates else None,
coordinates_updated_date=datetime.utcnow() if has_coordinates else None,
qr_type=qr_type, # ADDED: store QR type
photo_verification_enabled=photo_verification_enabled,
# NEW: Customization fields (only if columns exist)
**({
'fill_color': fill_color,
@@ -220,6 +223,7 @@ def create_qr_code():
'location_address': location_address,
'location_event': location_event,
'has_coordinates': has_coordinates,
'photo_verification_enabled': photo_verification_enabled,
'customization': {
'fill_color': fill_color,
'back_color': back_color,
@@ -457,8 +461,8 @@ def edit_qr_code(qr_id):
}
# Update QR code fields
new_name = request.form['name']
qr_code.name = new_name
# Name is locked after creation — ignore any submitted value to preserve QR URL integrity
new_name = qr_code.name
# --- ADDED: for dynamic QR codes, location/address are auto-managed ---
new_qr_type = request.form.get('qr_type', 'standard')
@@ -514,6 +518,9 @@ def edit_qr_code(qr_id):
else:
qr_code.project_id = None
# Per-QR photo verification toggle
qr_code.photo_verification_enabled = request.form.get('photo_verification_enabled', '1') != '0'
# Handle QR code customization (only if columns exist)
fill_color = request.form.get('fill_color', '#000000')
back_color = request.form.get('back_color', '#FFFFFF')
@@ -558,6 +565,11 @@ def edit_qr_code(qr_id):
db.session.commit()
logger_handler.logger.info(
f"QR Code '{qr_code.name}' (ID: {qr_id}) updated by user {session.get('username', 'unknown')}"
f"photo_verification_enabled={qr_code.photo_verification_enabled}"
)
# Success message
flash(f'QR Code "{qr_code.name}" updated successfully!', 'success')
return redirect(url_for('dashboard.dashboard'))
@@ -890,11 +902,12 @@ def qr_checkin(qr_url):
logger_handler.logger.warning("Could not calculate location accuracy — calculation returned None")
# CHECK DISTANCE THRESHOLD FOR PHOTO VERIFICATION
logger_handler.logger.debug(f"Photo verification enabled: {current_app.config.get('PHOTO_VERIFICATION_ENABLED', True)}")
logger_handler.logger.debug(f"Photo verification — global: {current_app.config.get('PHOTO_VERIFICATION_ENABLED', True)}, per-QR: {getattr(qr_code, 'photo_verification_enabled', True)}")
requires_verification = False
verification_photo_data = None
if current_app.config.get('PHOTO_VERIFICATION_ENABLED', True) and location_accuracy is not None and location_accuracy > current_app.config.get('DISTANCE_THRESHOLD_FOR_VERIFICATION', 0.3):
qr_photo_verification = getattr(qr_code, 'photo_verification_enabled', True)
if qr_photo_verification and current_app.config.get('PHOTO_VERIFICATION_ENABLED', True) and location_accuracy is not None and location_accuracy > current_app.config.get('DISTANCE_THRESHOLD_FOR_VERIFICATION', 0.3):
logger_handler.logger.info(f"Distance ({location_accuracy:.3f} mi) exceeds verification threshold for employee {employee_id}")
# Check if photo was provided
@@ -1224,4 +1237,4 @@ def deactivate_qr_code(qr_id):
return jsonify({
'success': False,
'message': 'Error deactivating QR code. Please try again.'
}), 500
}), 500
+1 -1
View File
@@ -1837,7 +1837,7 @@ def export_time_attendance_by_building_excel(records, project_name_for_filename,
if _bb_eff_wt not in ('SP', 'PW', 'PT'):
regular_only_hours += _bb_dur
daily_hours = round(daily_hours, 2)
daily_hours = _qtr(daily_hours)
weekly_total_hours += daily_hours
# Write pairs
+70 -86
View File
@@ -1,103 +1,87 @@
<!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>
<!-- Main CSS -->
<link
rel="stylesheet"
href="{{ url_for('static', filename='css/style.css') }}"
/>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}{{ COMPANY_NAME }}{% endblock %}</title>
<!-- Font Awesome -->
<link
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
rel="stylesheet"
/>
<!-- Main CSS -->
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}" />
<!-- Theme override — loaded before page-specific CSS so per-page sheets
(e.g. auth.css) take cascade precedence unless theme uses !important -->
{% 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 %}
<!-- Page-specific CSS -->
{% block extra_head %}{% endblock %}
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet" />
<!-- Favicon -->
<link
rel="icon"
type="image/x-icon"
href="{{ url_for('static', filename='images/favicon.ico') }}"
/>
</head>
<body class="login-layout">
<!-- Non-authenticated Layout (Login, Register, etc.) -->
<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 %}
<!-- Page-specific CSS -->
{% block extra_head %}{% endblock %}
<!-- Favicon -->
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='images/favicon.ico') }}" />
</head>
<body class="login-layout">
<!-- Non-authenticated Layout (Login, Register, etc.) -->
<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>
{% endif %} {% endwith %}
{% endfor %}
</div>
{% endif %} {% endwith %}
<!-- Page Content -->
<div class="container">{% block content %}{% endblock %}</div>
</main>
<!-- Page Content -->
<div class="container">{% block content %}{% endblock %}</div>
</main>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer-content">
<p>&copy; 2025 QR Code Management System. 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>
<!-- Footer -->
<footer class="footer">
<div class="container">
<div class="footer-content">
<p>&copy; {{ 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>
</footer>
</div>
</footer>
<!-- 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 %}
<!-- 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 -->
<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>
{% if turnstile_enabled %}
<!-- Cloudflare Turnstile -->
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
{% endif %}
</body>
</html>
</script>
{% if turnstile_enabled %}
<!-- Cloudflare Turnstile -->
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
{% endif %}
</body>
</html>
+232 -202
View File
@@ -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>&copy; {{ 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>&copy; {{ 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>
+620 -1
View File
@@ -544,6 +544,12 @@
font-size: 1.1rem;
}
</style>
<!-- CSRF token for AJAX requests (mirrors base_authenticated.html injection) -->
<script>
window.qrConfig = {
csrfToken: '{{ csrf_token() }}'
};
</script>
</head>
<body>
<div class="container">
@@ -772,6 +778,32 @@
</div>
<!-- Photo Verification Toggle -->
<div class="form-section">
<div class="section-header">
<h3>
<i class="fas fa-camera"></i>
Photo Verification
</h3>
<p>Require a photo when employee check-in location is too far from the QR code</p>
</div>
<div class="form-group">
<label class="toggle-label" for="photo_verification_enabled" style="display:flex;align-items:center;gap:0.75rem;cursor:pointer;">
<div class="toggle-switch" id="photoVerifyToggle" style="position:relative;display:inline-block;width:52px;height:28px;">
<input type="checkbox" id="photo_verification_enabled" name="photo_verification_enabled" value="1"
checked
style="opacity:0;width:0;height:0;position:absolute;"
onchange="syncPhotoVerifyHidden(this)">
<span class="toggle-slider" style="position:absolute;cursor:pointer;inset:0;background:#cbd5e1;border-radius:28px;transition:0.3s;"></span>
</div>
<span id="photoVerifyLabel" style="font-weight:600;color:var(--gray-700);font-size:0.875rem;">Enabled</span>
</label>
<input type="hidden" name="photo_verification_enabled" id="photo_verification_hidden" value="1">
<small class="form-help">When disabled, photo verification is skipped for this QR code regardless of distance</small>
</div>
</div>
<!-- QR Code Customization Section -->
<div class="form-section">
<div class="section-header">
@@ -1458,5 +1490,592 @@
}
</script>
<!-- ===== END Dynamic QR Type Toggle ===== -->
<style>
/* Photo Verification Toggle */
#photo_verification_enabled:checked + .toggle-slider { background: var(--success-color, #10b981); }
.toggle-slider::before {
content: "";
position: absolute;
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background: white;
border-radius: 50%;
transition: 0.3s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
#photo_verification_enabled:checked + .toggle-slider::before { transform: translateX(24px); }
</style>
<script>
function syncPhotoVerifyHidden(checkbox) {
document.getElementById('photo_verification_hidden').value = checkbox.checked ? '1' : '0';
document.getElementById('photoVerifyLabel').textContent = checkbox.checked ? 'Enabled' : 'Disabled';
document.getElementById('photoVerifyLabel').style.color = checkbox.checked ? 'var(--success-color, #10b981)' : 'var(--error-color, #ef4444)';
}
// Remove duplicate hidden field on submit (keep only the one synced by checkbox)
document.addEventListener('DOMContentLoaded', function() {
var form = document.getElementById('createQRForm');
if (form) {
form.addEventListener('submit', function() {
var cb = document.getElementById('photo_verification_enabled');
document.getElementById('photo_verification_hidden').value = cb && cb.checked ? '1' : '0';
});
}
});
</script>
</body>
</html>
</html>"latitude" name="latitude" value="" />
<input type="hidden" id="longitude" name="longitude" value="" />
<input type="hidden" id="coordinate_accuracy" name="coordinate_accuracy" value="" />
<!-- Form Actions -->
<div class="form-actions">
<a href="{{ url_for('dashboard.dashboard') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i>
Cancel
</a>
<button type="button" class="btn btn-outline" onclick="resetToDefaults()">
<i class="fas fa-undo"></i>
Reset Styling
</button>
<button type="submit" class="btn btn-primary" id="submitBtn">
<i class="fas fa-save"></i>
Create QR Code
</button>
</div>
</form>
</div>
</div>
<script>
// Character counting functionality
function updateCharacterCount(inputId, counterId, maxLength) {
const input = document.getElementById(inputId);
const counter = document.getElementById(counterId);
if (input && counter) {
const length = input.value.length;
counter.textContent = `${length}/${maxLength}`;
if (length > maxLength * 0.9) {
counter.classList.add('warning');
} else {
counter.classList.remove('warning');
}
}
}
// QR Code Customization JavaScript Functions
function applyStylePreset() {
const styleSelect = document.getElementById('style_id');
const selectedOption = styleSelect.options[styleSelect.selectedIndex];
if (selectedOption.value) {
// Apply preset values
const fillColor = selectedOption.getAttribute('data-fill');
const backColor = selectedOption.getAttribute('data-back');
const boxSize = selectedOption.getAttribute('data-box-size');
const border = selectedOption.getAttribute('data-border');
const errorCorrection = selectedOption.getAttribute('data-error-correction');
// Update form inputs
document.getElementById('fill_color').value = fillColor;
document.getElementById('fill_color_text').value = fillColor;
document.getElementById('back_color').value = backColor;
document.getElementById('back_color_text').value = backColor;
document.getElementById('box_size').value = boxSize;
document.getElementById('border').value = border;
document.getElementById('error_correction').value = errorCorrection;
// Update range value displays
updateRangeValue('box_size');
updateRangeValue('border');
// Update preview
updatePreview();
// Show toast notification
showToast(`Applied "${selectedOption.text}" style preset`, 'success');
}
}
function syncColorInput(type) {
const colorInput = document.getElementById(`${type}_color`);
const textInput = document.getElementById(`${type}_color_text`);
let value = textInput.value.trim().toUpperCase();
// Add # if missing
if (!value.startsWith('#')) {
value = '#' + value;
}
// Validate hex format
if (/^#[0-9A-F]{6}$/i.test(value)) {
colorInput.value = value;
textInput.value = value;
updatePreview();
} else {
// Reset to previous valid value
textInput.value = colorInput.value;
showToast('Invalid color format. Use #RRGGBB format.', 'warning');
}
}
function updateRangeValue(inputId) {
const input = document.getElementById(inputId);
const valueDisplay = document.getElementById(`${inputId}_value`);
if (inputId === 'box_size') {
valueDisplay.textContent = `${input.value}px`;
} else if (inputId === 'border') {
valueDisplay.textContent = `${input.value} modules`;
}
}
function updatePreview() {
const fillColor = document.getElementById('fill_color').value;
const backColor = document.getElementById('back_color').value;
const boxSize = document.getElementById('box_size').value;
const border = document.getElementById('border').value;
// Update preview QR modules
const qrSample = document.getElementById('qr_sample');
const qrModules = qrSample.querySelectorAll('.qr-module:not(.empty)');
// Apply colors to QR modules
qrModules.forEach(module => {
module.style.backgroundColor = fillColor;
});
// Apply background color
qrSample.style.backgroundColor = backColor;
// Apply border (padding represents border)
const borderPx = Math.max(4, parseInt(border) * 2);
qrSample.style.padding = `${borderPx}px`;
// Apply module size effect (simulate with scale)
const scale = Math.max(0.7, Math.min(1.3, parseInt(boxSize) / 10));
qrSample.style.transform = `scale(${scale})`;
// Sync text inputs with color inputs
document.getElementById('fill_color_text').value = fillColor.toUpperCase();
document.getElementById('back_color_text').value = backColor.toUpperCase();
// Add animation class
const preview = document.getElementById('qr_preview');
preview.style.animation = 'none';
setTimeout(() => {
preview.style.animation = 'pulse 0.3s ease-in-out';
}, 10);
}
function resetToDefaults() {
document.getElementById('style_id').value = '';
document.getElementById('fill_color').value = '#000000';
document.getElementById('fill_color_text').value = '#000000';
document.getElementById('back_color').value = '#FFFFFF';
document.getElementById('back_color_text').value = '#FFFFFF';
document.getElementById('box_size').value = '10';
document.getElementById('border').value = '4';
document.getElementById('error_correction').value = 'L';
updateRangeValue('box_size');
updateRangeValue('border');
updatePreview();
showToast('Reset to default QR code styling', 'info');
}
function showToast(message, type = 'info') {
// Create toast notification
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `
<div class="toast-content">
<i class="fas ${getToastIcon(type)}"></i>
<span>${message}</span>
</div>
`;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 1rem;
z-index: 1000;
opacity: 0;
transform: translateX(100%);
transition: all 0.3s ease;
border-left: 4px solid ${getToastColor(type)};
max-width: 350px;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '1';
toast.style.transform = 'translateX(0)';
}, 100);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transform = 'translateX(100%)';
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, 3000);
}
function getToastIcon(type) {
const icons = {
success: 'fa-check-circle',
error: 'fa-exclamation-circle',
warning: 'fa-exclamation-triangle',
info: 'fa-info-circle'
};
return icons[type] || icons.info;
}
function getToastColor(type) {
const colors = {
success: '#059669',
error: '#dc2626',
warning: '#d97706',
info: '#0891b2'
};
return colors[type] || colors.info;
}
// Form validation
function validateQRCustomization() {
const fillColor = document.getElementById('fill_color_text').value;
const backColor = document.getElementById('back_color_text').value;
// Check if colors are too similar
if (fillColor.toLowerCase() === backColor.toLowerCase()) {
showToast('QR code color and background color cannot be the same', 'error');
return false;
}
// Check contrast (basic check)
if (getColorBrightness(fillColor) === getColorBrightness(backColor)) {
showToast('Warning: Low contrast may affect QR code readability', 'warning');
}
return true;
}
function getColorBrightness(hex) {
const r = parseInt(hex.substr(1, 2), 16);
const g = parseInt(hex.substr(3, 2), 16);
const b = parseInt(hex.substr(5, 2), 16);
return (r * 299 + g * 587 + b * 114) / 1000;
}
// Geocoding functionality - Enhanced version to match your original
let coordinatesData = {
latitude: null,
longitude: null,
accuracy: null
};
function geocodeAddress(address) {
showStatus('info', 'Getting coordinates...');
// Show loading state
const geocodeBtn = document.getElementById('geocodeBtn');
geocodeBtn.innerHTML = '<span class="loading-spinner"></span> Getting coordinates...';
geocodeBtn.disabled = true;
// Try your API first, then fallback to OSM
fetch('/api/geocode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': (window.qrConfig && window.qrConfig.csrfToken) || '',
},
body: JSON.stringify({ address: address })
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Update coordinates from your API
coordinatesData = {
latitude: data.data.latitude,
longitude: data.data.longitude,
accuracy: data.data.accuracy || 'geocoded'
};
updateCoordinatesDisplay();
updateHiddenFields();
showStatus('success', data.message || 'Coordinates found successfully');
showCoordinatesSection();
} else {
// Fallback to OpenStreetMap Nominatim API
fallbackToOSMGeocoding(address);
}
})
.catch(error => {
console.error('Primary geocoding error:', error);
// Fallback to OpenStreetMap Nominatim API
fallbackToOSMGeocoding(address);
})
.finally(() => {
// Reset button state
geocodeBtn.innerHTML = '<i class="fas fa-search-location"></i> Get Coordinates';
geocodeBtn.disabled = false;
});
}
function fallbackToOSMGeocoding(address) {
// Using OpenStreetMap Nominatim API as fallback when Google Maps fails
const encodedAddress = encodeURIComponent(address);
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodedAddress}&limit=1`;
fetch(url)
.then(response => response.json())
.then(data => {
if (data && data.length > 0) {
const result = data[0];
coordinatesData = {
latitude: parseFloat(result.lat),
longitude: parseFloat(result.lon),
accuracy: 'geocoded'
};
updateCoordinatesDisplay();
updateHiddenFields();
showStatus('success', 'Coordinates found successfully (OpenStreetMap fallback - Google Maps unavailable)');
showCoordinatesSection();
} else {
showStatus('warning', 'No coordinates found for this address');
}
})
.catch(error => {
console.error('Fallback geocoding error:', error);
showStatus('error', 'Failed to get coordinates. Please try again.');
});
}
function updateCoordinatesDisplay() {
const latDisplay = document.getElementById('latitudeDisplay');
const lngDisplay = document.getElementById('longitudeDisplay');
const clearBtn = document.getElementById('clearCoordinatesBtn');
if (coordinatesData.latitude && coordinatesData.longitude) {
latDisplay.textContent = coordinatesData.latitude.toFixed(10);
lngDisplay.textContent = coordinatesData.longitude.toFixed(10);
clearBtn.style.display = 'inline-flex';
// Update button text
document.getElementById('geocodeBtn').innerHTML = '<i class="fas fa-search-location"></i> Update Coordinates';
} else {
latDisplay.textContent = '---.----------';
lngDisplay.textContent = '---.----------';
clearBtn.style.display = 'none';
// Reset button text
document.getElementById('geocodeBtn').innerHTML = '<i class="fas fa-search-location"></i> Get Coordinates';
}
}
function updateHiddenFields() {
console.log('📝 Updating hidden coordinate fields');
// Update hidden form fields with coordinate data
const latitudeField = document.getElementById('latitude');
const longitudeField = document.getElementById('longitude');
const accuracyField = document.getElementById('coordinate_accuracy');
if (coordinatesData.latitude && coordinatesData.longitude) {
latitudeField.value = coordinatesData.latitude;
longitudeField.value = coordinatesData.longitude;
accuracyField.value = coordinatesData.accuracy || 'geocoded';
console.log('✓ Hidden fields updated:', {
latitude: latitudeField.value,
longitude: longitudeField.value,
accuracy: accuracyField.value,
});
} else {
// Clear fields if no coordinates
latitudeField.value = '';
longitudeField.value = '';
accuracyField.value = '';
console.log('✓ Hidden fields cleared');
}
}
function clearCoordinates() {
coordinatesData = {
latitude: null,
longitude: null,
accuracy: null
};
updateCoordinatesDisplay();
updateHiddenFields();
showStatus('info', 'Coordinates cleared');
}
function showCoordinatesSection() {
const section = document.getElementById('coordinatesSection');
if (section) {
section.style.display = 'block';
}
}
function showStatus(type, message) {
const statusDiv = document.getElementById('coordinateStatus');
if (statusDiv) {
statusDiv.innerHTML = `<div class="status-${type}" style="padding: 0.5rem; border-radius: 4px; margin-top: 0.5rem; font-size: 0.875rem;">${message}</div>`;
setTimeout(() => {
statusDiv.innerHTML = '';
}, 5000);
}
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', function () {
// Set up character counters
document.getElementById('name').addEventListener('input', () => updateCharacterCount('name', 'nameCounter', 100));
document.getElementById('location').addEventListener('input', () => updateCharacterCount('location', 'locationCounter', 100));
document.getElementById('location_address').addEventListener('input', () => updateCharacterCount('location_address', 'addressCounter', 500));
// Set up initial values
updateRangeValue('box_size');
updateRangeValue('border');
updatePreview();
// Add form validation
const form = document.getElementById('createQRForm');
if (form) {
form.addEventListener('submit', function (e) {
if (!validateQRCustomization()) {
e.preventDefault();
return false;
}
});
}
// Add color input synchronization
document.getElementById('fill_color').addEventListener('change', function () {
document.getElementById('fill_color_text').value = this.value.toUpperCase();
updatePreview();
});
document.getElementById('back_color').addEventListener('change', function () {
document.getElementById('back_color_text').value = this.value.toUpperCase();
updatePreview();
});
// Initialize geocoding functionality with automatic section showing
const addressInput = document.getElementById('location_address');
const coordinatesSection = document.getElementById('coordinatesSection');
const geocodeBtn = document.getElementById('geocodeBtn');
const clearBtn = document.getElementById('clearCoordinatesBtn');
// Show coordinates section when address has content (like your original)
addressInput.addEventListener('input', function () {
const address = this.value.trim();
if (address.length > 10) {
coordinatesSection.style.display = 'block';
} else {
coordinatesSection.style.display = 'none';
clearCoordinates();
}
});
geocodeBtn.addEventListener('click', function () {
const address = document.getElementById('location_address').value.trim();
if (address.length > 10) {
geocodeAddress(address);
} else {
showStatus('error', 'Please enter a complete address first');
}
});
clearBtn.addEventListener('click', clearCoordinates);
});
</script>
<!-- ===== Dynamic QR Type Toggle ===== -->
<script>
function toggleQRTypeSection() {
var type = document.getElementById('qr_type').value;
var stdName = document.getElementById('standardLocationNameGroup');
var stdDetails = document.getElementById('standardLocationDetailsSection');
var locInput = document.getElementById('location');
var addrInput = document.getElementById('location_address');
if (type === 'dynamic') {
if (stdName) stdName.style.display = 'none';
if (stdDetails) stdDetails.style.display = 'none';
if (locInput) locInput.removeAttribute('required');
if (addrInput) addrInput.removeAttribute('required');
} else {
if (stdName) stdName.style.display = 'block';
if (stdDetails) stdDetails.style.display = 'block';
if (locInput) locInput.setAttribute('required', '');
if (addrInput) addrInput.setAttribute('required', '');
}
}
</script>
<!-- ===== END Dynamic QR Type Toggle ===== -->
<style>
/* Photo Verification Toggle */
#photo_verification_enabled:checked+.toggle-slider {
background: var(--success-color, #10b981);
}
.toggle-slider::before {
content: "";
position: absolute;
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background: white;
border-radius: 50%;
transition: 0.3s;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
#photo_verification_enabled:checked+.toggle-slider::before {
transform: translateX(24px);
}
</style>
<script>
function syncPhotoVerifyHidden(checkbox) {
document.getElementById('photo_verification_hidden').value = checkbox.checked ? '1' : '0';
document.getElementById('photoVerifyLabel').textContent = checkbox.checked ? 'Enabled' : 'Disabled';
document.getElementById('photoVerifyLabel').style.color = checkbox.checked ? 'var(--success-color, #10b981)' : 'var(--error-color, #ef4444)';
}
// Remove duplicate hidden field on submit (keep only the one synced by checkbox)
document.addEventListener('DOMContentLoaded', function () {
var form = document.getElementById('createQRForm');
if (form) {
form.addEventListener('submit', function () {
var cb = document.getElementById('photo_verification_enabled');
document.getElementById('photo_verification_hidden').value = cb && cb.checked ? '1' : '0';
});
}
});
</script>
</body>
</html>
+72 -7
View File
@@ -2,6 +2,23 @@
{% block title %}Edit QR Code - QR Management System{% endblock %}
{% block extra_head %}
<style>
/* Photo Verification Toggle */
#photo_verification_enabled:checked + .toggle-slider { background: var(--success-color, #10b981); }
.toggle-slider::before {
content: "";
position: absolute;
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background: white;
border-radius: 50%;
transition: 0.3s;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
#photo_verification_enabled:checked + .toggle-slider::before { transform: translateX(24px); }
</style>
<style>
:root {
--primary-color: #2563eb;
@@ -630,7 +647,10 @@
<div class="form-group">
<label for="name">
<i class="fas fa-tag"></i>
QR Code Name <span style="color: var(--error-color)">*</span>
QR Code Name
<span style="color: var(--secondary-color); font-size: 0.75rem; font-weight: 400; margin-left: 0.4rem;">
<i class="fas fa-lock"></i> Cannot be changed
</span>
</label>
<input
type="text"
@@ -641,13 +661,13 @@
data-original="{{ qr_code.name }}"
placeholder="e.g., Main Office Entrance, Conference Room A"
maxlength="100"
readonly
style="background: var(--gray-100, #f1f5f9); cursor: not-allowed; color: var(--gray-700, #334155);"
/>
<small class="form-help"
>A unique name to identify this QR code</small
>
<div class="character-counter">
<span id="nameCounter">{{ qr_code.name|length }}/100</span>
</div>
<small class="form-help">
<i class="fas fa-info-circle"></i>
QR code name cannot be changed after creation as it is linked to the QR URL.
</small>
</div>
<!-- ===== ADDED: QR Code Type selector ===== -->
@@ -834,6 +854,35 @@
</div>
<!-- Photo Verification Toggle -->
<div class="form-section">
<div class="section-header">
<h3>
<i class="fas fa-camera"></i>
Photo Verification
</h3>
<p>Require a photo when employee check-in location is too far from the QR code</p>
</div>
<div class="form-group">
<label class="toggle-label" for="photo_verification_enabled" style="display:flex;align-items:center;gap:0.75rem;cursor:pointer;">
<div class="toggle-switch" id="photoVerifyToggle" style="position:relative;display:inline-block;width:52px;height:28px;">
<input type="checkbox" id="photo_verification_enabled" name="photo_verification_enabled" value="1"
{% if qr_code.photo_verification_enabled != False %}checked{% endif %}
style="opacity:0;width:0;height:0;position:absolute;">
<span class="toggle-slider" style="position:absolute;cursor:pointer;inset:0;background:#cbd5e1;border-radius:28px;transition:0.3s;"></span>
</div>
<span id="photoVerifyLabel" style="font-weight:600;font-size:0.875rem;
{% if qr_code.photo_verification_enabled != False %}color:var(--success-color,#10b981){% else %}color:var(--error-color,#ef4444){% endif %}">
{% if qr_code.photo_verification_enabled != False %}Enabled{% else %}Disabled{% endif %}
</span>
</label>
<input type="hidden" name="photo_verification_enabled" id="photo_verification_hidden"
value="{% if qr_code.photo_verification_enabled != False %}1{% else %}0{% endif %}">
<small class="form-help">When disabled, photo verification is skipped for this QR code regardless of distance</small>
</div>
</div>
<!-- QR Code Customization Section -->
<div class="form-section">
<div class="section-header">
@@ -1509,4 +1558,20 @@
});
</script>
<!-- ===== END Dynamic QR Type Toggle ===== -->
<script>
// Photo Verification Toggle
document.addEventListener('DOMContentLoaded', function() {
var cb = document.getElementById('photo_verification_enabled');
var hidden = document.getElementById('photo_verification_hidden');
var label = document.getElementById('photoVerifyLabel');
if (cb) {
cb.addEventListener('change', function() {
hidden.value = cb.checked ? '1' : '0';
label.textContent = cb.checked ? 'Enabled' : 'Disabled';
label.style.color = cb.checked ? 'var(--success-color, #10b981)' : 'var(--error-color, #ef4444)';
});
}
});
</script>
<!-- ===== END Photo Verification Toggle ===== -->
{% endblock %}
@@ -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') }} &ndash; {{ stats.latest_record.strftime('%m/%d/%Y') }}
{% else %}
&mdash;
{% 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 &mdash;
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 %}
+262
View File
@@ -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()
@@ -0,0 +1,122 @@
"""
migration_photo_verification_toggle.py
=======================================
Adds `photo_verification_enabled` column to the `qr_codes` table.
Uses pymysql directly to avoid SQLAlchemy ORM loading the model
(which would fail if the column doesn't exist yet).
Default: 1 (True) for all existing rows preserves current behaviour.
Run once on each server (LT and GOV):
python3 tools/migration_photo_verification_toggle.py
Safe to re-run skips if column already exists.
"""
import os, sys, re
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from dotenv import load_dotenv
load_dotenv()
import pymysql
TABLE = 'qr_codes'
COLUMN = 'photo_verification_enabled'
def parse_db_url(url):
"""
Parse DATABASE_URL robustly using regex to handle special characters
(including @ or : ) in the password.
Supports:
mysql+pymysql://user:pass@host:port/dbname
mysql+pymysql://user:pass@host/dbname
"""
# Strip driver prefix
url = re.sub(r'^mysql\+pymysql://', '', url)
url = re.sub(r'^mysql://', '', url)
# Split credentials from host/db on the LAST @ before the host
# Pattern: user:password@host[:port]/dbname[?...]
m = re.match(
r'^(?P<user>[^:]+):(?P<password>.+)@(?P<host>[^@:/]+)(?::(?P<port>\d+))?/(?P<db>[^?]+)',
url
)
if not m:
print(f"[ERROR] Could not parse DATABASE_URL. Raw (redacted): {url[:30]}...")
sys.exit(1)
return {
'host': m.group('host'),
'port': int(m.group('port')) if m.group('port') else 3306,
'user': m.group('user'),
'password': m.group('password'),
'database': m.group('db'),
}
def get_connection():
db_url = os.environ.get('DATABASE_URL', '')
if not db_url:
print("[ERROR] DATABASE_URL not set in .env")
sys.exit(1)
params = parse_db_url(db_url)
return pymysql.connect(
host=params['host'],
port=params['port'],
user=params['user'],
password=params['password'],
database=params['database'],
charset='utf8mb4',
autocommit=False,
)
def run():
conn = get_connection()
try:
with conn.cursor() as cur:
# Check if column already exists
cur.execute(
"SELECT COUNT(*) FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
f"AND TABLE_NAME = '{TABLE}' "
f"AND COLUMN_NAME = '{COLUMN}'"
)
exists = cur.fetchone()[0] > 0
if exists:
print(f"[SKIP] Column '{COLUMN}' already exists on '{TABLE}'. Nothing to do.")
return
print(f"[ADD] Adding column '{COLUMN}' to '{TABLE}' ...")
cur.execute(
f"ALTER TABLE `{TABLE}` "
f"ADD COLUMN `{COLUMN}` TINYINT(1) NOT NULL DEFAULT 1"
)
conn.commit()
# Verify
cur.execute(
"SELECT COUNT(*) FROM information_schema.COLUMNS "
"WHERE TABLE_SCHEMA = DATABASE() "
f"AND TABLE_NAME = '{TABLE}' "
f"AND COLUMN_NAME = '{COLUMN}'"
)
if cur.fetchone()[0] > 0:
print(f"[OK] Column '{COLUMN}' added. All existing rows default to 1 (enabled).")
else:
print(f"[FAIL] Column was not created — check DB permissions.")
sys.exit(1)
finally:
conn.close()
if __name__ == '__main__':
run()
+7 -8
View File
@@ -1,4 +1,3 @@
"""
utils/helpers.py
================
@@ -17,7 +16,7 @@ from datetime import datetime, date, time, timedelta
from functools import wraps
import qrcode
from flask import session, redirect, flash, request
from flask import session, redirect, flash, request, url_for
from user_agents import parse
from extensions import logger_handler
@@ -141,7 +140,7 @@ def login_required(f):
def decorated_function(*args, **kwargs):
if 'user_id' not in session:
flash('Please log in to access this page.', 'error')
return redirect(url_for('login'))
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
@@ -152,11 +151,11 @@ def admin_required(f):
def decorated_function(*args, **kwargs):
if 'username' not in session:
flash('Please log in to access this page.', 'error')
return redirect(url_for('login'))
return redirect(url_for('auth.login'))
user_role = session.get('role')
if not has_admin_privileges(user_role):
flash('Administrator privileges required for this action.', 'error')
return redirect(url_for('dashboard'))
return redirect(url_for('dashboard.dashboard'))
return f(*args, **kwargs)
return decorated_function
@@ -167,11 +166,11 @@ def staff_or_admin_required(f):
def decorated_function(*args, **kwargs):
if 'username' not in session:
flash('Please log in to access this page.', 'error')
return redirect(url_for('login'))
return redirect(url_for('auth.login'))
user_role = session.get('role')
if not (has_admin_privileges(user_role) or has_staff_level_access(user_role)):
flash('Insufficient privileges to access this page.', 'error')
return redirect(url_for('dashboard'))
return redirect(url_for('dashboard.dashboard'))
return f(*args, **kwargs)
return decorated_function
@@ -372,4 +371,4 @@ def format_time_interval(minutes):
if remaining_hours == 0:
return f"{days} day{'s' if days != 1 else ''}"
else:
return f"{days}d {remaining_hours}h"
return f"{days}d {remaining_hours}h"