diff --git a/app/auth.py b/app/auth.py index ee8b8ca..7df739b 100644 --- a/app/auth.py +++ b/app/auth.py @@ -44,4 +44,29 @@ async def get_current_user(token: str = Depends(oauth2_scheme)): except jwt.PyJWTError: raise credentials_exception - return email \ No newline at end of file + return email + +async def get_current_user_with_role(token: str = Depends(oauth2_scheme)): + """Trả về cả email và role của user""" + from app.database.config import get_db + + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Không thể xác thực thông tin (Token không hợp lệ hoặc đã hết hạn)", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[ALGORITHM]) + email: str = payload.get("sub") + if email is None: + raise credentials_exception + except jwt.PyJWTError: + raise credentials_exception + + # Lấy thông tin user từ database để có role + db = get_db() + user = await db["hr_users"].find_one({"email": email}) + if not user: + raise credentials_exception + + return {"email": email, "role": user.get("role", "hr")} \ No newline at end of file diff --git a/app/database/__init__.py b/app/database/__init__.py new file mode 100644 index 0000000..7e91e76 --- /dev/null +++ b/app/database/__init__.py @@ -0,0 +1 @@ +# Database package initialization \ No newline at end of file diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..674bdee --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +# Router package initialization \ No newline at end of file diff --git a/app/routers/admin_router.py b/app/routers/admin_router.py index 86ee619..1813145 100644 --- a/app/routers/admin_router.py +++ b/app/routers/admin_router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException from bson import ObjectId -from app.auth import get_current_user +from app.auth import get_current_user, get_current_user_with_role from app.database.config import get_db import os @@ -9,12 +9,10 @@ VALID_ROLES = ("hr", "applicant", "admin") BOOTSTRAP_SECRET = os.getenv("ADMIN_BOOTSTRAP_SECRET", "") -async def require_admin(current_user: str = Depends(get_current_user)): - db = get_db() - user = await db["hr_users"].find_one({"email": current_user}) - if not user or user.get("role") != "admin": +async def require_admin(user_info: dict = Depends(get_current_user_with_role)): + if user_info["role"] != "admin": raise HTTPException(status_code=403, detail="Chỉ Admin mới có quyền thực hiện thao tác này") - return current_user + return user_info["email"] @router.post("/bootstrap") async def bootstrap_admin(body: dict): diff --git a/app/routers/applicant_router.py b/app/routers/applicant_router.py index f18695b..3f61873 100644 --- a/app/routers/applicant_router.py +++ b/app/routers/applicant_router.py @@ -1,6 +1,8 @@ from fastapi import APIRouter, Depends, UploadFile, File, HTTPException from bson import ObjectId from datetime import datetime, timezone +from typing import List, Optional +from pydantic import BaseModel from app.auth import get_current_user from app.database.config import get_db @@ -10,6 +12,14 @@ MAX_FILE_SIZE = 5 * 1024 * 1024 +class NotificationCreate(BaseModel): + title: str + message: str + type: str = "info" # success, error, info, warning + job_title: Optional[str] = None + application_id: Optional[str] = None + application_status: Optional[str] = None + async def require_applicant(current_user: str = Depends(get_current_user)): db = get_db() user = await db["hr_users"].find_one({"email": current_user}) @@ -147,8 +157,7 @@ async def apply_to_job( return { "status": "success", "message": f"Nộp hồ sơ thành công cho vị trí '{job.get('title')}'", - "submission_id": str(result.inserted_id), - "ai_score": scoring_result + "submission_id": str(result.inserted_id) } @router.get("/my-applications") @@ -156,10 +165,119 @@ async def my_applications(current_applicant: str = Depends(require_applicant)): db = get_db() cursor = db["applicant_submissions"].find( {"applicant_email": current_applicant}, - {"raw_text": 0} + {"raw_text": 0, "ai_score": 0} ).sort("submitted_at", -1) apps = await cursor.to_list(length=100) for a in apps: a["id"] = str(a["_id"]) del a["_id"] return apps + +# Notification endpoints +@router.get("/notifications") +async def get_notifications(current_applicant: str = Depends(require_applicant)): + """Lấy danh sách thông báo của ứng viên""" + db = get_db() + cursor = db["applicant_notifications"].find( + {"applicant_email": current_applicant} + ).sort("created_at", -1) + notifications = await cursor.to_list(length=100) + + for notification in notifications: + notification["id"] = str(notification["_id"]) + del notification["_id"] + + return notifications + +@router.patch("/notifications/{notification_id}/read") +async def mark_notification_read( + notification_id: str, + current_applicant: str = Depends(require_applicant) +): + """Đánh dấu thông báo đã đọc""" + db = get_db() + + try: + result = await db["applicant_notifications"].update_one( + { + "_id": ObjectId(notification_id), + "applicant_email": current_applicant + }, + {"$set": {"status": "read", "read_at": datetime.now(timezone.utc)}} + ) + + if result.matched_count == 0: + raise HTTPException(status_code=404, detail="Không tìm thấy thông báo") + + return {"status": "success", "message": "Đã đánh dấu thông báo là đã đọc"} + except Exception as e: + raise HTTPException(status_code=400, detail="ID thông báo không hợp lệ") + +@router.patch("/notifications/read-all") +async def mark_all_notifications_read(current_applicant: str = Depends(require_applicant)): + """Đánh dấu tất cả thông báo đã đọc""" + db = get_db() + + result = await db["applicant_notifications"].update_many( + { + "applicant_email": current_applicant, + "status": "unread" + }, + {"$set": {"status": "read", "read_at": datetime.now(timezone.utc)}} + ) + + return { + "status": "success", + "message": f"Đã đánh dấu {result.modified_count} thông báo là đã đọc" + } + +@router.delete("/notifications/{notification_id}") +async def delete_notification( + notification_id: str, + current_applicant: str = Depends(require_applicant) +): + """Xóa thông báo""" + db = get_db() + + try: + result = await db["applicant_notifications"].delete_one( + { + "_id": ObjectId(notification_id), + "applicant_email": current_applicant + } + ) + + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Không tìm thấy thông báo") + + return {"status": "success", "message": "Đã xóa thông báo"} + except Exception as e: + raise HTTPException(status_code=400, detail="ID thông báo không hợp lệ") + +# Helper function to create notifications +async def create_notification( + applicant_email: str, + title: str, + message: str, + notification_type: str = "info", + job_title: Optional[str] = None, + application_id: Optional[str] = None, + application_status: Optional[str] = None +): + """Tạo thông báo mới cho ứng viên""" + db = get_db() + + notification = { + "applicant_email": applicant_email, + "title": title, + "message": message, + "type": notification_type, + "status": "unread", + "job_title": job_title, + "application_id": application_id, + "application_status": application_status, + "created_at": datetime.now(timezone.utc) + } + + await db["applicant_notifications"].insert_one(notification) + return notification \ No newline at end of file diff --git a/app/routers/auth_router.py b/app/routers/auth_router.py index 0bbdf7e..180b65f 100644 --- a/app/routers/auth_router.py +++ b/app/routers/auth_router.py @@ -14,6 +14,19 @@ import httpx from fastapi.security import OAuth2PasswordRequestForm +class ProfileUpdate(BaseModel): + full_name: str + phone: str = None + address: str = None + github: str = None + linkedin: str = None + bio: str = None + avatar: str = None + +class PasswordChange(BaseModel): + current_password: str + new_password: str + router = APIRouter(prefix="/api/v1/auth", tags=["Authentication"]) GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID") @@ -165,6 +178,7 @@ async def google_login(request: GoogleAuthRequest = Body(...)): "hashed_password": hashed_pw, "avatar": final_avatar, "original_avatar": final_avatar, + "role": "applicant", # Mặc định là applicant khi đăng nhập bằng Google "is_verified": True, "created_at": datetime.now(timezone.utc) } @@ -193,6 +207,88 @@ async def google_login(request: GoogleAuthRequest = Body(...)): except ValueError: raise HTTPException(status_code=401, detail="Token từ Google không hợp lệ hoặc đã hết hạn") +@router.get("/profile") +async def get_profile(email: str = Depends(get_current_user)): + """Lấy thông tin profile đầy đủ của user""" + db = get_db() + user = await db["hr_users"].find_one({"email": email}) + + if not user: + raise HTTPException(status_code=404, detail="Không tìm thấy thông tin người dùng") + + return { + "email": user["email"], + "full_name": user.get("full_name", ""), + "phone": user.get("phone", ""), + "address": user.get("address", ""), + "github": user.get("github", ""), + "linkedin": user.get("linkedin", ""), + "bio": user.get("bio", ""), + "avatar": user.get("avatar", ""), + "role": user.get("role", "hr") + } + +@router.patch("/profile") +async def update_profile(profile_data: ProfileUpdate, email: str = Depends(get_current_user)): + """Cập nhật thông tin profile""" + db = get_db() + + # Prepare update data + update_data = { + "full_name": profile_data.full_name, + "phone": profile_data.phone, + "address": profile_data.address, + "github": profile_data.github, + "linkedin": profile_data.linkedin, + "bio": profile_data.bio, + "avatar": profile_data.avatar, + "updated_at": datetime.now(timezone.utc) + } + + # Remove None values + update_data = {k: v for k, v in update_data.items() if v is not None} + + result = await db["hr_users"].update_one( + {"email": email}, + {"$set": update_data} + ) + + if result.matched_count == 0: + raise HTTPException(status_code=404, detail="Không tìm thấy người dùng") + + return {"status": "success", "message": "Cập nhật thông tin thành công"} + +@router.patch("/change-password") +async def change_password(password_data: PasswordChange, email: str = Depends(get_current_user)): + """Đổi mật khẩu""" + db = get_db() + + # Get current user + user = await db["hr_users"].find_one({"email": email}) + if not user: + raise HTTPException(status_code=404, detail="Không tìm thấy người dùng") + + # Verify current password + if not verify_password(password_data.current_password, user["hashed_password"]): + raise HTTPException(status_code=400, detail="Mật khẩu hiện tại không đúng") + + # Hash new password + new_hashed_password = get_password_hash(password_data.new_password) + + # Update password + result = await db["hr_users"].update_one( + {"email": email}, + {"$set": { + "hashed_password": new_hashed_password, + "updated_at": datetime.now(timezone.utc) + }} + ) + + if result.matched_count == 0: + raise HTTPException(status_code=404, detail="Không tìm thấy người dùng") + + return {"status": "success", "message": "Đổi mật khẩu thành công"} + @router.get("/me") async def get_current_user_profile(email: str = Depends(get_current_user)): db = get_db() @@ -200,12 +296,22 @@ async def get_current_user_profile(email: str = Depends(get_current_user)): if not user: raise HTTPException(status_code=404, detail="Không tìm thấy thông tin người dùng") + + # Lấy role, nếu không có thì mặc định là "applicant" + role = user.get("role", "applicant") + + # Nếu user không có role trong DB, cập nhật luôn + if "role" not in user: + await db["hr_users"].update_one( + {"email": email}, + {"$set": {"role": "applicant"}} + ) return { "email": user["email"], - "full_name": user.get("full_name", "HR Manager"), + "full_name": user.get("full_name", "User"), "avatar": user.get("avatar", ""), - "role": user.get("role", "hr") + "role": role } @router.post("/docs-login", response_model=Token, include_in_schema=False) diff --git a/app/routers/cv_router.py b/app/routers/cv_router.py index 74c957d..788e1b3 100644 --- a/app/routers/cv_router.py +++ b/app/routers/cv_router.py @@ -8,7 +8,7 @@ from app.services.nlp_engine import extract_text, analyze_cv_text, score_cv from app.services.vector_engine import compress_cv_data, get_embedding -from app.auth import get_current_user +from app.auth import get_current_user, get_current_user_with_role router = APIRouter(prefix="/api/v1/cv", tags=["CV Processing & Talent Pool"]) MAX_FILE_SIZE = 5 * 1024 * 1024 @@ -162,11 +162,24 @@ async def map_cv_to_job( async def update_application_status( app_id: str, update_data: CVUpdate = Body(...), - current_hr: str = Depends(get_current_user) + user_info: dict = Depends(get_current_user_with_role) ): db = get_db() + current_hr = user_info["email"] + user_role = user_info["role"] + try: - filter_query = {"_id": ObjectId(app_id), "hr_email": current_hr} + # Admin có thể update bất kỳ application nào, HR thường chỉ update applications của mình + if user_role == "admin": + filter_query = {"_id": ObjectId(app_id)} + else: + filter_query = {"_id": ObjectId(app_id), "hr_email": current_hr} + + # Get current application data before update + current_app = await db["hr_applications"].find_one(filter_query) + if not current_app: + raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ ứng tuyển này") + update_query = {"$set": {}} if update_data.status is not None: @@ -183,18 +196,80 @@ async def update_application_status( if result.matched_count == 0: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ ứng tuyển này") + # Create notification for applicant if status changed + if update_data.status is not None and update_data.status != current_app.get("status"): + # Get job and applicant info + job = await db["hr_jobs"].find_one({"_id": ObjectId(current_app["job_id"])}) + cv = await db["hr_cvs"].find_one({"_id": ObjectId(current_app["cv_id"])}) + + # Check if this is from applicant submission + applicant_submission = await db["applicant_submissions"].find_one({ + "job_id": current_app["job_id"], + "applicant_email": {"$exists": True} + }) + + if applicant_submission and job: + # Create notification for applicant + notification_title, notification_message, notification_type = get_notification_content( + update_data.status, job.get("title", "Vị trí tuyển dụng") + ) + + notification = { + "applicant_email": applicant_submission["applicant_email"], + "title": notification_title, + "message": notification_message, + "type": notification_type, + "status": "unread", + "job_title": job.get("title"), + "application_id": str(current_app["_id"]), + "application_status": update_data.status, + "created_at": datetime.now(timezone.utc) + } + + await db["applicant_notifications"].insert_one(notification) + + # Also update the applicant_submissions status + await db["applicant_submissions"].update_one( + { + "job_id": current_app["job_id"], + "applicant_email": applicant_submission["applicant_email"] + }, + {"$set": {"status": update_data.status}} + ) + return {"status": "success", "message": "Đã cập nhật trạng thái ứng viên thành công"} except Exception as e: raise HTTPException(status_code=500, detail=str(e)) +def get_notification_content(status: str, job_title: str): + """Generate notification content based on application status""" + status_map = { + "Mới": ("Hồ sơ đã được tiếp nhận", f"Hồ sơ của bạn cho vị trí '{job_title}' đã được tiếp nhận và đang chờ xem xét.", "info"), + "Đang xem xét": ("Hồ sơ đang được xem xét", f"Hồ sơ của bạn cho vị trí '{job_title}' đang được HR xem xét kỹ lưỡng.", "info"), + "Phỏng vấn": ("Mời phỏng vấn", f"Chúc mừng! Bạn đã được mời phỏng vấn cho vị trí '{job_title}'. HR sẽ liên hệ với bạn sớm.", "success"), + "Đề nghị (Offer)": ("Đề nghị làm việc", f"Tuyệt vời! Bạn đã nhận được đề nghị làm việc cho vị trí '{job_title}'. Hãy kiểm tra email để biết thêm chi tiết.", "success"), + "Trúng tuyển": ("Chúc mừng - Trúng tuyển!", f"Chúc mừng bạn đã trúng tuyển vị trí '{job_title}'! Chào mừng bạn đến với đội ngũ của chúng tôi.", "success"), + "Từ chối": ("Thông báo kết quả", f"Cảm ơn bạn đã quan tâm đến vị trí '{job_title}'. Rất tiếc lần này chúng tôi không thể tiếp tục với hồ sơ của bạn.", "error"), + } + + return status_map.get(status, ("Cập nhật trạng thái", f"Trạng thái hồ sơ của bạn cho vị trí '{job_title}' đã được cập nhật thành '{status}'.", "info")) + @router.delete("/{cv_id}") async def delete_cv_from_pool( cv_id: str, - current_hr: str = Depends(get_current_user) + user_info: dict = Depends(get_current_user_with_role) ): db = get_db() + current_hr = user_info["email"] + user_role = user_info["role"] + try: - result = await db["hr_cvs"].delete_one({"_id": ObjectId(cv_id), "hr_email": current_hr}) + # Admin có thể xóa bất kỳ CV nào, HR thường chỉ xóa CV của mình + if user_role == "admin": + result = await db["hr_cvs"].delete_one({"_id": ObjectId(cv_id)}) + else: + result = await db["hr_cvs"].delete_one({"_id": ObjectId(cv_id), "hr_email": current_hr}) + if result.deleted_count == 0: raise HTTPException(status_code=404, detail="Không tìm thấy CV trong kho") @@ -205,10 +280,20 @@ async def delete_cv_from_pool( raise HTTPException(status_code=500, detail=str(e)) @router.get("/pool") -async def get_talent_pool(current_hr: str = Depends(get_current_user)): +async def get_talent_pool(user_info: dict = Depends(get_current_user_with_role)): db = get_db() + current_hr = user_info["email"] + user_role = user_info["role"] + try: - cursor = db["hr_cvs"].find({"hr_email": current_hr}).sort("created_at", -1) + # Admin thấy tất cả CVs, HR thường chỉ thấy CVs của mình + if user_role == "admin": + cursor = db["hr_cvs"].find({}).sort("created_at", -1) + print(f"DEBUG: Admin viewing all CVs") + else: + cursor = db["hr_cvs"].find({"hr_email": current_hr}).sort("created_at", -1) + print(f"DEBUG: HR {current_hr} viewing own CVs") + cvs = await cursor.to_list(length=500) for cv in cvs: @@ -224,14 +309,21 @@ async def get_talent_pool(current_hr: str = Depends(get_current_user)): @router.delete("/applications/{app_id}") async def remove_application_from_job( app_id: str, - current_hr: str = Depends(get_current_user) + user_info: dict = Depends(get_current_user_with_role) ): db = get_db() + current_hr = user_info["email"] + user_role = user_info["role"] + try: - result = await db["hr_applications"].delete_one({ - "_id": ObjectId(app_id), - "hr_email": current_hr - }) + # Admin có thể xóa bất kỳ application nào, HR thường chỉ xóa applications của mình + if user_role == "admin": + result = await db["hr_applications"].delete_one({"_id": ObjectId(app_id)}) + else: + result = await db["hr_applications"].delete_one({ + "_id": ObjectId(app_id), + "hr_email": current_hr + }) if result.deleted_count == 0: raise HTTPException(status_code=404, detail="Không tìm thấy hồ sơ ứng tuyển này") @@ -241,10 +333,20 @@ async def remove_application_from_job( raise HTTPException(status_code=500, detail=str(e)) @router.get("/applications/recent") -async def get_recent_applications(current_hr: str = Depends(get_current_user)): +async def get_recent_applications(user_info: dict = Depends(get_current_user_with_role)): db = get_db() + current_hr = user_info["email"] + user_role = user_info["role"] + try: - cursor = db["hr_applications"].find({"hr_email": current_hr}).sort("applied_at", -1).limit(100) + # Admin thấy tất cả applications, HR thường chỉ thấy applications của mình + if user_role == "admin": + cursor = db["hr_applications"].find({}).sort("applied_at", -1).limit(100) + print(f"DEBUG: Admin viewing all applications") + else: + cursor = db["hr_applications"].find({"hr_email": current_hr}).sort("applied_at", -1).limit(100) + print(f"DEBUG: HR {current_hr} viewing own applications") + applications = await cursor.to_list(length=100) result = [] diff --git a/app/routers/job_router.py b/app/routers/job_router.py index 303d8ae..6634c8c 100644 --- a/app/routers/job_router.py +++ b/app/routers/job_router.py @@ -3,7 +3,7 @@ from datetime import datetime, timezone from bson import ObjectId -from app.auth import get_current_user +from app.auth import get_current_user, get_current_user_with_role from app.database.config import get_db from app.database.models import JobCreateEnterprise, JobResponse from app.services.nlp_engine import score_cv @@ -62,18 +62,41 @@ async def create_job(job: JobCreateEnterprise, current_hr: str = Depends(get_cur return {"message": "Tạo chiến dịch thành công", "job_id": str(result.inserted_id)} @router.get("/", response_model=List[JobResponse]) -async def get_my_jobs(current_hr: str = Depends(get_current_user)): +async def get_my_jobs(user_info: dict = Depends(get_current_user_with_role)): db = get_db() - cursor = db["hr_jobs"].find({"created_by": current_hr}).sort("created_at", -1) + current_hr = user_info["email"] + user_role = user_info["role"] + + print(f"DEBUG: Getting jobs for HR: {current_hr}, Role: {user_role}") + + # Admin thấy tất cả jobs, HR thường chỉ thấy jobs của mình + if user_role == "admin": + cursor = db["hr_jobs"].find({}).sort("created_at", -1) + print("DEBUG: Admin - fetching ALL jobs") + else: + cursor = db["hr_jobs"].find({"created_by": current_hr}).sort("created_at", -1) + print(f"DEBUG: HR - fetching jobs created by {current_hr}") + jobs = await cursor.to_list(length=100) + print(f"DEBUG: Found {len(jobs)} jobs") + for job in jobs: job["id"] = str(job["_id"]) + print(f"DEBUG: Job ID: {job['id']}, Title: {job.get('title', 'No title')}, Created by: {job.get('created_by', 'Unknown')}") return jobs @router.get("/{job_id}", response_model=JobResponse) -async def get_job_detail(job_id: str, current_hr: str = Depends(get_current_user)): +async def get_job_detail(job_id: str, user_info: dict = Depends(get_current_user_with_role)): db = get_db() - job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "created_by": current_hr}) + current_hr = user_info["email"] + user_role = user_info["role"] + + # Admin có thể xem tất cả jobs, HR thường chỉ xem jobs của mình + if user_role == "admin": + job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id)}) + else: + job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "created_by": current_hr}) + if not job: raise HTTPException(status_code=404, detail="Không tìm thấy chiến dịch") job["id"] = str(job["_id"]) @@ -84,10 +107,18 @@ async def update_job( job_id: str, background_tasks: BackgroundTasks, job_update: JobCreateEnterprise = Body(...), - current_hr: str = Depends(get_current_user) + user_info: dict = Depends(get_current_user_with_role) ): db = get_db() - existing_job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "created_by": current_hr}) + current_hr = user_info["email"] + user_role = user_info["role"] + + # Admin có thể edit tất cả jobs, HR thường chỉ edit jobs của mình + if user_role == "admin": + existing_job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id)}) + else: + existing_job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "created_by": current_hr}) + if not existing_job: raise HTTPException(status_code=404, detail="Không tìm thấy Job hoặc bạn không có quyền chỉnh sửa") @@ -113,9 +144,17 @@ async def update_job( } @router.delete("/{job_id}") -async def delete_job(job_id: str, current_hr: str = Depends(get_current_user)): +async def delete_job(job_id: str, user_info: dict = Depends(get_current_user_with_role)): db = get_db() - result = await db["hr_jobs"].delete_one({"_id": ObjectId(job_id), "created_by": current_hr}) + current_hr = user_info["email"] + user_role = user_info["role"] + + # Admin có thể xóa tất cả jobs, HR thường chỉ xóa jobs của mình + if user_role == "admin": + result = await db["hr_jobs"].delete_one({"_id": ObjectId(job_id)}) + else: + result = await db["hr_jobs"].delete_one({"_id": ObjectId(job_id), "created_by": current_hr}) + if result.deleted_count == 0: raise HTTPException(status_code=404, detail="Không tìm thấy Job hoặc bạn không có quyền xóa") @@ -123,9 +162,17 @@ async def delete_job(job_id: str, current_hr: str = Depends(get_current_user)): return {"status": "success", "message": "Đã xóa chiến dịch. CV ứng viên vẫn được bảo lưu trong Kho hồ sơ."} @router.get("/{job_id}/ranking") -async def get_job_ranking(job_id: str, current_hr: str = Depends(get_current_user)): +async def get_job_ranking(job_id: str, user_info: dict = Depends(get_current_user_with_role)): db = get_db() - job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "created_by": current_hr}) + current_hr = user_info["email"] + user_role = user_info["role"] + + # Admin có thể xem ranking của tất cả jobs, HR thường chỉ xem jobs của mình + if user_role == "admin": + job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id)}) + else: + job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "created_by": current_hr}) + if not job: raise HTTPException(status_code=404, detail="Không tìm thấy chiến dịch") @@ -155,16 +202,30 @@ async def get_job_ranking(job_id: str, current_hr: str = Depends(get_current_use } @router.get("/dashboard/analytics") -async def get_dashboard_analytics(current_hr: str = Depends(get_current_user)): +async def get_dashboard_analytics(user_info: dict = Depends(get_current_user_with_role)): db = get_db() - total_jobs = await db["hr_jobs"].count_documents({"created_by": current_hr}) - open_jobs = await db["hr_jobs"].count_documents({"created_by": current_hr, "status": "open"}) - total_cvs_in_pool = await db["hr_cvs"].count_documents({"hr_email": current_hr}) - - pipeline = [ - {"$match": {"hr_email": current_hr}}, - {"$group": {"_id": "$status", "count": {"$sum": 1}}} - ] + current_hr = user_info["email"] + user_role = user_info["role"] + + # Admin xem analytics của tất cả, HR thường chỉ xem của mình + if user_role == "admin": + total_jobs = await db["hr_jobs"].count_documents({}) + open_jobs = await db["hr_jobs"].count_documents({"status": "open"}) + total_cvs_in_pool = await db["hr_cvs"].count_documents({}) + + pipeline = [ + {"$group": {"_id": "$status", "count": {"$sum": 1}}} + ] + else: + total_jobs = await db["hr_jobs"].count_documents({"created_by": current_hr}) + open_jobs = await db["hr_jobs"].count_documents({"created_by": current_hr, "status": "open"}) + total_cvs_in_pool = await db["hr_cvs"].count_documents({"hr_email": current_hr}) + + pipeline = [ + {"$match": {"hr_email": current_hr}}, + {"$group": {"_id": "$status", "count": {"$sum": 1}}} + ] + status_counts = await db["hr_applications"].aggregate(pipeline).to_list(length=None) status_breakdown = {item["_id"] if item["_id"] else "Mới": item["count"] for item in status_counts} diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..322e70c --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +# Services package initialization \ No newline at end of file diff --git a/app/services/nlp_engine.py b/app/services/nlp_engine.py index 2a89716..363cd8d 100644 --- a/app/services/nlp_engine.py +++ b/app/services/nlp_engine.py @@ -360,6 +360,7 @@ def score_cv(cv_data: dict, jd_data: dict) -> dict: total_score = min(100.0, total_score) + # Calculate penalty based on word count - FIXED VERSION raw_text = cv_data.get("raw_text", "").lower() eng_words = [" the ", " and ", " in ", " to ", " of ", " for ", " with "] vie_words = [" và ", " của ", " trong ", " cho ", " với ", " tại ", " là ", " các ", " người "] @@ -371,12 +372,14 @@ def score_cv(cv_data: dict, jd_data: dict) -> dict: threshold_severe = 100 if is_english else 200 threshold_light = 150 if is_english else 300 - word_count = cv_data.get("word_count") - penalty_score = 0.0 + word_count = cv_data.get("word_count", 0) + if word_count is None: + word_count = 0 - if word_count < threshold_severe: + penalty_score = 0.0 + if word_count > 0 and word_count < threshold_severe: penalty_score = 20.0 - elif word_count < threshold_light: + elif word_count > 0 and word_count < threshold_light: penalty_score = 10.0 total_score = max(0.0, total_score - penalty_score) diff --git a/app/services/nlp_engine.py.backup b/app/services/nlp_engine.py.backup new file mode 100644 index 0000000..2a89716 --- /dev/null +++ b/app/services/nlp_engine.py.backup @@ -0,0 +1,395 @@ +import os +import io +import re +import csv +from typing import Set, Dict, List, Tuple + +import pdfplumber +import docx + +from fastapi import UploadFile, HTTPException +from fastapi.concurrency import run_in_threadpool + +from app.services.vector_engine import calculate_cosine_similarity +import logging + +from datetime import datetime + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +SKILLS_FILE_PATH = os.path.join(BASE_DIR, "data", "skills.csv") + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +logging.getLogger("pdfminer").setLevel(logging.ERROR) + +def load_skills(file_path: str) -> Dict[str, List[str]]: + skill_map = {} + with open(file_path, encoding="utf-8") as f: + reader = csv.reader(f) + for row in reader: + if not row: + continue + main = row[0].strip().lower() + variants = [v.strip().lower() for v in row if v.strip()] + skill_map[main] = variants + return skill_map + +SKILL_MAP = load_skills(SKILLS_FILE_PATH) + +async def extract_text(file: UploadFile, content: bytes): + if file.filename.endswith(".pdf"): + return await run_in_threadpool(extract_text_from_pdf, content) + elif file.filename.endswith(".docx"): + return await run_in_threadpool(extract_text_from_docx, content) + else: + raise HTTPException(400, "Unsupported file format") + +def extract_text_from_pdf(file_bytes: bytes) -> str: + text = "" + with pdfplumber.open(io.BytesIO(file_bytes)) as pdf: + for page in pdf.pages: + t = page.extract_text() + if t: + text += t + "\n" + return text + +def extract_text_from_docx(file_bytes: bytes) -> str: + doc = docx.Document(io.BytesIO(file_bytes)) + return "\n".join(p.text for p in doc.paragraphs) + +def extract_skills(text: str) -> List[str]: + text_lower = text.lower() + found = set() + + for main, variants in SKILL_MAP.items(): + for v in variants: + pattern = rf"\b{re.escape(v)}\b" + if re.search(pattern, text_lower): + found.add(main) + break + + return sorted(list(found)) + +def extract_basic_info(text: str) -> Dict: + email = re.search(r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b", text) + phone_match = re.search(r"(?:\+84|0)(?:[ .-]?\d){9,10}", text) + phone = None + if phone_match: + phone = re.sub(r"[ .-]", "", phone_match.group(0)) + + return { + "email": email.group(0) if email else None, + "phone": phone + } + +def extract_social_links(text: str) -> dict: + links = { + "github": None, + "linkedin": None, + "portfolio": [] + } + + url_pattern = r'(?:https?:\/\/)?(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{2,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)' + + matches = re.finditer(url_pattern, text.lower()) + + for match in matches: + url = match.group(0).rstrip('.,;)]') + + if url.endswith(('.js', '.ts', '.php', '.py', '.html', '.css', '.cpp')): + continue + + if '@' in url and not url.startswith('http'): + continue + + if 'topcv.vn' in url or len(url) < 8: + continue + + if 'github.com' in url or 'gitlab.com' in url: + if not links['github']: + links['github'] = url + elif 'linkedin.com' in url: + if not links['linkedin']: + links['linkedin'] = url + else: + if url not in links['portfolio']: + links['portfolio'].append(url) + + return links + +def extract_years_of_experience(text: str) -> Tuple[float, Dict[str, float]]: + text_lower = text.lower() + + pattern1 = r"(\d+(?:\.\d+)?)\s*(?:\+)?\s*(?:năm|years?)\s*(?:kinh nghiệm|kinh nghiem|of experience|experience|exp)" + pattern2 = r"(?:kinh nghiệm|kinh nghiem|experience|exp).{0,20}?(\d+(?:\.\d+)?)\s*(?:năm|years?)" + + yoe_explicit = 0.0 + for pattern in [pattern1, pattern2]: + matches = re.findall(pattern, text_lower) + if matches: + numbers = [float(m) for m in matches] + yoe_explicit = max(max(numbers), yoe_explicit) + + lines = text_lower.split('\n') + date_pattern = r"(?:0?[1-9]|1[0-2])?[/.-]?20\d{2}\s*[-–~]?\s*(?:nay|present|hiện tại|(?:0?[1-9]|1[0-2])?[/.-]?20\d{2})" + + total_years_inferred = 0.0 + current_year = datetime.now().year + edu_keywords = ["đại học", "học viện", "cao đẳng", "thạc sĩ", "tiến sĩ", "university", "college", "school", "gpa"] + + skill_experience = {} + + for i, line in enumerate(lines): + matches = re.finditer(date_pattern, line) + for match in matches: + is_education = False + start_check = max(0, i - 2) + end_check = min(len(lines), i + 3) + context_text = " ".join(lines[start_check:end_check]) + + for edu_kw in edu_keywords: + if edu_kw in context_text: + is_education = True + break + + if is_education: + continue + + matched_str = match.group(0) + years = re.findall(r"20\d{2}", matched_str) + start_year = end_year = 0 + + if len(years) == 2: + start_year = int(years[0]) + end_year = int(years[1]) + elif len(years) == 1 and any(w in matched_str for w in ['nay', 'present', 'hiện tại']): + start_year = int(years[0]) + end_year = current_year + else: + continue + + if 1950 <= start_year <= end_year <= current_year: + dur = end_year - start_year + if dur == 0: + dur = 0.5 + + total_years_inferred += dur + + job_context_text = " ".join(lines[max(0, i - 1) : min(len(lines), i + 6)]) + local_skills = extract_skills(job_context_text) + + for skill in local_skills: + skill_experience[skill] = skill_experience.get(skill, 0.0) + dur + + final_total_yoe = max(yoe_explicit, total_years_inferred) + + return min(round(final_total_yoe, 1), 40.0), skill_experience + +def extract_education_level(text: str) -> str: + text_lower = text.lower() + + education_patterns = { + "Tiến sĩ (PhD)": r"\b(tiến sĩ|phd|ph\.d|doctorate)\b", + "Thạc sĩ (Master)": r"\b(thạc sĩ|thac si|master|mba|msc|m\.s|m\.a)\b", + "Cử nhân/Kỹ sư (Bachelor)": r"\b(cử nhân|cu nhan|kỹ sư|ky su|bachelor|bsc|b\.s|b\.a|engineer)\b", + "Cao đẳng (College)": r"\b(cao đẳng|cao dang|associate degree|college)\b" + } + + for level, pattern in education_patterns.items(): + if re.search(pattern, text_lower): + return level + + return "Không đề cập" + +def analyze_cv_text(text: str) -> Dict: + info = extract_basic_info(text) + skills = extract_skills(text) + yoe, skill_experience = extract_years_of_experience(text) + edu_level = extract_education_level(text) + social_links = extract_social_links(text) + + return { + **info, + "skills": skills, + "skill_count": len(skills), + "years_of_experience": yoe, + "skill_experience": skill_experience, + "education_level": edu_level, + "github": social_links["github"], + "linkedin": social_links["linkedin"], + "portfolio": social_links["portfolio"], + } + +def get_normalized_skill(raw_skill: str) -> str: + raw_lower = raw_skill.lower().strip() + for root, variants in SKILL_MAP.items(): + if raw_lower == root or raw_lower in variants: + return root + return raw_lower + +def calculate_skill_score(cv_skills: set, cv_skill_exp: dict, cv_yoe: float, jd_required: list, jd_preferred: list): + score = 0.0 + total_weight = sum(s.get('weight', 1.0) for s in jd_required) + sum(s.get('weight', 0.5) for s in jd_preferred) + + if total_weight == 0: + return 100.0, list(cv_skills), [] + + matched_skills = [] + missing_required_skills = [] + + def evaluate_skill(skill_dict, default_weight): + raw_name = skill_dict.get('name', '') + weight = skill_dict.get('weight', default_weight) + req_years = skill_dict.get('min_years', 0) + + norm_name = get_normalized_skill(raw_name) + + if norm_name in cv_skills: + cv_years = cv_skill_exp.get(norm_name, 0.0) + + if cv_years == 0.0 and cv_yoe > 0: + cv_years = cv_yoe * 0.5 + + if req_years > 0: + if cv_years >= req_years: + bonus = min((cv_years - req_years) * 0.1, 0.2) * weight + earned = weight + bonus + else: + ratio = cv_years / req_years + earned = weight * (0.5 + 0.5 * ratio) + else: + earned = weight + + return earned, raw_name, True + + return 0.0, raw_name, False + + for req in jd_required: + earned, raw_name, is_matched = evaluate_skill(req, 1.0) + if is_matched: + score += earned + matched_skills.append(raw_name) + else: + missing_required_skills.append(raw_name) + + for pref in jd_preferred: + earned, raw_name, is_matched = evaluate_skill(pref, 0.5) + if is_matched: + score += earned + matched_skills.append(raw_name) + + final_score = (score / total_weight) * 100 if total_weight > 0 else 0 + final_score = min(final_score, 110.0) + + return round(final_score, 2), matched_skills, missing_required_skills + +def calculate_experience_score(cv_yoe: int, jd_min_yoe: int) -> float: + if jd_min_yoe == 0: + return 100.0 + + if cv_yoe >= jd_min_yoe: + bonus = min((cv_yoe - jd_min_yoe) * 5, 10) + return 100.0 + bonus + else: + ratio = cv_yoe / jd_min_yoe + return round(ratio * 100, 2) + +def calculate_education_score(cv_edu: str, jd_min_edu: str) -> float: + if not jd_min_edu or jd_min_edu.lower() == "không yêu cầu": + return 100.0 + + edu_ranks = { + "không đề cập": 0, + "chứng chỉ nghề": 1, + "trung học phổ thông": 1, + "trung cấp": 2, + "cao đẳng": 3, + "cao đẳng (college)": 3, + "cử nhân": 4, + "cử nhân/kỹ sư (bachelor)": 4, + "thạc sĩ": 5, + "thạc sĩ (master)": 5, + "tiến sĩ": 6, + "tiến sĩ (phd)": 6 + } + + cv_rank = edu_ranks.get(cv_edu.lower().strip(), 0) + jd_rank = edu_ranks.get(jd_min_edu.lower().strip(), 0) + + if jd_rank == 0: + return 100.0 + + if cv_rank >= jd_rank: + return 100.0 + else: + return round((cv_rank / jd_rank) * 100, 2) + +def score_cv(cv_data: dict, jd_data: dict) -> dict: + jd_required_skills = jd_data.get("required_skills", []) + jd_preferred_skills = jd_data.get("preferred_skills", []) + jd_min_yoe = jd_data.get("min_yoe", 0) + + jd_education = jd_data.get("education", {}) + jd_min_edu = jd_education.get("min_level", "Không yêu cầu") + + cv_skills = set(cv_data.get("skills", [])) + cv_skill_exp = cv_data.get("skill_experience", {}) + cv_yoe = cv_data.get("years_of_experience", 0) + cv_edu = cv_data.get("education_level", "Không đề cập") + + jd_vector = jd_data.get("jd_vector", []) + cv_vector = cv_data.get("cv_vector", []) + + skill_score, matched_skills, missing_required_skills = calculate_skill_score(cv_skills, cv_skill_exp, cv_yoe, jd_required_skills, jd_preferred_skills) + experience_score = calculate_experience_score(cv_yoe, jd_min_yoe) + education_score = calculate_education_score(cv_edu, jd_min_edu) + + nlp_score = calculate_cosine_similarity(cv_vector, jd_vector) + + WEIGHT_SKILL = 0.40 + WEIGHT_NLP = 0.30 + WEIGHT_EXP = 0.20 + WEIGHT_EDU = 0.10 + + total_score = ( + (skill_score * WEIGHT_SKILL) + + (experience_score * WEIGHT_EXP) + + (education_score * WEIGHT_EDU) + + (nlp_score * WEIGHT_NLP) + ) + + total_score = min(100.0, total_score) + + raw_text = cv_data.get("raw_text", "").lower() + eng_words = [" the ", " and ", " in ", " to ", " of ", " for ", " with "] + vie_words = [" và ", " của ", " trong ", " cho ", " với ", " tại ", " là ", " các ", " người "] + + eng_count = sum(raw_text.count(w) for w in eng_words) + vie_count = sum(raw_text.count(w) for w in vie_words) + is_english = eng_count > vie_count + + threshold_severe = 100 if is_english else 200 + threshold_light = 150 if is_english else 300 + + word_count = cv_data.get("word_count") + penalty_score = 0.0 + + if word_count < threshold_severe: + penalty_score = 20.0 + elif word_count < threshold_light: + penalty_score = 10.0 + + total_score = max(0.0, total_score - penalty_score) + + return { + "total_score": round(total_score, 2), + "score_breakdown": { + "skills_score": skill_score, + "experience_score": experience_score, + "education_score": education_score, + "nlp_score": nlp_score, + "penalty_score": penalty_score + }, + "matched_skills": matched_skills, + "missing_required_skills": missing_required_skills + } \ No newline at end of file