first commit

This commit is contained in:
Anurag Jangid 2026-10-08 02:50:35 +05:30
commit 7a72438fd1
74 changed files with 7548 additions and 0 deletions

View file

@ -0,0 +1,3 @@
from app.routers import auth, resume, requirements, schedule, questions, pronunciation
__all__ = ["auth", "resume", "requirements", "schedule", "questions", "pronunciation"]

Binary file not shown.

Binary file not shown.

130
backend/app/routers/auth.py Normal file
View file

@ -0,0 +1,130 @@
"""
Google OAuth 2.0 flow:
1. GET /api/auth/google/login → redirect to Google consent screen
2. GET /api/auth/google/callback → exchange code for tokens → get user info
→ upsert user in MongoDB → issue JWT
→ redirect to frontend with token
3. GET /api/auth/me → return current user (protected)
4. POST /api/auth/logout → client-side only (stateless JWT), just 200
"""
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse, JSONResponse
import httpx
from datetime import datetime, timezone
from app.config import get_settings
from app.database import get_db
from app.dependencies import create_access_token, get_current_user
from app.models.user import UserOut
router = APIRouter(prefix="/api/auth", tags=["auth"])
settings = get_settings()
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"
SCOPES = "openid email profile"
# ── Step 1: Redirect to Google ─────────────────────────────────
@router.get("/google/login")
async def google_login():
redirect_uri = f"{settings.backend_url}/api/auth/google/callback"
params = (
f"?client_id={settings.google_client_id}"
f"&redirect_uri={redirect_uri}"
f"&response_type=code"
f"&scope={SCOPES}"
f"&access_type=offline"
f"&prompt=select_account"
)
return RedirectResponse(url=GOOGLE_AUTH_URL + params)
# ── Step 2: Callback from Google ───────────────────────────────
@router.get("/google/callback")
async def google_callback(code: str, db=Depends(get_db)):
redirect_uri = f"{settings.backend_url}/api/auth/google/callback"
# Exchange authorization code for tokens
async with httpx.AsyncClient() as client:
token_response = await client.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": settings.google_client_id,
"client_secret": settings.google_client_secret,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
},
)
if token_response.status_code != 200:
error_url = f"{settings.frontend_url}/login?error=google_token_failed"
return RedirectResponse(url=error_url)
tokens = token_response.json()
access_token = tokens.get("access_token")
# Fetch user profile from Google
async with httpx.AsyncClient() as client:
userinfo_response = await client.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
)
if userinfo_response.status_code != 200:
error_url = f"{settings.frontend_url}/login?error=google_userinfo_failed"
return RedirectResponse(url=error_url)
google_user = userinfo_response.json()
google_id = google_user.get("sub")
email = google_user.get("email", "")
name = google_user.get("name", "")
picture = google_user.get("picture", "")
# Upsert user in MongoDB
now = datetime.now(timezone.utc)
await db["users"].update_one(
{"google_id": google_id},
{
"$set": {
"email": email,
"name": name,
"picture": picture,
"last_login": now,
},
"$setOnInsert": {
"google_id": google_id,
"created_at": now,
},
},
upsert=True,
)
# Issue JWT
jwt_token = create_access_token(
data={"sub": google_id, "email": email, "name": name, "picture": picture}
)
# Redirect to frontend with token
frontend_redirect = f"{settings.frontend_url}/auth/callback?token={jwt_token}"
return RedirectResponse(url=frontend_redirect)
# ── Get current user (protected) ──────────────────────────────
@router.get("/me", response_model=UserOut)
async def get_me(current_user: dict = Depends(get_current_user)):
return UserOut(
google_id=current_user["google_id"],
email=current_user["email"],
name=current_user["name"],
picture=current_user.get("picture"),
)
# ── Logout (stateless — client drops the JWT) ─────────────────
@router.post("/logout")
async def logout():
return JSONResponse({"message": "Logged out successfully"})

View file

@ -0,0 +1,79 @@
from fastapi import APIRouter, Depends, HTTPException
from datetime import datetime, timezone
from typing import List
import random
from app.database import get_db
from app.models.pronunciation import PronunciationDB
from app.services.gemini_service import get_word_pronunciation
router = APIRouter(prefix="/api/pronunciation", tags=["pronunciation"])
TECH_TERMS = [
"Kubernetes", "PostgreSQL", "Nginx", "Polymorphism", "Encapsulation",
"Asynchronous", "Concurrency", "Django", "Vue", "TypeScript",
"GraphQL", "Docker", "Microservices", "Idempotent", "Heuristic",
"Tuple", "Boolean", "JSON", "YAML", "Ubuntu",
"Vite", "Pinia", "Axios", "FastAPI", "Uvicorn",
"OAuth", "JWT", "PyMuPDF", "GridFS", "MongoDB",
"Motor", "Asynchronous", "Middleware", "Interceptor", "Composition",
"Component", "Reactive", "Computed", "State", "Mutation",
"Action", "Getter", "Router", "Guard", "Plugin"
]
@router.get("/tech-terms", response_model=List[str])
async def get_tech_terms():
"""Returns a list of 50 common tech terms to prepopulate the UI."""
return TECH_TERMS
@router.get("/word-of-the-day")
async def get_word_of_the_day(db=Depends(get_db)):
"""Returns a random word from the DB, or a default one if DB is empty."""
count = await db["pronunciations"].count_documents({})
if count == 0:
return {"word": "Polymorphism", "phonetic": "/ˌpɒlɪˈmɔːfɪzəm/", "definition": "The provision of a single interface to entities of different types."}
# Pick a random document
import random
random_skip = random.randint(0, count - 1)
doc = await db["pronunciations"].find().skip(random_skip).limit(1).to_list(length=1)
if doc:
return {
"word": doc[0]["word"],
"phonetic": doc[0]["phonetic"],
"definition": doc[0]["definition"]
}
return {"word": "Polymorphism", "phonetic": "/ˌpɒlɪˈmɔːfɪzəm/", "definition": "The provision of a single interface to entities of different types."}
@router.get("/{word}", response_model=PronunciationDB)
async def get_pronunciation(word: str, db=Depends(get_db)):
"""Fetches pronunciation info from MongoDB or Gemini."""
word_lower = word.lower().strip()
# 1. Check DB Cache
existing = await db["pronunciations"].find_one({"word_lower": word_lower})
if existing:
existing["id"] = str(existing.pop("_id"))
return existing
# 2. Call Gemini
try:
data = await get_word_pronunciation(word)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# 3. Store in DB
now = datetime.now(timezone.utc)
doc = {
"word": data["word"],
"word_lower": word_lower,
"phonetic": data["phonetic"],
"syllables": data["syllables"],
"definition": data["definition"],
"created_at": now
}
result = await db["pronunciations"].insert_one(doc)
doc["id"] = str(result.inserted_id)
return doc

View file

@ -0,0 +1,84 @@
from fastapi import APIRouter, Depends, HTTPException, status
from bson import ObjectId
from datetime import datetime, timezone
from typing import List
from app.database import get_db
from app.dependencies import get_current_user
from app.models.question import QuestionSetIn, QuestionSetOut, QuestionItem
from app.services.gemini_service import generate_interview_questions
router = APIRouter(prefix="/api/questions", tags=["questions"])
@router.post("/generate", response_model=QuestionSetOut, status_code=status.HTTP_201_CREATED)
async def generate_questions(
req: QuestionSetIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
resume_oid = ObjectId(req.resume_id)
req_oid = ObjectId(req.requirement_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID format")
# Fetch resume
resume = await db["resumes"].find_one({"_id": resume_oid, "uploaded_by": current_user["google_id"]})
if not resume:
raise HTTPException(status_code=404, detail="Resume not found")
# Fetch requirements
requirement = await db["requirements"].find_one({"_id": req_oid, "user_id": current_user["google_id"]})
if not requirement:
raise HTTPException(status_code=404, detail="Requirement not found")
# Generate questions via Gemini
try:
# Pass resume text and requirements to Gemini
generated_data = await generate_interview_questions(
resume_text=resume.get("parsed", {}).get("raw_text", ""),
role_name=requirement.get("role_name", ""),
skills=requirement.get("mandatory_skills", []) + requirement.get("nice_to_have_skills", []),
experience=requirement.get("experience_years", 0),
projects=resume.get("parsed", {}).get("projects", [])
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# Save to MongoDB
now = datetime.now(timezone.utc)
doc = {
"user_id": current_user["google_id"],
"resume_id": req.resume_id,
"requirement_id": req.requirement_id,
"questions": generated_data,
"created_at": now
}
# Upsert logic to overwrite if same pair exists
result = await db["questions"].find_one_and_update(
{
"user_id": current_user["google_id"],
"resume_id": req.resume_id,
"requirement_id": req.requirement_id
},
{"$set": doc},
upsert=True,
return_document=True
)
result["id"] = str(result.pop("_id"))
return result
@router.get("/{resume_id}", response_model=List[QuestionSetOut])
async def get_questions_for_resume(
resume_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["questions"].find({"user_id": current_user["google_id"], "resume_id": resume_id}).sort("created_at", -1)
results = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
results.append(doc)
return results

View file

@ -0,0 +1,98 @@
from fastapi import APIRouter, Depends, HTTPException, status
from bson import ObjectId
from datetime import datetime, timezone
from typing import List
from app.database import get_db
from app.dependencies import get_current_user
from app.models.requirements import RequirementsIn, RequirementsOut
router = APIRouter(prefix="/api/requirements", tags=["requirements"])
@router.post("/", response_model=RequirementsOut, status_code=status.HTTP_201_CREATED)
async def create_requirements(
req: RequirementsIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
now = datetime.now(timezone.utc)
doc = req.model_dump()
doc["user_id"] = current_user["google_id"]
doc["created_at"] = now
doc["updated_at"] = now
result = await db["requirements"].insert_one(doc)
doc["id"] = str(result.inserted_id)
return doc
@router.get("/", response_model=List[RequirementsOut])
async def list_requirements(
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["requirements"].find({"user_id": current_user["google_id"]}).sort("updated_at", -1)
reqs = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
reqs.append(doc)
return reqs
@router.get("/{req_id}", response_model=RequirementsOut)
async def get_requirement(
req_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(req_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
doc = await db["requirements"].find_one({"_id": oid, "user_id": current_user["google_id"]})
if not doc:
raise HTTPException(status_code=404, detail="Requirement not found")
doc["id"] = str(doc.pop("_id"))
return doc
@router.put("/{req_id}", response_model=RequirementsOut)
async def update_requirement(
req_id: str,
req: RequirementsIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(req_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
update_data = req.model_dump()
update_data["updated_at"] = datetime.now(timezone.utc)
result = await db["requirements"].find_one_and_update(
{"_id": oid, "user_id": current_user["google_id"]},
{"$set": update_data},
return_document=True
)
if not result:
raise HTTPException(status_code=404, detail="Requirement not found")
result["id"] = str(result.pop("_id"))
return result
@router.delete("/{req_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_requirement(
req_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(req_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
result = await db["requirements"].delete_one({"_id": oid, "user_id": current_user["google_id"]})
if result.deleted_count == 0:
raise HTTPException(status_code=404, detail="Requirement not found")

View file

@ -0,0 +1,180 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from fastapi.responses import StreamingResponse
from bson import ObjectId
from datetime import datetime, timezone
import io
from app.database import get_db, get_gridfs
from app.dependencies import get_current_user
from app.services.resume_parser import parse_pdf, parse_docx
router = APIRouter(prefix="/api/resume", tags=["resume"])
ALLOWED_TYPES = {
"application/pdf": "pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/msword": "doc",
}
MAX_SIZE_MB = 10
# ── Upload & Parse ──────────────────────────────────────────────
@router.post("/upload", status_code=status.HTTP_201_CREATED)
async def upload_resume(
file: UploadFile = File(...),
db=Depends(get_db),
gridfs=Depends(get_gridfs),
current_user: dict = Depends(get_current_user),
):
# Validate content type
content_type = file.content_type or ""
if content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=400,
detail="Only PDF and DOCX files are accepted.",
)
file_bytes = await file.read()
# Validate size
if len(file_bytes) > MAX_SIZE_MB * 1024 * 1024:
raise HTTPException(
status_code=400,
detail=f"File too large. Max size is {MAX_SIZE_MB}MB.",
)
# Parse resume
file_type = ALLOWED_TYPES[content_type]
if file_type == "pdf":
parsed = parse_pdf(file_bytes)
else:
parsed = parse_docx(file_bytes)
# Store file in GridFS
gridfs_id = await gridfs.upload_from_stream(
file.filename,
io.BytesIO(file_bytes),
metadata={"content_type": content_type, "uploaded_by": current_user["google_id"]},
)
# Store metadata in MongoDB
doc = {
"filename": file.filename,
"file_type": file_type,
"content_type": content_type,
"uploaded_by": current_user["google_id"],
"gridfs_id": str(gridfs_id),
"parsed": parsed,
"uploaded_at": datetime.now(timezone.utc),
}
result = await db["resumes"].insert_one(doc)
return {
"id": str(result.inserted_id),
"filename": file.filename,
"file_type": file_type,
"parsed": parsed,
"uploaded_at": doc["uploaded_at"].isoformat(),
"message": "Resume uploaded and parsed successfully",
}
# ── List all resumes for current user ──────────────────────────
@router.get("/")
async def list_resumes(
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["resumes"].find(
{"uploaded_by": current_user["google_id"]},
{"parsed.raw_text": 0}, # exclude raw text from list view
).sort("uploaded_at", -1)
resumes = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
doc["uploaded_at"] = doc["uploaded_at"].isoformat() if doc.get("uploaded_at") else ""
resumes.append(doc)
return resumes
# ── Get single resume ──────────────────────────────────────────
@router.get("/{resume_id}")
async def get_resume(
resume_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(resume_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid resume ID")
doc = await db["resumes"].find_one(
{"_id": oid, "uploaded_by": current_user["google_id"]}
)
if not doc:
raise HTTPException(status_code=404, detail="Resume not found")
doc["id"] = str(doc.pop("_id"))
doc["uploaded_at"] = doc["uploaded_at"].isoformat() if doc.get("uploaded_at") else ""
return doc
# ── Download original file ─────────────────────────────────────
@router.get("/{resume_id}/download")
async def download_resume(
resume_id: str,
db=Depends(get_db),
gridfs=Depends(get_gridfs),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(resume_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid resume ID")
doc = await db["resumes"].find_one(
{"_id": oid, "uploaded_by": current_user["google_id"]}
)
if not doc:
raise HTTPException(status_code=404, detail="Resume not found")
gridfs_id = ObjectId(doc["gridfs_id"])
stream = await gridfs.open_download_stream(gridfs_id)
content = await stream.read()
return StreamingResponse(
io.BytesIO(content),
media_type=doc["content_type"],
headers={"Content-Disposition": f'attachment; filename="{doc["filename"]}"'},
)
# ── Delete resume ──────────────────────────────────────────────
@router.delete("/{resume_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_resume(
resume_id: str,
db=Depends(get_db),
gridfs=Depends(get_gridfs),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(resume_id)
except Exception:
raise HTTPException(status_code=400, detail="Invalid resume ID")
doc = await db["resumes"].find_one(
{"_id": oid, "uploaded_by": current_user["google_id"]}
)
if not doc:
raise HTTPException(status_code=404, detail="Resume not found")
# Delete from GridFS
try:
await gridfs.delete(ObjectId(doc["gridfs_id"]))
except Exception:
pass # GridFS file may already be gone
await db["resumes"].delete_one({"_id": oid})

View file

@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, HTTPException, status
from bson import ObjectId
from datetime import datetime, timezone
from typing import List
from app.database import get_db
from app.dependencies import get_current_user
from app.models.schedule import ScheduleIn, ScheduleOut
from app.services.email import send_interview_email
router = APIRouter(prefix="/api/schedule", tags=["schedule"])
@router.post("/", response_model=ScheduleOut, status_code=status.HTTP_201_CREATED)
async def create_schedule(
sched: ScheduleIn,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
now = datetime.now(timezone.utc)
doc = sched.model_dump()
doc["user_id"] = current_user["google_id"]
doc["status"] = "Scheduled"
doc["created_at"] = now
doc["updated_at"] = now
result = await db["schedules"].insert_one(doc)
doc["id"] = str(result.inserted_id)
# Try sending email
email_sent = await send_interview_email(
candidate_email=sched.candidate_email,
candidate_name=sched.candidate_name,
date=sched.interview_date,
time=sched.interview_time,
meeting_link=sched.meeting_link or "",
notes=sched.notes or ""
)
# Could log email_sent status if needed
return doc
@router.get("/", response_model=List[ScheduleOut])
async def list_schedules(
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
cursor = db["schedules"].find({"user_id": current_user["google_id"]}).sort("interview_date", 1)
scheds = []
async for doc in cursor:
doc["id"] = str(doc.pop("_id"))
scheds.append(doc)
return scheds
@router.get("/{sched_id}", response_model=ScheduleOut)
async def get_schedule(
sched_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(sched_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
doc = await db["schedules"].find_one({"_id": oid, "user_id": current_user["google_id"]})
if not doc:
raise HTTPException(status_code=404, detail="Schedule not found")
doc["id"] = str(doc.pop("_id"))
return doc
@router.put("/{sched_id}/status", response_model=ScheduleOut)
async def update_schedule_status(
sched_id: str,
status: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(sched_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
result = await db["schedules"].find_one_and_update(
{"_id": oid, "user_id": current_user["google_id"]},
{"$set": {"status": status, "updated_at": datetime.now(timezone.utc)}},
return_document=True
)
if not result:
raise HTTPException(status_code=404, detail="Schedule not found")
result["id"] = str(result.pop("_id"))
return result
@router.delete("/{sched_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_schedule(
sched_id: str,
db=Depends(get_db),
current_user: dict = Depends(get_current_user),
):
try:
oid = ObjectId(sched_id)
except:
raise HTTPException(status_code=400, detail="Invalid ID")
result = await db["schedules"].delete_one({"_id": oid, "user_id": current_user["google_id"]})
if result.deleted_count == 0:
raise HTTPException(status_code=404, detail="Schedule not found")