diff --git a/README.md b/README.md index f664082..6e7b4bf 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,58 @@ # Face Attendance -A modern Flask attendance application with staff authentication, role-based access control, face enrollment, camera-based recognition, and attendance reporting. +Face Attendance is a modern Flask application for managing attendance with face recognition. It includes staff authentication, role-based authorization, person enrollment, a webcam kiosk, attendance reporting, database migrations, and a responsive English UI. + +## Features + +- Application factory pattern with modular Blueprints. +- Admin-created staff accounts with `admin`, `operator`, and `viewer` roles. +- Secure login/logout with Flask-Login and Werkzeug password hashing. +- CSRF-protected forms and secure session/cookie defaults. +- Person enrollment with one to four face reference images. +- Webcam-based recognition kiosk for check-in and check-out events. +- Attendance reports with person, timestamp, source, confidence, and staff user. +- SQLAlchemy models with Flask-Migrate/Alembic migrations. +- Pytest coverage for authentication, roles, and attendance behavior. +- Modern Bootstrap-based UI with an English welcome page. ## Roles -- `admin`: manages staff accounts, enrolls people, records attendance, and views reports. -- `operator`: enrolls people, uses the attendance kiosk, and views reports. -- `viewer`: read-only access to people and attendance reports. +| Role | Permissions | +| --- | --- | +| `admin` | Manage staff accounts, enroll people, use the kiosk, and view reports. | +| `operator` | Enroll people, use the kiosk, and view reports. | +| `viewer` | View people and attendance reports only. | Only admins can create staff accounts. ## Requirements - Python 3.11 or 3.12 is recommended for production. -- Native build prerequisites for `face-recognition`/`dlib` may be required by your OS. +- Native build prerequisites for `face-recognition` and `dlib`. +- SQLite for local development, or PostgreSQL/MySQL for production. - A production WSGI server such as Gunicorn on Linux or Waitress on Windows. +## Project Structure + +```text +app/ + __init__.py Application factory + config.py Environment-based configuration + extensions.py Flask extensions + auth/ Login/logout + admin/ Staff account management + attendance/ Kiosk and attendance reports + people/ Person enrollment and profiles + recognition/ Recognition API routes + models/ SQLAlchemy models + services/ Business logic + templates/ Jinja templates + static/ CSS, JS, and images +migrations/ Alembic migrations +tests/ Pytest suite +wsgi.py WSGI entrypoint +``` + ## Setup ```powershell @@ -25,15 +62,28 @@ pip install -r requirements.txt Copy-Item .env.example .env ``` -Edit `.env` and set a strong `SECRET_KEY`. For production, set `APP_ENV=production` and use a durable database URL. +Edit `.env` before running the app. + +```env +APP_ENV=development +SECRET_KEY=replace-with-a-long-random-secret +DATABASE_URL=sqlite:///instance/face_attendance.sqlite3 +FACE_MATCH_TOLERANCE=0.55 +MAX_CONTENT_LENGTH=8388608 +``` ## Database -Initialize the database with migrations: +Apply migrations: ```powershell $env:FLASK_APP = "wsgi:app" flask db upgrade +``` + +Create the first admin account: + +```powershell flask create-admin ``` @@ -44,7 +94,7 @@ $env:FLASK_APP = "wsgi:app" flask run ``` -Open `http://127.0.0.1:5000` and sign in with the admin account created above. +Open `http://127.0.0.1:5000`. ## Tests @@ -52,23 +102,30 @@ Open `http://127.0.0.1:5000` and sign in with the admin account created above. pytest ``` -The tests use an isolated SQLite database and disable CSRF only for test requests. +The test suite uses an in-memory SQLite database and disables CSRF only for test requests. -## Production Notes +## Production Deployment -- Use HTTPS. Production config enables secure cookies. -- Set `SECRET_KEY`, `DATABASE_URL`, and `APP_ENV=production`. -- Run `flask db upgrade` during deployment. -- Run behind a reverse proxy that forwards HTTPS headers correctly. -- Treat face encodings as biometric data. Restrict database access, define a retention policy, and back up securely. +1. Set `APP_ENV=production`. +2. Set a strong `SECRET_KEY`. +3. Set `DATABASE_URL` to your production database. +4. Install native dependencies for `face-recognition`/`dlib`. +5. Run `flask db upgrade`. +6. Serve through a production WSGI server behind HTTPS. +7. Configure your reverse proxy to forward HTTPS headers correctly. -## What Changed +Production config enables secure cookies. Do not run the Flask development server in production. -- Replaced the legacy single-file Flask script with an application factory and Blueprints. -- Added SQLAlchemy models and Alembic migrations. -- Added Flask-Login authentication, admin-created accounts, password hashing, and role checks. -- Replaced raw SQL with ORM queries. -- Removed global enrollment state. -- Rebuilt the UI in English with reusable templates and responsive Bootstrap-based layouts. -- Added CSRF protection, secure cookie settings, security headers, validation, and error pages. -- Added pytest coverage for authentication, roles, models, and attendance recording. +## Security Notes + +- Face encodings are biometric data. Restrict database access and define a retention policy. +- Keep `.env` out of version control. +- Use HTTPS in production. +- Rotate staff accounts when operators leave the organization. +- Review database backups and access logs regularly. + +## Troubleshooting + +- If recognition endpoints return a dependency error, install `face-recognition` on Python 3.11 or 3.12 with the required `dlib` build tools. +- If the default Flask port is blocked on Windows, run `flask run --port 8000`. +- If database tables are missing, run `flask db upgrade`. diff --git a/app/main/routes.py b/app/main/routes.py index 1e652c7..6420a74 100644 --- a/app/main/routes.py +++ b/app/main/routes.py @@ -12,7 +12,7 @@ bp = Blueprint("main", __name__) def index(): if current_user.is_authenticated: return redirect(url_for("main.dashboard")) - return redirect(url_for("auth.login")) + return render_template("welcome.html") @bp.get("/dashboard") diff --git a/app/static/css/app.css b/app/static/css/app.css index 4ce9159..0ae3fd5 100644 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -257,11 +257,152 @@ a { text-align: center; } +.welcome-page { + margin: -2rem -1.5rem -3rem; + background: #f3f6f7; +} + +.welcome-hero { + min-height: 78vh; + position: relative; + display: flex; + align-items: center; + isolation: isolate; + overflow: hidden; + padding: 7rem clamp(1.5rem, 6vw, 7rem) 5rem; + background: url("../img/welcome-hero.png") center / cover no-repeat; + color: #ffffff; +} + +.welcome-hero::before { + content: ""; + position: absolute; + inset: 0; + z-index: -1; + background: rgba(6, 15, 28, 0.58); +} + +.welcome-nav { + position: absolute; + top: 1.5rem; + left: clamp(1.5rem, 6vw, 7rem); + right: clamp(1.5rem, 6vw, 7rem); + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.welcome-brand { + color: #ffffff; + font-size: 1.05rem; + font-weight: 700; + text-decoration: none; +} + +.welcome-copy { + max-width: 760px; +} + +.welcome-copy h1 { + margin: 0; + font-size: clamp(3rem, 7vw, 7rem); + font-weight: 800; + line-height: 0.95; +} + +.welcome-copy p { + max-width: 660px; + margin: 1.4rem 0 0; + color: rgba(255, 255, 255, 0.86); + font-size: clamp(1.05rem, 1.5vw, 1.35rem); +} + +.welcome-eyebrow { + color: rgba(255, 255, 255, 0.72); +} + +.welcome-actions { + display: flex; + flex-wrap: wrap; + gap: 0.85rem; + margin-top: 2rem; +} + +.welcome-section, +.workflow-band { + padding: clamp(3rem, 6vw, 5rem) clamp(1.5rem, 6vw, 7rem); +} + +.welcome-section-header { + max-width: 760px; + margin-bottom: 1.5rem; +} + +.welcome-section-header h2, +.workflow-band h2 { + margin: 0; + font-size: clamp(2rem, 3vw, 3rem); +} + +.feature-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1rem; +} + +.feature-card { + min-height: 250px; + padding: 1.25rem; + border: 1px solid var(--border); + border-radius: 8px; + background: #ffffff; +} + +.feature-card span { + color: var(--accent); + font-weight: 800; +} + +.feature-card h3 { + margin: 2.5rem 0 0.75rem; + font-size: 1.15rem; +} + +.feature-card p { + color: var(--muted); + margin: 0; +} + +.workflow-band { + display: grid; + grid-template-columns: minmax(0, 0.75fr) minmax(320px, 1fr); + gap: 2rem; + align-items: start; + background: #172033; + color: #ffffff; +} + +.workflow-band .eyebrow, +.workflow-list { + color: rgba(255, 255, 255, 0.72); +} + +.workflow-list { + display: grid; + gap: 1rem; + margin: 0; + padding-left: 1.25rem; + font-size: 1.05rem; +} + @media (max-width: 992px) { .stats-grid, .upload-grid, .detail-grid, - .kiosk-grid { + .kiosk-grid, + .feature-grid, + .workflow-band { grid-template-columns: 1fr 1fr; } } @@ -276,11 +417,22 @@ a { .stats-grid, .upload-grid, .detail-grid, - .kiosk-grid { + .kiosk-grid, + .feature-grid, + .workflow-band { grid-template-columns: 1fr; } .detail-list { grid-template-columns: 1fr; } + + .welcome-hero { + min-height: 82vh; + padding-top: 6rem; + } + + .welcome-actions .btn { + width: 100%; + } } diff --git a/app/static/img/welcome-hero.png b/app/static/img/welcome-hero.png new file mode 100644 index 0000000..a1df4e5 Binary files /dev/null and b/app/static/img/welcome-hero.png differ diff --git a/app/static/js/kiosk.js b/app/static/js/kiosk.js index 7aced6f..907cf07 100644 --- a/app/static/js/kiosk.js +++ b/app/static/js/kiosk.js @@ -82,7 +82,7 @@ function renderFaces(faces) { const confidence = face.confidence === null ? "n/a" : `${Math.round(face.confidence * 100)}%`; card.innerHTML = `

${escapeHtml(face.full_name)}

-

${escapeHtml(face.employee_id)} · Confidence ${confidence}
${escapeHtml(face.last_presence)}

+

${escapeHtml(face.employee_id)} - Confidence ${confidence}
${escapeHtml(face.last_presence)}

diff --git a/app/templates/admin/account_form.html b/app/templates/admin/account_form.html index 7e6a6e8..b9ff2d0 100644 --- a/app/templates/admin/account_form.html +++ b/app/templates/admin/account_form.html @@ -1,7 +1,7 @@ {% extends "base.html" %} {% from "macros/forms.html" import render_field, render_select %} -{% block title %}Create account · Face Attendance{% endblock %} +{% block title %}Create account - Face Attendance{% endblock %} {% block content %}