diff --git a/app/database/models.py b/app/database/models.py index 271c9a2..d2e5dcb 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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 @@ -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)) diff --git a/app/main.py b/app/main.py index f6c696b..57fffa9 100644 --- a/app/main.py +++ b/app/main.py @@ -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 @@ -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(): diff --git a/app/routers/admin_router.py b/app/routers/admin_router.py new file mode 100644 index 0000000..86ee619 --- /dev/null +++ b/app/routers/admin_router.py @@ -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}'"} diff --git a/app/routers/applicant_router.py b/app/routers/applicant_router.py new file mode 100644 index 0000000..f18695b --- /dev/null +++ b/app/routers/applicant_router.py @@ -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 diff --git a/app/routers/auth_router.py b/app/routers/auth_router.py index 26bfe3c..0bbdf7e 100644 --- a/app/routers/auth_router.py +++ b/app/routers/auth_router.py @@ -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) } @@ -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)