Merge pull request 'update(ttw): update auto sync user' (#148) from zelda.update-checkin-api into master

Reviewed-on: #148
This commit is contained in:
zelda 2025-12-12 14:17:33 +11:00
commit 25162be83d
22 changed files with 603 additions and 252 deletions

Binary file not shown.

View File

@ -6,46 +6,6 @@ from fastapi import UploadFile
URL_API = "https://ms.prology.net/api/v1"
# def send_image(id, file: UploadFile, student_name: str, status: str):
# id = str(id)
# # Tạo folder theo ngày
# today = datetime.datetime.now().strftime("%Y_%m_%d")
# folder_path = f"./images/{today}"
# if not os.path.exists(folder_path):
# os.makedirs(folder_path)
# # Tạo file name chuẩn
# file_name = (
# f"{student_name}_"
# f"{status}_at_{datetime.datetime.now().strftime('%Y_%m_%d_%H_%M_%S')}.png"
# )
# file_path = os.path.join(folder_path, file_name)
# # Lưu file UploadFile xuống
# with open(file_path, "wb") as f:
# f.write(file.file.read())
# # Mở lại file để gửi API
# with open(file_path, "rb") as image_file:
# files = {"image": image_file}
# data = {"id": id, "file_name": file_name}
# try:
# response = requests.post(
# URL_API + "/admin/tracking/send-image",
# data=data,
# files=files
# )
# response.raise_for_status()
# res = response.json()
# except Exception as e:
# return {"status": False, "message": str(e)}
# return res
def send_image(id, image_bytes, student_name: str, status: str):
id = str(id)
@ -78,8 +38,6 @@ def send_image(id, image_bytes, student_name: str, status: str):
print("Send image failed:", e)
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)
@ -88,3 +46,11 @@ def create_history(data):
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

@ -84,32 +84,20 @@ export default function TabFeatures() {
const { data } = await checkingApi.checkin({ file });
if (!data || !data?.data) {
if (!data || !data?.status) {
toast.error(
(data as any)?.message || "Error In Checking: " + JSON.stringify(data)
);
return;
}
if (data?.checking) {
setCurrentUser(data?.data || null);
// Set timeout mới
timeoutRef.current = setTimeout(() => {
setCurrentUser(null);
timeoutRef.current = null;
}, 2000);
}
const message =
(data as any)?.message ||
`Checking thành công lúc: ${formatTime(new Date().toLocaleString())}`;
toast.success(message);
if (!data?.status) return;
speak({ type: data?.status });
speak({ type: data?.status_type });
setRefreshLog(true);
} catch (error) {
const data = error as AxiosError;

View File

@ -1,24 +1,12 @@
/* eslint-disable no-constant-binary-expression */
/* eslint-disable @typescript-eslint/no-explicit-any */
"use client";
import type React from "react";
import { checkingApi } from "@/api/checking-api";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { TabsContent } from "@/components/ui/tabs";
import { cn } from "@/lib/utils";
import useUserStore from "@/stores/use-user-store";
import { Edit, MoreVertical, Trash2, UserCheck, Users } from "lucide-react";
import { Users } from "lucide-react";
import { useEffect, useState } from "react";
export default function TabUsers({ value }: { value: string }) {
@ -47,24 +35,6 @@ export default function TabUsers({ value }: { value: string }) {
}
};
const handleEdit = (user: IUser, e: React.MouseEvent) => {
e.stopPropagation();
console.log("Edit user:", user);
// TODO: Implement edit functionality
};
const handleDelete = (user: IUser, e: React.MouseEvent) => {
e.stopPropagation();
console.log("Delete user:", user);
// TODO: Implement delete functionality
};
const handleViewDetails = (user: IUser, e: React.MouseEvent) => {
e.stopPropagation();
console.log("View details:", user);
// TODO: Implement view details functionality
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
loadUsers();
@ -102,7 +72,7 @@ export default function TabUsers({ value }: { value: string }) {
</p>
</div>
<DropdownMenu>
{/* <DropdownMenu>
<DropdownMenuTrigger
asChild
onClick={(e) => e.stopPropagation()}
@ -130,7 +100,7 @@ export default function TabUsers({ value }: { value: string }) {
<span>Xóa</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenu> */}
</div>
</Card>
))}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

View File

@ -38,6 +38,7 @@ def get_db():
db.close()
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
def root():
return FileResponse("static/index.html")
@ -190,112 +191,272 @@ async def register_student(
# @app.post("/checkin")
# async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...), camera_id: str = Form("cam1"), db: Session = Depends(get_db)):
# image_data = await file.read()
# path = os.path.join(UPLOAD_DIR, "checkin.jpg")
# with open(path, "wb") as f:
# f.write(image_data)
# unknown_img = face_recognition.load_image_file(path)
# unknown_encodings = face_recognition.face_encodings(unknown_img)
# if not unknown_encodings:
# return {"message": "No face detected."}
# unknown_encoding = unknown_encodings[0]
# # Get all encodings with student info
# encodings = db.execute(
# text("""
# SELECT s.id, s.name, s.email, s.avatar, se.encoding
# FROM student_encodings se
# JOIN students s ON s.id = se.student_id
# """)
# ).fetchall()
# for encoding in encodings:
# known_encoding = np.frombuffer(encoding.encoding)
# result = face_recognition.compare_faces([known_encoding], unknown_encoding, tolerance=0.5)
# if result[0]:
# now = datetime.datetime.now()
# # Check recent checkin
# recent_check = db.execute(
# text("""
# SELECT id FROM checkin_logs
# WHERE student_id = :student_id
# AND time > :time_threshold
# """),
# {
# "student_id": encoding.id,
# "time_threshold": now - datetime.timedelta(minutes=0.5)
# }
# ).fetchone()
# if recent_check:
# return {
# "message": f"{encoding.name} already checked in recently.",
# "checking": False,
# "data": {
# "id": encoding.id,
# "name": encoding.name,
# "email": encoding.email,
# "avatar": encoding.avatar,
# "camera_id": camera_id,
# "time": now.isoformat()
# }
# }
# # thêm dô đây
# # id_log = 0
# # ms_response = create_history({"name": encoding.name.split('\n')[0], "time_string": f"{datetime.datetime.now()}", "status": "check in"})
# # id_log = ms_response.get('data').get('id')
# # status = ms_response.get('data').get('status')
# status = "check in"
# # # reset pointer
# # file.file.seek(0)
# # background_tasks.add_task(
# # send_image,
# # id_log,
# # image_data, # truyền bytes, không phải UploadFile
# # encoding.name,
# # status
# # )
# # Insert new checkin
# db.execute(
# text("""
# INSERT INTO checkin_logs (student_id, time, camera_id, status)
# VALUES (:student_id, :time, :camera_id, :status)
# """),
# {
# "student_id": encoding.id,
# "time": now,
# "camera_id": camera_id,
# "status": status
# }
# )
# db.commit()
# return {
# "message": f"Check-in successful for {encoding.name}",
# "checking": True,
# "status": status,
# "data": {
# "id": encoding.id,
# "name": encoding.name,
# "email": encoding.email,
# "avatar": encoding.avatar,
# "camera_id": camera_id,
# "time": now.isoformat()
# }
# }
# return {"message": "No match found."}
@app.post("/checkin")
async def checkin(background_tasks: BackgroundTasks, file: UploadFile = File(...), camera_id: str = Form("cam1"), db: Session = Depends(get_db)):
import logging
logging.basicConfig(level=logging.INFO)
image_data = await file.read()
path = os.path.join(UPLOAD_DIR, "checkin.jpg")
with open(path, "wb") as f:
f.write(image_data)
unknown_img = face_recognition.load_image_file(path)
unknown_encodings = face_recognition.face_encodings(unknown_img)
# Option: dùng CNN detector (chậm nhưng chính xác hơn) nếu bạn đã cài dlib với CUDA / muốn chính xác tối đa:
# unknown_locations = face_recognition.face_locations(unknown_img, model="cnn")
# unknown_encodings = face_recognition.face_encodings(unknown_img, unknown_locations)
unknown_encodings = face_recognition.face_encodings(unknown_img)
if not unknown_encodings:
return {"message": "No face detected."}
return {"message": "No face detected.", "status": False}
unknown_encoding = unknown_encodings[0]
# Get all encodings with student info
encodings = db.execute(
# TÙY CHỈNH: threshold nhỏ hơn → ít nhầm lẫn, nhưng dễ false negative.
# Thử: 0.4 (chặt), 0.45 (cân bằng), 0.55 (lỏng)
DIST_THRESHOLD = 0.42
# Lấy tất cả encodings (mỗi row là một encoding blob) kèm student info
rows = db.execute(
text("""
SELECT s.id, s.name, s.email, s.avatar, se.encoding
SELECT s.id AS student_id, s.name AS student_name, se.encoding AS encoding_blob
FROM student_encodings se
JOIN students s ON s.id = se.student_id
""")
).fetchall()
for encoding in encodings:
known_encoding = np.frombuffer(encoding.encoding)
result = face_recognition.compare_faces([known_encoding], unknown_encoding, tolerance=0.5)
if result[0]:
now = datetime.datetime.now()
# Gom các encoding theo student_id
from collections import defaultdict
student_encodings = defaultdict(list)
student_names = {}
# Check recent checkin
recent_check = db.execute(
text("""
SELECT id FROM checkin_logs
WHERE student_id = :student_id
AND time > :time_threshold
"""),
{
"student_id": encoding.id,
"time_threshold": now - datetime.timedelta(minutes=0.5)
}
).fetchone()
for r in rows:
sid = r.student_id
student_names[sid] = r.student_name
# chuyển BLOB -> numpy array đúng dtype & shape
try:
enc = np.frombuffer(r.encoding_blob, dtype=np.float64)
# Một bản encoding phải dài 128
if enc.size == 128:
student_encodings[sid].append(enc)
else:
logging.warning(f"encoding size invalid for student {sid}: {enc.size}")
except Exception as e:
logging.exception(f"Error decoding encoding for student {sid}: {e}")
if recent_check:
return {
"message": f"{encoding.name} already checked in recently.",
"checking": False,
"data": {
"id": encoding.id,
"name": encoding.name,
"email": encoding.email,
"avatar": encoding.avatar,
"camera_id": camera_id,
"time": now.isoformat()
}
}
# Nếu không có encoding nào trong DB
if not student_encodings:
return {"message": "No known encodings in DB.", "status": False}
# Tìm khoảng cách nhỏ nhất cho từng student
best_student = None
best_distance = float("inf")
second_best_distance = float("inf")
for sid, enc_list in student_encodings.items():
# calc distances between unknown and all encs of this student
try:
dists = face_recognition.face_distance(enc_list, unknown_encoding) # returns array
except Exception:
# fallback if enc_list is list of 1D arrays -> convert to 2D array
arr = np.vstack(enc_list)
dists = face_recognition.face_distance(arr, unknown_encoding)
# thêm dô đây
id_log = 0
ms_response = create_history({"name": encoding.name.split('\n')[0], "time_string": f"{datetime.datetime.now()}", "status": "check in"})
id_log = ms_response.get('data').get('id')
status = ms_response.get('data').get('status')
min_dist = float(np.min(dists))
logging.info(f"Student {sid} ({student_names.get(sid)}) min_dist = {min_dist:.4f}")
# reset pointer
file.file.seek(0)
# update best / second best global
if min_dist < best_distance:
second_best_distance = best_distance
best_distance = min_dist
best_student = sid
elif min_dist < second_best_distance:
second_best_distance = min_dist
background_tasks.add_task(
send_image,
id_log,
image_data, # truyền bytes, không phải UploadFile
encoding.name,
status
)
# Debug log best/second distances
logging.info(f"Best student {best_student} dist={best_distance:.4f}, second_best={second_best_distance:.4f}")
# Insert new checkin
db.execute(
text("""
INSERT INTO checkin_logs (student_id, time, camera_id, status)
VALUES (:student_id, :time, :camera_id, :status)
"""),
{
"student_id": encoding.id,
"time": now,
"camera_id": camera_id,
"status": status
}
)
db.commit()
# Ratio check: nếu best much better than second best => more confident
ratio_ok = True
if second_best_distance < float("inf"):
ratio = best_distance / (second_best_distance + 1e-8)
logging.info(f"Distance ratio (best/second) = {ratio:.4f}")
# Nếu ratio quá gần 1 (ví dụ > 0.85) => không đủ phân biệt
if ratio > 0.85:
ratio_ok = False
return {
"message": f"Check-in successful for {encoding.name}",
"checking": True,
"status": status,
"data": {
"id": encoding.id,
"name": encoding.name,
"email": encoding.email,
"avatar": encoding.avatar,
"camera_id": camera_id,
"time": now.isoformat()
}
# Quyết định match nếu best_distance nhỏ hơn threshold và ratio ok
if best_distance <= DIST_THRESHOLD and ratio_ok and best_student is not None:
# kiểm tra recent check (nửa phút trước)
now = datetime.datetime.now()
recent_check = db.execute(
text("""
SELECT id FROM checkin_logs
WHERE student_id = :student_id
AND time > :time_threshold
"""),
{
"student_id": best_student,
"time_threshold": now - datetime.timedelta(minutes=0.5)
}
).fetchone()
return {"message": "No match found."}
if recent_check:
return {"message": f"{student_names.get(best_student)} already checked in recently.", "status": True}
# thêm dô đây
id_log = 0
ms_response = create_history({"name": student_names.get(best_student).split('\n')[0], "time_string": f"{datetime.datetime.now()}", "status": "check in"})
id_log = ms_response.get('data').get('id')
status = ms_response.get('data').get('status')
status = "check in"
# reset pointer
file.file.seek(0)
background_tasks.add_task(
send_image,
id_log,
image_data, # truyền bytes, không phải UploadFile
student_names.get(best_student),
status
)
db.execute(
text("""
INSERT INTO checkin_logs (student_id, time, camera_id, status)
VALUES (:student_id, :time, :camera_id, :status)
"""),
{
"student_id": best_student,
"time": now,
"camera_id": camera_id,
"status": status
}
)
db.commit()
return {"message": f"Check-in successful for {student_names.get(best_student)} (dist={best_distance:.4f})", "status": True, "status_type":status}
# Nếu không thỏa threshold/rule thì trả no match (và log lý do)
reasons = []
if best_distance > DIST_THRESHOLD:
reasons.append(f"best_distance({best_distance:.4f}) > threshold({DIST_THRESHOLD})")
if not ratio_ok:
reasons.append(f"ratio not confident ({best_distance:.4f}/{second_best_distance:.4f})")
logging.info("No confident match: " + "; ".join(reasons))
return {"message": "No match found.", "reasons": reasons, "status": False}
@app.get("/logs")

View File

@ -9,7 +9,7 @@ class Student(Base):
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
email = Column(String(100), nullable=False, unique=True, index=True)
avatar = Column(String(500), nullable=True, unique=True, index=True)
avatar = Column(String(500), nullable=True, index=True)
__table_args__ = (UniqueConstraint('email', name='uq_student_email'),)

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,7 +8,7 @@
<script
type="module"
crossorigin
src="/camera/static/assets/index-Cs3L7CRl.js"
src="/camera/static/assets/index-BtpLNeIZ.js"
></script>
<link
rel="stylesheet"

266
TrackingToolWeb/sync.py Normal file
View File

@ -0,0 +1,266 @@
from api import users
from database import SessionLocal
import os
import requests
import tempfile
import face_recognition
from sqlalchemy.exc import IntegrityError
from sqlalchemy import text
from database import SessionLocal
import json
URL_BASE_RESOURCE = "https://ms.prology.net/image/storage/"
def register_face_handler(name: str, email: str, avatar: str | None, image_url: str):
print(f"[DEBUG] Bắt đầu register_face_handler với email: {email}, image_url: {image_url}")
db = SessionLocal()
try:
# 1. Tải ảnh từ URL
try:
res = requests.get(image_url)
res.raise_for_status()
except Exception as e:
print(f"[ERROR] Không tải được ảnh từ URL {image_url}: {e}")
return {"status": False, "message": "Không tải được ảnh từ URL."}
# 2. Lưu ảnh vào file tạm
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
tmp.write(res.content)
tmp_path = tmp.name
print(f"[DEBUG] Ảnh đã lưu tạm ở: {tmp_path}")
# 3. Encode khuôn mặt
image = face_recognition.load_image_file(tmp_path)
encodings = face_recognition.face_encodings(image)
if not encodings:
print("[ERROR] Không phát hiện khuôn mặt trong ảnh.")
return {"status": False, "message": "Không phát hiện khuôn mặt."}
encoding_bytes = encodings[0].tobytes()
print("[DEBUG] Đã encode khuôn mặt.")
# 4. Check email tồn tại
print(f"[DEBUG] Kiểm tra email tồn tại: {email}")
existing = db.execute(
text("SELECT id FROM students WHERE email = :email"),
{"email": email}
).fetchone()
if existing:
student_id = existing[0]
print(f"[DEBUG] Email đã tồn tại, student_id = {student_id}. Thêm encoding mới…")
db.execute(
text("""
INSERT INTO student_encodings (student_id, encoding)
VALUES (:student_id, :encoding)
"""),
{"student_id": student_id, "encoding": encoding_bytes}
)
db.commit()
return {"status": True, "message": "Đã thêm encoding mới."}
# 5. Email chưa tồn tại → tạo student mới
print(f"[DEBUG] Email chưa tồn tại, tạo student mới: {name}, {email}")
db.execute(
text("""
INSERT INTO students (name, email, avatar)
VALUES (:name, :email, :avatar)
"""),
{
"name": name,
"email": email,
"avatar": avatar,
}
)
db.commit()
student_id = db.execute(text("SELECT LAST_INSERT_ID()")).fetchone()[0]
print(f"[DEBUG] Student mới ID = {student_id}")
# 6. Lưu encoding
db.execute(
text("""
INSERT INTO student_encodings (student_id, encoding)
VALUES (:student_id, :encoding)
"""),
{"student_id": student_id, "encoding": encoding_bytes}
)
db.commit()
return {"status": True, "message": "Đăng ký thành công."}
except IntegrityError as e:
db.rollback()
print(f"[ERROR] IntegrityError (email có thể đã tồn tại): {e}")
return {"status": False, "message": "Email đã tồn tại."}
except Exception as e:
print(f"[ERROR] Lỗi không xác định: {e}")
return {"status": False, "message": "Lỗi server."}
finally:
db.close()
def extract_images(history_list):
images = []
for day_item in history_list:
values = day_item.get("values", [])
for v in values:
img = v.get("image")
if img:
images.append(img)
return images
def sync_data_user():
response = users({"month": 11, "year": 2025})
if not response.get("status"):
return
raw_data = response.get("data")
if isinstance(raw_data, str):
try:
data = json.loads(raw_data)
except:
print(raw_data)
return
else:
data = raw_data
db = SessionLocal()
for item in data:
histories = item.get("history", [])
user = item.get("user")
if len(histories) <= 0:
continue
# 👉 Lấy số lượng encoding hiện có trong DB
try:
count = db.execute(
text("""
SELECT COUNT(*)
FROM student_encodings se
JOIN students s ON s.id = se.student_id
WHERE s.email = :email
"""),
{"email": user.get("email")}
).fetchone()[0]
except Exception as e:
print("[ERROR] Khi lấy count:", e)
continue
# 👉 Nếu đủ 5 bản ghi → SKIP người này
limit = 10
if count >= limit:
print(f"==> Bỏ qua {user.get('email')} vì đã đủ {limit} encoding ({count}/{limit})")
continue
# 👉 Nếu chưa đủ thì mới xử lý ảnh
histories_list = extract_images(histories)
for image in histories_list:
# Kiểm tra lại lần nữa trước khi thêm (tránh dư khi có nhiềsu ảnh)
if count >= limit:
print(f"==> Đã đạt {limit} encoding, dừng cho {user.get('email')}")
break
avatar = URL_BASE_RESOURCE + user.get("avatar", "")
image_url = URL_BASE_RESOURCE + image
print(user.get("name"), image_url)
result = register_face_handler(
name=user.get("name"),
email=user.get("email"),
avatar=avatar,
image_url=image_url
)
print("Result:", result)
# Tăng biến đếm sau mỗi lần thêm
if result.get("status"):
count += 1
db.close()
return response
def test_valid_data():
response = users({"month": 10, "year": 2025})
if not response.get("status"):
print("API trả status=False")
return
raw_data = response.get("data")
if isinstance(raw_data, str):
try:
data = json.loads(raw_data)
except Exception as e:
print("[ERROR] Không parse được data:", e)
return
else:
data = raw_data
for item in data:
histories = item.get("history", [])
user = item.get("user")
if len(histories) <= 0 or not user:
continue
histories_list = extract_images(histories)
for image in histories_list:
# Tải ảnh từ server trước khi gửi
image_url = URL_BASE_RESOURCE + image
try:
res = requests.get(image_url)
res.raise_for_status()
except Exception as e:
print(f"[ERROR] Không tải được ảnh {image_url}: {e}")
continue
# Lưu tạm để upload
with tempfile.NamedTemporaryFile(delete=False, suffix=".jpg") as tmp:
tmp.write(res.content)
tmp_path = tmp.name
# Gửi lên API
with open(tmp_path, "rb") as f:
r = requests.post(
"http://localhost:8000/checkin",
files={"file": f},
data={"camera_id": "cam1"}
)
print(r.status_code, r.json(), user.get("name"))
# Xóa file tạm
os.remove(tmp_path)
return response
# sync_data_user()
# test_valid_data()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 222 KiB

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 223 KiB

After

Width:  |  Height:  |  Size: 223 KiB