Compare commits
12
Commits
a4405e2aa0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7655810d8 | ||
|
|
24d48f5d34 | ||
|
|
38a899d6f9 | ||
|
|
f81e3cea7f | ||
|
|
0a85fa8fe3 | ||
|
|
eb991bd645 | ||
|
|
023b2fdf46 | ||
|
|
74e0b71a11 | ||
|
|
5e0c014ca7 | ||
|
|
9bded4ed1f | ||
|
|
f591384b12 | ||
|
|
1960e84d4e |
@@ -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 ' *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -207,5 +207,3 @@ cython_debug/
|
||||
marimo/_static/
|
||||
marimo/_lsp/
|
||||
__marimo__/
|
||||
README.md
|
||||
Claude.md
|
||||
@@ -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 1–8 — Foundational Work
|
||||
See §20 Sets 1–8 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
|
||||
@@ -0,0 +1,101 @@
|
||||
# Legacy Attendance — Deployment Package
|
||||
|
||||
Adds a read-only "Legacy Attendance" sidebar item: dashboard + records list +
|
||||
Excel export, sourced LIVE from the OLD remote MySQL server
|
||||
(`contract` / `employee` / `locations` / `records` tables). No import, no
|
||||
writes to the remote DB, nothing copied into the local database.
|
||||
|
||||
Apply this same package to **both LT and GOV** (each server uses its own
|
||||
`REMOTE_DB_*` values in its own `.env`).
|
||||
|
||||
---
|
||||
|
||||
## 1. Files in this package
|
||||
|
||||
### NEW files (just drop in — no existing file touched)
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `legacy_attendance_service.py` | Read-only pymysql data access to the legacy DB (queries, pagination, Excel builder) |
|
||||
| `routes/legacy_attendance.py` | Blueprint `legacy_attendance` — dashboard, records, export routes |
|
||||
| `templates/legacy_attendance_dashboard.html` | Dashboard page |
|
||||
| `templates/legacy_attendance_records.html` | Records list (matches the Time Attendance table layout) |
|
||||
| `tools/migration_legacy_attendance_remote_indexes.py` | One-time index migration for the REMOTE legacy DB |
|
||||
|
||||
### MODIFIED files (replace existing)
|
||||
| File | What changed |
|
||||
|------|--------------|
|
||||
| `config.py` | Added `REMOTE_DB_HOST/PORT/USERNAME/PASSWORD/NAME` to `Config` |
|
||||
| `app.py` | Registered the `legacy_attendance` blueprint |
|
||||
| `templates/base_authenticated.html` | Added "Legacy Attendance" sidebar link (admin + payroll/accounting sections) |
|
||||
|
||||
Nothing was removed or renamed. Existing routes/functions/variables untouched.
|
||||
|
||||
---
|
||||
|
||||
## 2. .env additions (BOTH servers)
|
||||
|
||||
Add these to `.env` on each server, using that server's own legacy DB
|
||||
credentials:
|
||||
|
||||
```
|
||||
# Remote MySQL Server Configuration (Source — legacy attendance)
|
||||
REMOTE_DB_HOST=xxx.xxx.xxx.xxx
|
||||
REMOTE_DB_PORT=3306
|
||||
REMOTE_DB_USERNAME=xxx
|
||||
REMOTE_DB_PASSWORD=xxx
|
||||
REMOTE_DB_NAME=xxx
|
||||
```
|
||||
|
||||
If these are left blank, the Legacy Attendance pages show a clean
|
||||
"legacy database is not configured" flash message instead of erroring.
|
||||
|
||||
---
|
||||
|
||||
## 3. One-time index migration (BOTH servers)
|
||||
|
||||
The legacy schema ships with no useful indexes, which makes the live queries
|
||||
full-scan and can trip the gunicorn worker timeout at scale. Run once per
|
||||
server (safe to re-run — skips indexes that already exist; only adds indexes,
|
||||
never touches data):
|
||||
|
||||
```
|
||||
python3 tools/migration_legacy_attendance_remote_indexes.py
|
||||
```
|
||||
|
||||
Expected first-run output: `[ADD]` lines for 6 indexes on
|
||||
`records` / `employee` / `locations`, then `[DONE]`.
|
||||
|
||||
> Note: on a fresh legacy DB dump these indexes may already be present
|
||||
> (a recent dump already includes them). In that case every line prints
|
||||
> `[SKIP]` — that's fine.
|
||||
|
||||
---
|
||||
|
||||
## 4. Deploy steps (per server)
|
||||
|
||||
1. Copy the NEW files into place.
|
||||
2. Replace the 3 MODIFIED files.
|
||||
3. Add the `REMOTE_DB_*` block to `.env`.
|
||||
4. `python3 tools/migration_legacy_attendance_remote_indexes.py`
|
||||
5. Restart gunicorn.
|
||||
6. Log in, open **Legacy Attendance** in the sidebar, confirm the dashboard
|
||||
stats populate and the records list shows Location Name + Event Description.
|
||||
|
||||
No local DB migration is required — this feature reads the remote DB only.
|
||||
|
||||
---
|
||||
|
||||
## 5. Key facts worth remembering
|
||||
|
||||
- `records.locationId` holds the numeric **`locations.index`** (NOT the
|
||||
location name). The join is
|
||||
`locations.index = CAST(records.locationId AS UNSIGNED)`.
|
||||
- `records.employeeId` is varchar; joined as
|
||||
`employee.id = CAST(records.employeeId AS UNSIGNED)`.
|
||||
- `records.type` values are `CHECK IN` / `CHECK OUT`.
|
||||
- Records list column mapping:
|
||||
ID=`employeeId`, Name=`Last, First`, Platform=`Manual`/`—` (from `isManual`),
|
||||
Date/Time from `time`, Location Name=`locations.location`,
|
||||
Action Description=`type` badge, Event Description=`locations.address`,
|
||||
Recorded Address=`records.recordedAddress`.
|
||||
- Read-only by design: no detail page, no delete (so no Actions column).
|
||||
@@ -91,10 +91,11 @@ def create_app() -> Flask:
|
||||
from routes.statistics import bp as statistics_bp
|
||||
from routes.employees import bp as employees_bp
|
||||
from routes.time_attendance import bp as time_attendance_bp
|
||||
from routes.legacy_attendance import bp as legacy_attendance_bp
|
||||
|
||||
for bp in (auth_bp, dashboard_bp, users_bp, admin_bp, projects_bp,
|
||||
qr_codes_bp, attendance_bp, statistics_bp,
|
||||
employees_bp, time_attendance_bp):
|
||||
employees_bp, time_attendance_bp, legacy_attendance_bp):
|
||||
app.register_blueprint(bp)
|
||||
|
||||
# Register location-logging routes (from location_logging.py)
|
||||
@@ -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,16 +314,58 @@ def create_app() -> Flask:
|
||||
# ------------------------------------------------------------------
|
||||
# Startup initialization (runs under gunicorn and flask run alike)
|
||||
# ------------------------------------------------------------------
|
||||
_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:
|
||||
from extensions import logger_handler as _startup_lh
|
||||
_startup_lh.logger.error(f"Startup initialization failed: {e}", exc_info=True)
|
||||
raise
|
||||
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)
|
||||
|
||||
return app
|
||||
_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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
+17
-4
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-29
@@ -1,38 +1,29 @@
|
||||
<!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') }}"
|
||||
/>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}" />
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
|
||||
<!-- Theme override — loaded before page-specific CSS so per-page sheets
|
||||
(e.g. auth.css) take cascade precedence unless theme uses !important -->
|
||||
<!-- 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" />
|
||||
|
||||
<!-- Page-specific CSS -->
|
||||
{% block extra_head %}{% endblock %}
|
||||
|
||||
<!-- Favicon -->
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/x-icon"
|
||||
href="{{ url_for('static', filename='images/favicon.ico') }}"
|
||||
/>
|
||||
<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">
|
||||
@@ -43,13 +34,9 @@
|
||||
{% 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>
|
||||
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'"
|
||||
>
|
||||
<button class="alert-close" onclick="this.parentElement.style.display='none'">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -65,7 +52,7 @@
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<p>© 2025 QR Code Management System. All rights reserved.</p>
|
||||
<p>© {{ CURRENT_YEAR }} {{ COMPANY_NAME }}. All rights reserved.</p>
|
||||
<div class="footer-links">
|
||||
<a href="#" class="footer-link">Privacy Policy</a>
|
||||
<a href="#" class="footer-link">Terms of Service</a>
|
||||
@@ -93,11 +80,8 @@
|
||||
</script>
|
||||
{% if turnstile_enabled %}
|
||||
<!-- Cloudflare Turnstile -->
|
||||
<script
|
||||
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
|
||||
async
|
||||
defer
|
||||
></script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
|
||||
{% endif %}
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,29 +1,40 @@
|
||||
<!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') }}" />
|
||||
<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') }}" />
|
||||
<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" />
|
||||
<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 %}
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}" />
|
||||
<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">
|
||||
@@ -51,8 +62,10 @@
|
||||
<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 %}">
|
||||
<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>
|
||||
@@ -78,13 +91,22 @@
|
||||
<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 %}">
|
||||
<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 %}">
|
||||
<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>
|
||||
@@ -107,6 +129,11 @@
|
||||
<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">
|
||||
@@ -173,9 +200,13 @@
|
||||
{% 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>
|
||||
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'">
|
||||
<button
|
||||
class="alert-close"
|
||||
onclick="this.parentElement.style.display='none'"
|
||||
>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -206,10 +237,7 @@
|
||||
<!-- Main JavaScript -->
|
||||
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
||||
|
||||
<!-- Page-specific JavaScript -->
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
|
||||
<!-- Global JavaScript Variables -->
|
||||
<!-- Global JavaScript Variables — must come BEFORE extra_scripts so csrfToken is available -->
|
||||
<script>
|
||||
// Pass Flask session data to JavaScript
|
||||
window.qrConfig = {
|
||||
@@ -219,6 +247,8 @@
|
||||
csrfToken: '{{ csrf_token() if csrf_token else '' }}'
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
|
||||
<!-- Page-specific JavaScript -->
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -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>"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>
|
||||
@@ -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') }} – {{ stats.latest_record.strftime('%m/%d/%Y') }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</h3>
|
||||
<p>Date Range</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
</div>
|
||||
<h3>This page reads live from the legacy database</h3>
|
||||
<p>
|
||||
Records are queried directly from the old attendance system on each visit —
|
||||
nothing is imported or stored locally. Use
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_records') }}">View All Records</a>
|
||||
to search, filter, and export.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,262 @@
|
||||
{% extends "base_authenticated.html" %}
|
||||
{% block title %}Legacy Attendance Records - {{ COMPANY_NAME }}{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/time_attendance.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="time-attendance-page">
|
||||
<!-- Page Header -->
|
||||
<div class="time-attendance-header">
|
||||
<div class="header-content">
|
||||
<h1>
|
||||
<i class="fas fa-history"></i>
|
||||
Legacy Attendance Records
|
||||
</h1>
|
||||
<p class="header-description">Live results from the legacy database</p>
|
||||
</div>
|
||||
|
||||
<div class="header-actions">
|
||||
<a href="{{ url_for('legacy_attendance.export_legacy_attendance', **filters) }}" class="btn btn-secondary">
|
||||
<i class="fas fa-file-excel"></i>
|
||||
Export to Excel
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filter-section">
|
||||
<div class="filter-header">
|
||||
<h3><i class="fas fa-filter"></i> Filters</h3>
|
||||
</div>
|
||||
<div class="filter-body">
|
||||
<form method="GET" class="filter-form">
|
||||
<div class="form-group">
|
||||
<label for="employee_search">
|
||||
<i class="fas fa-user-search"></i>
|
||||
Employee (ID or Name)
|
||||
</label>
|
||||
<input type="text"
|
||||
id="employee_search"
|
||||
name="employee_search"
|
||||
class="form-input"
|
||||
placeholder="Search by ID or name..."
|
||||
value="{{ filters.employee_search }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="location">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
Location
|
||||
</label>
|
||||
<select id="location" name="location" class="form-select">
|
||||
<option value="">All Locations</option>
|
||||
{% for loc in unique_locations %}
|
||||
<option value="{{ loc }}" {% if filters.location == loc %}selected{% endif %}>{{ loc }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="record_type">
|
||||
<i class="fas fa-tag"></i>
|
||||
Type
|
||||
</label>
|
||||
<select id="record_type" name="record_type" class="form-select">
|
||||
<option value="">All Types</option>
|
||||
{% for rt in record_types %}
|
||||
<option value="{{ rt }}" {% if filters.record_type == rt %}selected{% endif %}>{{ rt }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="start_date">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
Start Date
|
||||
</label>
|
||||
<input type="date" id="start_date" name="start_date" class="form-input" value="{{ filters.start_date }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="end_date">
|
||||
<i class="fas fa-calendar-alt"></i>
|
||||
End Date
|
||||
</label>
|
||||
<input type="date" id="end_date" name="end_date" class="form-input" value="{{ filters.end_date }}">
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<i class="fas fa-search"></i>
|
||||
Apply Filters
|
||||
</button>
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_records') }}" class="btn btn-outline">
|
||||
<i class="fas fa-times"></i>
|
||||
Clear
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Records Table -->
|
||||
<div class="records-section">
|
||||
<div class="records-header">
|
||||
<h2><i class="fas fa-table"></i> Attendance Records</h2>
|
||||
<div class="records-meta">
|
||||
{% if records and records.items %}
|
||||
Showing {{ records.per_page * (records.page - 1) + 1 }} -
|
||||
{{ records.per_page * (records.page - 1) + records.items|length }}
|
||||
of {{ records.total }} records
|
||||
{% else %}
|
||||
No records found
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if records and records.items %}
|
||||
<div class="records-table-container">
|
||||
<table class="records-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Platform</th>
|
||||
<th>Date</th>
|
||||
<th>Time</th>
|
||||
<th>Location Name</th>
|
||||
<th>Action Description</th>
|
||||
<th>Event Description</th>
|
||||
<th>Recorded Address</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for record in records.items %}
|
||||
<tr>
|
||||
<!-- 1. ID (Employee ID) -->
|
||||
<td>
|
||||
<div class="employee-id-cell">
|
||||
<span class="employee-id">{{ record.employee_id }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- 2. Name -->
|
||||
<td>
|
||||
<div class="employee-name-cell">
|
||||
<span>{{ record.resolved_employee_name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- 3. Platform -->
|
||||
<td class="platform-cell">
|
||||
{{ 'Manual' if record.is_manual else '—' }}
|
||||
</td>
|
||||
|
||||
<!-- 4. Date -->
|
||||
<td class="date-cell">
|
||||
{{ record.record_time.strftime('%Y-%m-%d') if record.record_time else '' }}
|
||||
</td>
|
||||
|
||||
<!-- 5. Time -->
|
||||
<td class="time-cell">
|
||||
{{ record.record_time.strftime('%H:%M:%S') if record.record_time else '' }}
|
||||
</td>
|
||||
|
||||
<!-- 6. Location Name -->
|
||||
<td class="location-cell">
|
||||
<div class="location-info">
|
||||
<i class="fas fa-map-marker-alt"></i>
|
||||
{{ record.location_name if record.location_name else '-' }}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<!-- 7. Action Description -->
|
||||
<td>
|
||||
<span class="action-badge {{ record.record_type.lower().replace(' ', '-') }}">
|
||||
{% if record.record_type.lower() == 'check in' %}
|
||||
<i class="fas fa-sign-in-alt"></i>
|
||||
{% elif record.record_type.lower() == 'check out' %}
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-clock"></i>
|
||||
{% endif %}
|
||||
{{ record.record_type }}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<!-- 8. Event Description -->
|
||||
<td class="event-cell">
|
||||
{{ record.location_address if record.location_address else '-' }}
|
||||
</td>
|
||||
|
||||
<!-- 9. Recorded Address -->
|
||||
<td class="address-cell">
|
||||
{% if record.recorded_address %}
|
||||
<span title="{{ record.recorded_address }}">
|
||||
{{ record.recorded_address[:40] }}{% if record.recorded_address|length > 40 %}...{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">No address</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if records.pages > 1 %}
|
||||
<div class="pagination">
|
||||
<div class="pagination-info">
|
||||
Page {{ records.page }} of {{ records.pages }}
|
||||
</div>
|
||||
<div class="pagination-controls">
|
||||
{% if records.has_prev %}
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_records', page=records.prev_num, **filters) }}"
|
||||
class="pagination-btn">
|
||||
<i class="fas fa-chevron-left"></i>
|
||||
Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in records.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != records.page %}
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_records', page=page_num, **filters) }}"
|
||||
class="pagination-btn">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="pagination-btn active">{{ page_num }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="pagination-ellipsis">...</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if records.has_next %}
|
||||
<a href="{{ url_for('legacy_attendance.legacy_attendance_records', page=records.next_num, **filters) }}"
|
||||
class="pagination-btn">
|
||||
Next
|
||||
<i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">
|
||||
<i class="fas fa-folder-open"></i>
|
||||
</div>
|
||||
<h3>No legacy attendance records found</h3>
|
||||
<p>Try adjusting your filters.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
migration_legacy_attendance_remote_indexes.py
|
||||
================================================
|
||||
Adds missing indexes to the REMOTE legacy database (the one described by
|
||||
QrCodeLtServices.sql — contract / employee / locations / records) so the
|
||||
Legacy Attendance feature's live queries don't full-table-scan on every
|
||||
page load.
|
||||
|
||||
As shipped, that schema has NO index on:
|
||||
records.employeeId, records.locationId, records.time, records.contractId
|
||||
employee.id (only the meaningless auto-increment `index` is indexed)
|
||||
locations.location
|
||||
|
||||
Adding an index does not change or risk any existing data — it only
|
||||
speeds up reads. Safe to re-run: each ALTER is skipped if the index
|
||||
already exists.
|
||||
|
||||
Uses pymysql directly (never SQLAlchemy ORM), consistent with every other
|
||||
migration script in tools/ — this connects to REMOTE_DB_* (the legacy
|
||||
server), not the app's own local database.
|
||||
|
||||
Run once per server (LT and GOV each point at their own legacy DB):
|
||||
python3 tools/migration_legacy_attendance_remote_indexes.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
import pymysql
|
||||
|
||||
# (table, column, index_name)
|
||||
INDEXES_TO_ADD = [
|
||||
('records', 'employeeId', 'idx_records_employeeId'),
|
||||
('records', 'locationId', 'idx_records_locationId'),
|
||||
('records', 'time', 'idx_records_time'),
|
||||
('records', 'contractId', 'idx_records_contractId'),
|
||||
('employee', 'id', 'idx_employee_id'),
|
||||
('locations', 'location', 'idx_locations_location'),
|
||||
]
|
||||
|
||||
|
||||
def get_connection():
|
||||
host = os.environ.get('REMOTE_DB_HOST', '')
|
||||
port = int(os.environ.get('REMOTE_DB_PORT', '3306'))
|
||||
user = os.environ.get('REMOTE_DB_USERNAME', '')
|
||||
password = os.environ.get('REMOTE_DB_PASSWORD', '')
|
||||
database = os.environ.get('REMOTE_DB_NAME', '')
|
||||
|
||||
if not host or not database:
|
||||
print("[ERROR] REMOTE_DB_HOST / REMOTE_DB_NAME not set in .env — aborting.")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[INFO] Connecting to legacy DB {user}@{host}:{port}/{database} ...")
|
||||
return pymysql.connect(
|
||||
host=host, port=port, user=user, password=password, database=database,
|
||||
charset='utf8mb4', connect_timeout=10
|
||||
)
|
||||
|
||||
|
||||
def index_exists(cursor, table, index_name):
|
||||
cursor.execute("""
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND INDEX_NAME = %s
|
||||
""", (table, index_name))
|
||||
return cursor.fetchone()[0] > 0
|
||||
|
||||
|
||||
def main():
|
||||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for table, column, index_name in INDEXES_TO_ADD:
|
||||
if index_exists(cur, table, index_name):
|
||||
print(f"[SKIP] {table}.{index_name} already exists")
|
||||
continue
|
||||
print(f"[ADD] {table}.{index_name} ON ({column}) ...")
|
||||
cur.execute(f"ALTER TABLE `{table}` ADD INDEX `{index_name}` (`{column}`)")
|
||||
conn.commit()
|
||||
print(f"[OK] {table}.{index_name} created")
|
||||
print("[DONE] Legacy database indexes are up to date.")
|
||||
except pymysql.MySQLError as e:
|
||||
print(f"[ERROR] {e}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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()
|
||||
+6
-7
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user