bump project

This commit is contained in:
2026-05-01 21:24:21 +03:30
parent dc4b1a1400
commit 139b3e26da
83 changed files with 2455 additions and 18349 deletions

123
app/__init__.py Normal file
View 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
View File

@@ -0,0 +1 @@

45
app/admin/forms.py Normal file
View 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
View 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"))

View File

@@ -0,0 +1 @@

60
app/attendance/routes.py Normal file
View 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
View File

@@ -0,0 +1 @@

12
app/auth/forms.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@

28
app/main/routes.py Normal file
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@

24
app/people/forms.py Normal file
View 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
View 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"))

View File

@@ -0,0 +1 @@

44
app/recognition/routes.py Normal file
View 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})

View 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
View 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
View 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
View 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
View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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
View 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>

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}