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

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

View File

@ -1,56 +1,44 @@
import os
import base64
import datetime
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")
folder_path = f"./images/{today}"
os.makedirs(folder_path, exist_ok=True)
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 ("-", "_"))
timestamp = datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S")
file_name = f"{safe_student}_{safe_status}_at_{timestamp}.png"
file_path = os.path.join(folder_path, file_name)
# Lưu xuống
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")
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:
with open(file_path, "rb") as image_file:
response = requests.post(
URL_API + "/admin/tracking/send-image",
data={"id": id, "file_name": file_name},
files={"image": image_file}
)
response = requests.post(HOST + "/api/log-time/check-in-out", json=payload)
response.raise_for_status()
res = response.json()
print("[sync_checkin] response:", res)
return res
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 }) {
const formData = new FormData();
formData.append("file", file, "frame.jpg");

View File

@ -2,29 +2,51 @@
"use client";
import { checkingApi } from "@/api/checking-api";
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 { useConfirm } from "@/components/confirm-modal-provider";
import { cn } from "@/lib/utils";
import useAppStore from "@/stores/use-app-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 { toast } from "sonner";
export default function TabUsers({ value }: { value: string }) {
const [users, setUsers] = useState<IUser[]>([]);
const { currentUser, setCurrentUser } = useUserStore();
const { refreshUsers, setRefreshUsers } = useAppStore();
const confirm = useConfirm();
const loadUsers = async () => {
try {
const { data } = await checkingApi.users();
setUsers(data);
} catch (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) => {
if (currentUser) {
if (data.id === currentUser.id) {
@ -51,65 +73,42 @@ export default function TabUsers({ value }: { value: string }) {
<TabsContent value={value} className="">
<div className="flex flex-col gap-2 flex-1 p-4 space-y-2 overflow-y-auto h-[90vh]">
{users.map((user) => (
<Card
<div
key={user.id}
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 &&
"bg-blue-50 dark:bg-blue-950 border-blue-500 shadow-md"
)}
onClick={() => toggle(user)}
>
<div className="flex items-center gap-3">
<Avatar className="size-12">
<Avatar className="size-12 shrink-0">
<AvatarImage
src={
`https://ms.prology.net/image/storage/${user?.avatar}` || ""
}
/>
<AvatarFallback> {user.name.charAt(0)}</AvatarFallback>
<AvatarFallback>{user.name.charAt(0)}</AvatarFallback>
</Avatar>
<div className="flex-1">
<h4 className="font-semibold text-gray-900 dark:text-gray-100">
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 dark:text-gray-100 truncate">
{user.name}
</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}
</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
<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)}
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> */}
<Trash2 className="h-4 w-4" />
</Button>
</div>
</Card>
))}
</div>

View File

@ -1,8 +1,11 @@
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from dotenv import load_dotenv
# DATABASE_URL = "mysql+pymysql://root:root@localhost/face_checkin_au?charset=utf8mb4"
DATABASE_URL = "mysql+pymysql://admin:Work1234%^@localhost/face_checkin_au?charset=utf8mb4"
load_dotenv()
DATABASE_URL = os.getenv("DATABASE_URL", "mysql+pymysql://root:root@localhost/face_checkin_au?charset=utf8mb4")
engine = create_engine(DATABASE_URL)
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 import text
from fastapi.middleware.cors import CORSMiddleware
from api import create_history, send_image
from api import sync_checkin
logging.basicConfig(level=logging.INFO)
@ -23,6 +23,7 @@ logging.basicConfig(level=logging.INFO)
_enc_matrix: np.ndarray | None = None # shape (N, 128)
_enc_student_ids: np.ndarray | None = None # shape (N,) int64
_enc_student_names: dict = {}
_enc_student_emails: dict = {}
_cache_lock = threading.Lock()
_cache_dirty = True
@ -33,20 +34,20 @@ def invalidate_encoding_cache():
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:
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(
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
JOIN students s ON s.id = se.student_id
""")
).fetchall()
encodings, student_ids, names = [], [], {}
encodings, student_ids, names, emails = [], [], {}, {}
for r in rows:
try:
enc = np.frombuffer(r.encoding_blob, dtype=np.float64)
@ -54,6 +55,7 @@ def _load_encoding_cache(db):
encodings.append(enc)
student_ids.append(r.student_id)
names[r.student_id] = r.student_name
emails[r.student_id] = r.student_email
else:
logging.warning(f"encoding size invalid for student {r.student_id}: {enc.size}")
except Exception as e:
@ -67,9 +69,10 @@ def _load_encoding_cache(db):
_enc_student_ids = np.array([], dtype=np.int64)
_enc_student_names = names
_enc_student_emails = emails
_cache_dirty = False
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) ---
@ -272,7 +275,7 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
DIST_THRESHOLD = 0.42
# 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:
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
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:
# Gửi thông tin check-in lên MS server để tạo history
ms_response = create_history({"name": name.split('\n')[0], "time_string": time_string, "status": local_status})
id_log = ms_response.get('data', {}).get('id', 0)
ms_status = ms_response.get('data', {}).get('status', local_status)
# Nếu MS server trả về status khác với status local thì đồng bộ lại DB
res = sync_checkin(email, timestamp_ms, img_data, name, local_status)
check_in_flag = res.get("data", {}).get("checkIn")
if check_in_flag is None:
return
ms_status = "check in" if check_in_flag else "check out"
if ms_status != local_status:
fix_db = SessionLocal()
try:
@ -369,25 +371,21 @@ async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...
{"status": ms_status, "id": checkin_log_id}
)
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:
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:
logging.error(f"MS sync error: {e}")
# Chạy đồng bộ MS ở background để không block response trả về client
# TODO: bỏ comment khi deploy thật
# background_tasks.add_task(
# _sync_to_ms,
# enc_names.get(best_student),
# f"{datetime.datetime.now()}",
# image_data,
# status,
# log_id,
# )
background_tasks.add_task(
_sync_to_ms,
enc_emails.get(best_student, ""),
int(now.timestamp() * 1000),
image_data,
enc_names.get(best_student, ""),
status,
log_id,
)
student = db.execute(
text("""
@ -485,3 +483,20 @@ def get_users(db: Session = Depends(get_db)):
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
requests
pymysql
python-dotenv
# pip install -r requirements.txt
# sudo apt-get install cmake or brew install cmake
# 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
type="module"
crossorigin
src="au/checkin/assets/index-yYwv6FSW.js"
src="/au/checkin/static/assets/index-BKsPQIjb.js"
></script>
<link
rel="stylesheet"
crossorigin
href="au/checkin/assets/index-CDZdzCu6.css"
href="/au/checkin/static/assets/index-BTDrLopT.css"
/>
</head>
<body>