Oluwatosin Ayeni
fix: Python 3.10 datetime.fromisoformat fails on 5-digit microseconds from Supabase — pad to 6 digits before parsing
6f114ee
Raw
History Blame Contribute Delete
10.2 kB
import base64
import io
import os
import re
import traceback
from datetime import datetime, timezone
import numpy as np
from dotenv import load_dotenv
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from insightface.app import FaceAnalysis
from PIL import Image
from pydantic import BaseModel
from supabase import create_client
load_dotenv()
SUPABASE_URL = os.environ["SUPABASE_URL"]
SUPABASE_SERVICE_KEY = os.environ["SUPABASE_SERVICE_KEY"]
ALLOWED_ORIGIN = os.environ.get("ALLOWED_ORIGIN", "*")
FACE_MATCH_THRESHOLD = 0.65 # cosine distance (lower = more similar; 0.65 is lenient for webcam-vs-photo variation)
supabase = create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
def get_client():
"""Return a fresh Supabase client to avoid stale HTTP/2 connections."""
return create_client(SUPABASE_URL, SUPABASE_SERVICE_KEY)
# Load InsightFace model once at startup
fa = FaceAnalysis(name="buffalo_sc", providers=["CPUExecutionProvider"])
fa.prepare(ctx_id=-1, det_size=(320, 320))
app = FastAPI(title="Facial Attendance API")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ─── helpers ──────────────────────────────────────────────────────────────────
def image_from_bytes(data: bytes) -> np.ndarray:
img = Image.open(io.BytesIO(data)).convert("RGB")
arr = np.array(img)
# InsightFace expects BGR (OpenCV convention); PIL gives RGB
return arr[:, :, ::-1]
def image_from_base64(b64: str) -> np.ndarray:
if "," in b64:
b64 = b64.split(",", 1)[1]
return image_from_bytes(base64.b64decode(b64))
def encode_face(img_array: np.ndarray) -> list[float]:
"""Return the L2-normalised 512-dim embedding of the largest detected face."""
faces = fa.get(img_array)
if not faces:
return []
# Pick the largest face by bounding-box area
face = max(faces, key=lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1]))
return face.normed_embedding.tolist()
def parse_iso_dt(ts: str) -> datetime:
"""Parse ISO timestamp from Supabase, handling non-6-digit microseconds (Python 3.10 compat)."""
# Python 3.10 fromisoformat requires exactly 6 digits for fractional seconds when timezone present
# Supabase may return 5 digits e.g. "14:32:02.53557+00:00" — pad to 6: "14:32:02.535570+00:00"
ts = re.sub(r'\.(\d+)([+-])', lambda m: f'.{m.group(1)[:6]:0<6}{m.group(2)}', ts)
return datetime.fromisoformat(ts)
def load_encodings_for_admin(admin_id: str | None) -> list[dict]:
"""Return employees scoped to one admin, or all employees if no admin_id given."""
client = get_client()
query = client.table("employees").select("id, name, email, face_encoding")
if admin_id:
query = query.eq("admin_id", admin_id)
return query.execute().data or []
def best_match(unknown: list[float], employees: list[dict]) -> tuple[dict | None, float]:
"""Cosine distance match. Returns (best_employee, best_distance)."""
if not employees:
return None, float("inf")
unknown_arr = np.array(unknown)
best_emp, best_dist = None, float("inf")
for emp in employees:
known = np.array(emp["face_encoding"])
dist = 1.0 - float(np.dot(unknown_arr, known)) # embeddings are L2-normalised
if dist < best_dist:
best_dist = dist
best_emp = emp
if best_dist <= FACE_MATCH_THRESHOLD:
return best_emp, best_dist
return None, best_dist
# ─── routes ───────────────────────────────────────────────────────────────────
@app.get("/debug/employees")
def debug_employees(admin_id: str | None = None):
"""Debug: return employee count and names for given admin_id."""
try:
client = get_client()
query = client.table("employees").select("id, name, admin_id")
if admin_id:
query = query.eq("admin_id", admin_id)
data = query.execute().data or []
return {"count": len(data), "employees": data}
except Exception as e:
return {"error": traceback.format_exc()}
@app.get("/debug/full-test")
def debug_full_test(employee_id: str, admin_id: str | None = None):
"""Debug: simulate the full clock DB path for a given employee_id."""
try:
client = get_client()
# 1. Load employees
emps = load_encodings_for_admin(admin_id)
emp = next((e for e in emps if e["id"] == employee_id), None)
if not emp:
return {"step": "load_employees", "error": "employee not found", "count": len(emps)}
# 2. Attendance SELECT
today = datetime.now(timezone.utc).date().isoformat()
rows = (
client.table("attendance")
.select("id, check_in, check_out, hours_worked")
.eq("employee_id", employee_id)
.eq("date", today)
.execute()
)
record = rows.data[0] if rows.data else None
# 3. Datetime parse test
dt_parse_ok = True
if record and record.get("check_in"):
try:
check_in_dt = parse_iso_dt(record["check_in"])
hours = round((datetime.now(timezone.utc) - check_in_dt).total_seconds() / 3600, 2)
except Exception as e:
dt_parse_ok = False
return {"step": "datetime_parse", "error": str(e), "check_in": record["check_in"]}
return {
"ok": True,
"employee": emp["name"],
"today": today,
"attendance_record": record,
"datetime_parse_ok": dt_parse_ok,
"would_action": "sign_out" if record and record.get("check_out") is None else
"already_complete" if record else "sign_in"
}
except Exception as e:
return {"error": traceback.format_exc()}
@app.get("/health")
def health():
return {"status": "ok", "version": "2"}
@app.post("/register")
async def register(
name: str = Form(...),
email: str = Form(...),
department: str = Form(""),
admin_id: str = Form(""),
photo: UploadFile = File(...),
):
if not admin_id:
raise HTTPException(status_code=400, detail="admin_id is required.")
try:
photo_bytes = await photo.read()
img = image_from_bytes(photo_bytes)
encoding = encode_face(img)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Image processing error: {traceback.format_exc()}")
if not encoding:
raise HTTPException(status_code=400, detail="No face detected in the photo. Please retake.")
try:
client = get_client()
storage_path = f"{admin_id}/{email.replace('@', '_').replace('.', '_')}.jpg"
client.storage.from_("employee-photos").upload(
path=storage_path,
file=photo_bytes,
file_options={"content-type": "image/jpeg", "upsert": "true"},
)
photo_url = client.storage.from_("employee-photos").get_public_url(storage_path)
client.table("employees").upsert(
{
"name": name,
"email": email,
"department": department,
"photo_url": photo_url,
"face_encoding": encoding,
"admin_id": admin_id,
},
on_conflict="email",
).execute()
except Exception as e:
raise HTTPException(status_code=500, detail=f"Database error: {traceback.format_exc()}")
return {"success": True, "message": f"{name} registered successfully."}
class ClockRequest(BaseModel):
image: str
admin_id: str | None = None
@app.post("/clock")
def clock(req: ClockRequest):
try:
img = image_from_base64(req.image)
encoding = encode_face(img)
except Exception as e:
return {"status": "error", "detail": traceback.format_exc()}
if not encoding:
return {"status": "no_face"}
try:
employees = load_encodings_for_admin(req.admin_id)
except Exception:
return {"status": "error", "detail": traceback.format_exc()}
match, best_dist = best_match(encoding, employees)
if not match:
return {"status": "not_registered", "_debug_emp_count": len(employees), "_debug_best_dist": round(best_dist, 4)}
employee_id = match["id"]
name = match["name"]
today = datetime.now(timezone.utc).date().isoformat()
now_iso = datetime.now(timezone.utc).isoformat()
try:
client = get_client()
rows = (
client.table("attendance")
.select("id, check_in, check_out, hours_worked")
.eq("employee_id", employee_id)
.eq("date", today)
.execute()
)
record = rows.data[0] if rows.data else None
if record is None:
client.table("attendance").insert(
{"employee_id": employee_id, "date": today, "check_in": now_iso, "status": "present"}
).execute()
return {"status": "matched", "action": "signed_in", "name": name, "time": now_iso}
if record["check_out"] is None:
check_in_dt = parse_iso_dt(record["check_in"])
check_out_dt = datetime.now(timezone.utc)
hours = round((check_out_dt - check_in_dt).total_seconds() / 3600, 2)
client.table("attendance").update(
{"check_out": now_iso, "hours_worked": hours, "status": "present"}
).eq("id", record["id"]).execute()
return {"status": "matched", "action": "signed_out", "name": name, "time": now_iso, "hours_worked": hours}
return {"status": "matched", "action": "already_complete", "name": name, "hours_worked": record["hours_worked"]}
except Exception as e:
return {"status": "error", "detail": traceback.format_exc()}