bump project
This commit is contained in:
123
app/__init__.py
Normal file
123
app/__init__.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, render_template
|
||||
|
||||
from app.config import ProductionConfig, config_by_name
|
||||
from app.extensions import csrf, db, login_manager, migrate
|
||||
|
||||
|
||||
def create_app(config_name: str | None = None, config_overrides: dict | None = None) -> Flask:
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
app = Flask(__name__, instance_relative_config=True)
|
||||
Path(app.instance_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
selected_config = config_name or os.getenv("APP_ENV", "development")
|
||||
config_class = config_by_name.get(selected_config, config_by_name["development"])
|
||||
app.config.from_object(config_class)
|
||||
if config_overrides:
|
||||
app.config.update(config_overrides)
|
||||
if config_class is ProductionConfig:
|
||||
validate_production_config(app)
|
||||
|
||||
configure_logging(app)
|
||||
init_extensions(app)
|
||||
register_blueprints(app)
|
||||
register_error_handlers(app)
|
||||
register_security_headers(app)
|
||||
register_template_context(app)
|
||||
|
||||
from app.commands import register_commands
|
||||
|
||||
register_commands(app)
|
||||
return app
|
||||
|
||||
|
||||
def init_extensions(app: Flask) -> None:
|
||||
from app.models import Account
|
||||
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
csrf.init_app(app)
|
||||
|
||||
login_manager.login_view = "auth.login"
|
||||
login_manager.login_message_category = "warning"
|
||||
login_manager.init_app(app)
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(account_id: str) -> Account | None:
|
||||
if not account_id.isdigit():
|
||||
return None
|
||||
return db.session.get(Account, int(account_id))
|
||||
|
||||
|
||||
def register_blueprints(app: Flask) -> None:
|
||||
from app.admin.routes import bp as admin_bp
|
||||
from app.attendance.routes import bp as attendance_bp
|
||||
from app.auth.routes import bp as auth_bp
|
||||
from app.main.routes import bp as main_bp
|
||||
from app.people.routes import bp as people_bp
|
||||
from app.recognition.routes import bp as recognition_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(admin_bp)
|
||||
app.register_blueprint(people_bp)
|
||||
app.register_blueprint(attendance_bp)
|
||||
app.register_blueprint(recognition_bp)
|
||||
|
||||
|
||||
def register_error_handlers(app: Flask) -> None:
|
||||
@app.errorhandler(403)
|
||||
def forbidden(error):
|
||||
return render_template("errors/403.html"), 403
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return render_template("errors/404.html"), 404
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
db.session.rollback()
|
||||
app.logger.exception("Unhandled application error: %s", error)
|
||||
return render_template("errors/500.html"), 500
|
||||
|
||||
|
||||
def register_security_headers(app: Flask) -> None:
|
||||
@app.after_request
|
||||
def set_default_security_headers(response):
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
response.headers.setdefault("X-Frame-Options", "SAMEORIGIN")
|
||||
response.headers.setdefault("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
response.headers.setdefault("Permissions-Policy", "camera=(self), microphone=()")
|
||||
return response
|
||||
|
||||
|
||||
def register_template_context(app: Flask) -> None:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import Role
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
return {"current_year": datetime.now(timezone.utc).year, "Role": Role}
|
||||
|
||||
|
||||
def configure_logging(app: Flask) -> None:
|
||||
if app.debug or app.testing:
|
||||
return
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
|
||||
def validate_production_config(app: Flask) -> None:
|
||||
if app.config["SECRET_KEY"] in {"", "dev-change-me"}:
|
||||
raise RuntimeError("SECRET_KEY must be set for production.")
|
||||
1
app/admin/__init__.py
Normal file
1
app/admin/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
45
app/admin/forms.py
Normal file
45
app/admin/forms.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import EmailField, PasswordField, SelectField, StringField, SubmitField
|
||||
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError
|
||||
|
||||
from app.models import Account, Role
|
||||
|
||||
|
||||
class AccountCreateForm(FlaskForm):
|
||||
full_name = StringField("Full name", validators=[DataRequired(), Length(max=160)])
|
||||
username = StringField("Username", validators=[DataRequired(), Length(min=3, max=80)])
|
||||
email = EmailField("Email", validators=[DataRequired(), Email(), Length(max=255)])
|
||||
role = SelectField(
|
||||
"Role",
|
||||
choices=[(Role.OPERATOR.value, "Operator"), (Role.VIEWER.value, "Viewer"), (Role.ADMIN.value, "Admin")],
|
||||
validators=[DataRequired()],
|
||||
)
|
||||
password = PasswordField("Password", validators=[DataRequired(), Length(min=12, max=128)])
|
||||
confirm_password = PasswordField(
|
||||
"Confirm password",
|
||||
validators=[DataRequired(), EqualTo("password", message="Passwords must match.")],
|
||||
)
|
||||
submit = SubmitField("Create account")
|
||||
|
||||
def validate_username(self, field) -> None:
|
||||
username = field.data.strip().lower()
|
||||
if not re.fullmatch(r"[a-z0-9_.-]+", username):
|
||||
raise ValidationError("Use only letters, numbers, dots, hyphens, and underscores.")
|
||||
if Account.query.filter_by(username=username).first():
|
||||
raise ValidationError("That username is already in use.")
|
||||
field.data = username
|
||||
|
||||
def validate_email(self, field) -> None:
|
||||
email = field.data.strip().lower()
|
||||
if Account.query.filter_by(email=email).first():
|
||||
raise ValidationError("That email is already in use.")
|
||||
field.data = email
|
||||
|
||||
def validate_password(self, field) -> None:
|
||||
password = field.data
|
||||
if not re.search(r"[A-Z]", password) or not re.search(r"[a-z]", password) or not re.search(r"\d", password):
|
||||
raise ValidationError("Use at least one uppercase letter, one lowercase letter, and one number.")
|
||||
52
app/admin/routes.py
Normal file
52
app/admin/routes.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
from app.admin.forms import AccountCreateForm
|
||||
from app.extensions import db
|
||||
from app.models import Account, Role
|
||||
from app.services.security import roles_required
|
||||
|
||||
bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
|
||||
|
||||
@bp.get("/accounts")
|
||||
@roles_required(Role.ADMIN)
|
||||
def accounts():
|
||||
items = Account.query.order_by(Account.created_at.desc()).all()
|
||||
return render_template("admin/accounts.html", accounts=items)
|
||||
|
||||
|
||||
@bp.route("/accounts/new", methods=["GET", "POST"])
|
||||
@roles_required(Role.ADMIN)
|
||||
def new_account():
|
||||
form = AccountCreateForm()
|
||||
if form.validate_on_submit():
|
||||
account = Account(
|
||||
full_name=form.full_name.data.strip(),
|
||||
username=form.username.data.strip().lower(),
|
||||
email=form.email.data.strip().lower(),
|
||||
role=form.role.data,
|
||||
)
|
||||
account.set_password(form.password.data)
|
||||
db.session.add(account)
|
||||
db.session.commit()
|
||||
flash(f"Account created for {account.full_name}.", "success")
|
||||
return redirect(url_for("admin.accounts"))
|
||||
return render_template("admin/account_form.html", form=form)
|
||||
|
||||
|
||||
@bp.post("/accounts/<int:account_id>/toggle")
|
||||
@roles_required(Role.ADMIN)
|
||||
def toggle_account(account_id: int):
|
||||
account = db.session.get(Account, account_id)
|
||||
if not account:
|
||||
abort(404)
|
||||
if account.id == current_user.id:
|
||||
flash("You cannot deactivate your own account.", "warning")
|
||||
return redirect(url_for("admin.accounts"))
|
||||
account.active = not account.active
|
||||
db.session.commit()
|
||||
flash(f"{account.full_name} is now {'active' if account.active else 'inactive'}.", "success")
|
||||
return redirect(url_for("admin.accounts"))
|
||||
1
app/attendance/__init__.py
Normal file
1
app/attendance/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
60
app/attendance/routes.py
Normal file
60
app/attendance/routes.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, abort, jsonify, redirect, render_template, request, url_for
|
||||
from flask_login import current_user
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import AttendanceEvent, AttendanceType, Person, Role
|
||||
from app.services.attendance import last_presence_summary, record_attendance
|
||||
from app.services.security import roles_required
|
||||
|
||||
bp = Blueprint("attendance", __name__, url_prefix="/attendance")
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@roles_required(Role.ADMIN, Role.OPERATOR, Role.VIEWER)
|
||||
def index():
|
||||
events = AttendanceEvent.query.order_by(AttendanceEvent.occurred_at.desc()).limit(250).all()
|
||||
return render_template("attendance/index.html", events=events)
|
||||
|
||||
|
||||
@bp.get("/kiosk")
|
||||
@roles_required(Role.ADMIN, Role.OPERATOR)
|
||||
def kiosk():
|
||||
return render_template("attendance/kiosk.html")
|
||||
|
||||
|
||||
@bp.post("/events")
|
||||
@roles_required(Role.ADMIN, Role.OPERATOR)
|
||||
def create_event():
|
||||
person_id = request.form.get("person_id", type=int)
|
||||
raw_event_type = request.form.get("event_type", "")
|
||||
confidence = request.form.get("confidence", type=float)
|
||||
|
||||
person = db.session.get(Person, person_id) if person_id else None
|
||||
if not person or not person.active:
|
||||
abort(404)
|
||||
|
||||
try:
|
||||
event_type = AttendanceType(raw_event_type)
|
||||
except ValueError:
|
||||
abort(400)
|
||||
|
||||
event = record_attendance(
|
||||
person,
|
||||
event_type,
|
||||
created_by_id=current_user.id,
|
||||
confidence=confidence,
|
||||
source="web-kiosk",
|
||||
)
|
||||
|
||||
if request.accept_mimetypes.accept_json:
|
||||
return jsonify(
|
||||
{
|
||||
"id": event.id,
|
||||
"event_type": event.type_label,
|
||||
"occurred_at": event.occurred_at.strftime("%Y-%m-%d %H:%M"),
|
||||
"summary": last_presence_summary(person),
|
||||
}
|
||||
)
|
||||
return redirect(url_for("attendance.index"))
|
||||
1
app/auth/__init__.py
Normal file
1
app/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
12
app/auth/forms.py
Normal file
12
app/auth/forms.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import BooleanField, PasswordField, StringField, SubmitField
|
||||
from wtforms.validators import DataRequired, Length
|
||||
|
||||
|
||||
class LoginForm(FlaskForm):
|
||||
username = StringField("Username", validators=[DataRequired(), Length(max=80)])
|
||||
password = PasswordField("Password", validators=[DataRequired()])
|
||||
remember = BooleanField("Remember this device")
|
||||
submit = SubmitField("Sign in")
|
||||
39
app/auth/routes.py
Normal file
39
app/auth/routes.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from flask import Blueprint, flash, redirect, render_template, request, url_for
|
||||
from flask_login import current_user, login_required, login_user, logout_user
|
||||
|
||||
from app.auth.forms import LoginForm
|
||||
from app.models import Account
|
||||
|
||||
bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
|
||||
|
||||
@bp.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("main.dashboard"))
|
||||
|
||||
form = LoginForm()
|
||||
if form.validate_on_submit():
|
||||
username = form.username.data.strip().lower()
|
||||
account = Account.query.filter_by(username=username).first()
|
||||
if account and account.active and account.check_password(form.password.data):
|
||||
login_user(account, remember=form.remember.data)
|
||||
next_url = request.args.get("next")
|
||||
if not next_url or urlsplit(next_url).netloc:
|
||||
next_url = url_for("main.dashboard")
|
||||
return redirect(next_url)
|
||||
flash("Invalid username or password.", "danger")
|
||||
|
||||
return render_template("auth/login.html", form=form)
|
||||
|
||||
|
||||
@bp.post("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash("You have been signed out.", "info")
|
||||
return redirect(url_for("auth.login"))
|
||||
28
app/commands.py
Normal file
28
app/commands.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import click
|
||||
from flask import Flask
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import Account, Role
|
||||
|
||||
|
||||
def register_commands(app: Flask) -> None:
|
||||
@app.cli.command("create-admin")
|
||||
@click.option("--username", prompt=True)
|
||||
@click.option("--email", prompt=True)
|
||||
@click.option("--full-name", prompt=True)
|
||||
@click.password_option()
|
||||
def create_admin(username: str, email: str, full_name: str, password: str) -> None:
|
||||
"""Create an initial administrator account."""
|
||||
username = username.strip().lower()
|
||||
email = email.strip().lower()
|
||||
|
||||
if Account.query.filter((Account.username == username) | (Account.email == email)).first():
|
||||
raise click.ClickException("An account with that username or email already exists.")
|
||||
|
||||
account = Account(username=username, email=email, full_name=full_name.strip(), role=Role.ADMIN.value)
|
||||
account.set_password(password)
|
||||
db.session.add(account)
|
||||
db.session.commit()
|
||||
click.echo(f"Created admin account: {username}")
|
||||
57
app/config.py
Normal file
57
app/config.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.getenv("SECRET_KEY", "dev-change-me")
|
||||
SQLALCHEMY_DATABASE_URI = os.getenv(
|
||||
"DATABASE_URL",
|
||||
f"sqlite:///{BASE_DIR / 'instance' / 'face_attendance.sqlite3'}",
|
||||
)
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
MAX_CONTENT_LENGTH = int(os.getenv("MAX_CONTENT_LENGTH", str(8 * 1024 * 1024)))
|
||||
FACE_MATCH_TOLERANCE = float(os.getenv("FACE_MATCH_TOLERANCE", "0.55"))
|
||||
|
||||
PERMANENT_SESSION_LIFETIME = timedelta(hours=12)
|
||||
SESSION_COOKIE_HTTPONLY = True
|
||||
SESSION_COOKIE_SAMESITE = "Lax"
|
||||
REMEMBER_COOKIE_HTTPONLY = True
|
||||
REMEMBER_COOKIE_DURATION = timedelta(days=14)
|
||||
|
||||
WTF_CSRF_TIME_LIMIT = None
|
||||
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
SESSION_COOKIE_SECURE = False
|
||||
REMEMBER_COOKIE_SECURE = False
|
||||
|
||||
|
||||
class TestingConfig(Config):
|
||||
TESTING = True
|
||||
WTF_CSRF_ENABLED = False
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
|
||||
SERVER_NAME = "localhost.test"
|
||||
SESSION_COOKIE_SECURE = False
|
||||
REMEMBER_COOKIE_SECURE = False
|
||||
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
TESTING = False
|
||||
SESSION_COOKIE_SECURE = True
|
||||
REMEMBER_COOKIE_SECURE = True
|
||||
|
||||
|
||||
config_by_name = {
|
||||
"development": DevelopmentConfig,
|
||||
"testing": TestingConfig,
|
||||
"production": ProductionConfig,
|
||||
}
|
||||
17
app/extensions.py
Normal file
17
app/extensions.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask_login import LoginManager
|
||||
from flask_migrate import Migrate
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_wtf import CSRFProtect
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
db = SQLAlchemy(model_class=Base)
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
csrf = CSRFProtect()
|
||||
1
app/main/__init__.py
Normal file
1
app/main/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
28
app/main/routes.py
Normal file
28
app/main/routes.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, redirect, render_template, url_for
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app.models import Account, AttendanceEvent, Person
|
||||
|
||||
bp = Blueprint("main", __name__)
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
def index():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for("main.dashboard"))
|
||||
return redirect(url_for("auth.login"))
|
||||
|
||||
|
||||
@bp.get("/dashboard")
|
||||
@login_required
|
||||
def dashboard():
|
||||
stats = {
|
||||
"people": Person.query.count(),
|
||||
"active_people": Person.query.filter_by(active=True).count(),
|
||||
"events": AttendanceEvent.query.count(),
|
||||
"accounts": Account.query.count(),
|
||||
}
|
||||
latest_events = AttendanceEvent.query.order_by(AttendanceEvent.occurred_at.desc()).limit(8).all()
|
||||
return render_template("dashboard.html", stats=stats, latest_events=latest_events)
|
||||
12
app/models/__init__.py
Normal file
12
app/models/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from app.models.account import Account, Role
|
||||
from app.models.attendance import AttendanceEvent, AttendanceType
|
||||
from app.models.person import FaceEncoding, Person
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AttendanceEvent",
|
||||
"AttendanceType",
|
||||
"FaceEncoding",
|
||||
"Person",
|
||||
"Role",
|
||||
]
|
||||
68
app/models/account.py
Normal file
68
app/models/account.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from flask_login import UserMixin
|
||||
from sqlalchemy import Boolean, DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
from werkzeug.security import check_password_hash, generate_password_hash
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
ADMIN = "admin"
|
||||
OPERATOR = "operator"
|
||||
VIEWER = "viewer"
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return self.value.title()
|
||||
|
||||
|
||||
class Account(UserMixin, db.Model):
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(80), unique=True, index=True, nullable=False)
|
||||
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
||||
full_name: Mapped[str] = mapped_column(String(160), nullable=False)
|
||||
role: Mapped[str] = mapped_column(String(20), default=Role.VIEWER.value, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
attendance_events = relationship("AttendanceEvent", back_populates="created_by")
|
||||
|
||||
@property
|
||||
def is_active(self) -> bool:
|
||||
return self.active
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
self.password_hash = generate_password_hash(password, method="scrypt")
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
def has_role(self, *roles: str | Role) -> bool:
|
||||
normalized = {role.value if isinstance(role, Role) else role for role in roles}
|
||||
return self.role in normalized
|
||||
|
||||
@property
|
||||
def is_admin(self) -> bool:
|
||||
return self.role == Role.ADMIN.value
|
||||
|
||||
@property
|
||||
def can_operate(self) -> bool:
|
||||
return self.role in {Role.ADMIN.value, Role.OPERATOR.value}
|
||||
44
app/models/attendance.py
Normal file
44
app/models/attendance.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import DateTime, Float, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class AttendanceType(str, Enum):
|
||||
CHECK_IN = "check_in"
|
||||
CHECK_OUT = "check_out"
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return "Check in" if self is AttendanceType.CHECK_IN else "Check out"
|
||||
|
||||
|
||||
class AttendanceEvent(db.Model):
|
||||
__tablename__ = "attendance_events"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
person_id: Mapped[int] = mapped_column(ForeignKey("people.id"), nullable=False, index=True)
|
||||
event_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False, index=True)
|
||||
source: Mapped[str] = mapped_column(String(80), default="web", nullable=False)
|
||||
confidence: Mapped[float | None] = mapped_column(Float)
|
||||
created_by_id: Mapped[int | None] = mapped_column(ForeignKey("accounts.id"), index=True)
|
||||
|
||||
person = relationship("Person", back_populates="attendance_events")
|
||||
created_by = relationship("Account", back_populates="attendance_events")
|
||||
|
||||
@property
|
||||
def type_label(self) -> str:
|
||||
try:
|
||||
return AttendanceType(self.event_type).label
|
||||
except ValueError:
|
||||
return self.event_type
|
||||
62
app/models/person.py
Normal file
62
app/models/person.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.extensions import db
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Person(db.Model):
|
||||
__tablename__ = "people"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
full_name: Mapped[str] = mapped_column(String(160), nullable=False, index=True)
|
||||
employee_id: Mapped[str] = mapped_column(String(80), unique=True, index=True, nullable=False)
|
||||
notes: Mapped[str | None] = mapped_column(Text)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=utcnow,
|
||||
onupdate=utcnow,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
face_encodings: Mapped[list["FaceEncoding"]] = relationship(
|
||||
back_populates="person",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
attendance_events = relationship(
|
||||
"AttendanceEvent",
|
||||
back_populates="person",
|
||||
cascade="all, delete-orphan",
|
||||
lazy="selectin",
|
||||
)
|
||||
|
||||
|
||||
class FaceEncoding(db.Model):
|
||||
__tablename__ = "face_encodings"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
person_id: Mapped[int] = mapped_column(ForeignKey("people.id"), nullable=False, index=True)
|
||||
image_slot: Mapped[int] = mapped_column(default=1, nullable=False)
|
||||
vector_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False)
|
||||
|
||||
person: Mapped[Person] = relationship(back_populates="face_encodings")
|
||||
|
||||
@property
|
||||
def vector(self) -> list[float]:
|
||||
return json.loads(self.vector_json)
|
||||
|
||||
@vector.setter
|
||||
def vector(self, value: list[float]) -> None:
|
||||
self.vector_json = json.dumps([float(item) for item in value])
|
||||
1
app/people/__init__.py
Normal file
1
app/people/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
24
app/people/forms.py
Normal file
24
app/people/forms.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import FileField, StringField, SubmitField, TextAreaField
|
||||
from wtforms.validators import DataRequired, Length, ValidationError
|
||||
|
||||
from app.models import Person
|
||||
|
||||
|
||||
class PersonForm(FlaskForm):
|
||||
full_name = StringField("Full name", validators=[DataRequired(), Length(max=160)])
|
||||
employee_id = StringField("Employee ID", validators=[DataRequired(), Length(max=80)])
|
||||
notes = TextAreaField("Notes", validators=[Length(max=2000)])
|
||||
photo_1 = FileField("Reference photo 1")
|
||||
photo_2 = FileField("Reference photo 2")
|
||||
photo_3 = FileField("Reference photo 3")
|
||||
photo_4 = FileField("Reference photo 4")
|
||||
submit = SubmitField("Enroll person")
|
||||
|
||||
def validate_employee_id(self, field) -> None:
|
||||
employee_id = field.data.strip()
|
||||
if Person.query.filter_by(employee_id=employee_id).first():
|
||||
raise ValidationError("That employee ID is already enrolled.")
|
||||
field.data = employee_id
|
||||
84
app/people/routes.py
Normal file
84
app/people/routes.py
Normal file
@@ -0,0 +1,84 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, abort, flash, redirect, render_template, request, url_for
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import FaceEncoding, Person, Role
|
||||
from app.people.forms import PersonForm
|
||||
from app.services.attendance import last_presence_summary
|
||||
from app.services.recognition import RecognitionUnavailable, encode_uploaded_image
|
||||
from app.services.security import roles_required
|
||||
|
||||
bp = Blueprint("people", __name__, url_prefix="/people")
|
||||
|
||||
|
||||
@bp.get("/")
|
||||
@roles_required(Role.ADMIN, Role.OPERATOR, Role.VIEWER)
|
||||
def index():
|
||||
people = Person.query.order_by(Person.full_name.asc()).all()
|
||||
return render_template("people/index.html", people=people)
|
||||
|
||||
|
||||
@bp.route("/new", methods=["GET", "POST"])
|
||||
@roles_required(Role.ADMIN, Role.OPERATOR)
|
||||
def new_person():
|
||||
form = PersonForm()
|
||||
if form.validate_on_submit():
|
||||
files = [field.data for field in (form.photo_1, form.photo_2, form.photo_3, form.photo_4) if field.data]
|
||||
files = [file for file in files if file.filename]
|
||||
if not files:
|
||||
flash("Upload at least one reference photo.", "danger")
|
||||
return render_template("people/form.html", form=form)
|
||||
|
||||
try:
|
||||
vectors = [encode_uploaded_image(file) for file in files]
|
||||
except RecognitionUnavailable as exc:
|
||||
flash(str(exc), "danger")
|
||||
return render_template("people/form.html", form=form), 503
|
||||
except ValueError as exc:
|
||||
flash(str(exc), "danger")
|
||||
return render_template("people/form.html", form=form)
|
||||
|
||||
person = Person(
|
||||
full_name=form.full_name.data.strip(),
|
||||
employee_id=form.employee_id.data.strip(),
|
||||
notes=form.notes.data.strip() or None,
|
||||
)
|
||||
for slot, vector in enumerate(vectors, start=1):
|
||||
encoding = FaceEncoding(image_slot=slot)
|
||||
encoding.vector = vector
|
||||
person.face_encodings.append(encoding)
|
||||
|
||||
db.session.add(person)
|
||||
db.session.commit()
|
||||
flash(f"{person.full_name} has been enrolled.", "success")
|
||||
return redirect(url_for("people.detail", person_id=person.id))
|
||||
return render_template("people/form.html", form=form)
|
||||
|
||||
|
||||
@bp.get("/<int:person_id>")
|
||||
@roles_required(Role.ADMIN, Role.OPERATOR, Role.VIEWER)
|
||||
def detail(person_id: int):
|
||||
person = db.session.get(Person, person_id)
|
||||
if not person:
|
||||
abort(404)
|
||||
events = person.attendance_events[:]
|
||||
events.sort(key=lambda event: event.occurred_at, reverse=True)
|
||||
return render_template(
|
||||
"people/detail.html",
|
||||
person=person,
|
||||
events=events[:20],
|
||||
presence_summary=last_presence_summary(person),
|
||||
)
|
||||
|
||||
|
||||
@bp.post("/<int:person_id>/toggle")
|
||||
@roles_required(Role.ADMIN, Role.OPERATOR)
|
||||
def toggle_person(person_id: int):
|
||||
person = db.session.get(Person, person_id)
|
||||
if not person:
|
||||
abort(404)
|
||||
person.active = not person.active
|
||||
db.session.commit()
|
||||
flash(f"{person.full_name} is now {'active' if person.active else 'inactive'}.", "success")
|
||||
return redirect(request.referrer or url_for("people.index"))
|
||||
1
app/recognition/__init__.py
Normal file
1
app/recognition/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
44
app/recognition/routes.py
Normal file
44
app/recognition/routes.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, current_app, jsonify, request
|
||||
|
||||
from app.models import Person, Role
|
||||
from app.services.attendance import last_presence_summary
|
||||
from app.services.recognition import RecognitionUnavailable, recognize_faces
|
||||
from app.services.security import roles_required
|
||||
|
||||
bp = Blueprint("recognition", __name__, url_prefix="/recognition")
|
||||
|
||||
|
||||
@bp.post("/identify")
|
||||
@roles_required(Role.ADMIN, Role.OPERATOR)
|
||||
def identify():
|
||||
image = request.form.get("image", "")
|
||||
if not image:
|
||||
return jsonify({"error": "No image was provided."}), 400
|
||||
|
||||
people = Person.query.filter_by(active=True).all()
|
||||
try:
|
||||
result = recognize_faces(image, people, tolerance=current_app.config["FACE_MATCH_TOLERANCE"])
|
||||
except RecognitionUnavailable as exc:
|
||||
return jsonify({"error": str(exc)}), 503
|
||||
except ValueError as exc:
|
||||
return jsonify({"error": str(exc)}), 400
|
||||
|
||||
faces = []
|
||||
for face in result.faces:
|
||||
if not face.person:
|
||||
faces.append({"matched": False, "confidence": None})
|
||||
continue
|
||||
faces.append(
|
||||
{
|
||||
"matched": True,
|
||||
"person_id": face.person.id,
|
||||
"full_name": face.person.full_name,
|
||||
"employee_id": face.person.employee_id,
|
||||
"confidence": round(face.confidence, 3) if face.confidence is not None else None,
|
||||
"last_presence": last_presence_summary(face.person),
|
||||
}
|
||||
)
|
||||
|
||||
return jsonify({"faces": faces, "annotated_image": result.annotated_image})
|
||||
51
app/services/attendance.py
Normal file
51
app/services/attendance.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.extensions import db
|
||||
from app.models import AttendanceEvent, AttendanceType, Person
|
||||
|
||||
|
||||
def record_attendance(
|
||||
person: Person,
|
||||
event_type: AttendanceType,
|
||||
*,
|
||||
created_by_id: int | None = None,
|
||||
confidence: float | None = None,
|
||||
source: str = "web",
|
||||
) -> AttendanceEvent:
|
||||
event = AttendanceEvent(
|
||||
person=person,
|
||||
event_type=event_type.value,
|
||||
occurred_at=datetime.now(timezone.utc),
|
||||
created_by_id=created_by_id,
|
||||
confidence=confidence,
|
||||
source=source,
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
return event
|
||||
|
||||
|
||||
def last_presence_summary(person: Person) -> str:
|
||||
check_in = (
|
||||
AttendanceEvent.query.filter_by(person_id=person.id, event_type=AttendanceType.CHECK_IN.value)
|
||||
.order_by(AttendanceEvent.occurred_at.desc())
|
||||
.first()
|
||||
)
|
||||
check_out = (
|
||||
AttendanceEvent.query.filter_by(person_id=person.id, event_type=AttendanceType.CHECK_OUT.value)
|
||||
.order_by(AttendanceEvent.occurred_at.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
if not check_in and not check_out:
|
||||
return "No attendance recorded"
|
||||
|
||||
check_in_text = _format_event_time(check_in) if check_in else "no check-in"
|
||||
check_out_text = _format_event_time(check_out) if check_out else "no check-out"
|
||||
return f"From {check_in_text} to {check_out_text}"
|
||||
|
||||
|
||||
def _format_event_time(event: AttendanceEvent) -> str:
|
||||
return event.occurred_at.strftime("%Y-%m-%d %H:%M")
|
||||
125
app/services/recognition.py
Normal file
125
app/services/recognition.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
from app.models import Person
|
||||
|
||||
|
||||
class RecognitionUnavailable(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DetectedFace:
|
||||
person: "Person | None"
|
||||
confidence: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecognitionResult:
|
||||
faces: list[DetectedFace]
|
||||
annotated_image: str
|
||||
|
||||
|
||||
def encode_uploaded_image(file: "FileStorage") -> list[float]:
|
||||
cv2, np, face_recognition = _load_dependencies()
|
||||
raw = file.read()
|
||||
image = _decode_bytes(raw, cv2, np)
|
||||
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
encodings = face_recognition.face_encodings(rgb)
|
||||
if len(encodings) == 0:
|
||||
raise ValueError(f"No face was detected in {file.filename}.")
|
||||
if len(encodings) > 1:
|
||||
raise ValueError(f"Upload a single-face photo for {file.filename}.")
|
||||
return [float(value) for value in encodings[0]]
|
||||
|
||||
|
||||
def recognize_faces(data_url: str, people: list["Person"], *, tolerance: float) -> RecognitionResult:
|
||||
cv2, np, face_recognition = _load_dependencies()
|
||||
image = _decode_data_url(data_url, cv2, np)
|
||||
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
||||
|
||||
locations = face_recognition.face_locations(rgb)
|
||||
encodings = face_recognition.face_encodings(rgb, locations)
|
||||
candidates = [
|
||||
(person, encoding.vector)
|
||||
for person in people
|
||||
for encoding in person.face_encodings
|
||||
]
|
||||
|
||||
faces: list[DetectedFace] = []
|
||||
for location, face_encoding in zip(locations, encodings, strict=False):
|
||||
person = None
|
||||
confidence = None
|
||||
label = "Unknown"
|
||||
|
||||
if candidates:
|
||||
vectors = [candidate[1] for candidate in candidates]
|
||||
distances = face_recognition.face_distance(vectors, face_encoding)
|
||||
best_index = int(np.argmin(distances))
|
||||
best_distance = float(distances[best_index])
|
||||
if best_distance <= tolerance:
|
||||
person = candidates[best_index][0]
|
||||
confidence = max(0.0, 1.0 - best_distance)
|
||||
label = person.full_name
|
||||
|
||||
faces.append(DetectedFace(person=person, confidence=confidence))
|
||||
_draw_face_label(image, location, label, cv2)
|
||||
|
||||
if not faces:
|
||||
return RecognitionResult(faces=[DetectedFace(person=None, confidence=None)], annotated_image=_encode_png(image, cv2))
|
||||
return RecognitionResult(faces=faces, annotated_image=_encode_png(image, cv2))
|
||||
|
||||
|
||||
def _load_dependencies():
|
||||
try:
|
||||
import cv2
|
||||
import face_recognition
|
||||
import numpy as np
|
||||
except ImportError as exc:
|
||||
raise RecognitionUnavailable(
|
||||
"Face recognition dependencies are not installed. Install requirements on a supported Python version."
|
||||
) from exc
|
||||
return cv2, np, face_recognition
|
||||
|
||||
|
||||
def _decode_data_url(data_url: str, cv2, np):
|
||||
if "," not in data_url:
|
||||
raise ValueError("Invalid image payload.")
|
||||
header, encoded = data_url.split(",", 1)
|
||||
if not header.startswith("data:image/"):
|
||||
raise ValueError("Only image uploads are supported.")
|
||||
try:
|
||||
raw = base64.b64decode(encoded, validate=True)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid image encoding.") from exc
|
||||
return _decode_bytes(raw, cv2, np)
|
||||
|
||||
|
||||
def _decode_bytes(raw: bytes, cv2, np):
|
||||
if not raw:
|
||||
raise ValueError("The uploaded image is empty.")
|
||||
array = np.frombuffer(raw, np.uint8)
|
||||
image = cv2.imdecode(array, cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
raise ValueError("The uploaded file is not a readable image.")
|
||||
return image
|
||||
|
||||
|
||||
def _draw_face_label(image, location, label: str, cv2) -> None:
|
||||
top, right, bottom, left = location
|
||||
cv2.rectangle(image, (left, top), (right, bottom), (30, 136, 229), 2)
|
||||
cv2.rectangle(image, (left, bottom - 28), (right, bottom), (30, 136, 229), cv2.FILLED)
|
||||
cv2.putText(image, label[:32], (left + 6, bottom - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1)
|
||||
|
||||
|
||||
def _encode_png(image, cv2) -> str:
|
||||
ok, buffer = cv2.imencode(".png", image)
|
||||
if not ok:
|
||||
raise ValueError("Could not encode processed image.")
|
||||
return "data:image/png;base64," + base64.b64encode(buffer).decode("ascii")
|
||||
28
app/services/security.py
Normal file
28
app/services/security.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import TypeVar
|
||||
|
||||
from flask import abort
|
||||
from flask_login import current_user, login_required
|
||||
|
||||
from app.models import Role
|
||||
|
||||
F = TypeVar("F", bound=Callable)
|
||||
|
||||
|
||||
def roles_required(*roles: str | Role) -> Callable[[F], F]:
|
||||
allowed_roles = {role.value if isinstance(role, Role) else role for role in roles}
|
||||
|
||||
def decorator(view: F) -> F:
|
||||
@wraps(view)
|
||||
@login_required
|
||||
def wrapped(*args, **kwargs):
|
||||
if not current_user.is_authenticated or current_user.role not in allowed_roles:
|
||||
abort(403)
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped # type: ignore[return-value]
|
||||
|
||||
return decorator
|
||||
286
app/static/css/app.css
Normal file
286
app/static/css/app.css
Normal file
@@ -0,0 +1,286 @@
|
||||
:root {
|
||||
--surface: #ffffff;
|
||||
--surface-muted: #f6f7f9;
|
||||
--border: #d8dee6;
|
||||
--text: #172033;
|
||||
--muted: #657084;
|
||||
--accent: #0f6b5f;
|
||||
--accent-strong: #0b5a50;
|
||||
--info: #275f9f;
|
||||
--warning: #a35f00;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background: var(--surface-muted);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
--bs-btn-bg: var(--accent);
|
||||
--bs-btn-border-color: var(--accent);
|
||||
--bs-btn-hover-bg: var(--accent-strong);
|
||||
--bs-btn-hover-border-color: var(--accent-strong);
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
padding: 2rem 0 3rem;
|
||||
}
|
||||
|
||||
.flash-stack {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(1.75rem, 2vw, 2.35rem);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.35rem;
|
||||
color: var(--muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.auth-layout {
|
||||
min-height: calc(100vh - 4rem);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.auth-panel,
|
||||
.form-section,
|
||||
.content-section,
|
||||
.camera-panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 16px 40px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: min(100%, 440px);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.form-section,
|
||||
.content-section,
|
||||
.camera-panel {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-header h2,
|
||||
.content-section h2 {
|
||||
font-size: 1.15rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.1rem;
|
||||
}
|
||||
|
||||
.stat-card span {
|
||||
display: block;
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-card strong {
|
||||
display: block;
|
||||
margin-top: 0.35rem;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 8px;
|
||||
color: var(--muted);
|
||||
padding: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
color: var(--muted);
|
||||
font-size: 0.9rem;
|
||||
margin: 0.85rem 0 0;
|
||||
}
|
||||
|
||||
.upload-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-grid,
|
||||
.kiosk-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.7fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 1fr;
|
||||
gap: 0.65rem 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.detail-list dt {
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.detail-list dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.event-list,
|
||||
.result-list {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.event-row,
|
||||
.result-card {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.event-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.camera-frame {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
aspect-ratio: 16 / 10;
|
||||
background: #111827;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.camera-frame video,
|
||||
.camera-frame img,
|
||||
.camera-frame canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.camera-frame img,
|
||||
.camera-frame canvas {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.camera-frame.has-preview video {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.camera-frame.has-preview img {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.camera-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.result-card h3 {
|
||||
font-size: 1rem;
|
||||
margin: 0 0 0.35rem;
|
||||
}
|
||||
|
||||
.result-card p {
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.result-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.error-page {
|
||||
max-width: 620px;
|
||||
margin: 10vh auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.stats-grid,
|
||||
.upload-grid,
|
||||
.detail-grid,
|
||||
.kiosk-grid {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.page-header,
|
||||
.section-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stats-grid,
|
||||
.upload-grid,
|
||||
.detail-grid,
|
||||
.kiosk-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
146
app/static/js/kiosk.js
Normal file
146
app/static/js/kiosk.js
Normal file
@@ -0,0 +1,146 @@
|
||||
const kiosk = document.querySelector(".kiosk-grid");
|
||||
const video = document.getElementById("cameraStream");
|
||||
const canvas = document.getElementById("snapshotCanvas");
|
||||
const preview = document.getElementById("snapshotPreview");
|
||||
const startButton = document.getElementById("startCamera");
|
||||
const captureButton = document.getElementById("capturePhoto");
|
||||
const statusText = document.getElementById("cameraStatus");
|
||||
const results = document.getElementById("recognitionResults");
|
||||
const frame = document.querySelector(".camera-frame");
|
||||
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
|
||||
|
||||
let activeStream = null;
|
||||
let lastFaces = [];
|
||||
|
||||
startButton.addEventListener("click", async () => {
|
||||
try {
|
||||
activeStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: "user" }, audio: false });
|
||||
video.srcObject = activeStream;
|
||||
frame.classList.remove("has-preview");
|
||||
captureButton.disabled = false;
|
||||
statusText.textContent = "Camera is active.";
|
||||
} catch (error) {
|
||||
statusText.textContent = `Camera access failed: ${error.name}`;
|
||||
}
|
||||
});
|
||||
|
||||
captureButton.addEventListener("click", async () => {
|
||||
if (!video.videoWidth || !video.videoHeight) {
|
||||
statusText.textContent = "Camera is not ready yet.";
|
||||
return;
|
||||
}
|
||||
|
||||
canvas.width = video.videoWidth;
|
||||
canvas.height = video.videoHeight;
|
||||
canvas.getContext("2d").drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||
const image = canvas.toDataURL("image/png");
|
||||
await identify(image);
|
||||
});
|
||||
|
||||
async function identify(image) {
|
||||
setLoading("Identifying faces...");
|
||||
const formData = new FormData();
|
||||
formData.append("image", image);
|
||||
|
||||
const response = await fetch(kiosk.dataset.identifyUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRFToken": csrfToken, "Accept": "application/json" },
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
showError(payload.error || "Recognition failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
lastFaces = payload.faces;
|
||||
preview.src = payload.annotated_image;
|
||||
frame.classList.add("has-preview");
|
||||
renderFaces(payload.faces);
|
||||
}
|
||||
|
||||
function renderFaces(faces) {
|
||||
results.innerHTML = "";
|
||||
if (!faces.length) {
|
||||
results.innerHTML = '<div class="empty-state">No faces were detected.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
faces.forEach((face) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "result-card";
|
||||
|
||||
if (!face.matched) {
|
||||
card.innerHTML = "<h3>Unknown person</h3><p>No enrolled person matched this face.</p>";
|
||||
results.appendChild(card);
|
||||
return;
|
||||
}
|
||||
|
||||
const confidence = face.confidence === null ? "n/a" : `${Math.round(face.confidence * 100)}%`;
|
||||
card.innerHTML = `
|
||||
<h3>${escapeHtml(face.full_name)}</h3>
|
||||
<p>${escapeHtml(face.employee_id)} · Confidence ${confidence}<br>${escapeHtml(face.last_presence)}</p>
|
||||
<div class="result-actions">
|
||||
<button class="btn btn-success btn-sm" data-event-type="check_in" data-person-id="${face.person_id}">Check in</button>
|
||||
<button class="btn btn-warning btn-sm" data-event-type="check_out" data-person-id="${face.person_id}">Check out</button>
|
||||
</div>
|
||||
<div class="small text-secondary mt-2" data-status></div>
|
||||
`;
|
||||
results.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
results.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("button[data-event-type]");
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const face = lastFaces.find((item) => item.person_id === Number(button.dataset.personId));
|
||||
const status = button.closest(".result-card").querySelector("[data-status]");
|
||||
button.disabled = true;
|
||||
status.textContent = "Recording attendance...";
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("person_id", button.dataset.personId);
|
||||
formData.append("event_type", button.dataset.eventType);
|
||||
if (face && face.confidence !== null) {
|
||||
formData.append("confidence", face.confidence);
|
||||
}
|
||||
|
||||
const response = await fetch(kiosk.dataset.recordUrl, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "same-origin",
|
||||
headers: { "X-CSRFToken": csrfToken, "Accept": "application/json" },
|
||||
});
|
||||
const payload = await response.json();
|
||||
button.disabled = false;
|
||||
|
||||
if (!response.ok) {
|
||||
status.textContent = "Could not record attendance.";
|
||||
return;
|
||||
}
|
||||
status.textContent = `${payload.event_type} recorded at ${payload.occurred_at}. ${payload.summary}`;
|
||||
});
|
||||
|
||||
function setLoading(message) {
|
||||
statusText.textContent = message;
|
||||
results.innerHTML = `<div class="empty-state">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
function showError(message) {
|
||||
statusText.textContent = message;
|
||||
results.innerHTML = `<div class="empty-state">${escapeHtml(message)}</div>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
31
app/templates/admin/account_form.html
Normal file
31
app/templates/admin/account_form.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/forms.html" import render_field, render_select %}
|
||||
|
||||
{% block title %}Create account · Face Attendance{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Administration</p>
|
||||
<h1>Create account</h1>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('admin.accounts') }}">Back</a>
|
||||
</div>
|
||||
|
||||
<section class="form-section">
|
||||
<form method="post" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">{{ render_field(form.full_name) }}</div>
|
||||
<div class="col-md-6">{{ render_field(form.username) }}</div>
|
||||
<div class="col-md-6">{{ render_field(form.email) }}</div>
|
||||
<div class="col-md-6">{{ render_select(form.role) }}</div>
|
||||
<div class="col-md-6">{{ render_field(form.password) }}</div>
|
||||
<div class="col-md-6">{{ render_field(form.confirm_password) }}</div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
53
app/templates/admin/accounts.html
Normal file
53
app/templates/admin/accounts.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Accounts · Face Attendance{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Administration</p>
|
||||
<h1>Accounts</h1>
|
||||
</div>
|
||||
<a class="btn btn-primary" href="{{ url_for('admin.new_account') }}">Create account</a>
|
||||
</div>
|
||||
|
||||
<section class="content-section">
|
||||
{% if accounts %}
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Username</th>
|
||||
<th>Email</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for account in accounts %}
|
||||
<tr>
|
||||
<td>{{ account.full_name }}</td>
|
||||
<td>{{ account.username }}</td>
|
||||
<td>{{ account.email }}</td>
|
||||
<td>{{ account.role|title }}</td>
|
||||
<td><span class="badge text-bg-{{ 'success' if account.active else 'secondary' }}">{{ "Active" if account.active else "Inactive" }}</span></td>
|
||||
<td class="text-end">
|
||||
{% if account.id != current_user.id %}
|
||||
<form method="post" action="{{ url_for('admin.toggle_account', account_id=account.id) }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">{{ "Deactivate" if account.active else "Activate" }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">No staff accounts exist yet.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
48
app/templates/attendance/index.html
Normal file
48
app/templates/attendance/index.html
Normal file
@@ -0,0 +1,48 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Attendance · Face Attendance{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Reports</p>
|
||||
<h1>Attendance</h1>
|
||||
</div>
|
||||
{% if current_user.can_operate %}
|
||||
<a class="btn btn-primary" href="{{ url_for('attendance.kiosk') }}">Open kiosk</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section class="content-section">
|
||||
{% if events %}
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Person</th>
|
||||
<th>Employee ID</th>
|
||||
<th>Type</th>
|
||||
<th>Time</th>
|
||||
<th>Source</th>
|
||||
<th>Recorded by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('people.detail', person_id=event.person.id) }}">{{ event.person.full_name }}</a></td>
|
||||
<td>{{ event.person.employee_id }}</td>
|
||||
<td><span class="badge text-bg-{{ 'success' if event.event_type == 'check_in' else 'warning' }}">{{ event.type_label }}</span></td>
|
||||
<td>{{ event.occurred_at.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td>{{ event.source }}</td>
|
||||
<td>{{ event.created_by.full_name if event.created_by else "System" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">No attendance has been recorded yet.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
39
app/templates/attendance/kiosk.html
Normal file
39
app/templates/attendance/kiosk.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Kiosk · Face Attendance{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Recognition</p>
|
||||
<h1>Attendance kiosk</h1>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('attendance.index') }}">Reports</a>
|
||||
</div>
|
||||
|
||||
<div class="kiosk-grid" data-identify-url="{{ url_for('recognition.identify') }}" data-record-url="{{ url_for('attendance.create_event') }}">
|
||||
<section class="camera-panel">
|
||||
<div class="camera-frame">
|
||||
<video id="cameraStream" autoplay playsinline muted></video>
|
||||
<img id="snapshotPreview" alt="Processed camera snapshot">
|
||||
<canvas id="snapshotCanvas"></canvas>
|
||||
</div>
|
||||
<div class="camera-actions">
|
||||
<button class="btn btn-outline-secondary" id="startCamera" type="button">Start camera</button>
|
||||
<button class="btn btn-primary" id="capturePhoto" type="button" disabled>Capture and identify</button>
|
||||
</div>
|
||||
<p class="text-secondary small mb-0" id="cameraStatus">Camera is not active.</p>
|
||||
</section>
|
||||
|
||||
<section class="content-section result-panel">
|
||||
<h2>Recognition results</h2>
|
||||
<div id="recognitionResults" class="result-list">
|
||||
<div class="empty-state">Capture a face to see matching people.</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='js/kiosk.js') }}"></script>
|
||||
{% endblock %}
|
||||
27
app/templates/auth/login.html
Normal file
27
app/templates/auth/login.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/forms.html" import render_field %}
|
||||
|
||||
{% block title %}Sign in · Face Attendance{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="auth-layout">
|
||||
<section class="auth-panel">
|
||||
<div class="mb-4">
|
||||
<p class="eyebrow">Secure attendance management</p>
|
||||
<h1 class="h3 mb-2">Sign in</h1>
|
||||
<p class="text-secondary mb-0">Use your staff account to access the attendance system.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
{{ render_field(form.username, "username") }}
|
||||
{{ render_field(form.password, "password") }}
|
||||
<div class="form-check mb-4">
|
||||
{{ form.remember(class="form-check-input") }}
|
||||
{{ form.remember.label(class="form-check-label") }}
|
||||
</div>
|
||||
{{ form.submit(class="btn btn-primary w-100") }}
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
65
app/templates/base.html
Normal file
65
app/templates/base.html
Normal file
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<title>{% block title %}Face Attendance{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||
</head>
|
||||
<body>
|
||||
{% if current_user.is_authenticated %}
|
||||
<nav class="navbar navbar-expand-lg border-bottom bg-white sticky-top">
|
||||
<div class="container-fluid px-4">
|
||||
<a class="navbar-brand fw-semibold" href="{{ url_for('main.dashboard') }}">Face Attendance</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#mainNav" aria-controls="mainNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="mainNav">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('main.dashboard') }}">Dashboard</a></li>
|
||||
{% if current_user.can_operate %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('attendance.kiosk') }}">Kiosk</a></li>
|
||||
{% endif %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('people.index') }}">People</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('attendance.index') }}">Attendance</a></li>
|
||||
{% if current_user.is_admin %}
|
||||
<li class="nav-item"><a class="nav-link" href="{{ url_for('admin.accounts') }}">Accounts</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<div class="d-flex align-items-center gap-3">
|
||||
<span class="small text-secondary">{{ current_user.full_name }} · {{ current_user.role|title }}</span>
|
||||
<form method="post" action="{{ url_for('auth.logout') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<main class="page-shell">
|
||||
<div class="container-fluid px-4">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-stack">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
67
app/templates/dashboard.html
Normal file
67
app/templates/dashboard.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard · Face Attendance{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Overview</p>
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
{% if current_user.can_operate %}
|
||||
<a class="btn btn-primary" href="{{ url_for('attendance.kiosk') }}">Open kiosk</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span>Active people</span>
|
||||
<strong>{{ stats.active_people }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Total people</span>
|
||||
<strong>{{ stats.people }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Attendance events</span>
|
||||
<strong>{{ stats.events }}</strong>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span>Staff accounts</span>
|
||||
<strong>{{ stats.accounts }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="content-section">
|
||||
<div class="section-header">
|
||||
<h2>Recent attendance</h2>
|
||||
<a href="{{ url_for('attendance.index') }}">View all</a>
|
||||
</div>
|
||||
{% if latest_events %}
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Person</th>
|
||||
<th>Type</th>
|
||||
<th>Time</th>
|
||||
<th>Recorded by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in latest_events %}
|
||||
<tr>
|
||||
<td>{{ event.person.full_name }}</td>
|
||||
<td><span class="badge text-bg-{{ 'success' if event.event_type == 'check_in' else 'warning' }}">{{ event.type_label }}</span></td>
|
||||
<td>{{ event.occurred_at.strftime("%Y-%m-%d %H:%M") }}</td>
|
||||
<td>{{ event.created_by.full_name if event.created_by else "System" }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">No attendance has been recorded yet.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
9
app/templates/errors/403.html
Normal file
9
app/templates/errors/403.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Access denied · Face Attendance{% endblock %}
|
||||
{% block content %}
|
||||
<section class="error-page">
|
||||
<h1>Access denied</h1>
|
||||
<p>Your account does not have permission to perform this action.</p>
|
||||
<a class="btn btn-primary" href="{{ url_for('main.dashboard') }}">Go to dashboard</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
9
app/templates/errors/404.html
Normal file
9
app/templates/errors/404.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Not found · Face Attendance{% endblock %}
|
||||
{% block content %}
|
||||
<section class="error-page">
|
||||
<h1>Page not found</h1>
|
||||
<p>The page or record you requested does not exist.</p>
|
||||
<a class="btn btn-primary" href="{{ url_for('main.dashboard') }}">Go to dashboard</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
9
app/templates/errors/500.html
Normal file
9
app/templates/errors/500.html
Normal file
@@ -0,0 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Server error · Face Attendance{% endblock %}
|
||||
{% block content %}
|
||||
<section class="error-page">
|
||||
<h1>Server error</h1>
|
||||
<p>The application could not complete the request. The error has been logged.</p>
|
||||
<a class="btn btn-primary" href="{{ url_for('main.dashboard') }}">Go to dashboard</a>
|
||||
</section>
|
||||
{% endblock %}
|
||||
35
app/templates/macros/forms.html
Normal file
35
app/templates/macros/forms.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% macro render_field(field, placeholder='') %}
|
||||
<div class="mb-3">
|
||||
{{ field.label(class="form-label") }}
|
||||
{{ field(class="form-control" + (" is-invalid" if field.errors else ""), placeholder=placeholder) }}
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{{ field.errors|join(" ") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_textarea(field, rows=4) %}
|
||||
<div class="mb-3">
|
||||
{{ field.label(class="form-label") }}
|
||||
{{ field(class="form-control" + (" is-invalid" if field.errors else ""), rows=rows) }}
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{{ field.errors|join(" ") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
{% macro render_select(field) %}
|
||||
<div class="mb-3">
|
||||
{{ field.label(class="form-label") }}
|
||||
{{ field(class="form-select" + (" is-invalid" if field.errors else "")) }}
|
||||
{% if field.errors %}
|
||||
<div class="invalid-feedback">
|
||||
{{ field.errors|join(" ") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
46
app/templates/people/detail.html
Normal file
46
app/templates/people/detail.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ person.full_name }} · Face Attendance{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Person profile</p>
|
||||
<h1>{{ person.full_name }}</h1>
|
||||
<p class="text-secondary mb-0">{{ person.employee_id }} · {{ "Active" if person.active else "Inactive" }}</p>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('people.index') }}">Back</a>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<section class="content-section">
|
||||
<h2>Profile</h2>
|
||||
<dl class="detail-list">
|
||||
<dt>Employee ID</dt>
|
||||
<dd>{{ person.employee_id }}</dd>
|
||||
<dt>Face samples</dt>
|
||||
<dd>{{ person.face_encodings|length }}</dd>
|
||||
<dt>Last presence</dt>
|
||||
<dd>{{ presence_summary }}</dd>
|
||||
<dt>Notes</dt>
|
||||
<dd>{{ person.notes or "No notes" }}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="content-section">
|
||||
<h2>Recent events</h2>
|
||||
{% if events %}
|
||||
<div class="event-list">
|
||||
{% for event in events %}
|
||||
<div class="event-row">
|
||||
<span class="badge text-bg-{{ 'success' if event.event_type == 'check_in' else 'warning' }}">{{ event.type_label }}</span>
|
||||
<span>{{ event.occurred_at.strftime("%Y-%m-%d %H:%M") }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">No attendance events for this person.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
39
app/templates/people/form.html
Normal file
39
app/templates/people/form.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "macros/forms.html" import render_field, render_textarea %}
|
||||
|
||||
{% block title %}Enroll person · Face Attendance{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Enrollment</p>
|
||||
<h1>Enroll person</h1>
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary" href="{{ url_for('people.index') }}">Back</a>
|
||||
</div>
|
||||
|
||||
<section class="form-section">
|
||||
<form method="post" enctype="multipart/form-data" novalidate>
|
||||
{{ form.hidden_tag() }}
|
||||
<div class="row g-3">
|
||||
<div class="col-md-6">{{ render_field(form.full_name) }}</div>
|
||||
<div class="col-md-6">{{ render_field(form.employee_id) }}</div>
|
||||
<div class="col-12">{{ render_textarea(form.notes, 3) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="upload-grid">
|
||||
{% for field in [form.photo_1, form.photo_2, form.photo_3, form.photo_4] %}
|
||||
<div>
|
||||
{{ field.label(class="form-label") }}
|
||||
{{ field(class="form-control", accept="image/png,image/jpeg") }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<p class="form-help">Upload one to four clear single-face photos. More samples usually improve recognition quality.</p>
|
||||
<div class="form-actions">
|
||||
{{ form.submit(class="btn btn-primary") }}
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
53
app/templates/people/index.html
Normal file
53
app/templates/people/index.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}People · Face Attendance{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<p class="eyebrow">Enrollment</p>
|
||||
<h1>People</h1>
|
||||
</div>
|
||||
{% if current_user.can_operate %}
|
||||
<a class="btn btn-primary" href="{{ url_for('people.new_person') }}">Enroll person</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<section class="content-section">
|
||||
{% if people %}
|
||||
<div class="table-responsive">
|
||||
<table class="table align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Employee ID</th>
|
||||
<th>Face samples</th>
|
||||
<th>Status</th>
|
||||
<th class="text-end">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for person in people %}
|
||||
<tr>
|
||||
<td><a href="{{ url_for('people.detail', person_id=person.id) }}">{{ person.full_name }}</a></td>
|
||||
<td>{{ person.employee_id }}</td>
|
||||
<td>{{ person.face_encodings|length }}</td>
|
||||
<td><span class="badge text-bg-{{ 'success' if person.active else 'secondary' }}">{{ "Active" if person.active else "Inactive" }}</span></td>
|
||||
<td class="text-end">
|
||||
{% if current_user.can_operate %}
|
||||
<form method="post" action="{{ url_for('people.toggle_person', person_id=person.id) }}" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">{{ "Deactivate" if person.active else "Activate" }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">No people have been enrolled yet.</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user