Merge pull request 'zelda.checkin-for-au' (#162) from zelda.checkin-for-au into master

Reviewed-on: #162
This commit is contained in:
zelda 2026-05-15 18:04:49 +10:00
commit 574e92bd23
11 changed files with 159 additions and 145 deletions

View File

@ -3,4 +3,5 @@ __pycache__
images images
uploads uploads
log.log log.log
venv venv
.env

View File

@ -1,56 +1,44 @@
import os import os
import base64
import datetime import datetime
import requests import requests
from fastapi import UploadFile from dotenv import load_dotenv
URL_API = "http://172.16.6.38:8000/api/v1" load_dotenv()
HOST = os.getenv("MS_HOST", "http://10.20.2.26:3002")
def send_image(id, image_bytes, student_name: str, status: str):
id = str(id)
def sync_checkin(email: str, timestamp_ms: int, image_data: bytes, student_name: str, status: str):
today = datetime.datetime.now().strftime("%Y_%m_%d") today = datetime.datetime.now().strftime("%Y_%m_%d")
folder_path = f"./images/{today}" folder_path = f"./images/{today}"
os.makedirs(folder_path, exist_ok=True) os.makedirs(folder_path, exist_ok=True)
safe_student = "".join(c for c in student_name if c.isalnum() or c in ("-", "_")) safe_student = "".join(c for c in student_name if c.isalnum() or c in ("-", "_"))
safe_status = "".join(c for c in status if c.isalnum() or c in ("-", "_")) safe_status = "".join(c for c in status if c.isalnum() or c in ("-", "_"))
timestamp = datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S") ts_str = datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")
file_path = os.path.join(folder_path, f"{safe_student}_{safe_status}_at_{ts_str}.png")
file_name = f"{safe_student}_{safe_status}_at_{timestamp}.png"
file_path = os.path.join(folder_path, file_name)
# Lưu xuống
with open(file_path, "wb") as f: with open(file_path, "wb") as f:
f.write(image_bytes) f.write(image_data)
# Gửi API image_b64 = "data:image/jpeg;base64," + base64.b64encode(image_data).decode("utf-8")
payload = {
"email": email,
"time": timestamp_ms,
"image": image_b64,
}
try: try:
with open(file_path, "rb") as image_file: response = requests.post(HOST + "/api/log-time/check-in-out", json=payload)
response = requests.post(
URL_API + "/admin/tracking/send-image",
data={"id": id, "file_name": file_name},
files={"image": image_file}
)
response.raise_for_status() response.raise_for_status()
res = response.json()
print("[sync_checkin] response:", res)
return res
except Exception as e: except Exception as e:
print("Send image failed:", e) print("[sync_checkin] failed:", e)
return {}
def create_history(data):
# Gửi yêu cầu POST với dữ liệu đã chỉ định
response = requests.post(URL_API+"/admin/tracking/scan-create", data=data)
res = response.json()
print(res)
return res
def users(params):
# Gửi yêu cầu POST với dữ liệu đã chỉ định
response = requests.get(URL_API+"/admin/timekeeping", params=params, headers={"authorization": "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJodHRwczovL21zLnByb2xvZ3kubmV0L2FwaS92MS9hZG1pbi9sb2dpbiIsImlhdCI6MTc1Njg2MDQ1OSwiZXhwIjoxNzg4Mzk2NDU5LCJuYmYiOjE3NTY4NjA0NTksImp0aSI6IkRrb0NLbHBKV1pkNnZCN0QiLCJzdWIiOiIxNSIsInBydiI6ImQyZmYyOTMzOWE4YTNlODJjMzU4MmE1YThlNzM5ZGYxNzg5YmIxMmYifQ.DoHqHeAGGxpvzlNQ9dAZjZf2Yl573XCgNBT8ZiSx5N4"})
res = response.json()
return res

View File

@ -50,6 +50,13 @@ class CheckingApi {
}); });
} }
async deleteUser(id: string | number) {
return await axios({
method: "DELETE",
url: `/users/${id}`,
});
}
async checkin({ file }: { file: any }) { async checkin({ file }: { file: any }) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file, "frame.jpg"); formData.append("file", file, "frame.jpg");

View File

@ -2,29 +2,51 @@
"use client"; "use client";
import { checkingApi } from "@/api/checking-api"; import { checkingApi } from "@/api/checking-api";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button";
import { TabsContent } from "@/components/ui/tabs"; import { TabsContent } from "@/components/ui/tabs";
import { useConfirm } from "@/components/confirm-modal-provider";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import useAppStore from "@/stores/use-app-store"; import useAppStore from "@/stores/use-app-store";
import useUserStore from "@/stores/use-user-store"; import useUserStore from "@/stores/use-user-store";
import { Users } from "lucide-react"; import { Trash2, Users } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner";
export default function TabUsers({ value }: { value: string }) { export default function TabUsers({ value }: { value: string }) {
const [users, setUsers] = useState<IUser[]>([]); const [users, setUsers] = useState<IUser[]>([]);
const { currentUser, setCurrentUser } = useUserStore(); const { currentUser, setCurrentUser } = useUserStore();
const { refreshUsers, setRefreshUsers } = useAppStore(); const { refreshUsers, setRefreshUsers } = useAppStore();
const confirm = useConfirm();
const loadUsers = async () => { const loadUsers = async () => {
try { try {
const { data } = await checkingApi.users(); const { data } = await checkingApi.users();
setUsers(data); setUsers(data);
} catch (error) { } catch (error) {
console.log(error); console.log(error);
} }
}; };
const handleDelete = async (user: IUser, e: React.MouseEvent) => {
e.stopPropagation();
const ok = await confirm({
title: "Xóa người dùng",
message: `Bạn có chắc muốn xóa "${user.name}"? Toàn bộ lịch sử điểm danh sẽ bị xóa theo.`,
confirmText: "Xóa",
cancelText: "Hủy",
variant: "destructive",
});
if (!ok) return;
try {
await checkingApi.deleteUser(user.id);
toast.success(`Đã xóa ${user.name}`);
if (currentUser?.id === user.id) setCurrentUser(null);
setUsers((prev) => prev.filter((u) => u.id !== user.id));
} catch {
toast.error("Xóa thất bại");
}
};
const toggle = (data: IUser) => { const toggle = (data: IUser) => {
if (currentUser) { if (currentUser) {
if (data.id === currentUser.id) { if (data.id === currentUser.id) {
@ -51,65 +73,42 @@ export default function TabUsers({ value }: { value: string }) {
<TabsContent value={value} className=""> <TabsContent value={value} className="">
<div className="flex flex-col gap-2 flex-1 p-4 space-y-2 overflow-y-auto h-[90vh]"> <div className="flex flex-col gap-2 flex-1 p-4 space-y-2 overflow-y-auto h-[90vh]">
{users.map((user) => ( {users.map((user) => (
<Card <div
key={user.id} key={user.id}
className={cn( className={cn(
"p-4 cursor-pointer transition-all duration-200 hover:shadow-md hover:scale-[1.01] select-none", "flex items-center gap-3 p-4 rounded-xl border shadow-sm bg-card cursor-pointer transition-all duration-200 hover:shadow-md hover:scale-[1.01] select-none",
currentUser?.id === user.id && currentUser?.id === user.id &&
"bg-blue-50 dark:bg-blue-950 border-blue-500 shadow-md" "bg-blue-50 dark:bg-blue-950 border-blue-500 shadow-md"
)} )}
onClick={() => toggle(user)} onClick={() => toggle(user)}
> >
<div className="flex items-center gap-3"> <Avatar className="size-12 shrink-0">
<Avatar className="size-12"> <AvatarImage
<AvatarImage src={
src={ `https://ms.prology.net/image/storage/${user?.avatar}` || ""
`https://ms.prology.net/image/storage/${user?.avatar}` || "" }
} />
/> <AvatarFallback>{user.name.charAt(0)}</AvatarFallback>
<AvatarFallback> {user.name.charAt(0)}</AvatarFallback> </Avatar>
</Avatar>
<div className="flex-1"> <div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 dark:text-gray-100"> <h4 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
{user.name} {user.name}
</h4> </h4>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400 truncate">
{user.email} {user.email}
</p> </p>
</div>
{/* <DropdownMenu>
<DropdownMenuTrigger
asChild
onClick={(e) => e.stopPropagation()}
>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreVertical className="h-4 w-4" />
<span className="sr-only">Mở menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={(e) => handleViewDetails(user, e)}>
<UserCheck className="mr-2 h-4 w-4" />
<span>Xem chi tiết</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={(e) => handleEdit(user, e)}>
<Edit className="mr-2 h-4 w-4" />
<span>Chỉnh sửa</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={(e) => handleDelete(user, e)}
className="text-red-600 focus:text-red-600 dark:text-red-400 dark:focus:text-red-400"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Xóa</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu> */}
</div> </div>
</Card>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
onClick={(e) => handleDelete(user, e)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))} ))}
</div> </div>

View File

@ -1,8 +1,11 @@
import os
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base from sqlalchemy.orm import sessionmaker, declarative_base
from dotenv import load_dotenv
# DATABASE_URL = "mysql+pymysql://root:root@localhost/face_checkin_au?charset=utf8mb4" load_dotenv()
DATABASE_URL = "mysql+pymysql://admin:Work1234%^@localhost/face_checkin_au?charset=utf8mb4"
DATABASE_URL = os.getenv("DATABASE_URL", "mysql+pymysql://root:root@localhost/face_checkin_au?charset=utf8mb4")
engine = create_engine(DATABASE_URL) engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine, autoflush=False) SessionLocal = sessionmaker(bind=engine, autoflush=False)

View File

@ -15,7 +15,7 @@ from models import Base, Student, CheckInLog, StudentEncoding
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy import text from sqlalchemy import text
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from api import create_history, send_image from api import sync_checkin
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -23,6 +23,7 @@ logging.basicConfig(level=logging.INFO)
_enc_matrix: np.ndarray | None = None # shape (N, 128) _enc_matrix: np.ndarray | None = None # shape (N, 128)
_enc_student_ids: np.ndarray | None = None # shape (N,) int64 _enc_student_ids: np.ndarray | None = None # shape (N,) int64
_enc_student_names: dict = {} _enc_student_names: dict = {}
_enc_student_emails: dict = {}
_cache_lock = threading.Lock() _cache_lock = threading.Lock()
_cache_dirty = True _cache_dirty = True
@ -33,20 +34,20 @@ def invalidate_encoding_cache():
def _load_encoding_cache(db): def _load_encoding_cache(db):
global _enc_matrix, _enc_student_ids, _enc_student_names, _cache_dirty global _enc_matrix, _enc_student_ids, _enc_student_names, _enc_student_emails, _cache_dirty
with _cache_lock: with _cache_lock:
if not _cache_dirty and _enc_matrix is not None: if not _cache_dirty and _enc_matrix is not None:
return _enc_matrix, _enc_student_ids, _enc_student_names return _enc_matrix, _enc_student_ids, _enc_student_names, _enc_student_emails
rows = db.execute( rows = db.execute(
text(""" text("""
SELECT s.id AS student_id, s.name AS student_name, se.encoding AS encoding_blob SELECT s.id AS student_id, s.name AS student_name, s.email AS student_email, se.encoding AS encoding_blob
FROM student_encodings se FROM student_encodings se
JOIN students s ON s.id = se.student_id JOIN students s ON s.id = se.student_id
""") """)
).fetchall() ).fetchall()
encodings, student_ids, names = [], [], {} encodings, student_ids, names, emails = [], [], {}, {}
for r in rows: for r in rows:
try: try:
enc = np.frombuffer(r.encoding_blob, dtype=np.float64) enc = np.frombuffer(r.encoding_blob, dtype=np.float64)
@ -54,6 +55,7 @@ def _load_encoding_cache(db):
encodings.append(enc) encodings.append(enc)
student_ids.append(r.student_id) student_ids.append(r.student_id)
names[r.student_id] = r.student_name names[r.student_id] = r.student_name
emails[r.student_id] = r.student_email
else: else:
logging.warning(f"encoding size invalid for student {r.student_id}: {enc.size}") logging.warning(f"encoding size invalid for student {r.student_id}: {enc.size}")
except Exception as e: except Exception as e:
@ -67,9 +69,10 @@ def _load_encoding_cache(db):
_enc_student_ids = np.array([], dtype=np.int64) _enc_student_ids = np.array([], dtype=np.int64)
_enc_student_names = names _enc_student_names = names
_enc_student_emails = emails
_cache_dirty = False _cache_dirty = False
logging.info(f"Encoding cache loaded: {_enc_matrix.shape[0]} encodings, {len(names)} students") logging.info(f"Encoding cache loaded: {_enc_matrix.shape[0]} encodings, {len(names)} students")
return _enc_matrix, _enc_student_ids, _enc_student_names return _enc_matrix, _enc_student_ids, _enc_student_names, _enc_student_emails
# --- Image preprocessing (Phương án 3: resize trước khi detect) --- # --- Image preprocessing (Phương án 3: resize trước khi detect) ---
@ -272,7 +275,7 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
DIST_THRESHOLD = 0.42 DIST_THRESHOLD = 0.42
# Phương án 1: dùng cache RAM thay vì query DB mỗi request # Phương án 1: dùng cache RAM thay vì query DB mỗi request
enc_matrix, enc_sids, enc_names = _load_encoding_cache(db) enc_matrix, enc_sids, enc_names, enc_emails = _load_encoding_cache(db)
if enc_matrix.shape[0] == 0: if enc_matrix.shape[0] == 0:
return {"message": "No known encodings in DB.", "status": False} return {"message": "No known encodings in DB.", "status": False}
@ -353,14 +356,13 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
log_id = insert_result.lastrowid log_id = insert_result.lastrowid
db.commit() db.commit()
def _sync_to_ms(name: str, time_string: str, img_data: bytes, local_status: str, checkin_log_id: int): def _sync_to_ms(email: str, timestamp_ms: int, img_data: bytes, name: str, local_status: str, checkin_log_id: int):
try: try:
# Gửi thông tin check-in lên MS server để tạo history res = sync_checkin(email, timestamp_ms, img_data, name, local_status)
ms_response = create_history({"name": name.split('\n')[0], "time_string": time_string, "status": local_status}) check_in_flag = res.get("data", {}).get("checkIn")
id_log = ms_response.get('data', {}).get('id', 0) if check_in_flag is None:
ms_status = ms_response.get('data', {}).get('status', local_status) return
ms_status = "check in" if check_in_flag else "check out"
# Nếu MS server trả về status khác với status local thì đồng bộ lại DB
if ms_status != local_status: if ms_status != local_status:
fix_db = SessionLocal() fix_db = SessionLocal()
try: try:
@ -369,25 +371,21 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
{"status": ms_status, "id": checkin_log_id} {"status": ms_status, "id": checkin_log_id}
) )
fix_db.commit() fix_db.commit()
logging.info(f"Corrected log #{checkin_log_id} status: {local_status}{ms_status}") logging.info(f"Corrected log #{checkin_log_id}: {local_status}{ms_status}")
finally: finally:
fix_db.close() fix_db.close()
# Upload ảnh check-in lên MS server gắn với log id vừa tạo
send_image(id_log, img_data, name, ms_status)
except Exception as e: except Exception as e:
logging.error(f"MS sync error: {e}") logging.error(f"MS sync error: {e}")
# Chạy đồng bộ MS ở background để không block response trả về client background_tasks.add_task(
# TODO: bỏ comment khi deploy thật _sync_to_ms,
# background_tasks.add_task( enc_emails.get(best_student, ""),
# _sync_to_ms, int(now.timestamp() * 1000),
# enc_names.get(best_student), image_data,
# f"{datetime.datetime.now()}", enc_names.get(best_student, ""),
# image_data, status,
# status, log_id,
# log_id, )
# )
student = db.execute( student = db.execute(
text(""" text("""
@ -485,3 +483,20 @@ def get_users(db: Session = Depends(get_db)):
return result return result
@app.delete("/users/{user_id}")
def delete_user(user_id: int, db: Session = Depends(get_db)):
student = db.execute(
text("SELECT id FROM students WHERE id = :id"),
{"id": user_id}
).fetchone()
if not student:
raise HTTPException(status_code=404, detail="User not found")
db.execute(text("DELETE FROM student_encodings WHERE student_id = :id"), {"id": user_id})
db.execute(text("DELETE FROM checkin_logs WHERE student_id = :id"), {"id": user_id})
db.execute(text("DELETE FROM students WHERE id = :id"), {"id": user_id})
db.commit()
invalidate_encoding_cache()
return {"message": "User deleted successfully"}

View File

@ -6,6 +6,7 @@ numpy
opencv-python opencv-python
requests requests
pymysql pymysql
python-dotenv
# pip install -r requirements.txt # pip install -r requirements.txt
# sudo apt-get install cmake or brew install cmake # sudo apt-get install cmake or brew install cmake
# pip install dlib # pip install dlib

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -8,12 +8,12 @@
<script <script
type="module" type="module"
crossorigin crossorigin
src="au/checkin/assets/index-yYwv6FSW.js" src="/au/checkin/static/assets/index-BKsPQIjb.js"
></script> ></script>
<link <link
rel="stylesheet" rel="stylesheet"
crossorigin crossorigin
href="au/checkin/assets/index-CDZdzCu6.css" href="/au/checkin/static/assets/index-BTDrLopT.css"
/> />
</head> </head>
<body> <body>