1276 lines
69 KiB
Markdown
1276 lines
69 KiB
Markdown
# 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; carries the work-type code (`1234SP`) — see §11 |
|
||
| `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` | |
|
||
| `/qr/<url>/last-work-type` | `qr_codes.qr_last_work_type` | GET, public — work type of the employee's open check-in |
|
||
|
||
### 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)
|
||
- **Remember Me** → permanent cookie, 30 days (`PERMANENT_SESSION_LIFETIME`), renewed on each request.
|
||
**Without it** → browser-session cookie; `before_request` hook `adjust_session_lifetime()` clears
|
||
the session 10 hours after `session['login_epoch']` (set in `auth.login`)
|
||
- **Never assign `app.permanent_session_lifetime` per request, and never lower
|
||
`PERMANENT_SESSION_LIFETIME` below 30 days.** Flask checks every session cookie's age against
|
||
it in `open_session()` *before* any `before_request` hook, and the value is shared by the whole
|
||
worker — setting it to 10 h for anonymous traffic (QR scans) logged Remember Me users out (Set 19)
|
||
- `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.
|
||
|
||
### Type of Work — Check-In Dropdown (Sept 2026)
|
||
|
||
The check-in page carries a **Type of Work / Tipo de Trabajo** `<select>` directly after
|
||
the Employee ID field. The employee enters a **numeric-only** ID and picks the type;
|
||
`Regular` is selected by default.
|
||
|
||
| Option value | Label shown (bilingual, one plain string) |
|
||
|---|---|
|
||
| `""` | Regular / Trabajo Regular |
|
||
| `PW` | Periodic Work / Trabajo Periódico (PW) |
|
||
| `SP` | Special Project / Proyecto Especial (SP) |
|
||
| `C` | Covering / Cobertura (C) |
|
||
|
||
`<option>` cannot hold the coloured `english-text` / `spanish-text` spans, so both
|
||
languages share one label separated by `/`.
|
||
|
||
**Storage — no new column.** `qr_checkin` appends the code to the numeric ID, keeping
|
||
the format every calculator and export already parses:
|
||
|
||
```python
|
||
work_type = request.form.get('work_type', '').strip().upper() # '' = Regular
|
||
if work_type and work_type not in VALID_CHECKIN_WORK_TYPES: # ('SP','PW','PT','C')
|
||
return jsonify({...}), 400 # reject unknown codes
|
||
if employee_id and work_type:
|
||
base_employee_id, existing = parse_employee_id_for_work_type(employee_id)
|
||
employee_id = f"{base_employee_id}{work_type}" # never "1234SPSP"
|
||
```
|
||
|
||
`PT` is accepted server-side for backward compatibility but is **not** offered in the
|
||
dropdown. `WORK_TYPE_LABELS` + `_work_type_label()` in `routes/qr_codes.py` are the
|
||
source of the bilingual labels echoed back to the page.
|
||
|
||
**Employee ID is numeric only** — `inputmode="numeric"`, `pattern="[0-9]*"`, an `input`
|
||
listener stripping non-digits (paste / autofill), a re-strip at submit, and a
|
||
digits-only guard before the POST. A `localStorage` value stored before this rule
|
||
(e.g. `1234SP`) is cleaned to `1234` on auto-fill. There is **no** `maxlength` cap —
|
||
capping at 4 would break any 5-digit employee ID.
|
||
|
||
### Type of Work — Anti-Mistake Measures (Sept 2026)
|
||
|
||
**1. ~~Work type echoed on the submit button~~ — REMOVED (Sept 2026).**
|
||
The button briefly carried a second line naming the selected type; it broke the button
|
||
layout and was reverted at the user's request. The label is plain `Check In / Entrada` or
|
||
`Check Out / Salida` again. **Do not re-add it.** `renderSubmitButton()` survives as the
|
||
single place that builds the button markup, so `resetEnhancedSubmitButton()` does not
|
||
duplicate it — it still returns early while the button is `disabled`, so it cannot
|
||
clobber the "Processing" spinner. Confirmation of the type is measures 2 and 3 only.
|
||
|
||
**2. The success card shows what was stored.**
|
||
A **Type of Work / Tipo de Trabajo** row sits under Employee ID. Its value comes from the
|
||
check-in response (`data.work_type`, `data.work_type_label`), not the form, so a wrong
|
||
pick is visible immediately. Both renderers fill it: the inline one in
|
||
`qr_destination.html` and `handleCheckinSuccess()` in `qr_destination.js`
|
||
(`updateElement("successWorkType", ...)`).
|
||
|
||
**3. Check-out inherits the open check-in's type.**
|
||
`GET /qr/<url>/last-work-type?employee_id=&selected_location_name=` returns the work type
|
||
of the employee's most recent **unpaired** check-in, and the page pre-selects it with an
|
||
amber bilingual reminder ("You checked in as Special Project at 07:12 AM (Bldg A).
|
||
Confirm or change the type of work.").
|
||
|
||
- Answers **only on Check Out scans**. `_resolve_qr_event()` resolves the scan's effective
|
||
event exactly as `qr_checkin` does — a dynamic QR inherits the event of the standard QR
|
||
behind the selected location, and returns `None` while no location is chosen.
|
||
- `_resolve_record_event()` resolves a stored record's event the same way
|
||
(`attendance_data` does not store the event).
|
||
- Latest scan in a **one-day** window (so overnight shifts resolve). If it was a check-in
|
||
the pair is open → suggest its type; if it was a check-out the pair is closed → quiet.
|
||
- Matches every stored ID spelling via `expand_employee_id_filter()`.
|
||
- **Every failure path returns `{'work_type': None}`, HTTP 200** — a broken hint must
|
||
never block a check-out.
|
||
- `work_type: ""` means an open **Regular** check-in (still worth confirming);
|
||
`work_type: null` means "no suggestion, stay quiet".
|
||
- Fires 500 ms after ID entry, on load (the ID is usually auto-filled), and again after a
|
||
dynamic QR's location is confirmed. A manual dropdown change sets
|
||
`workTypeChosenByEmployee` and the suggestion never overwrites it afterwards.
|
||
|
||
**The form is replaced 1.5 s after load — the biggest trap here.**
|
||
`initializeLocationServicesCheck()` in `qr_destination.js` clones `#checkinForm` and
|
||
swaps it in, to take over the submit handler. `cloneNode(true)` copies **attributes, not
|
||
live control state**, so the `<select>` reverted to the option carrying the `selected`
|
||
attribute (Regular) and typed input values were lost — 1.5 s after load, i.e. after every
|
||
re-apply had already run. The amber reminder survived because its text and inline style
|
||
are attributes on the cloned nodes, which is exactly what the symptom looked like:
|
||
*correct type shown briefly, then back to Regular, reminder still right.*
|
||
|
||
Three things keep this working — do not remove any of them:
|
||
1. `preserveFormControlState(oldForm, newForm)` copies each control's live
|
||
`value` / `checked` into the clone (matched **by position**, since the clone is
|
||
structurally identical — no selector escaping).
|
||
2. The replace dispatches a `checkinFormReplaced` CustomEvent; the page re-binds
|
||
`attachEmployeeIdListeners()` + `attachWorkTypeListeners()` on the new nodes and
|
||
re-asserts the suggestion. Every listener bound to the old controls dies with them.
|
||
3. All re-attachable listeners live in those two functions — never bind directly to
|
||
form controls in an init function, or the binding is lost at 1.5 s.
|
||
|
||
This also silently affected a **manual** pick: choosing Covering within 1.5 s of load and
|
||
submitting afterwards would have submitted Regular.
|
||
|
||
**iOS Safari — do not undo these three guards.** On iPhone the dropdown reverted to
|
||
Regular while desktop browsers worked, because Safari restores form-control state *after*
|
||
load (i.e. after the fetch resolves) and caches plain GETs aggressively:
|
||
1. `suggestedWorkTypeCode` is retained and re-applied by `applyWorkTypeSuggestion()` —
|
||
on response, at +250 ms, at +900 ms, and on `pageshow`. A single set at response time
|
||
is not enough.
|
||
2. A `change` event only counts as the employee's choice when a real interaction
|
||
(`pointerdown` / `touchstart` / `focus` / `keydown`) preceded it — tracked by
|
||
`workTypeTouchedByEmployee`. Safari's restore fires `change` too, and without this
|
||
guard it permanently latched `workTypeChosenByEmployee`, blocking every suggestion.
|
||
3. The fetch uses `cache: "no-store"` and the endpoint replies through `_no_store_json()`
|
||
(`Cache-Control: no-store, no-cache, max-age=0, must-revalidate` + `Pragma: no-cache`)
|
||
on **all seven** return paths — a cached "no suggestion" is indistinguishable from the
|
||
bug this endpoint exists to prevent.
|
||
- The URL is built from `window.location.pathname` with trailing slashes stripped, so a
|
||
scanner that appends `/` cannot produce `//last-work-type`.
|
||
- The amber reminder is shown even if the dropdown did not take the value, so the employee
|
||
always sees what they checked in as. **Diagnostic:** reminder visible but dropdown on
|
||
Regular = the `<select>` is being overwritten; no reminder at all = the fetch failed or
|
||
was served stale.
|
||
- GET, so the CSRF `before_request` (POST/PUT/PATCH/DELETE only) does not apply.
|
||
- Public and unauthenticated like the check-in page: a guessed ID reveals whether it has
|
||
an open check-in, with time and location — the same disclosure class as the existing
|
||
30-minute cooldown message.
|
||
|
||
### Check-In Flow
|
||
1. Employee scans QR → `qr_destination.html`
|
||
2. Enters numeric ID and picks Type of Work (Regular by default); GPS captured by browser
|
||
3. On a Check Out scan the page pre-selects the open check-in's work type
|
||
4. Work-type code appended to the ID server-side (`1234` → `1234SP`)
|
||
5. 30-min interval guard (configurable via `TIME_INTERVAL`)
|
||
6. Dynamic QR: server rejects if `selected_location_name` is empty; `location_name` in record is always the resolved name, never `'Dynamic'`
|
||
7. Haversine distance calculated; photo required if beyond threshold (and both toggles enabled)
|
||
8. Server-side photo size check: rejects > `VERIFICATION_PHOTO_MAX_SIZE` with HTTP 413
|
||
9. Record saved to `attendance_data`; success card echoes the stored Type of Work
|
||
|
||
**Interval guard caveat:** the cooldown keys on the *composed* ID, so `1234` and `1234SP`
|
||
do not block each other — an employee can check in as Regular and again as Covering
|
||
within the interval. This is pre-existing behaviour for suffixed IDs.
|
||
|
||
### Check-In Page Features
|
||
- Bilingual: English **and Spanish shown side by side** — coloured `english-text` /
|
||
`spanish-text` spans separated by `language-separator`. There is no toggle button in
|
||
this template; the `data-en` / `data-es` toggle code in `qr_destination.js` is inert here
|
||
- Type of Work dropdown + submit-button echo + check-out reminder (see above)
|
||
- 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),
|
||
**C** = Covering (added Sept 2026)
|
||
- Parsed from `employee_id` via `parse_employee_id_for_work_type()`
|
||
- Suffix and prefix forms, with or without a separator: `1234SP`, `1234 SP`, `SP1234`,
|
||
`1234.PW`. Two-letter codes are matched **before** the single-letter `C`
|
||
- Like SP/PW/PT, `C` hours are excluded from the 40-hour overtime rule
|
||
- **Codes are declared in four places — keep them in sync:**
|
||
| File | Symbol |
|
||
|---|---|
|
||
| `working_hours_calculator.py` | `work_type_codes` list in `parse_employee_id_for_work_type()` |
|
||
| `utils/helpers.py` | `WORK_TYPE_CODES` (drives report/export ID filters) |
|
||
| `routes/qr_codes.py` | `VALID_CHECKIN_WORK_TYPES` + `WORK_TYPE_LABELS` |
|
||
| `static/js/attendance_report.js` | regex alternations in `parseEmployeeIdWorkType()` |
|
||
- `single_checkin_calculator.py` is legacy and deliberately **not** updated — its
|
||
aggregation dicts are keyed `regular/SP/PW/PT`, so adding `C` to its parser alone
|
||
would raise `KeyError`
|
||
|
||
### 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/c_hours` to prevent double-counting in summary rows.
|
||
`effective_work_type` prefers the OUT's code, so forgetting the type on **one** scan of a
|
||
pair still attributes the hours to the special type — forgetting on **both** is the real
|
||
payroll error, which is what the check-out reminder in §11 targets.
|
||
|
||
### Summary Rows (SP / PW / PT / C + 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='C' col9=_qtr(c_hours) ← only if c_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)
|
||
```
|
||
Both exports emit the `C` row: the main export from `grand_totals['c_hours'] +
|
||
cross_type_c_hours`, the by-building export from `_building_special_hours(emp_records, 'C')`.
|
||
`WorkingHoursCalculator` returns `c_hours` / `c_minutes` at daily, weekly, and grand-total
|
||
level alongside the existing sp/pw/pt keys.
|
||
|
||
### 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.
|
||
|
||
### Export by Building — Extra Sheets (Sept 2026)
|
||
`export_time_attendance_by_building_excel()` writes **Sheet0 unchanged**, then adds two sheets
|
||
built from row bookkeeping collected while Sheet0 is written (`_bb_blocks`, `_bb_sp_summary_rows`,
|
||
each employee's `weeks` dict) — Sheet0 is never re-read or modified:
|
||
1. **`Filtered Report`** (`_build_filtered_building_sheet`) — copy of Sheet0 minus every employee
|
||
block whose base ID is in `BUILDING_SUMMARY_EXCLUDED_EMPLOYEE_IDS` (project managers; leading
|
||
zeros ignored), minus the `SP` summary rows. Punch rows whose location carries `(SP)` and all
|
||
GRAND TOTAL rows are **kept**. Buildings left with no employee are dropped and the rest renumbered.
|
||
2. **`Weekly Hours by Location`** (`_build_weekly_hours_by_location_sheet`) — one row per
|
||
employee per building, hours per report week (anchored to the report start date like the Weekly
|
||
Total rows), a Location Totals table (SUMIF formulas) and the project total. **SP hours are
|
||
excluded** (pair is SP when its effective type — OUT's code first — is `SP`); PW/PT/C are counted.
|
||
Each day is `_qtr()`-rounded before summing, so non-SP employees match Sheet0's Weekly Total exactly.
|
||
Both are wrapped in try/except — a failure removes them and still delivers Sheet0.
|
||
|
||
### 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)
|
||
BUILDING_SUMMARY_EXCLUDED_EMPLOYEE_IDS # Export by Building PM IDs to filter (default 4921,4944,4816,3979; empty = none)
|
||
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.
|
||
```
|
||
|
||
### Employee Filter — Two Layers, Both Must Be Work-Type Aware
|
||
|
||
The employee filter is applied **twice**, and both passes must agree or extra-work rows
|
||
silently disappear:
|
||
|
||
1. **Server** — `routes/attendance.py` expands each selected ID via
|
||
`expand_employee_id_filter()` into an `IN (...)` list plus REGEXP patterns.
|
||
2. **Client** — `applyFilters()` in `static/js/attendance_report.js` re-filters the
|
||
server-rendered rows on every page load (`initializeReport()` calls it). It uses
|
||
`parseEmployeeIdWorkType()` and compares **base IDs**, never whole strings.
|
||
|
||
Semantics (identical on both sides, mirroring `_work_type_codes_for()`):
|
||
- a plain `1234` matches the regular record **and** `1234SP` / `1234PW` / `1234PT` / `1234C`
|
||
- an ID that already carries a code (`1234SP`) matches only that code
|
||
- leading zeros tolerated (`01234` = `1234`); `12345` must never match a `1234` filter
|
||
|
||
**Do not reintroduce** `record.employeeId.toLowerCase() === id` in `applyFilters()` — that
|
||
exact-match test is what dropped every SP/PW/PT row the query had already returned.
|
||
|
||
---
|
||
|
||
## 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)` (later restored to 30 days — the 10-hour limit now lives in `adjust_session_lifetime()`, see Set 19) |
|
||
| `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 |
|
||
|
||
---
|
||
|
||
### Set 12 — Type of Work on Check-In + `C` Work Type (Sept 2026)
|
||
| File | Change |
|
||
|---|---|
|
||
| `templates/qr_destination.html` | Type of Work `<select>` (Regular / PW / SP / C) after Employee ID, bilingual labels |
|
||
| `templates/qr_destination.html` | Employee ID numeric-only: `inputmode`, `pattern`, input-strip, submit guard, stored-ID cleanup |
|
||
| `static/js/qr_destination.js` | Same numeric-only filter + `work_type` appended to the check-in `FormData` |
|
||
| `routes/qr_codes.py` | `VALID_CHECKIN_WORK_TYPES`; `work_type` validated and appended to the numeric ID; existing typed suffix stripped first |
|
||
| `working_hours_calculator.py` | `C` added to the parser and to every `regular/SP/PW/PT` dict; `c_hours` / `c_minutes` at daily, weekly, grand-total level |
|
||
| `utils/helpers.py` | `WORK_TYPE_CODES` includes `C` (report + export ID filters) |
|
||
| `routes/time_attendance_export.py` | `C` location suffix, `cross_type_c_hours`, excluded from `regular_only_hours`, `C` summary row in both exports |
|
||
| — | **No migration** — the code lives inside `attendance_data.employee_id` |
|
||
|
||
### Set 13 — Attendance Report Client-Side Filter Dropped Extra-Work Rows (Sept 2026)
|
||
| File | Fix |
|
||
|---|---|
|
||
| `static/js/attendance_report.js` | `applyFilters()` compared `record.employeeId === id` exactly, filtering out the SP/PW/PT rows the SQL had correctly returned. Added `parseEmployeeIdWorkType()` + base-ID comparison — see §19 |
|
||
|
||
### Set 14 — Type of Work Anti-Mistake Measures (Sept 2026)
|
||
| File | Change |
|
||
|---|---|
|
||
| `routes/qr_codes.py` | `WORK_TYPE_LABELS` + `_work_type_label()`; `work_type` / `work_type_label` added to the check-in success payload |
|
||
| `routes/qr_codes.py` | `_resolve_qr_event()`, `_resolve_record_event()`, `GET /qr/<url>/last-work-type` — check-out inherits the open check-in's work type |
|
||
| `templates/qr_destination.html` | `renderSubmitButton()` centralises the button markup; `resetEnhancedSubmitButton()` delegates to it (the work-type line it briefly carried was reverted — see Set 17) |
|
||
| `templates/qr_destination.html` | Type of Work row on the success card; `#workTypeHint` amber reminder; `initializeWorkTypeSelector()` + `loadOpenCheckInWorkType()` |
|
||
| `static/js/qr_destination.js` | `updateElement("successWorkType", ...)` in `handleCheckinSuccess()` |
|
||
|
||
### Set 15 — iOS Safari Reset the Suggested Work Type (Sept 2026)
|
||
| File | Fix |
|
||
|---|---|
|
||
| `templates/qr_destination.html` | Suggestion retained in `suggestedWorkTypeCode` and re-applied via `applyWorkTypeSuggestion()` on response, +250 ms, +900 ms and `pageshow` — Safari's form restore lands after the fetch |
|
||
| `templates/qr_destination.html` | `change` counts as the employee's choice only after a real interaction (`workTypeTouchedByEmployee`); Safari's restore-fired `change` no longer latches the flag |
|
||
| `templates/qr_destination.html` | `fetch(..., { cache: "no-store" })`; URL built from `pathname` with trailing slashes stripped |
|
||
| `routes/qr_codes.py` | `_no_store_json()` sets `Cache-Control` / `Pragma` on all seven return paths of `qr_last_work_type` |
|
||
|
||
### Set 16 — Form Clone Discarded the Selected Work Type (Sept 2026)
|
||
| File | Fix |
|
||
|---|---|
|
||
| `static/js/qr_destination.js` | `preserveFormControlState()` copies live `value` / `checked` into the clone before `replaceChild` — `cloneNode(true)` had reset the work-type `<select>` to Regular and cleared typed input values 1.5 s after load |
|
||
| `static/js/qr_destination.js` | Dispatches `checkinFormReplaced` after the swap so the page can re-bind listeners the clone destroyed |
|
||
| `templates/qr_destination.html` | Listeners split into re-attachable `attachEmployeeIdListeners()` / `attachWorkTypeListeners()`; `checkinFormReplaced` handler re-binds both, re-applies the suggestion, re-renders the button |
|
||
|
||
### Set 17 — Submit Button Label Reverted to Plain Check In / Check Out (Sept 2026)
|
||
| File | Change |
|
||
|---|---|
|
||
| `templates/qr_destination.html` | Work-type line removed from the submit button — it broke the button layout. `.submit-work-type` CSS deleted, and the `renderSubmitButton()` calls that only existed to refresh it (dropdown `change`, `applyWorkTypeSuggestion`, form re-bind, init) dropped |
|
||
| — | The type of work is still confirmed by the amber check-out reminder and the success-card row — **do not re-add it to the button** |
|
||
|
||
### Set 18 — Export by Building: Filtered Report + Weekly Hours by Location (Sept 2026)
|
||
| File | Change |
|
||
|---|---|
|
||
| `routes/time_attendance_export.py` | Row bookkeeping in the by-building loop; `_build_filtered_building_sheet()` + `_build_weekly_hours_by_location_sheet()` add two sheets after Sheet0 (see §13). Sheet0 verified cell-for-cell identical to the previous export |
|
||
| `config.py` | `BUILDING_SUMMARY_EXCLUDED_EMPLOYEE_IDS` (comma-separated, default `4921,4944,4816,3979`) |
|
||
| `templates/time_attendance_records.html` | Tooltip on the Export by Building button |
|
||
| — | Replaces the manual "Copilot" procedure (delete PM rows + SP rows, then build a weekly table). Decisions: PM IDs removed from both sheets; SP hours excluded from weekly hours |
|
||
|
||
### Set 19 — Remember Me Logged Users Out Within 30 Days (Sept 15, 2026)
|
||
| File | Fix |
|
||
|---|---|
|
||
| `app.py` | `adjust_session_lifetime()` no longer assigns `app.permanent_session_lifetime` (10 h / 30 d per request). Flask validates the cookie's age against that worker-wide value in `open_session()` before hooks run, so any request without Remember Me — including the login POST itself and anonymous QR scans — made Remember Me cookies older than 10 h unreadable; under gevent it could also stamp a 10 h expiry on them. The hook now clears non-Remember-Me sessions 10 h after `login_epoch` instead |
|
||
| `routes/auth.py` | Login sets `session['login_epoch']` |
|
||
| `config.py` | Comment corrected: `PERMANENT_SESSION_LIFETIME` stays 30 days — it is the cookie age limit for every session |
|
||
| — | Verified by loading the real hook (before/after) into a Flask app with a controlled clock: Remember Me now survives 11 h, 25 days of daily use and 29 idle days, and expires after 31 idle days; non-Remember-Me still ends at 10 h |
|
||
| — | No forced re-login on deploy: valid sessions keep working; non-Remember-Me sessions from before the deploy (no `login_epoch`) get their 10 hours counted from their first request after it |
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
### Session (September 2–3, 2026) — Type of Work on Check-In
|
||
- Added the **Type of Work** dropdown to the check-in page (Regular default, PW / SP / C)
|
||
and made the Employee ID numeric-only; the code is appended to the ID server-side so no
|
||
schema change or migration was needed (§11, §20 Set 12)
|
||
- Added **C (Covering)** as a first-class work type through the parser, the calculator's
|
||
daily/weekly/grand totals, the ID filters, and the `C` summary row in both Excel exports
|
||
- Deliberately left `single_checkin_calculator.py` untouched (legacy; its dicts would
|
||
`KeyError` on a `C` key) and did **not** cap the ID at 4 characters (would break
|
||
5-digit IDs)
|
||
- Made the dropdown labels bilingual as one plain string per option — `<option>` cannot
|
||
hold the page's coloured `english-text` / `spanish-text` spans
|
||
- **Fixed the Attendance Report employee filter** (§20 Set 13): the July server-side fix
|
||
was being undone by an exact-match client-side re-filter in `attendance_report.js`, so
|
||
`1234SP` / `1234PT` rows were dropped after the query returned them
|
||
- Added two anti-mistake measures chosen from a set of options (§20 Set 14): the submit
|
||
button and success card now name the selected type, and a **Check Out** scan pre-selects
|
||
the work type of the employee's still-open check-in with a bilingual reminder
|
||
- Verification used throughout: `ast.parse` on every Python edit, Jinja parse of the
|
||
template, `node --check` on the JS (including the extracted inline script), an
|
||
end-to-end `WorkingHoursCalculator` run proving regular/OT totals unchanged, and
|
||
offline rule checks for the filter matcher and the `last-work-type` decision paths
|
||
- Both LT and GOV instances need these changes; only LT was reachable in this session
|
||
(the Gitea MCP server failed to connect)
|
||
- **iPhone follow-up:** the pre-selected work type reverted to Regular on iOS Safari only.
|
||
Cause was Safari-specific form-state restore (fires after the async response, and fires
|
||
a `change` event that was being read as an employee choice) plus aggressive GET caching.
|
||
Fixed with re-application on a schedule, interaction-gated `change` handling, and
|
||
`no-store` on both ends (§11, §20 Set 15)
|
||
- **iPhone follow-up 2 — the actual root cause:** the reminder was correct but the
|
||
dropdown reverted, because `qr_destination.js` clones and replaces the whole form 1.5 s
|
||
after load and `cloneNode(true)` drops live control state. Fixed with
|
||
`preserveFormControlState()` + a `checkinFormReplaced` event that re-binds the page's
|
||
listeners (§11, §20 Set 16). The Safari guards from Set 15 stay — they address a
|
||
different, real failure mode
|
||
- Reverted the work-type line on the submit button at the user's request (layout); the
|
||
plain `Check In` / `Check Out` label is back. The reminder and success-card row keep
|
||
the confirmation (§20 Set 17)
|