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()}