Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion app/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,14 +77,21 @@ class HRUserCreate(BaseModel):
email: EmailStr
full_name: str = Field(..., min_length=6, example="Trần Nam")
password: str
role: str = Field(default="hr", example="hr hoặc applicant")

@field_validator('password')
def validate_password(cls, v):
pattern = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$"
pattern = r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#+\-_=])[A-Za-z\d@$!%*?&#+\-_=]{8,}$"
if not re.match(pattern, v):
raise ValueError("Mật khẩu phải từ 8 ký tự, gồm ít nhất 1 chữ hoa, 1 chữ thường, 1 số và 1 ký tự đặc biệt.")
return v

@field_validator('role')
def validate_role(cls, v):
if v not in ("hr", "applicant"):
raise ValueError("Role phải là 'hr' hoặc 'applicant'.")
return v

class HRUserLogin(BaseModel):
email: EmailStr
password: str
Expand All @@ -96,6 +103,7 @@ class HRUserDB(BaseModel):
hashed_password: str
avatar: str
original_avatar: str
role: str = Field(default="hr")
is_verified: bool = Field(default=False)
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))

Expand Down
3 changes: 3 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from fastapi.middleware.cors import CORSMiddleware
from app.database.config import connect_to_mongo, close_mongo_connection
from app.routers import auth_router, cv_router, job_router
from app.routers import admin_router, applicant_router
from contextlib import asynccontextmanager

@asynccontextmanager
Expand All @@ -28,6 +29,8 @@ async def lifespan(app: FastAPI):
app.include_router(auth_router.router)
app.include_router(cv_router.router)
app.include_router(job_router.router)
app.include_router(admin_router.router)
app.include_router(applicant_router.router)

@app.get("/", tags=["Health Check"])
def root():
Expand Down
57 changes: 57 additions & 0 deletions app/routers/admin_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, HTTPException
from bson import ObjectId
from app.auth import get_current_user
from app.database.config import get_db
import os

router = APIRouter(prefix="/api/v1/admin", tags=["Admin"])

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":
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

@router.post("/bootstrap")
async def bootstrap_admin(body: dict):
"""Dùng 1 lần để tạo admin đầu tiên. Cần ADMIN_BOOTSTRAP_SECRET trong .env"""
if not BOOTSTRAP_SECRET:
raise HTTPException(status_code=403, detail="Bootstrap đã bị tắt")
if body.get("secret") != BOOTSTRAP_SECRET:
raise HTTPException(status_code=403, detail="Secret không đúng")
email = body.get("email")
if not email:
raise HTTPException(status_code=400, detail="Thiếu email")
db = get_db()
result = await db["hr_users"].update_one({"email": email}, {"$set": {"role": "admin"}})
if result.matched_count == 0:
raise HTTPException(status_code=404, detail="Không tìm thấy tài khoản")
return {"status": "success", "message": f"{email} đã được set làm Admin"}

@router.get("/users")
async def list_users(_: str = Depends(require_admin)):
db = get_db()
cursor = db["hr_users"].find({}, {"hashed_password": 0, "raw_text": 0})
users = await cursor.to_list(length=500)
for u in users:
u["id"] = str(u["_id"])
del u["_id"]
return users

@router.patch("/users/{user_id}/role")
async def update_user_role(user_id: str, body: dict, _: str = Depends(require_admin)):
new_role = body.get("role")
if new_role not in VALID_ROLES:
raise HTTPException(status_code=400, detail=f"Role không hợp lệ. Chọn: {VALID_ROLES}")
db = get_db()
result = await db["hr_users"].update_one(
{"_id": ObjectId(user_id)},
{"$set": {"role": new_role}}
)
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": f"Đã cập nhật role thành '{new_role}'"}
165 changes: 165 additions & 0 deletions app/routers/applicant_router.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
from bson import ObjectId
from datetime import datetime, timezone

from app.auth import get_current_user
from app.database.config import get_db
from app.services.nlp_engine import extract_text, analyze_cv_text, score_cv

router = APIRouter(prefix="/api/v1/apply", tags=["Applicant"])

MAX_FILE_SIZE = 5 * 1024 * 1024

async def require_applicant(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") not in ("applicant", "admin"):
raise HTTPException(status_code=403, detail="Chỉ Applicant mới có thể nộp đơn ứng tuyển")
return current_user

@router.get("/jobs")
async def list_open_jobs():
"""Danh sách job đang mở — public, không cần đăng nhập"""
db = get_db()
cursor = db["hr_jobs"].find({"status": "open"}).sort("created_at", -1)
jobs = await cursor.to_list(length=100)
result = []
for job in jobs:
result.append({
"id": str(job["_id"]),
"title": job.get("title"),
"company_name": job.get("company_name"),
"job_level": job.get("job_level"),
"work_mode": job.get("work_mode"),
"employment_type": job.get("employment_type"),
"location": job.get("location"),
"salary": job.get("salary"),
"deadline": job.get("deadline"),
"description": job.get("description"),
"requirements": job.get("requirements"),
"benefits": job.get("benefits"),
"required_skills": [s.get("name") for s in job.get("required_skills", [])],
"created_at": job.get("created_at"),
})
return result

@router.post("/jobs/{job_id}")
async def apply_to_job(
job_id: str,
file: UploadFile = File(...),
current_applicant: str = Depends(require_applicant)
):
db = get_db()

try:
job = await db["hr_jobs"].find_one({"_id": ObjectId(job_id), "status": "open"})
except Exception:
raise HTTPException(status_code=400, detail="Job ID không hợp lệ")

if not job:
raise HTTPException(status_code=404, detail="Không tìm thấy vị trí tuyển dụng hoặc đã đóng")

deadline = job.get("deadline")
if deadline:
if isinstance(deadline, str):
from datetime import timezone as tz
deadline = datetime.fromisoformat(deadline.replace("Z", "+00:00"))
if deadline.tzinfo is None:
deadline = deadline.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > deadline:
raise HTTPException(status_code=400, detail="Chiến dịch tuyển dụng đã hết hạn nộp hồ sơ")

# Kiểm tra đã nộp chưa
existing = await db["applicant_submissions"].find_one({
"applicant_email": current_applicant,
"job_id": job_id
})
if existing:
raise HTTPException(status_code=400, detail="Bạn đã nộp hồ sơ cho vị trí này rồi!")

content = await file.read()
if len(content) > MAX_FILE_SIZE:
raise HTTPException(status_code=400, detail="File vượt quá 5MB")

raw_text = await extract_text(file, content)
if not raw_text.strip():
raise HTTPException(status_code=400, detail="Không thể đọc nội dung file")

cv_data = analyze_cv_text(raw_text)
scoring_result = score_cv({
"raw_text": raw_text,
"skills": cv_data.get("skills", []),
"years_of_experience": cv_data.get("years_of_experience", 0),
"skill_experience": cv_data.get("skill_experience", {}),
"education_level": cv_data.get("education_level", "Không đề cập")
}, job)

submission = {
"applicant_email": current_applicant,
"job_id": job_id,
"job_title": job.get("title"),
"hr_email": job.get("created_by"),
"filename": file.filename,
"candidate_info": {
"email": cv_data.get("email") or current_applicant,
"phone": cv_data.get("phone"),
"github": cv_data.get("github"),
"linkedin": cv_data.get("linkedin"),
"education_level": cv_data.get("education_level"),
"years_of_experience": cv_data.get("years_of_experience", 0),
"skill_experience": cv_data.get("skill_experience", {}),
},
"extracted_skills": cv_data.get("skills", []),
"raw_text": raw_text,
"ai_score": scoring_result,
"status": "Mới",
"note": "",
"submitted_at": datetime.now(timezone.utc)
}

result = await db["applicant_submissions"].insert_one(submission)

# Đồng thời tạo bản ghi trong hr_applications để HR thấy trong leaderboard
cv_pool_record = {
"hr_email": job.get("created_by"),
"filename": file.filename,
"raw_text": raw_text,
"candidate_info": submission["candidate_info"],
"extracted_skills": cv_data.get("skills", []),
"applicant_email": current_applicant,
"created_at": datetime.now(timezone.utc)
}
cv_result = await db["hr_cvs"].insert_one(cv_pool_record)

app_record = {
"hr_email": job.get("created_by"),
"job_id": job_id,
"cv_id": str(cv_result.inserted_id),
"ai_score": scoring_result,
"status": "Mới",
"note": "",
"applied_at": datetime.now(timezone.utc),
"source": "applicant",
"applicant_email": current_applicant
}
await db["hr_applications"].insert_one(app_record)

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
}

@router.get("/my-applications")
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}
).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
4 changes: 3 additions & 1 deletion app/routers/auth_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ async def register_hr(background_tasks: BackgroundTasks, user: HRUserCreate = Bo
"hashed_password": hashed_pw,
"avatar": auto_avatar,
"original_avatar": auto_avatar,
"role": user.role,
"is_verified": False,
"created_at": datetime.now(timezone.utc)
}
Expand Down Expand Up @@ -203,7 +204,8 @@ async def get_current_user_profile(email: str = Depends(get_current_user)):
return {
"email": user["email"],
"full_name": user.get("full_name", "HR Manager"),
"avatar": user.get("avatar", "")
"avatar": user.get("avatar", ""),
"role": user.get("role", "hr")
}

@router.post("/docs-login", response_model=Token, include_in_schema=False)
Expand Down
Loading