Merge pull request 'zelda.checkin-for-au' (#162) from zelda.checkin-for-au into master
Reviewed-on: #162
This commit is contained in:
commit
574e92bd23
|
|
@ -4,3 +4,4 @@ images
|
|||
uploads
|
||||
log.log
|
||||
venv
|
||||
.env
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue