425 lines
14 KiB
Python
425 lines
14 KiB
Python
import os
|
|
import mysql.connector
|
|
from mysql.connector import errorcode
|
|
from supabase import create_client, Client
|
|
from dotenv import load_dotenv
|
|
|
|
# Load environment variables from .env file using absolute path relative to this script
|
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
|
dotenv_path = os.path.join(current_dir, ".env")
|
|
load_dotenv(dotenv_path=dotenv_path)
|
|
|
|
|
|
# ==============================================================================
|
|
# Schema (unified single table):
|
|
#
|
|
# CREATE TABLE IF NOT EXISTS data_fcgntion (
|
|
# id_tabel INT NOT NULL AUTO_INCREMENT,
|
|
# create_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
# person VARCHAR(128) NULL,
|
|
# rfid_uid VARCHAR(64) NULL DEFAULT NULL,
|
|
# fc JSON NULL DEFAULT NULL,
|
|
# PRIMARY KEY (id_tabel),
|
|
# UNIQUE KEY uniq_rfid (rfid_uid),
|
|
# KEY idx_person (person)
|
|
# ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
#
|
|
# CREATE TABLE IF NOT EXISTS attendance_log (
|
|
# id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
# uid VARCHAR(32) NOT NULL,
|
|
# recognized_name VARCHAR(128) NULL,
|
|
# face_ok TINYINT(1) NOT NULL,
|
|
# created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
# PRIMARY KEY (id),
|
|
# KEY idx_uid_time (uid, created_at)
|
|
# ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
# ==============================================================================
|
|
|
|
|
|
def get_connection_from_env():
|
|
"""Connect to MySQL from .env config. Auto-creates the database if missing."""
|
|
cfg = {
|
|
"user": os.environ.get("DB_USER", "root"),
|
|
"password": os.environ.get("DB_PASSWORD", ""),
|
|
"host": os.environ.get("DB_HOST", "127.0.0.1"),
|
|
"port": int(os.environ.get("DB_PORT", 3306)),
|
|
"database": os.environ.get("DB_NAME", "face_db"),
|
|
}
|
|
try:
|
|
conn = mysql.connector.connect(**cfg)
|
|
except mysql.connector.Error as err:
|
|
if err.errno == errorcode.ER_BAD_DB_ERROR:
|
|
tmp_cfg = cfg.copy()
|
|
tmp_cfg.pop("database")
|
|
conn = mysql.connector.connect(**tmp_cfg)
|
|
cursor = conn.cursor()
|
|
cursor.execute(f"CREATE DATABASE {cfg['database']}")
|
|
conn.database = cfg["database"]
|
|
else:
|
|
raise
|
|
return conn
|
|
|
|
|
|
def get_supabase_client() -> "Client | None":
|
|
"""Get Supabase client from environment variables."""
|
|
url = os.environ.get("SUPABASE_URL")
|
|
key = os.environ.get("SUPABASE_ANON_KEY")
|
|
if not url or not key:
|
|
return None
|
|
try:
|
|
return create_client(url, key)
|
|
except Exception as e:
|
|
print(f"[SUPABASE] Error creating client: {e}")
|
|
return None
|
|
|
|
|
|
# ==============================================================================
|
|
# TABLE SETUP
|
|
# ==============================================================================
|
|
|
|
def ensure_data_table(conn):
|
|
"""Ensure unified data_fcgntion table and attendance_log table exist."""
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS data_fcgntion (
|
|
id_tabel INT NOT NULL AUTO_INCREMENT,
|
|
create_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
person VARCHAR(128) NULL,
|
|
rfid_uid VARCHAR(64) NULL DEFAULT NULL,
|
|
fc JSON NULL DEFAULT NULL,
|
|
PRIMARY KEY (id_tabel),
|
|
UNIQUE KEY uniq_rfid (rfid_uid),
|
|
KEY idx_person (person)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
"""
|
|
)
|
|
cursor.execute(
|
|
"""
|
|
CREATE TABLE IF NOT EXISTS attendance_log (
|
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
|
uid VARCHAR(32) NOT NULL,
|
|
recognized_name VARCHAR(128) NULL,
|
|
face_ok TINYINT(1) NOT NULL,
|
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (id),
|
|
KEY idx_uid_time (uid, created_at)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
"""
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
# ==============================================================================
|
|
# MODE DAFTAR - Enrollment Functions
|
|
# ==============================================================================
|
|
|
|
def get_or_create_data_row(conn, name: str) -> int:
|
|
"""Get or create a row in data_fcgntion by person name. Returns id_tabel."""
|
|
name = (name or "").strip()
|
|
if not name:
|
|
raise ValueError("name is empty")
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT id_tabel FROM data_fcgntion WHERE person=%s LIMIT 1", (name,))
|
|
row = cursor.fetchone()
|
|
if row:
|
|
return int(row[0])
|
|
cursor.execute("INSERT INTO data_fcgntion (person) VALUES (%s)", (name,))
|
|
conn.commit()
|
|
return int(cursor.lastrowid)
|
|
|
|
|
|
def save_or_update_person(conn, name: str, embedding_json_str: str,
|
|
rfid_uid=None) -> int:
|
|
"""Save face embedding for a person. Updates existing row or inserts new.
|
|
Returns id_tabel.
|
|
"""
|
|
name = (name or "").strip()
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT id_tabel FROM data_fcgntion WHERE person=%s LIMIT 1", (name,))
|
|
row = cursor.fetchone()
|
|
if row:
|
|
row_id = int(row[0])
|
|
if rfid_uid:
|
|
cursor.execute(
|
|
"UPDATE data_fcgntion SET fc=%s, rfid_uid=COALESCE(%s, rfid_uid) WHERE id_tabel=%s",
|
|
(embedding_json_str, rfid_uid, row_id),
|
|
)
|
|
else:
|
|
cursor.execute(
|
|
"UPDATE data_fcgntion SET fc=%s WHERE id_tabel=%s",
|
|
(embedding_json_str, row_id),
|
|
)
|
|
conn.commit()
|
|
return row_id
|
|
else:
|
|
cursor.execute(
|
|
"INSERT INTO data_fcgntion (person, rfid_uid, fc) VALUES (%s, %s, %s)",
|
|
(name, rfid_uid, embedding_json_str),
|
|
)
|
|
conn.commit()
|
|
return int(cursor.lastrowid)
|
|
|
|
|
|
def upsert_person_rfid(conn, rfid_uid: str, name=None):
|
|
"""Insert or update rfid_uid in data_fcgntion.
|
|
|
|
Priority:
|
|
1. rfid_uid already in table -> update name if given, done.
|
|
2. row with matching name exists and no rfid -> assign rfid_uid.
|
|
3. Otherwise insert new row.
|
|
"""
|
|
rfid_uid = (rfid_uid or "").strip().upper()
|
|
if not rfid_uid:
|
|
return
|
|
cursor = conn.cursor()
|
|
cursor.execute("SELECT id_tabel FROM data_fcgntion WHERE rfid_uid=%s LIMIT 1", (rfid_uid,))
|
|
existing = cursor.fetchone()
|
|
if existing:
|
|
if name:
|
|
cursor.execute(
|
|
"UPDATE data_fcgntion SET person=COALESCE(%s, person) WHERE id_tabel=%s",
|
|
(name, int(existing[0])),
|
|
)
|
|
conn.commit()
|
|
return
|
|
if name:
|
|
cursor.execute(
|
|
"SELECT id_tabel FROM data_fcgntion WHERE person=%s AND rfid_uid IS NULL LIMIT 1",
|
|
(name,),
|
|
)
|
|
row = cursor.fetchone()
|
|
if row:
|
|
cursor.execute(
|
|
"UPDATE data_fcgntion SET rfid_uid=%s WHERE id_tabel=%s",
|
|
(rfid_uid, int(row[0])),
|
|
)
|
|
conn.commit()
|
|
return
|
|
cursor.execute(
|
|
"INSERT INTO data_fcgntion (person, rfid_uid) VALUES (%s, %s)",
|
|
(name or rfid_uid, rfid_uid),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
def claim_rfid_by_name(conn, name: str, rfid_uid: str) -> bool:
|
|
"""Assign rfid_uid to a row where person=name and rfid_uid IS NULL.
|
|
Returns True if a row was updated.
|
|
"""
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT id_tabel FROM data_fcgntion WHERE person=%s AND rfid_uid IS NULL LIMIT 1",
|
|
(name,),
|
|
)
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return False
|
|
cursor.execute(
|
|
"UPDATE data_fcgntion SET rfid_uid=%s WHERE id_tabel=%s",
|
|
(rfid_uid.strip().upper(), int(row[0])),
|
|
)
|
|
conn.commit()
|
|
return True
|
|
|
|
|
|
# ==============================================================================
|
|
# MODE ABSEN - Recognition / Attendance Functions
|
|
# ==============================================================================
|
|
|
|
def load_all_faces(conn) -> list:
|
|
"""Load all face embeddings from data_fcgntion where fc IS NOT NULL.
|
|
Returns list of dicts: {id, name, rfid_uid, embedding}.
|
|
"""
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT id_tabel, person, rfid_uid, fc FROM data_fcgntion WHERE fc IS NOT NULL"
|
|
)
|
|
rows = cursor.fetchall()
|
|
result = []
|
|
for r in rows:
|
|
result.append({"id": r[0], "name": r[1], "rfid_uid": r[2], "embedding": r[3]})
|
|
return result
|
|
|
|
|
|
def get_person_by_rfid(conn, rfid_uid):
|
|
"""Get data_fcgntion row by rfid_uid. Returns dict or None."""
|
|
if rfid_uid is None:
|
|
return None
|
|
rfid_uid = rfid_uid.strip().upper()
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT id_tabel, person, rfid_uid, fc, create_at FROM data_fcgntion WHERE rfid_uid=%s LIMIT 1",
|
|
(rfid_uid,),
|
|
)
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return {
|
|
"id": row[0],
|
|
"person": row[1],
|
|
"rfid_uid": row[2],
|
|
"embedding": row[3], # <-- fc field (JSON embedding)
|
|
"has_face": row[3] is not None,
|
|
"create_at": row[4],
|
|
}
|
|
|
|
|
|
def get_person_by_name(conn, name: str):
|
|
"""Get data_fcgntion row by person name. Returns dict or None."""
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT id_tabel, person, rfid_uid, fc, create_at FROM data_fcgntion WHERE person=%s LIMIT 1",
|
|
(name.strip(),),
|
|
)
|
|
row = cursor.fetchone()
|
|
if not row:
|
|
return None
|
|
return {
|
|
"id": row[0],
|
|
"person": row[1],
|
|
"rfid_uid": row[2],
|
|
"has_face": row[3] is not None,
|
|
"create_at": row[4],
|
|
}
|
|
|
|
|
|
def log_attendance(conn, uid: str, recognized_name, face_ok: bool):
|
|
"""Log an attendance event and update create_at on matching data_fcgntion row."""
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"INSERT INTO attendance_log (uid, recognized_name, face_ok) VALUES (%s, %s, %s)",
|
|
(uid, recognized_name, 1 if face_ok else 0),
|
|
)
|
|
cursor.execute(
|
|
"UPDATE data_fcgntion SET create_at=CURRENT_TIMESTAMP WHERE UPPER(TRIM(rfid_uid))=UPPER(TRIM(%s))",
|
|
(uid,),
|
|
)
|
|
rows_affected = cursor.rowcount
|
|
conn.commit()
|
|
print(f"[ATTENDANCE] uid={uid} name={recognized_name} face_ok={face_ok} rows={rows_affected}")
|
|
if rows_affected == 0:
|
|
print(f"[ATTENDANCE] WARNING: no data_fcgntion row matched rfid_uid={uid}")
|
|
if recognized_name and recognized_name != "Unknown":
|
|
send_attendance_to_supabase(recognized_name)
|
|
|
|
|
|
def send_attendance_to_supabase(recognized_name: str):
|
|
"""Send attendance record to Supabase output_alat table (non-blocking background thread)."""
|
|
import threading
|
|
import requests
|
|
|
|
def _push():
|
|
try:
|
|
url = os.environ.get("SUPABASE_URL", "").rstrip("/")
|
|
key = os.environ.get("SUPABASE_ANON_KEY", "")
|
|
if not url or not key:
|
|
return
|
|
headers = {
|
|
"apikey": key,
|
|
"Authorization": f"Bearer {key}",
|
|
"Content-Type": "application/json",
|
|
"Prefer": "return=minimal",
|
|
}
|
|
resp = requests.post(f"{url}/rest/v1/output_alat", headers=headers,
|
|
json={"nama_user": recognized_name}, timeout=10)
|
|
if resp.status_code in (200, 201):
|
|
print(f"[SUPABASE] sent nama_user='{recognized_name}' status={resp.status_code}")
|
|
else:
|
|
print(f"[SUPABASE] failed status={resp.status_code} body={resp.text[:200]}")
|
|
except Exception as e:
|
|
print(f"[SUPABASE] exception: {e}")
|
|
|
|
threading.Thread(target=_push, daemon=True).start()
|
|
|
|
|
|
# ==============================================================================
|
|
# LIST / READ
|
|
# ==============================================================================
|
|
|
|
def list_all_persons(conn, limit: int = 200) -> list:
|
|
"""List all rows from data_fcgntion ordered newest first."""
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT id_tabel, person, rfid_uid, create_at, "
|
|
"CASE WHEN fc IS NOT NULL THEN 1 ELSE 0 END AS has_face "
|
|
"FROM data_fcgntion ORDER BY create_at DESC LIMIT %s",
|
|
(int(limit),),
|
|
)
|
|
rows = cursor.fetchall()
|
|
result = []
|
|
for r in rows:
|
|
result.append({
|
|
"id": r[0],
|
|
"person": r[1],
|
|
"rfid_uid": r[2],
|
|
"create_at": r[3],
|
|
"has_face": bool(r[4]),
|
|
})
|
|
return result
|
|
|
|
|
|
def list_attendance_log(conn, limit: int = 200) -> list:
|
|
"""List attendance log entries ordered newest first."""
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"SELECT uid, recognized_name, face_ok, created_at "
|
|
"FROM attendance_log ORDER BY created_at DESC LIMIT %s",
|
|
(int(limit),),
|
|
)
|
|
rows = cursor.fetchall()
|
|
result = []
|
|
for r in rows:
|
|
result.append({
|
|
"uid": r[0],
|
|
"recognized_name": r[1],
|
|
"face_ok": bool(r[2]),
|
|
"created_at": r[3],
|
|
})
|
|
return result
|
|
|
|
|
|
# ==============================================================================
|
|
# MODE HAPUS - Delete / Cleanup
|
|
# ==============================================================================
|
|
|
|
def delete_person_by_rfid(conn, rfid_uid: str):
|
|
"""Delete data_fcgntion row by rfid_uid."""
|
|
cursor = conn.cursor()
|
|
cursor.execute("DELETE FROM data_fcgntion WHERE rfid_uid=%s", (rfid_uid.strip().upper(),))
|
|
conn.commit()
|
|
|
|
|
|
def delete_person_by_name(conn, name: str):
|
|
"""Delete data_fcgntion row(s) by person name."""
|
|
cursor = conn.cursor()
|
|
cursor.execute("DELETE FROM data_fcgntion WHERE person=%s", (name.strip(),))
|
|
conn.commit()
|
|
|
|
|
|
def delete_unused_persons(conn) -> int:
|
|
"""Delete rows where BOTH fc IS NULL AND rfid_uid IS NULL.
|
|
Returns number of rows deleted.
|
|
"""
|
|
cursor = conn.cursor()
|
|
cursor.execute("DELETE FROM data_fcgntion WHERE fc IS NULL AND rfid_uid IS NULL")
|
|
count = cursor.rowcount
|
|
conn.commit()
|
|
print(f"[CLEANUP] Deleted {count} unused rows (fc=NULL, rfid=NULL).")
|
|
return count
|
|
|
|
|
|
def delete_old_attendance_logs(conn, days: int = 30) -> int:
|
|
"""Delete attendance_log rows older than `days` days.
|
|
Returns number of rows deleted.
|
|
"""
|
|
cursor = conn.cursor()
|
|
cursor.execute(
|
|
"DELETE FROM attendance_log WHERE created_at < NOW() - INTERVAL %s DAY",
|
|
(int(days),),
|
|
)
|
|
count = cursor.rowcount
|
|
conn.commit()
|
|
print(f"[CLEANUP] Deleted {count} attendance log rows older than {days} days.")
|
|
return count
|