Spaces:
Sleeping
Sleeping
Oluwatosin Ayeni
fix: Python 3.10 datetime.fromisoformat fails on 5-digit microseconds from Supabase — pad to 6 digits before parsing
6f114ee | 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 ─────────────────────────────────────────────────────────────────── | |
| 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()} | |
| 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()} | |
| def health(): | |
| return {"status": "ok", "version": "2"} | |
| 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 | |
| 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()} | |