From 759183becd6c4635ad66592afaf34b5f048c7442 Mon Sep 17 00:00:00 2001 From: KIVOX-dev <07kaviarasan@gmail.com> Date: Sat, 1 Aug 2026 17:16:12 +0530 Subject: [PATCH 01/24] fix(deps): pin pydantic[email] to close unpinned duplicate declaration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requirements.txt declared pydantic twice: `pydantic==2.11.3` followed by a bare, unpinned `pydantic[email]`. The `safety` scanner flags the unpinned specifier because it nominally admits ancient pydantic 1.x releases carrying known CVEs. pip intersected the two constraints and resolved 2.11.3 regardless, so no vulnerable version was ever installed — but the range is now closed explicitly instead of depending on that resolver behaviour, and the duplicate declaration is gone. Verified: pip resolves pydantic 2.11.3 with the email extra; `safety check` goes from "0 reported, 4 ignored" to "0 reported, 0 ignored"; pip-audit remains clean. Co-Authored-By: Claude Opus 5 --- python-service/requirements.txt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/python-service/requirements.txt b/python-service/requirements.txt index 1b23837..1750542 100644 --- a/python-service/requirements.txt +++ b/python-service/requirements.txt @@ -28,8 +28,13 @@ certifi==2026.7.22 # --- Validation & Serialization --- -pydantic==2.11.3 -pydantic[email] +# Single pinned entry with the [email] extra. This was previously two lines — +# `pydantic==2.11.3` plus a bare, unpinned `pydantic[email]` — a duplicate +# declaration that `safety` flags: the unpinned specifier nominally admits +# ancient pydantic 1.x releases that carry known CVEs. pip intersected the two +# and resolved 2.11.3 anyway, so nothing vulnerable was ever installed, but the +# range is now closed explicitly rather than relying on that interaction. +pydantic[email]==2.11.3 pydantic-settings==2.9.1 # --- Authentication --- From d621b5ecc01bce9792e452fcc1c5d8a54bb34484 Mon Sep 17 00:00:00 2001 From: KIVOX-dev <07kaviarasan@gmail.com> Date: Sat, 1 Aug 2026 17:21:50 +0530 Subject: [PATCH 02/24] style: apply black formatting across python-service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formatting-only change — no behaviour, no logic, no dependency changes. Black is AST-preserving; verified after the reformat: black --check . -> 56 files unchanged ruff check . -> all checks passed pytest -> 13/13 passed clean-room boot -> /health 200, protected 401, unknown 404 Kept as its own commit so the preceding security/MongoDB work stays reviewable. Add this commit to .git-blame-ignore-revs (done in the follow-up commit) so `git blame` skips it. black is pinned in requirements-dev.txt and enforced by a dedicated `black --check .` step in the python-lint CI job. Its line-length is set to 100 in pyproject.toml to match [tool.ruff], so the two tools cannot disagree about where to wrap. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 5 + python-service/app/api/router.py | 1 + python-service/app/api/v1/achievements.py | 95 ++++-- python-service/app/api/v1/ai.py | 11 +- python-service/app/api/v1/assessments.py | 149 +++++++--- python-service/app/api/v1/auth.py | 95 +++--- python-service/app/api/v1/batches.py | 78 +++-- python-service/app/api/v1/chat.py | 56 ++-- python-service/app/api/v1/colleges.py | 24 +- python-service/app/api/v1/dashboard.py | 74 +++-- python-service/app/api/v1/interviews.py | 62 ++-- python-service/app/api/v1/jobs.py | 56 ++-- python-service/app/api/v1/persistence.py | 6 +- python-service/app/api/v1/placements.py | 27 +- python-service/app/api/v1/profile.py | 281 ++++++++++++++---- python-service/app/api/v1/resume.py | 114 ++++--- python-service/app/api/v1/router.py | 1 + python-service/app/api/v1/students.py | 165 ++++++---- python-service/app/api/v1/tests.py | 4 +- python-service/app/api/v1/users.py | 84 +++--- python-service/app/config.py | 2 +- python-service/app/core/exceptions.py | 6 +- python-service/app/core/middleware.py | 4 +- python-service/app/core/rbac.py | 13 +- python-service/app/core/websocket_manager.py | 6 +- python-service/app/db_indexes.py | 71 +++-- python-service/app/dependencies.py | 1 + python-service/app/main.py | 3 + python-service/app/mongodb.py | 5 + python-service/app/repositories/base.py | 4 +- .../app/repositories/college_repo.py | 7 +- python-service/app/repositories/user_repo.py | 22 +- python-service/app/schemas/auth.py | 9 + python-service/app/schemas/college.py | 1 + python-service/app/schemas/common.py | 4 + python-service/app/schemas/placement.py | 4 +- python-service/app/schemas/user.py | 10 +- python-service/app/services/auth_service.py | 93 +++--- python-service/pyproject.toml | 7 + python-service/requirements-dev.txt | 1 + python-service/seed_mongo.py | 62 ++-- 41 files changed, 1149 insertions(+), 574 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 547e703..33e8209 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,6 +90,11 @@ jobs: - run: pip install -r requirements-dev.txt - name: Lint (ruff) run: ruff check . + # Formatting is checked separately from ruff so a failure says clearly + # which of the two is unhappy. Both are pinned to line-length 100 in + # pyproject.toml, so they cannot disagree about wrapping. + - name: Format check (black) + run: black --check . python-test: name: python-service / test diff --git a/python-service/app/api/router.py b/python-service/app/api/router.py index 9b6fc03..cdd7e6d 100644 --- a/python-service/app/api/router.py +++ b/python-service/app/api/router.py @@ -1,6 +1,7 @@ """ UpScaler-AI V2 — Main API Router """ + from fastapi import APIRouter from app.api.v1.router import api_router as v1_router from app.config import get_settings diff --git a/python-service/app/api/v1/achievements.py b/python-service/app/api/v1/achievements.py index 33a3195..95d18f9 100644 --- a/python-service/app/api/v1/achievements.py +++ b/python-service/app/api/v1/achievements.py @@ -6,6 +6,7 @@ router = APIRouter(tags=["Achievements"]) + def to_dict(obj): if not obj: return None @@ -19,8 +20,11 @@ def to_dict(obj): obj[k] = to_dict(v) return obj + @router.get("/students/{student_id}/achievements") -def student_achievements(student_id: int, db = Depends(get_db), college_scope: int | None = get_college_scope): +def student_achievements( + student_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope +): query = {"user_id": student_id} if college_scope: query["college_id"] = college_scope @@ -29,23 +33,48 @@ def student_achievements(student_id: int, db = Depends(get_db), college_scope: i @router.post("/students/{student_id}/achievements/evaluate") -def evaluate_achievements(student_id: int, db = Depends(get_db), current_user = Depends(get_current_user)): +def evaluate_achievements( + student_id: int, db=Depends(get_db), current_user=Depends(get_current_user) +): assert_can_act_on_student(current_user, student_id, db) student = db["users"].find_one({"id": student_id}) if not student: return [] college_id = student.get("college_id") or 1 - - test_count = db["assessment_attempts"].count_documents({"student_id": student_id, "status": "completed"}) + + test_count = db["assessment_attempts"].count_documents( + {"student_id": student_id, "status": "completed"} + ) interview_count = db["interview_attempts"].count_documents({"student_id": student_id}) placement_count = db["placements"].count_documents({"student_id": student_id}) - + rules = [ - ("practice-5", test_count >= 5, "Practice Starter", "Completed 5 tests", "test", test_count), - ("interview-1", interview_count >= 1, "Interview Ready", "Completed a mock interview", "interview", interview_count), - ("placed", placement_count >= 1, "Placed Talent", "Submitted a placement record", "placement", placement_count), + ( + "practice-5", + test_count >= 5, + "Practice Starter", + "Completed 5 tests", + "test", + test_count, + ), + ( + "interview-1", + interview_count >= 1, + "Interview Ready", + "Completed a mock interview", + "interview", + interview_count, + ), + ( + "placed", + placement_count >= 1, + "Placed Talent", + "Submitted a placement record", + "placement", + placement_count, + ), ] - + created = [] for ach_type, condition, title, desc, module, value in rules: exists = db["achievements"].find_one({"user_id": student_id, "achievement_type": ach_type}) @@ -63,56 +92,56 @@ def evaluate_achievements(student_id: int, db = Depends(get_db), current_user = } db["achievements"].insert_one(achievement) created.append(achievement) - + all_ach = db["achievements"].find({"user_id": student_id}).sort("achieved_at", -1) return [to_dict(doc) for doc in all_ach] @router.get("/leaderboard") def leaderboard( - scope: str = "national", - db = Depends(get_db), - current_user = Depends(get_current_user) + scope: str = "national", db=Depends(get_db), current_user=Depends(get_current_user) ): query = {"role": "student", "status": "approved"} - + if scope == "college": query["college_id"] = current_user.get("college_id") - + users = list(db["users"].find(query).limit(100)) rows = [] for user in users: profile = db["student_profiles"].find_one({"user_id": user["id"]}) - + tests = profile.get("tests_completed", 0) if profile else 0 acc = profile.get("avg_accuracy", 0) if profile else 0 score = int(tests * acc * 10) - + trend = "same" if tests > 0: trend = "up" if score % 3 == 0 else "down" if score % 2 == 0 else "same" - + college = db["colleges"].find_one({"id": user.get("college_id")}) college_name = college["name"] if college else "Independent" - - rows.append({ - "id": user["id"], - "name": user.get("name", "Student"), - "college": college_name, - "score": score, - "accuracy": acc, - "avatar": user.get("name", "U")[0].upper() if user.get("name") else "U", - "trend": trend, - "rank": 0, - }) - + + rows.append( + { + "id": user["id"], + "name": user.get("name", "Student"), + "college": college_name, + "score": score, + "accuracy": acc, + "avatar": user.get("name", "U")[0].upper() if user.get("name") else "U", + "trend": trend, + "rank": 0, + } + ) + active_rows = [r for r in rows if r["score"] > 0] if not active_rows and rows: active_rows = rows - + active_rows.sort(key=lambda item: item["score"], reverse=True) - + for index, row in enumerate(active_rows, start=1): row["rank"] = index - + return {"success": True, "data": active_rows} diff --git a/python-service/app/api/v1/ai.py b/python-service/app/api/v1/ai.py index 5b1dbff..b38c988 100644 --- a/python-service/app/api/v1/ai.py +++ b/python-service/app/api/v1/ai.py @@ -8,12 +8,14 @@ router = APIRouter(prefix="/ai", tags=["AI"]) + class ResumeData(BaseModel): objective: str education: str skills: str experience: str + @router.post("/resume/improve") def improve_resume(data: ResumeData, current_user: DotDict = Depends(get_current_user)): """ @@ -27,12 +29,12 @@ def improve_resume(data: ResumeData, current_user: DotDict = Depends(get_current "education": data.education, "skills": data.skills, "experience": data.experience, - "message": "GROQ_API_KEY not configured. Returning original text." + "message": "GROQ_API_KEY not configured. Returning original text.", } try: client = Groq(api_key=groq_api_key) - + prompt = f""" You are an expert Resume Writer and Career Coach. Improve the following resume sections to make them sound professional, impactful, and ATS-friendly. @@ -51,13 +53,14 @@ def improve_resume(data: ResumeData, current_user: DotDict = Depends(get_current messages=[{"role": "user", "content": prompt}], temperature=0.7, max_tokens=1024, - response_format={"type": "json_object"} + response_format={"type": "json_object"}, ) import json + result_text = completion.choices[0].message.content improved_data = json.loads(result_text) - + return improved_data except Exception as e: raise HTTPException(status_code=500, detail=f"Failed to improve resume: {str(e)}") diff --git a/python-service/app/api/v1/assessments.py b/python-service/app/api/v1/assessments.py index c662b74..34c941e 100644 --- a/python-service/app/api/v1/assessments.py +++ b/python-service/app/api/v1/assessments.py @@ -8,10 +8,17 @@ from app.core.rbac import UserRole, get_college_scope, get_current_user, require_roles from app.database import get_db from app.config import get_settings -from app.schemas.assessment import AssessmentCreateRequest, AssessmentResponse, AssessmentUpdateRequest, AttemptResponse, TestSubmitRequest +from app.schemas.assessment import ( + AssessmentCreateRequest, + AssessmentResponse, + AssessmentUpdateRequest, + AttemptResponse, + TestSubmitRequest, +) router = APIRouter(prefix="/assessments", tags=["Assessments"]) + def to_dict(obj): # Convert MongoDB _id to string or map to id if necessary if obj is None: @@ -19,8 +26,9 @@ def to_dict(obj): obj["id"] = obj.get("id", str(obj.get("_id", ""))) return obj + @router.get("", response_model=list[AssessmentResponse]) -def list_assessments(db = Depends(get_db), college_scope: int | None = get_college_scope): +def list_assessments(db=Depends(get_db), college_scope: int | None = get_college_scope): query = {} if college_scope: query["college_id"] = int(college_scope) @@ -28,8 +36,14 @@ def list_assessments(db = Depends(get_db), college_scope: int | None = get_colle return [to_dict(doc) for doc in cursor] -@router.post("", response_model=AssessmentResponse, dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)]) -def create_assessment(data: AssessmentCreateRequest, current_user = Depends(get_current_user), db = Depends(get_db)): +@router.post( + "", + response_model=AssessmentResponse, + dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], +) +def create_assessment( + data: AssessmentCreateRequest, current_user=Depends(get_current_user), db=Depends(get_db) +): doc = data.model_dump() doc["id"] = db["assessments"].count_documents({}) + 1 doc["college_id"] = current_user.college_id or 1 @@ -40,21 +54,32 @@ def create_assessment(data: AssessmentCreateRequest, current_user = Depends(get_ doc["max_attempts"] = 1 doc["created_at"] = datetime.now(timezone.utc).isoformat() doc["updated_at"] = datetime.now(timezone.utc).isoformat() - + db["assessments"].insert_one(doc) return to_dict(doc) -@router.post("/generate-questions", dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)]) +@router.post( + "/generate-questions", + dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], +) def generate_assessment_questions(data: dict): title = data.get("title", "Assessment") type = data.get("type", "general") difficulty = data.get("difficulty", "medium") - + groq_api_key = get_settings().GROQ_API_KEY if not groq_api_key: - return {"questions": [{"question": f"Sample {difficulty} {type} question for {title}", "options": ["A", "B", "C", "D"], "correct_answer": "A"}]} - + return { + "questions": [ + { + "question": f"Sample {difficulty} {type} question for {title}", + "options": ["A", "B", "C", "D"], + "correct_answer": "A", + } + ] + } + try: client = Groq(api_key=groq_api_key) prompt = f""" @@ -77,7 +102,7 @@ def generate_assessment_questions(data: dict): messages=[{"role": "user", "content": prompt}], temperature=0.7, max_tokens=2048, - response_format={"type": "json_object"} + response_format={"type": "json_object"}, ) result_text = completion.choices[0].message.content.strip() @@ -95,16 +120,21 @@ def generate_assessment_questions(data: dict): @router.get("/overview/stats") -def overview_stats(db = Depends(get_db), college_scope: int | None = get_college_scope): +def overview_stats(db=Depends(get_db), college_scope: int | None = get_college_scope): query = {} if college_scope: query["college_id"] = int(college_scope) - + total = db["assessments"].count_documents(query) attempts = list(db["assessment_attempts"].find(query)) - - avg_score = round(sum(a.get("percentage", 0) for a in attempts) / len(attempts), 2) if attempts else 0 - return {"success": True, "data": {"total": total, "attempts": len(attempts), "avg_score": avg_score}} + + avg_score = ( + round(sum(a.get("percentage", 0) for a in attempts) / len(attempts), 2) if attempts else 0 + ) + return { + "success": True, + "data": {"total": total, "attempts": len(attempts), "avg_score": avg_score}, + } def get_assessment_internal(assessment_id: int, db, college_scope: int | None): @@ -118,47 +148,68 @@ def get_assessment_internal(assessment_id: int, db, college_scope: int | None): @router.get("/{assessment_id}", response_model=AssessmentResponse) -def get_assessment(assessment_id: int, db = Depends(get_db), college_scope: int | None = get_college_scope): +def get_assessment( + assessment_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope +): assessment = get_assessment_internal(assessment_id, db, college_scope) return to_dict(assessment) -@router.put("/{assessment_id}", response_model=AssessmentResponse, dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)]) -def update_assessment(assessment_id: int, data: AssessmentUpdateRequest, db = Depends(get_db), college_scope: int | None = get_college_scope): - get_assessment_internal(assessment_id, db, college_scope) # existence/scope check; raises if not authorized +@router.put( + "/{assessment_id}", + response_model=AssessmentResponse, + dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], +) +def update_assessment( + assessment_id: int, + data: AssessmentUpdateRequest, + db=Depends(get_db), + college_scope: int | None = get_college_scope, +): + get_assessment_internal( + assessment_id, db, college_scope + ) # existence/scope check; raises if not authorized update_data = data.model_dump(exclude_unset=True) update_data["updated_at"] = datetime.now(timezone.utc).isoformat() - + db["assessments"].update_one({"id": assessment_id}, {"$set": update_data}) - + updated = get_assessment_internal(assessment_id, db, college_scope) return to_dict(updated) @router.delete("/{assessment_id}") -def delete_assessment(assessment_id: int, db = Depends(get_db), college_scope: int | None = get_college_scope): - get_assessment_internal(assessment_id, db, college_scope) # existence/scope check; raises if not authorized +def delete_assessment( + assessment_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope +): + get_assessment_internal( + assessment_id, db, college_scope + ) # existence/scope check; raises if not authorized db["assessments"].delete_one({"id": assessment_id}) return {"success": True, "message": "Assessment deleted"} @router.get("/{assessment_id}/results", response_model=list[AttemptResponse]) -def assessment_results(assessment_id: int, db = Depends(get_db), college_scope: int | None = get_college_scope): +def assessment_results( + assessment_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope +): query = {"assessment_id": assessment_id} if college_scope: query["college_id"] = int(college_scope) - + attempts = db["assessment_attempts"].find(query).sort("created_at", -1) return [to_dict(doc) for doc in attempts] @router.post("/submit", response_model=AttemptResponse) -def submit_test(data: TestSubmitRequest, current_user = Depends(get_current_user), db = Depends(get_db)): +def submit_test( + data: TestSubmitRequest, current_user=Depends(get_current_user), db=Depends(get_db) +): assessment = None if data.assessment_id: assessment = db["assessments"].find_one({"id": data.assessment_id}) - + if not assessment: assessment = { "id": db["assessments"].count_documents({}) + 1, @@ -173,16 +224,22 @@ def submit_test(data: TestSubmitRequest, current_user = Depends(get_current_user "status": "active", "difficulty": "medium", "created_at": datetime.now(timezone.utc).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat() + "updated_at": datetime.now(timezone.utc).isoformat(), } db["assessments"].insert_one(assessment) - - pct = data.percentage if data.percentage is not None else round((data.score / data.max_score) * 100, 2) - attempt_number = db["assessment_attempts"].count_documents({ - "assessment_id": assessment["id"], - "student_id": current_user.id - }) + 1 - + + pct = ( + data.percentage + if data.percentage is not None + else round((data.score / data.max_score) * 100, 2) + ) + attempt_number = ( + db["assessment_attempts"].count_documents( + {"assessment_id": assessment["id"], "student_id": current_user.id} + ) + + 1 + ) + attempt = { "id": db["assessment_attempts"].count_documents({}) + 1, "assessment_id": assessment["id"], @@ -202,18 +259,18 @@ def submit_test(data: TestSubmitRequest, current_user = Depends(get_current_user "weak_areas": data.weak_areas, } db["assessment_attempts"].insert_one(attempt) - + # Update profile stats profile = db["student_profiles"].find_one({"user_id": current_user.id}) if profile: tests_completed = profile.get("tests_completed", 0) + 1 avg_acc = profile.get("avg_accuracy", 0) new_avg = round(((avg_acc * (tests_completed - 1)) + pct) / tests_completed, 2) - + today_date = datetime.now(timezone.utc).date() last_test_str = profile.get("last_test_date") streak = profile.get("streak", 0) - + if last_test_str: try: last_test_date = datetime.fromisoformat(last_test_str).date() @@ -230,12 +287,14 @@ def submit_test(data: TestSubmitRequest, current_user = Depends(get_current_user db["student_profiles"].update_one( {"user_id": current_user.id}, - {"$set": { - "tests_completed": tests_completed, - "avg_accuracy": new_avg, - "streak": streak, - "last_test_date": today_date.isoformat() - }} + { + "$set": { + "tests_completed": tests_completed, + "avg_accuracy": new_avg, + "streak": streak, + "last_test_date": today_date.isoformat(), + } + }, ) - + return to_dict(attempt) diff --git a/python-service/app/api/v1/auth.py b/python-service/app/api/v1/auth.py index df4f7f2..48305dc 100644 --- a/python-service/app/api/v1/auth.py +++ b/python-service/app/api/v1/auth.py @@ -1,13 +1,20 @@ """ UpScaler-AI V2 — Auth Router """ + from urllib.parse import urlencode, parse_qs import httpx from fastapi import APIRouter, Depends, Response, status, HTTPException from fastapi.responses import RedirectResponse from app.config import get_settings -from app.schemas.auth import RegisterRequest, LoginRequest, RefreshTokenRequest, UserBriefResponse, ChangePasswordRequest +from app.schemas.auth import ( + RegisterRequest, + LoginRequest, + RefreshTokenRequest, + UserBriefResponse, + ChangePasswordRequest, +) from app.schemas.common import MessageResponse from app.services.auth_service import AuthService from app.dependencies import get_auth_service @@ -50,11 +57,7 @@ def _set_token_cookie(response: Response, token: str, expires_in: int): @router.post("/register", status_code=status.HTTP_201_CREATED) -def register( - data: dict, - response: Response, - auth_service: AuthService = Depends(get_auth_service) -): +def register(data: dict, response: Response, auth_service: AuthService = Depends(get_auth_service)): """Register a new user account.""" role = data.get("role") or "student" if role == "hr": @@ -72,29 +75,34 @@ def register( company_name=data.get("company_name") or data.get("company"), ) except ValueError as e: - # Pydantic ValidationError is a subclass of ValueError in v2, + # Pydantic ValidationError is a subclass of ValueError in v2, # but let's import it safely if needed. Or just catch Exception # We'll use a direct approach since we just want the message. from pydantic import ValidationError + if isinstance(e, ValidationError): - raise HTTPException(status_code=400, detail=e.errors()[0]["msg"].replace("Value error, ", "")) + raise HTTPException( + status_code=400, detail=e.errors()[0]["msg"].replace("Value error, ", "") + ) raise HTTPException(status_code=400, detail=str(e)) user = auth_service.register(request) - + if user.status == "pending": - return {"success": True, "message": "Registration successful. Please wait for approval.", "data": {"user": {"email": user.email, "status": "pending"}}} - - token_response = auth_service.login(LoginRequest(email=request.email, password=request.password)) + return { + "success": True, + "message": "Registration successful. Please wait for approval.", + "data": {"user": {"email": user.email, "status": "pending"}}, + } + + token_response = auth_service.login( + LoginRequest(email=request.email, password=request.password) + ) _set_token_cookie(response, token_response.access_token, token_response.expires_in) return _token_payload(token_response) @router.post("/login") -def login( - data: dict, - response: Response, - auth_service: AuthService = Depends(get_auth_service) -): +def login(data: dict, response: Response, auth_service: AuthService = Depends(get_auth_service)): """Login and receive access & refresh tokens.""" if data.get("studentId") and not data.get("email"): token_response = auth_service.login_with_student_id( @@ -103,7 +111,9 @@ def login( data.get("collegeId") or data.get("college_id"), ) else: - token_response = auth_service.login(LoginRequest(email=data.get("email"), password=data.get("password", ""))) + token_response = auth_service.login( + LoginRequest(email=data.get("email"), password=data.get("password", "")) + ) _set_token_cookie(response, token_response.access_token, token_response.expires_in) return _token_payload(token_response) @@ -112,7 +122,7 @@ def login( def refresh_token( data: RefreshTokenRequest, response: Response, - auth_service: AuthService = Depends(get_auth_service) + auth_service: AuthService = Depends(get_auth_service), ): """Refresh an access token using a valid refresh token (token rotation enabled).""" token_response = auth_service.refresh_token(data.refresh_token) @@ -123,7 +133,7 @@ def refresh_token( @router.post("/logout", response_model=MessageResponse) def logout( current_user: DotDict = Depends(get_current_user), - auth_service: AuthService = Depends(get_auth_service) + auth_service: AuthService = Depends(get_auth_service), ): """Logout the user by revoking all their refresh tokens.""" auth_service.logout(current_user.id) @@ -131,9 +141,7 @@ def logout( @router.get("/me", response_model=UserBriefResponse) -def get_me( - current_user: DotDict = Depends(get_current_user) -): +def get_me(current_user: DotDict = Depends(get_current_user)): """Get the current authenticated user's profile info.""" return UserBriefResponse.model_validate(current_user) @@ -141,11 +149,12 @@ def get_me( from app.database import get_db from app.schemas.auth import UpdateProfileRequest + @router.put("/update", response_model=UserBriefResponse) def update_profile( data: UpdateProfileRequest, current_user: DotDict = Depends(get_current_user), - db = Depends(get_db) + db=Depends(get_db), ): """Update user profile.""" update_data = {} @@ -160,18 +169,19 @@ def update_profile( current_user.avatar_url = data.avatar_url if data.company_name is not None and current_user.role == "recruiter": - pass # Handle recruiter profile logic if needed + pass # Handle recruiter profile logic if needed if update_data: db["users"].update_one({"id": current_user.id}, {"$set": update_data}) return UserBriefResponse.model_validate(current_user) + @router.put("/change-password", response_model=MessageResponse) def change_password( data: ChangePasswordRequest, current_user: DotDict = Depends(get_current_user), - auth_service: AuthService = Depends(get_auth_service) + auth_service: AuthService = Depends(get_auth_service), ): """Change user password.""" auth_service.change_password(current_user.id, data) @@ -188,7 +198,9 @@ def google_login_redirect(role: str, redirect: str = "/onboarding"): if role not in GOOGLE_ALLOWED_ROLES: raise HTTPException(status_code=400, detail=f"Invalid role: {role}") if not settings.GOOGLE_CLIENT_ID: - raise HTTPException(status_code=503, detail="Google sign-in is not configured on this server") + raise HTTPException( + status_code=503, detail="Google sign-in is not configured on this server" + ) params = { "client_id": settings.GOOGLE_CLIENT_ID, @@ -218,13 +230,16 @@ async def google_login_callback( redirect_path = parsed_state.get("redirect", "/onboarding") async with httpx.AsyncClient(timeout=10) as client: - token_res = await client.post(GOOGLE_TOKEN_URL, data={ - "code": code, - "client_id": settings.GOOGLE_CLIENT_ID, - "client_secret": settings.GOOGLE_CLIENT_SECRET, - "redirect_uri": settings.GOOGLE_OAUTH_REDIRECT_URI, - "grant_type": "authorization_code", - }) + token_res = await client.post( + GOOGLE_TOKEN_URL, + data={ + "code": code, + "client_id": settings.GOOGLE_CLIENT_ID, + "client_secret": settings.GOOGLE_CLIENT_SECRET, + "redirect_uri": settings.GOOGLE_OAUTH_REDIRECT_URI, + "grant_type": "authorization_code", + }, + ) if token_res.status_code >= 400: raise HTTPException(status_code=502, detail="Google token exchange failed") google_tokens = token_res.json() @@ -246,9 +261,11 @@ async def google_login_callback( except AccountPendingError: return RedirectResponse(f"{settings.FRONTEND_URL}{redirect_path}?pending=1") - query = urlencode({ - "token": token_response.access_token, - "refresh": token_response.refresh_token, - "redirect": redirect_path, - }) + query = urlencode( + { + "token": token_response.access_token, + "refresh": token_response.refresh_token, + "redirect": redirect_path, + } + ) return RedirectResponse(f"{settings.FRONTEND_URL}/oauth/callback?{query}") diff --git a/python-service/app/api/v1/batches.py b/python-service/app/api/v1/batches.py index 021a83f..c65f53c 100644 --- a/python-service/app/api/v1/batches.py +++ b/python-service/app/api/v1/batches.py @@ -28,8 +28,14 @@ def _now(): return datetime.now(timezone.utc).isoformat() -@router.post("", response_model=BatchResponse, dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)]) -def create_batch(data: BatchCreateRequest, current_user = Depends(get_current_user), db = Depends(get_db)): +@router.post( + "", + response_model=BatchResponse, + dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], +) +def create_batch( + data: BatchCreateRequest, current_user=Depends(get_current_user), db=Depends(get_db) +): batch = { "id": _next_id(db, "batches"), "name": data.name, @@ -45,7 +51,10 @@ def create_batch(data: BatchCreateRequest, current_user = Depends(get_current_us db["batches"].insert_one(batch) for student_data in data.students: - email = (student_data.email or f"{student_data.roll or student_data.student_id}@upscaler-ai.local").lower() + email = ( + student_data.email + or f"{student_data.roll or student_data.student_id}@upscaler-ai.local" + ).lower() student = db["users"].find_one({"email": email}) if not student: user_id = _next_id(db, "users") @@ -63,25 +72,29 @@ def create_batch(data: BatchCreateRequest, current_user = Depends(get_current_us "updated_at": _now(), } db["users"].insert_one(student) - db["student_profiles"].insert_one({ - "id": _next_id(db, "student_profiles"), - "user_id": user_id, - "student_id": (student_data.roll or student_data.student_id or "").upper(), - "year": student_data.year, - "tests_completed": 0, - "avg_accuracy": 0.0, - }) - db["batch_students"].insert_one({ - "id": _next_id(db, "batch_students"), - "batch_id": batch["id"], - "student_id": student["id"], - }) + db["student_profiles"].insert_one( + { + "id": _next_id(db, "student_profiles"), + "user_id": user_id, + "student_id": (student_data.roll or student_data.student_id or "").upper(), + "year": student_data.year, + "tests_completed": 0, + "avg_accuracy": 0.0, + } + ) + db["batch_students"].insert_one( + { + "id": _next_id(db, "batch_students"), + "batch_id": batch["id"], + "student_id": student["id"], + } + ) return _clean(batch) @router.get("/history", response_model=list[BatchResponse]) -def batch_history(db = Depends(get_db), college_scope: int | None = get_college_scope): +def batch_history(db=Depends(get_db), college_scope: int | None = get_college_scope): query = {} if college_scope: query["college_id"] = int(college_scope) @@ -90,19 +103,27 @@ def batch_history(db = Depends(get_db), college_scope: int | None = get_college_ @router.get("/students") -def batch_students(db = Depends(get_db), college_scope: int | None = get_college_scope): +def batch_students(db=Depends(get_db), college_scope: int | None = get_college_scope): query = {"role": "student"} if college_scope: query["college_id"] = int(college_scope) students = db["users"].find(query).sort("name", 1) - return {"success": True, "data": [ - {"id": s["id"], "name": s.get("name"), "email": s.get("email"), "department": s.get("department")} - for s in students - ]} + return { + "success": True, + "data": [ + { + "id": s["id"], + "name": s.get("name"), + "email": s.get("email"), + "department": s.get("department"), + } + for s in students + ], + } @router.get("/pending", response_model=list[BatchResponse]) -def pending_batches(db = Depends(get_db), college_scope: int | None = get_college_scope): +def pending_batches(db=Depends(get_db), college_scope: int | None = get_college_scope): query = {"status": "active"} if college_scope: query["college_id"] = int(college_scope) @@ -111,12 +132,19 @@ def pending_batches(db = Depends(get_db), college_scope: int | None = get_colleg @router.put("/{batch_id}/status", response_model=MessageResponse) -def update_batch_status(batch_id: int, data: BatchStatusRequest, db = Depends(get_db), college_scope: int | None = get_college_scope): +def update_batch_status( + batch_id: int, + data: BatchStatusRequest, + db=Depends(get_db), + college_scope: int | None = get_college_scope, +): query = {"id": batch_id} if college_scope: query["college_id"] = int(college_scope) batch = db["batches"].find_one(query) if not batch: raise NotFoundError("Batch", str(batch_id)) - db["batches"].update_one({"id": batch_id}, {"$set": {"status": data.status, "updated_at": _now()}}) + db["batches"].update_one( + {"id": batch_id}, {"$set": {"status": data.status, "updated_at": _now()}} + ) return MessageResponse(message="Batch status updated") diff --git a/python-service/app/api/v1/chat.py b/python-service/app/api/v1/chat.py index ebb98c2..ab89326 100644 --- a/python-service/app/api/v1/chat.py +++ b/python-service/app/api/v1/chat.py @@ -13,6 +13,7 @@ router = APIRouter(prefix="/chat", tags=["Chat"]) + # Returns a local DummyUser (below), not a full user record — callers only use # .id/.name/.role. async def get_current_user_ws(token: str, db: Database): @@ -22,21 +23,22 @@ async def get_current_user_ws(token: str, db: Database): user = db["users"].find_one({"id": user_id}) if not user: raise ValueError("User not found") + # create a dummy object so user.id works class DummyUser: def __init__(self, d): self.id = d["id"] self.name = d.get("name", "") self.role = d.get("role", "") + return DummyUser(user) except Exception: raise ValueError("Invalid token") + @router.websocket("/ws") async def websocket_endpoint( - websocket: WebSocket, - token: str = Query(...), - db: Database = Depends(get_db) + websocket: WebSocket, token: str = Query(...), db: Database = Depends(get_db) ): try: user = await get_current_user_ws(token, db) @@ -46,7 +48,7 @@ async def websocket_endpoint( await manager.connect(websocket, user.id) mongo_db = get_mongo_db() - + try: while True: data = await websocket.receive_text() @@ -54,13 +56,13 @@ async def websocket_endpoint( message_data = json.loads(data) except json.JSONDecodeError: continue - + receiver_id = message_data.get("receiver_id") content = message_data.get("content") - + if not receiver_id or not content: continue - + # Construct message document for MongoDB msg_doc = { "sender_id": user.id, @@ -69,45 +71,51 @@ async def websocket_endpoint( "receiver_id": int(receiver_id), "content": content, "timestamp": datetime.now(timezone.utc).isoformat(), - "read": False + "read": False, } - + # Save to MongoDB if mongo_db is not None: await mongo_db["messages"].insert_one(msg_doc) msg_doc.pop("_id", None) - + # Send to sender for confirmation await manager.send_personal_message(msg_doc, user.id) # Deliver to receiver in real-time await manager.send_personal_message(msg_doc, int(receiver_id)) - + except WebSocketDisconnect: manager.disconnect(websocket, user.id) + @router.get("/history/{other_user_id}") async def get_chat_history( - other_user_id: int, - limit: int = 50, - current_user: DotDict = Depends(get_current_user) + other_user_id: int, limit: int = 50, current_user: DotDict = Depends(get_current_user) ): """Fetch chat history between the current user and another user.""" mongo_db = get_mongo_db() if mongo_db is None: raise HTTPException(status_code=500, detail="MongoDB not connected") - - cursor = mongo_db["messages"].find({ - "$or": [ - {"sender_id": current_user.id, "receiver_id": other_user_id}, - {"sender_id": other_user_id, "receiver_id": current_user.id} - ] - }).sort("timestamp", -1).limit(limit) - + + cursor = ( + mongo_db["messages"] + .find( + { + "$or": [ + {"sender_id": current_user.id, "receiver_id": other_user_id}, + {"sender_id": other_user_id, "receiver_id": current_user.id}, + ] + } + ) + .sort("timestamp", -1) + .limit(limit) + ) + messages = await cursor.to_list(length=limit) # MongoDB returns newest first due to sort(-1), we want chronological order for UI messages.reverse() - + for msg in messages: msg["_id"] = str(msg["_id"]) - + return {"messages": messages} diff --git a/python-service/app/api/v1/colleges.py b/python-service/app/api/v1/colleges.py index 290f837..df44d87 100644 --- a/python-service/app/api/v1/colleges.py +++ b/python-service/app/api/v1/colleges.py @@ -17,19 +17,19 @@ def to_dict(obj): @router.get("", response_model=list[CollegeResponse]) -def list_colleges(db = Depends(get_db)): +def list_colleges(db=Depends(get_db)): colleges = db["colleges"].find({"is_active": True}).sort("name", 1) return [to_dict(c) for c in colleges] @router.get("/me", response_model=CollegeResponse) -def current_college(current_user = Depends(get_current_user), db = Depends(get_db)): +def current_college(current_user=Depends(get_current_user), db=Depends(get_db)): college = db["colleges"].find_one({"id": current_user.college_id}) return to_dict(college) @router.post("", response_model=CollegeResponse, dependencies=[require_roles(UserRole.SUPER_ADMIN)]) -def create_college(data: CollegeCreateRequest, db = Depends(get_db)): +def create_college(data: CollegeCreateRequest, db=Depends(get_db)): college_id = db["colleges"].count_documents({}) + 1 college = { "id": college_id, @@ -45,13 +45,15 @@ def create_college(data: CollegeCreateRequest, db = Depends(get_db)): "updated_at": datetime.now(timezone.utc).isoformat(), } db["colleges"].insert_one(college) - + for dept in data.departments: - db["departments"].insert_one({ - "id": db["departments"].count_documents({}) + 1, - "college_id": college_id, - "name": dept.name if hasattr(dept, 'name') else dept["name"], - "code": dept.code if hasattr(dept, 'code') else dept["code"] - }) - + db["departments"].insert_one( + { + "id": db["departments"].count_documents({}) + 1, + "college_id": college_id, + "name": dept.name if hasattr(dept, "name") else dept["name"], + "code": dept.code if hasattr(dept, "code") else dept["code"], + } + ) + return to_dict(college) diff --git a/python-service/app/api/v1/dashboard.py b/python-service/app/api/v1/dashboard.py index 6026dbd..9100d53 100644 --- a/python-service/app/api/v1/dashboard.py +++ b/python-service/app/api/v1/dashboard.py @@ -1,5 +1,11 @@ from fastapi import APIRouter, Depends -from app.core.rbac import UserRole, get_college_scope, require_roles, get_current_user, assert_can_act_on_student +from app.core.rbac import ( + UserRole, + get_college_scope, + require_roles, + get_current_user, + assert_can_act_on_student, +) from app.database import get_db router = APIRouter(prefix="/dashboard", tags=["Dashboard"]) @@ -20,7 +26,7 @@ def to_dict(obj): @router.get("/student/{student_id}") -def student_dashboard(student_id: int, db = Depends(get_db), current_user = Depends(get_current_user)): +def student_dashboard(student_id: int, db=Depends(get_db), current_user=Depends(get_current_user)): """Per-student summary. Totals are computed by the database in a single aggregation rather than @@ -31,14 +37,18 @@ def student_dashboard(student_id: int, db = Depends(get_db), current_user = Depe attempt_filter = {"student_id": student_id, "status": "completed"} summary = list( - db["assessment_attempts"].aggregate([ - {"$match": attempt_filter}, - {"$group": { - "_id": None, - "tests_completed": {"$sum": 1}, - "avg_accuracy": {"$avg": {"$ifNull": ["$percentage", 0]}}, - }}, - ]) + db["assessment_attempts"].aggregate( + [ + {"$match": attempt_filter}, + { + "$group": { + "_id": None, + "tests_completed": {"$sum": 1}, + "avg_accuracy": {"$avg": {"$ifNull": ["$percentage", 0]}}, + } + }, + ] + ) ) tests_completed = summary[0]["tests_completed"] if summary else 0 @@ -47,10 +57,7 @@ def student_dashboard(student_id: int, db = Depends(get_db), current_user = Depe # Newest first off the index, capped. `history` is reversed back into # chronological order so existing chart code keeps working unchanged. history_desc = list( - db["assessment_attempts"] - .find(attempt_filter) - .sort("created_at", -1) - .limit(HISTORY_LIMIT) + db["assessment_attempts"].find(attempt_filter).sort("created_at", -1).limit(HISTORY_LIMIT) ) history = [to_dict(a) for a in reversed(history_desc)] @@ -70,8 +77,11 @@ def student_dashboard(student_id: int, db = Depends(get_db), current_user = Depe } -@router.get("/admin", dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)]) -def admin_dashboard(db = Depends(get_db), college_scope: int | None = get_college_scope): +@router.get( + "/admin", + dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], +) +def admin_dashboard(db=Depends(get_db), college_scope: int | None = get_college_scope): """Institution-wide counters. Previously this pulled every attempt row for the college into memory purely @@ -85,14 +95,18 @@ def admin_dashboard(db = Depends(get_db), college_scope: int | None = get_colleg base_query["college_id"] = int(college_scope) attempt_summary = list( - db["assessment_attempts"].aggregate([ - {"$match": base_query}, - {"$group": { - "_id": None, - "attempts": {"$sum": 1}, - "avg_score": {"$avg": {"$ifNull": ["$percentage", 0]}}, - }}, - ]) + db["assessment_attempts"].aggregate( + [ + {"$match": base_query}, + { + "$group": { + "_id": None, + "attempts": {"$sum": 1}, + "avg_score": {"$avg": {"$ifNull": ["$percentage", 0]}}, + } + }, + ] + ) ) attempts = attempt_summary[0]["attempts"] if attempt_summary else 0 @@ -115,17 +129,19 @@ def admin_dashboard(db = Depends(get_db), college_scope: int | None = get_colleg @router.get("/super", dependencies=[require_roles(UserRole.SUPER_ADMIN)]) -def super_dashboard(db = Depends(get_db)): +def super_dashboard(db=Depends(get_db)): """Platform-wide role counts. One grouped pass over `users` replaces four separate count queries, each of which was its own round trip to the cluster. """ rows = list( - db["users"].aggregate([ - {"$match": {"role": {"$in": ["student", "faculty", "college_admin", "recruiter"]}}}, - {"$group": {"_id": "$role", "count": {"$sum": 1}}}, - ]) + db["users"].aggregate( + [ + {"$match": {"role": {"$in": ["student", "faculty", "college_admin", "recruiter"]}}}, + {"$group": {"_id": "$role", "count": {"$sum": 1}}}, + ] + ) ) by_role = {row["_id"]: row["count"] for row in rows} diff --git a/python-service/app/api/v1/interviews.py b/python-service/app/api/v1/interviews.py index f5cfb63..3098fcf 100644 --- a/python-service/app/api/v1/interviews.py +++ b/python-service/app/api/v1/interviews.py @@ -8,6 +8,7 @@ router = APIRouter(prefix="/interviews", tags=["Interviews"]) + def to_dict(obj): if not obj: return None @@ -17,7 +18,9 @@ def to_dict(obj): @router.get("/student/{student_id}") -def list_interviews(student_id: int, db = Depends(get_db), college_scope: int | None = get_college_scope): +def list_interviews( + student_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope +): query = {"student_id": student_id} if college_scope: query["college_id"] = college_scope @@ -26,7 +29,12 @@ def list_interviews(student_id: int, db = Depends(get_db), college_scope: int | @router.post("/student/{student_id}") -def submit_interview(student_id: int, data: InterviewSubmitRequest, current_user = Depends(get_current_user), db = Depends(get_db)): +def submit_interview( + student_id: int, + data: InterviewSubmitRequest, + current_user=Depends(get_current_user), + db=Depends(get_db), +): attempt_number = db["interview_attempts"].count_documents({"student_id": student_id}) + 1 attempt = { "id": db["interview_attempts"].count_documents({}) + 1, @@ -43,44 +51,54 @@ def submit_interview(student_id: int, data: InterviewSubmitRequest, current_user "created_at": datetime.now(timezone.utc).isoformat(), } db["interview_attempts"].insert_one(attempt) - + for response in data.responses: r_dict = response.model_dump() r_dict["attempt_id"] = attempt["id"] r_dict["id"] = db["interview_responses"].count_documents({}) + 1 db["interview_responses"].insert_one(r_dict) - + db["student_profiles"].update_one( - {"user_id": student_id}, - {"$inc": {"interviews_completed": 1}} + {"user_id": student_id}, {"$inc": {"interviews_completed": 1}} ) return to_dict(attempt) @router.post("/generate", response_model=list[dict]) -def generate_questions(role: str, company: str = "general", current_user = Depends(get_current_user), db = Depends(get_db)): +def generate_questions( + role: str, company: str = "general", current_user=Depends(get_current_user), db=Depends(get_db) +): """ Generates 10 random questions for the given role and company using Groq LLM. """ from groq import Groq from app.config import get_settings - + groq_api_key = get_settings().GROQ_API_KEY if not groq_api_key: import random + question_pool = [ "What are the key differences between React and Angular?", "Explain the concept of closures in JavaScript.", "How would you optimize a slow-performing database query?", "Describe a time you had to resolve a conflict within your team.", - "What is the difference between TCP and UDP?" + "What is the difference between TCP and UDP?", ] selected_questions = random.sample(question_pool * 2, 10) - return [{"id": i + 1, "text": f"[{company.upper()} - {role.upper()}] {q}", "time_limit_seconds": 60, "type": "technical"} for i, q in enumerate(selected_questions)] + return [ + { + "id": i + 1, + "text": f"[{company.upper()} - {role.upper()}] {q}", + "time_limit_seconds": 60, + "type": "technical", + } + for i, q in enumerate(selected_questions) + ] try: client = Groq(api_key=groq_api_key) - + prompt = f""" You are an expert technical interviewer at {company} hiring for a {role} position. Generate exactly 10 interview questions for this specific role and company. @@ -94,7 +112,7 @@ def generate_questions(role: str, company: str = "general", current_user = Depen messages=[{"role": "user", "content": prompt}], temperature=0.7, max_tokens=1024, - response_format={"type": "json_object"} + response_format={"type": "json_object"}, ) result_text = completion.choices[0].message.content.strip() @@ -107,19 +125,21 @@ def generate_questions(role: str, company: str = "general", current_user = Depen result_text = result_text.strip() data = json.loads(result_text) generated_questions = data.get("questions", []) - + if not generated_questions or len(generated_questions) < 10: raise ValueError("LLM did not return enough questions") - + formatted_questions = [] for i, q in enumerate(generated_questions[:10]): - formatted_questions.append({ - "id": i + 1, - "text": q, - "time_limit_seconds": 60, - "type": "technical" if i < 7 else "behavioral" - }) - + formatted_questions.append( + { + "id": i + 1, + "text": q, + "time_limit_seconds": 60, + "type": "technical" if i < 7 else "behavioral", + } + ) + return formatted_questions except Exception as e: print(f"Failed to generate questions with AI: {e}") diff --git a/python-service/app/api/v1/jobs.py b/python-service/app/api/v1/jobs.py index d73d7b2..d0d6f9a 100644 --- a/python-service/app/api/v1/jobs.py +++ b/python-service/app/api/v1/jobs.py @@ -4,7 +4,11 @@ from app.core.rbac import get_college_scope, get_current_user from app.database import get_db -from app.schemas.placement import JobPostingCreateRequest, JobPostingResponse, JobApplicationResponse +from app.schemas.placement import ( + JobPostingCreateRequest, + JobPostingResponse, + JobApplicationResponse, +) router = APIRouter(prefix="/jobs", tags=["Jobs"]) @@ -25,14 +29,14 @@ def _now(): @router.get("", response_model=list[JobPostingResponse]) -def get_jobs(db = Depends(get_db)): +def get_jobs(db=Depends(get_db)): """Get all active job postings.""" cursor = db["job_postings"].find({"is_active": True}).sort("created_at", -1) return [_clean(doc) for doc in cursor] @router.get("/me", response_model=list[JobPostingResponse]) -def get_my_jobs(db = Depends(get_db), current_user = Depends(get_current_user)): +def get_my_jobs(db=Depends(get_db), current_user=Depends(get_current_user)): """Get jobs posted by the current HR/Recruiter.""" cursor = db["job_postings"].find({"recruiter_id": current_user.id}).sort("created_at", -1) return [_clean(doc) for doc in cursor] @@ -41,8 +45,8 @@ def get_my_jobs(db = Depends(get_db), current_user = Depends(get_current_user)): @router.post("", response_model=JobPostingResponse) def create_job( job_data: JobPostingCreateRequest, - db = Depends(get_db), - current_user = Depends(get_current_user), + db=Depends(get_db), + current_user=Depends(get_current_user), ): """Post a new job vacancy — a recruiter's open role, or a college's own placement drive.""" allowed_roles = ["hr", "recruiter", "college_admin", "super_admin"] @@ -81,7 +85,7 @@ def create_job( @router.get("/drives") -def college_drives(db = Depends(get_db), college_scope: int | None = get_college_scope): +def college_drives(db=Depends(get_db), college_scope: int | None = get_college_scope): """Placement drives (job postings) for the caller's college, with applicant counts. Used by the college-admin placements dashboard — 'drive' is just the @@ -96,10 +100,12 @@ def college_drives(db = Depends(get_db), college_scope: int | None = get_college posting_ids = [p["id"] for p in postings] counts: dict[int, int] = {} - for row in db["job_applications"].aggregate([ - {"$match": {"job_posting_id": {"$in": posting_ids}}}, - {"$group": {"_id": "$job_posting_id", "count": {"$sum": 1}}}, - ]): + for row in db["job_applications"].aggregate( + [ + {"$match": {"job_posting_id": {"$in": posting_ids}}}, + {"$group": {"_id": "$job_posting_id", "count": {"$sum": 1}}}, + ] + ): counts[row["_id"]] = row["count"] return [ @@ -119,7 +125,7 @@ def college_drives(db = Depends(get_db), college_scope: int | None = get_college @router.get("/applications/me", response_model=list[JobApplicationResponse]) -def get_my_job_applications(db = Depends(get_db), current_user = Depends(get_current_user)): +def get_my_job_applications(db=Depends(get_db), current_user=Depends(get_current_user)): """Get all applications for jobs posted by the current HR/Recruiter.""" if current_user.role not in ["hr", "recruiter"]: raise HTTPException(status_code=403, detail="Only recruiters can view applications") @@ -134,17 +140,19 @@ def get_my_job_applications(db = Depends(get_db), current_user = Depends(get_cur result = [] for app in applications: student = db["users"].find_one({"id": app.get("student_id")}, {"name": 1, "email": 1}) - result.append({ - "id": app.get("id"), - "job_posting_id": app.get("job_posting_id"), - "student_id": app.get("student_id"), - "status": app.get("status"), - "applied_at": app.get("applied_at"), - "updated_at": app.get("updated_at"), - "notes": app.get("notes"), - "interview_scheduled_at": app.get("interview_scheduled_at"), - "student_name": student.get("name") if student else None, - "student_email": student.get("email") if student else None, - "job_title": job_titles.get(app.get("job_posting_id")), - }) + result.append( + { + "id": app.get("id"), + "job_posting_id": app.get("job_posting_id"), + "student_id": app.get("student_id"), + "status": app.get("status"), + "applied_at": app.get("applied_at"), + "updated_at": app.get("updated_at"), + "notes": app.get("notes"), + "interview_scheduled_at": app.get("interview_scheduled_at"), + "student_name": student.get("name") if student else None, + "student_email": student.get("email") if student else None, + "job_title": job_titles.get(app.get("job_posting_id")), + } + ) return result diff --git a/python-service/app/api/v1/persistence.py b/python-service/app/api/v1/persistence.py index 134d8a4..e1a0703 100644 --- a/python-service/app/api/v1/persistence.py +++ b/python-service/app/api/v1/persistence.py @@ -8,7 +8,7 @@ @router.get("", response_model=UserDataResponse) -def load_user_data(current_user = Depends(get_current_user), db = Depends(get_db)): +def load_user_data(current_user=Depends(get_current_user), db=Depends(get_db)): state = db["user_data_states"].find_one({"user_id": current_user.id}) if not state: return UserDataResponse(data={}) @@ -16,7 +16,9 @@ def load_user_data(current_user = Depends(get_current_user), db = Depends(get_db @router.post("", response_model=UserDataResponse) -def save_user_data(data: UserDataRequest, current_user = Depends(get_current_user), db = Depends(get_db)): +def save_user_data( + data: UserDataRequest, current_user=Depends(get_current_user), db=Depends(get_db) +): db["user_data_states"].update_one( {"user_id": current_user.id}, {"$set": {"user_id": current_user.id, "data": data.data}}, diff --git a/python-service/app/api/v1/placements.py b/python-service/app/api/v1/placements.py index a175bb9..9cec854 100644 --- a/python-service/app/api/v1/placements.py +++ b/python-service/app/api/v1/placements.py @@ -26,7 +26,7 @@ def _now(): @router.get("", response_model=list[PlacementResponse]) -def list_placements(db = Depends(get_db), college_scope: int | None = get_college_scope): +def list_placements(db=Depends(get_db), college_scope: int | None = get_college_scope): query = {} if college_scope: query["college_id"] = int(college_scope) @@ -35,7 +35,9 @@ def list_placements(db = Depends(get_db), college_scope: int | None = get_colleg @router.post("", response_model=PlacementResponse) -def create_placement(data: PlacementCreateRequest, current_user = Depends(get_current_user), db = Depends(get_db)): +def create_placement( + data: PlacementCreateRequest, current_user=Depends(get_current_user), db=Depends(get_db) +): student_id = data.student_id or current_user.id student = db["users"].find_one({"id": student_id}) if not student: @@ -60,12 +62,16 @@ def create_placement(data: PlacementCreateRequest, current_user = Depends(get_cu "updated_at": _now(), } db["placements"].insert_one(placement) - db["student_profiles"].update_one({"user_id": student_id}, {"$set": {"placement_status": "placed"}}) + db["student_profiles"].update_one( + {"user_id": student_id}, {"$set": {"placement_status": "placed"}} + ) return _clean(placement) @router.get("/student/{student_id}", response_model=list[PlacementResponse]) -def student_placements(student_id: int, db = Depends(get_db), college_scope: int | None = get_college_scope): +def student_placements( + student_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope +): query = {"student_id": student_id} if college_scope: query["college_id"] = int(college_scope) @@ -73,8 +79,17 @@ def student_placements(student_id: int, db = Depends(get_db), college_scope: int return [_clean(doc) for doc in cursor] -@router.put("/{placement_id}/verify", response_model=PlacementResponse, dependencies=[require_roles(UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)]) -def verify_placement(placement_id: int, data: PlacementVerifyRequest, current_user = Depends(get_current_user), db = Depends(get_db)): +@router.put( + "/{placement_id}/verify", + response_model=PlacementResponse, + dependencies=[require_roles(UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], +) +def verify_placement( + placement_id: int, + data: PlacementVerifyRequest, + current_user=Depends(get_current_user), + db=Depends(get_db), +): placement = db["placements"].find_one({"id": placement_id}) if not placement: raise NotFoundError("Placement", str(placement_id)) diff --git a/python-service/app/api/v1/profile.py b/python-service/app/api/v1/profile.py index a536261..51539b5 100644 --- a/python-service/app/api/v1/profile.py +++ b/python-service/app/api/v1/profile.py @@ -7,6 +7,7 @@ super_admin), faculty. Students land straight on their dashboard and never hit this router. """ + import os import uuid @@ -30,7 +31,9 @@ def _portal_for(user: DotDict) -> str: portal = ROLE_TO_PORTAL.get(user.role) if portal is None: - raise HTTPException(status_code=400, detail=f"No onboarding profile exists for role '{user.role}'") + raise HTTPException( + status_code=400, detail=f"No onboarding profile exists for role '{user.role}'" + ) return portal @@ -47,22 +50,46 @@ def _portal_for(user: DotDict) -> str: "title": "Preferences", "description": "General settings for your account", "fields": [ - {"name": "timezone", "label": "Timezone", "type": "select", "required": True, "options": [ - {"label": "(GMT+5:30) India Standard Time", "value": "Asia/Kolkata"}, - {"label": "(GMT+0:00) UTC", "value": "UTC"}, - {"label": "(GMT-5:00) Eastern Time", "value": "America/New_York"}, - {"label": "(GMT+4:00) Gulf Standard Time", "value": "Asia/Dubai"}, - ]}, - {"name": "language", "label": "Language", "type": "select", "required": True, "options": [ - {"label": "English", "value": "en"}, - {"label": "Hindi", "value": "hi"}, - {"label": "Tamil", "value": "ta"}, - {"label": "Arabic", "value": "ar"}, - ]}, - {"name": "profilePhoto", "label": "Profile Photo", "type": "file", "required": False, - "accept": "image/*", "helpText": "Optional. PNG or JPG, up to 5MB."}, - {"name": "signature", "label": "Signature Upload", "type": "file", "required": False, - "accept": "image/*", "helpText": "Optional. Used on generated documents."}, + { + "name": "timezone", + "label": "Timezone", + "type": "select", + "required": True, + "options": [ + {"label": "(GMT+5:30) India Standard Time", "value": "Asia/Kolkata"}, + {"label": "(GMT+0:00) UTC", "value": "UTC"}, + {"label": "(GMT-5:00) Eastern Time", "value": "America/New_York"}, + {"label": "(GMT+4:00) Gulf Standard Time", "value": "Asia/Dubai"}, + ], + }, + { + "name": "language", + "label": "Language", + "type": "select", + "required": True, + "options": [ + {"label": "English", "value": "en"}, + {"label": "Hindi", "value": "hi"}, + {"label": "Tamil", "value": "ta"}, + {"label": "Arabic", "value": "ar"}, + ], + }, + { + "name": "profilePhoto", + "label": "Profile Photo", + "type": "file", + "required": False, + "accept": "image/*", + "helpText": "Optional. PNG or JPG, up to 5MB.", + }, + { + "name": "signature", + "label": "Signature Upload", + "type": "file", + "required": False, + "accept": "image/*", + "helpText": "Optional. Used on generated documents.", + }, ], } @@ -76,35 +103,95 @@ def _portal_for(user: DotDict) -> str: "title": "Employment Details", "description": "Your role within the organization", "fields": [ - {"name": "employeeId", "label": "Employee ID", "type": "text", "required": True, "placeholder": "EMP-00123"}, - {"name": "department", "label": "Department", "type": "select", "required": True, "options": [ - {"label": "Human Resources", "value": "hr"}, - {"label": "Talent Acquisition", "value": "talent_acquisition"}, - {"label": "Payroll", "value": "payroll"}, - {"label": "Operations", "value": "operations"}, - ]}, - {"name": "designation", "label": "Designation", "type": "text", "required": True, "placeholder": "HR Manager"}, - {"name": "yearsOfExperience", "label": "Years of Experience", "type": "number", "required": True, "validation": {"min": 0, "max": 60}}, - {"name": "linkedinUrl", "label": "LinkedIn URL", "type": "url", "required": False, "placeholder": "https://linkedin.com/in/yourname"}, + { + "name": "employeeId", + "label": "Employee ID", + "type": "text", + "required": True, + "placeholder": "EMP-00123", + }, + { + "name": "department", + "label": "Department", + "type": "select", + "required": True, + "options": [ + {"label": "Human Resources", "value": "hr"}, + {"label": "Talent Acquisition", "value": "talent_acquisition"}, + {"label": "Payroll", "value": "payroll"}, + {"label": "Operations", "value": "operations"}, + ], + }, + { + "name": "designation", + "label": "Designation", + "type": "text", + "required": True, + "placeholder": "HR Manager", + }, + { + "name": "yearsOfExperience", + "label": "Years of Experience", + "type": "number", + "required": True, + "validation": {"min": 0, "max": 60}, + }, + { + "name": "linkedinUrl", + "label": "LinkedIn URL", + "type": "url", + "required": False, + "placeholder": "https://linkedin.com/in/yourname", + }, ], }, { "id": "contact", "title": "Contact Information", "fields": [ - {"name": "officePhone", "label": "Office Phone", "type": "tel", "required": True, "placeholder": "+91 22 1234 5678"}, - {"name": "mobileNumber", "label": "Mobile Number", "type": "tel", "required": True, "placeholder": "+91 98765 43210"}, - {"name": "emergencyContact", "label": "Emergency Contact", "type": "tel", "required": True, "placeholder": "+91 90000 00000"}, + { + "name": "officePhone", + "label": "Office Phone", + "type": "tel", + "required": True, + "placeholder": "+91 22 1234 5678", + }, + { + "name": "mobileNumber", + "label": "Mobile Number", + "type": "tel", + "required": True, + "placeholder": "+91 98765 43210", + }, + { + "name": "emergencyContact", + "label": "Emergency Contact", + "type": "tel", + "required": True, + "placeholder": "+91 90000 00000", + }, ], }, { "id": "location", "title": "Location", "fields": [ - {"name": "country", "label": "Country", "type": "select", "required": True, "options": _COUNTRY_OPTIONS}, + { + "name": "country", + "label": "Country", + "type": "select", + "required": True, + "options": _COUNTRY_OPTIONS, + }, {"name": "state", "label": "State", "type": "text", "required": True}, {"name": "city", "label": "City", "type": "text", "required": True}, - {"name": "officeLocation", "label": "Office Location", "type": "text", "required": True, "placeholder": "HQ - Tower B, 4th Floor"}, + { + "name": "officeLocation", + "label": "Office Location", + "type": "text", + "required": True, + "placeholder": "HQ - Tower B, 4th Floor", + }, ], }, _COMMON_SECTION, @@ -118,30 +205,74 @@ def _portal_for(user: DotDict) -> str: "id": "institution", "title": "Institution Details", "fields": [ - {"name": "institutionName", "label": "Institution Name", "type": "text", "required": True}, - {"name": "institutionCode", "label": "Institution Code", "type": "text", "required": True, "placeholder": "INST-4521"}, + { + "name": "institutionName", + "label": "Institution Name", + "type": "text", + "required": True, + }, + { + "name": "institutionCode", + "label": "Institution Code", + "type": "text", + "required": True, + "placeholder": "INST-4521", + }, {"name": "adminId", "label": "Admin ID", "type": "text", "required": True}, - {"name": "designation", "label": "Designation", "type": "text", "required": True, "placeholder": "Principal / Registrar"}, - {"name": "website", "label": "Website", "type": "url", "required": False, "placeholder": "https://institution.edu"}, + { + "name": "designation", + "label": "Designation", + "type": "text", + "required": True, + "placeholder": "Principal / Registrar", + }, + { + "name": "website", + "label": "Website", + "type": "url", + "required": False, + "placeholder": "https://institution.edu", + }, ], }, { "id": "contact", "title": "Contact Information", "fields": [ - {"name": "officePhone", "label": "Office Phone", "type": "tel", "required": True}, - {"name": "mobileNumber", "label": "Mobile Number", "type": "tel", "required": True}, + { + "name": "officePhone", + "label": "Office Phone", + "type": "tel", + "required": True, + }, + { + "name": "mobileNumber", + "label": "Mobile Number", + "type": "tel", + "required": True, + }, ], }, { "id": "location", "title": "Location", "fields": [ - {"name": "country", "label": "Country", "type": "select", "required": True, "options": _COUNTRY_OPTIONS}, + { + "name": "country", + "label": "Country", + "type": "select", + "required": True, + "options": _COUNTRY_OPTIONS, + }, {"name": "state", "label": "State", "type": "text", "required": True}, {"name": "district", "label": "District", "type": "text", "required": True}, {"name": "city", "label": "City", "type": "text", "required": True}, - {"name": "officeAddress", "label": "Office Address", "type": "textarea", "required": True}, + { + "name": "officeAddress", + "label": "Office Address", + "type": "textarea", + "required": True, + }, ], }, _COMMON_SECTION, @@ -157,26 +288,72 @@ def _portal_for(user: DotDict) -> str: "fields": [ {"name": "facultyId", "label": "Faculty ID", "type": "text", "required": True}, {"name": "department", "label": "Department", "type": "text", "required": True}, - {"name": "designation", "label": "Designation", "type": "text", "required": True, "placeholder": "Assistant Professor"}, - {"name": "qualification", "label": "Qualification", "type": "text", "required": True, "placeholder": "Ph.D. in Computer Science"}, - {"name": "experience", "label": "Experience", "type": "number", "required": True, "validation": {"min": 0, "max": 60}}, - {"name": "subjectsHandling", "label": "Subjects Handling", "type": "textarea", "required": True, "placeholder": "Data Structures, Algorithms"}, - {"name": "officeRoomNumber", "label": "Office Room Number", "type": "text", "required": False, "placeholder": "B-204"}, + { + "name": "designation", + "label": "Designation", + "type": "text", + "required": True, + "placeholder": "Assistant Professor", + }, + { + "name": "qualification", + "label": "Qualification", + "type": "text", + "required": True, + "placeholder": "Ph.D. in Computer Science", + }, + { + "name": "experience", + "label": "Experience", + "type": "number", + "required": True, + "validation": {"min": 0, "max": 60}, + }, + { + "name": "subjectsHandling", + "label": "Subjects Handling", + "type": "textarea", + "required": True, + "placeholder": "Data Structures, Algorithms", + }, + { + "name": "officeRoomNumber", + "label": "Office Room Number", + "type": "text", + "required": False, + "placeholder": "B-204", + }, ], }, { "id": "contact", "title": "Contact Information", "fields": [ - {"name": "mobileNumber", "label": "Mobile Number", "type": "tel", "required": True}, - {"name": "alternateNumber", "label": "Alternate Number", "type": "tel", "required": False}, + { + "name": "mobileNumber", + "label": "Mobile Number", + "type": "tel", + "required": True, + }, + { + "name": "alternateNumber", + "label": "Alternate Number", + "type": "tel", + "required": False, + }, ], }, { "id": "location", "title": "Location", "fields": [ - {"name": "country", "label": "Country", "type": "select", "required": True, "options": _COUNTRY_OPTIONS}, + { + "name": "country", + "label": "Country", + "type": "select", + "required": True, + "options": _COUNTRY_OPTIONS, + }, {"name": "state", "label": "State", "type": "text", "required": True}, {"name": "district", "label": "District", "type": "text", "required": True}, {"name": "city", "label": "City", "type": "text", "required": True}, @@ -239,10 +416,14 @@ async def _save_profile(request: Request, current_user: DotDict, db) -> dict: @router.post("") -async def create_profile(request: Request, current_user: DotDict = Depends(get_current_user), db=Depends(get_db)): +async def create_profile( + request: Request, current_user: DotDict = Depends(get_current_user), db=Depends(get_db) +): return await _save_profile(request, current_user, db) @router.put("") -async def update_profile(request: Request, current_user: DotDict = Depends(get_current_user), db=Depends(get_db)): +async def update_profile( + request: Request, current_user: DotDict = Depends(get_current_user), db=Depends(get_db) +): return await _save_profile(request, current_user, db) diff --git a/python-service/app/api/v1/resume.py b/python-service/app/api/v1/resume.py index 74f8e63..e3da155 100644 --- a/python-service/app/api/v1/resume.py +++ b/python-service/app/api/v1/resume.py @@ -14,6 +14,7 @@ logger = logging.getLogger("upscaler_ai.resume") router = APIRouter(prefix="/resume", tags=["Resume"]) + class ResumeSaveRequest(BaseModel): personal: Dict[str, Any] objective: str @@ -34,32 +35,38 @@ class ResumeSaveRequest(BaseModel): template: str zoom: Optional[float] = 1.0 + class SaveVersionRequest(BaseModel): name: str + class JDMatchRequest(BaseModel): jd_text: str + class AISuggestRequest(BaseModel): action: str # 'summary' | 'bullet' | 'skills' | 'rewrite' | 'grammar' | 'cover_letter' | 'interview_prep' section: Optional[str] = None content: Optional[str] = None jd_text: Optional[str] = None + class ResumeParseRequest(BaseModel): text: str + def get_groq_client(): groq_api_key = os.getenv("GROQ_API_KEY") if not groq_api_key: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="GROQ_API_KEY is not configured in environment variables." + detail="GROQ_API_KEY is not configured in environment variables.", ) return Groq(api_key=groq_api_key) + @router.get("") -def get_resume(db = Depends(get_db), current_user: DotDict = Depends(get_current_user)): +def get_resume(db=Depends(get_db), current_user: DotDict = Depends(get_current_user)): """Fetch the active resume and saved versions for the current student.""" resume = db["resumes"].find_one({"user_id": current_user.id}) if not resume: @@ -74,7 +81,7 @@ def get_resume(db = Depends(get_db), current_user: DotDict = Depends(get_current "github": "", "portfolio": "", "address": "", - "role": "" + "role": "", }, "objective": "", "education": [], @@ -90,10 +97,18 @@ def get_resume(db = Depends(get_db), current_user: DotDict = Depends(get_current "volunteer": [], "references": [], "customSections": [], - "sectionOrder": ["personal", "objective", "education", "experience", "projects", "skills", "certifications"], + "sectionOrder": [ + "personal", + "objective", + "education", + "experience", + "projects", + "skills", + "certifications", + ], "template": "modern", "created_at": datetime.now(timezone.utc).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat() + "updated_at": datetime.now(timezone.utc).isoformat(), } db["resumes"].insert_one(resume) @@ -109,50 +124,57 @@ def get_resume(db = Depends(get_db), current_user: DotDict = Depends(get_current return {"resume": resume, "versions": versions} + @router.post("") -def save_resume(data: ResumeSaveRequest, db = Depends(get_db), current_user: DotDict = Depends(get_current_user)): +def save_resume( + data: ResumeSaveRequest, db=Depends(get_db), current_user: DotDict = Depends(get_current_user) +): """Save the active resume details (auto-save endpoint).""" now = datetime.now(timezone.utc).isoformat() update_doc = data.model_dump() update_doc["updated_at"] = now - - db["resumes"].update_one( - {"user_id": current_user.id}, - {"$set": update_doc}, - upsert=True - ) - + + db["resumes"].update_one({"user_id": current_user.id}, {"$set": update_doc}, upsert=True) + # Also update the user's main profile skills list if present skills_list = [s.get("name") for s in data.skills if s.get("name")] if skills_list: skills_str = ", ".join(skills_list) db["student_profiles"].update_one( - {"user_id": current_user.id}, - {"$set": {"skills": skills_str}} + {"user_id": current_user.id}, {"$set": {"skills": skills_str}} ) return {"success": True, "updated_at": now} + @router.post("/version") -def save_version(payload: SaveVersionRequest, db = Depends(get_db), current_user: DotDict = Depends(get_current_user)): +def save_version( + payload: SaveVersionRequest, + db=Depends(get_db), + current_user: DotDict = Depends(get_current_user), +): """Save the current resume state as a new version.""" resume = db["resumes"].find_one({"user_id": current_user.id}) if not resume: raise HTTPException(status_code=404, detail="No active resume found to version") - + resume.pop("_id", None) version_doc = { **resume, "name": payload.name, - "created_at": datetime.now(timezone.utc).isoformat() + "created_at": datetime.now(timezone.utc).isoformat(), } db["resume_versions"].insert_one(version_doc) return {"success": True, "message": f"Version '{payload.name}' saved successfully"} + @router.post("/version/{version_id}/restore") -def restore_version(version_id: str, db = Depends(get_db), current_user: DotDict = Depends(get_current_user)): +def restore_version( + version_id: str, db=Depends(get_db), current_user: DotDict = Depends(get_current_user) +): """Restore a previously saved version as the active resume.""" from bson import ObjectId + try: obj_id = ObjectId(version_id) except Exception: @@ -166,17 +188,17 @@ def restore_version(version_id: str, db = Depends(get_db), current_user: DotDict version.pop("name", None) version["updated_at"] = datetime.now(timezone.utc).isoformat() - db["resumes"].update_one( - {"user_id": current_user.id}, - {"$set": version}, - upsert=True - ) + db["resumes"].update_one({"user_id": current_user.id}, {"$set": version}, upsert=True) return {"success": True, "message": "Resume restored to selected version"} + @router.delete("/version/{version_id}") -def delete_version(version_id: str, db = Depends(get_db), current_user: DotDict = Depends(get_current_user)): +def delete_version( + version_id: str, db=Depends(get_db), current_user: DotDict = Depends(get_current_user) +): """Delete a saved version.""" from bson import ObjectId + try: obj_id = ObjectId(version_id) except Exception: @@ -187,15 +209,16 @@ def delete_version(version_id: str, db = Depends(get_db), current_user: DotDict raise HTTPException(status_code=404, detail="Version not found") return {"success": True, "message": "Version deleted"} + @router.post("/analyze") -def analyze_resume(db = Depends(get_db), current_user: DotDict = Depends(get_current_user)): +def analyze_resume(db=Depends(get_db), current_user: DotDict = Depends(get_current_user)): """Analyze the student's active resume against ATS standards using Groq.""" resume = db["resumes"].find_one({"user_id": current_user.id}) if not resume: raise HTTPException(status_code=404, detail="Active resume not found") client = get_groq_client() - + # Strip unnecessary fields for lower token usage resume.pop("_id", None) resume.pop("user_id", None) @@ -232,22 +255,25 @@ def analyze_resume(db = Depends(get_db), current_user: DotDict = Depends(get_cur messages=[{"role": "user", "content": prompt}], temperature=0.3, max_tokens=2048, - response_format={"type": "json_object"} + response_format={"type": "json_object"}, ) return json.loads(completion.choices[0].message.content) except Exception as e: logger.error(f"ATS Analysis failed: {e}") raise HTTPException(status_code=500, detail=f"ATS Analysis failed: {str(e)}") + @router.post("/match-jd") -def match_job_description(payload: JDMatchRequest, db = Depends(get_db), current_user: DotDict = Depends(get_current_user)): +def match_job_description( + payload: JDMatchRequest, db=Depends(get_db), current_user: DotDict = Depends(get_current_user) +): """Compare student resume with a pasted Job Description using Groq.""" resume = db["resumes"].find_one({"user_id": current_user.id}) if not resume: raise HTTPException(status_code=404, detail="Active resume not found") client = get_groq_client() - + resume.pop("_id", None) resume.pop("user_id", None) @@ -279,20 +305,25 @@ def match_job_description(payload: JDMatchRequest, db = Depends(get_db), current messages=[{"role": "user", "content": prompt}], temperature=0.3, max_tokens=2048, - response_format={"type": "json_object"} + response_format={"type": "json_object"}, ) return json.loads(completion.choices[0].message.content) except Exception as e: logger.error(f"JD Match failed: {e}") raise HTTPException(status_code=500, detail=f"JD Matching failed: {str(e)}") + @router.post("/ai-suggest") -def ai_suggest(payload: AISuggestRequest, db = Depends(get_db), current_user: DotDict = Depends(get_current_user)): +def ai_suggest( + payload: AISuggestRequest, db=Depends(get_db), current_user: DotDict = Depends(get_current_user) +): """Generates summary, cover letters, rewrites experiences, or suggests skills using Groq.""" client = get_groq_client() - system_instruction = "You are a professional ATS resume writer. Help the student optimize their profile." - + system_instruction = ( + "You are a professional ATS resume writer. Help the student optimize their profile." + ) + if payload.action == "summary": prompt = f"Based on the following content, write a concise, compelling, and professional resume summary (maximum 3 sentences):\n{payload.content}" elif payload.action == "rewrite": @@ -317,13 +348,13 @@ def ai_suggest(payload: AISuggestRequest, db = Depends(get_db), current_user: Do model="llama-3.3-70b-versatile", messages=[ {"role": "system", "content": system_instruction}, - {"role": "user", "content": prompt} + {"role": "user", "content": prompt}, ], temperature=0.5, - max_tokens=1500 + max_tokens=1500, ) output = completion.choices[0].message.content.strip() - + # If skills action, try to load JSON if payload.action == "skills": try: @@ -334,17 +365,18 @@ def ai_suggest(payload: AISuggestRequest, db = Depends(get_db), current_user: Do output = json.loads(output[start:end]) except Exception: pass - + return {"result": output} except Exception as e: logger.error(f"AI Suggestion failed: {e}") raise HTTPException(status_code=500, detail=f"AI suggestion failed: {str(e)}") + @router.post("/parse") -def parse_resume_text(payload: ResumeParseRequest, current_user = Depends(get_current_user)): +def parse_resume_text(payload: ResumeParseRequest, current_user=Depends(get_current_user)): """Parse raw resume text into structured JSON fields using Groq.""" client = get_groq_client() - + prompt = f""" You are an expert resume parsing tool. Extract the candidate information from the following text into structured JSON fields matching this exact key structure: @@ -368,7 +400,7 @@ def parse_resume_text(payload: ResumeParseRequest, current_user = Depends(get_cu messages=[{"role": "user", "content": prompt}], temperature=0.1, max_tokens=2048, - response_format={"type": "json_object"} + response_format={"type": "json_object"}, ) return json.loads(completion.choices[0].message.content) except Exception as e: diff --git a/python-service/app/api/v1/router.py b/python-service/app/api/v1/router.py index b80c0e8..186ecca 100644 --- a/python-service/app/api/v1/router.py +++ b/python-service/app/api/v1/router.py @@ -1,6 +1,7 @@ """ UpScaler-AI V2 — API v1 Router """ + from fastapi import APIRouter from app.api.v1 import ( achievements, diff --git a/python-service/app/api/v1/students.py b/python-service/app/api/v1/students.py index 5ec5e6e..e45a121 100644 --- a/python-service/app/api/v1/students.py +++ b/python-service/app/api/v1/students.py @@ -10,6 +10,7 @@ router = APIRouter(prefix="/students", tags=["Students"]) + def to_dict(obj): if not obj: return None @@ -23,13 +24,14 @@ def to_dict(obj): obj[k] = to_dict(v) return obj + @router.get("") def list_students( search: str | None = None, department: str | None = None, year: int | None = None, limit: int = Query(default=100, ge=1, le=1000), - db = Depends(get_db), + db=Depends(get_db), college_scope: int | None = get_college_scope, ): query = {"role": "student"} @@ -40,41 +42,41 @@ def list_students( if search: query["$or"] = [ {"name": {"$regex": search, "$options": "i"}}, - {"email": {"$regex": search, "$options": "i"}} + {"email": {"$regex": search, "$options": "i"}}, ] - + users = list(db["users"].find(query).sort("name", 1).limit(limit)) - + # Attach profiles for user in users: prof = db["student_profiles"].find_one({"user_id": user["id"]}) if prof: user["student_profile"] = prof - + # Filter by year if needed if year: users = [u for u in users if u.get("student_profile", {}).get("year") == year] - + return [to_dict(u) for u in users] @router.post("/identify") -def identify_student(data: dict, db = Depends(get_db)): +def identify_student(data: dict, db=Depends(get_db)): college_name = str(data.get("college_id") or data.get("collegeName") or "").strip() roll_no = str(data.get("roll_no") or data.get("studentId") or "").strip().upper() - + college = db["colleges"].find_one({"name": {"$regex": college_name, "$options": "i"}}) if not college: raise NotFoundError("College", college_name) - + profile = db["student_profiles"].find_one({"student_id": roll_no}) if not profile: raise NotFoundError("Student", roll_no) - + student = db["users"].find_one({"id": profile["user_id"], "college_id": college["id"]}) if not student: raise NotFoundError("Student", roll_no) - + return { "success": True, "student": { @@ -99,8 +101,9 @@ def identify_student(data: dict, db = Depends(get_db)): "history": {"tests": [], "interviews": [], "total_attempts": 0}, } + @router.get("/{student_id}") -def get_student(student_id: int, db = Depends(get_db), college_scope: int | None = get_college_scope): +def get_student(student_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope): query = {"id": student_id, "role": "student"} if college_scope: query["college_id"] = college_scope @@ -109,11 +112,12 @@ def get_student(student_id: int, db = Depends(get_db), college_scope: int | None raise NotFoundError("Student", str(student_id)) return to_dict(student) + @router.put("/{student_id}") def update_student( student_id: int, data: UserUpdateRequest, - db = Depends(get_db), + db=Depends(get_db), college_scope: int | None = get_college_scope, ): get_student(student_id, db, college_scope) # existence/scope check; raises if not authorized @@ -121,12 +125,13 @@ def update_student( db["users"].update_one({"id": student_id}, {"$set": update_data}) return to_dict(db["users"].find_one({"id": student_id})) + @router.put("/{student_id}/profile") def update_student_profile( student_id: int, data: StudentProfileUpdateRequest, - db = Depends(get_db), - current_user = Depends(get_current_user), + db=Depends(get_db), + current_user=Depends(get_current_user), ): assert_can_act_on_student(current_user, student_id, db) profile = db["student_profiles"].find_one({"user_id": student_id}) @@ -139,8 +144,11 @@ def update_student_profile( db["student_profiles"].update_one({"user_id": student_id}, {"$set": update_data}) return MessageResponse(message="Student profile updated") + @router.get("/{student_id}/dashboard") -def get_student_dashboard(student_id: int, db = Depends(get_db), current_user = Depends(get_current_user)): +def get_student_dashboard( + student_id: int, db=Depends(get_db), current_user=Depends(get_current_user) +): assert_can_act_on_student(current_user, student_id, db) profile = db["student_profiles"].find_one({"user_id": student_id}) if not profile: @@ -150,23 +158,27 @@ def get_student_dashboard(student_id: int, db = Depends(get_db), current_user = "tests_completed": 0, "avg_accuracy": 0, "interviews_completed": 0, - "streak": 0 + "streak": 0, } db["student_profiles"].insert_one(profile) - - recent_attempts = list(db["assessment_attempts"].find({"student_id": student_id}).sort("created_at", -1).limit(5)) + + recent_attempts = list( + db["assessment_attempts"].find({"student_id": student_id}).sort("created_at", -1).limit(5) + ) recent_activity = [] for a in recent_attempts: assessment = db["assessments"].find_one({"id": a["assessment_id"]}) title = assessment["title"] if assessment else "Practice Test" - recent_activity.append({ - "id": a["id"], - "title": title, - "score": a.get("score", 0), - "max_score": a.get("max_score", 100), - "percentage": a.get("percentage", 0), - "date": a.get("created_at") - }) + recent_activity.append( + { + "id": a["id"], + "title": title, + "score": a.get("score", 0), + "max_score": a.get("max_score", 100), + "percentage": a.get("percentage", 0), + "date": a.get("created_at"), + } + ) return { "tests_completed": profile.get("tests_completed", 0), @@ -175,21 +187,25 @@ def get_student_dashboard(student_id: int, db = Depends(get_db), current_user = "streak": profile.get("streak", 0), "national_rank": profile.get("national_rank", "-"), "placement_status": profile.get("placement_status", None), - "recent_activity": recent_activity + "recent_activity": recent_activity, } + @router.get("/{student_id}/tests") -def student_tests(student_id: int, db = Depends(get_db), current_user = Depends(get_current_user)): +def student_tests(student_id: int, db=Depends(get_db), current_user=Depends(get_current_user)): query = {"student_id": student_id} if current_user.role != "super_admin" and current_user.id != student_id: if current_user.college_id: query["college_id"] = current_user.college_id - + attempts = db["assessment_attempts"].find(query).sort("created_at", -1) return [to_dict(a) for a in attempts] + @router.post("/{student_id}/tests") -def log_student_test(student_id: int, data: dict, db = Depends(get_db), current_user = Depends(get_current_user)): +def log_student_test( + student_id: int, data: dict, db=Depends(get_db), current_user=Depends(get_current_user) +): assert_can_act_on_student(current_user, student_id, db) student = db["users"].find_one({"id": student_id}) if not student: @@ -199,7 +215,7 @@ def log_student_test(student_id: int, data: dict, db = Depends(get_db), current_ max_score = int(data.get("max_score") or data.get("total") or 100) pct = float(data.get("percentage") or round((score / max_score) * 100, 2)) assessment_id = int(data.get("assessment_id") or 0) - + assessment = db["assessments"].find_one({"id": assessment_id}) if assessment_id else None if not assessment: assessment = { @@ -218,7 +234,7 @@ def log_student_test(student_id: int, data: dict, db = Depends(get_db), current_ "updated_at": datetime.now(timezone.utc).isoformat(), } db["assessments"].insert_one(assessment) - + attempt_num = db["assessment_attempts"].count_documents({"student_id": student_id}) + 1 attempt = { "id": db["assessment_attempts"].count_documents({}) + 1, @@ -235,17 +251,17 @@ def log_student_test(student_id: int, data: dict, db = Depends(get_db), current_ "completed_at": datetime.now(timezone.utc).isoformat(), } db["assessment_attempts"].insert_one(attempt) - + profile = db["student_profiles"].find_one({"user_id": student_id}) if profile: tests_completed = profile.get("tests_completed", 0) + 1 avg_acc = profile.get("avg_accuracy", 0) new_avg = round(((avg_acc * (tests_completed - 1)) + pct) / tests_completed, 2) - + today_date = datetime.now(timezone.utc).date() last_test_str = profile.get("last_test_date") streak = profile.get("streak", 0) - + if last_test_str: try: last_test_date = datetime.fromisoformat(last_test_str).date() @@ -259,42 +275,56 @@ def log_student_test(student_id: int, data: dict, db = Depends(get_db), current_ streak = 1 else: streak = 1 - + db["student_profiles"].update_one( {"user_id": student_id}, - {"$set": { - "tests_completed": tests_completed, - "avg_accuracy": new_avg, - "streak": streak, - "last_test_date": today_date.isoformat() - }} + { + "$set": { + "tests_completed": tests_completed, + "avg_accuracy": new_avg, + "streak": streak, + "last_test_date": today_date.isoformat(), + } + }, ) - + return MessageResponse(message="Test attempt logged") + @router.get("/{student_id}/tests/analytics") -def student_test_analytics(student_id: int, db = Depends(get_db), current_user = Depends(get_current_user)): +def student_test_analytics( + student_id: int, db=Depends(get_db), current_user=Depends(get_current_user) +): assert_can_act_on_student(current_user, student_id, db) attempts = list(db["assessment_attempts"].find({"student_id": student_id})) avg = round(sum(a.get("percentage", 0) for a in attempts) / len(attempts), 2) if attempts else 0 - return {"success": True, "data": {"attempts": len(attempts), "average": avg, "history": [to_dict(a) for a in attempts]}} + return { + "success": True, + "data": { + "attempts": len(attempts), + "average": avg, + "history": [to_dict(a) for a in attempts], + }, + } + @router.get("/{student_id}/interviews") -def student_interviews(student_id: int, db = Depends(get_db), current_user = Depends(get_current_user)): +def student_interviews(student_id: int, db=Depends(get_db), current_user=Depends(get_current_user)): query = {"student_id": student_id} if current_user.role != "super_admin" and current_user.id != student_id: if current_user.college_id: query["college_id"] = current_user.college_id - + attempts = db["interview_attempts"].find(query).sort("created_at", -1) return [to_dict(a) for a in attempts] + @router.post("/{student_id}/interviews") def log_student_interview( student_id: int, data: InterviewSubmitRequest, - db = Depends(get_db), - current_user = Depends(get_current_user), + db=Depends(get_db), + current_user=Depends(get_current_user), ): assert_can_act_on_student(current_user, student_id, db) student = db["users"].find_one({"id": student_id}) @@ -317,37 +347,39 @@ def log_student_interview( "created_at": datetime.now(timezone.utc).isoformat(), } db["interview_attempts"].insert_one(attempt) - + for resp in data.responses: r_dict = resp.model_dump() r_dict["attempt_id"] = attempt["id"] r_dict["id"] = db["interview_responses"].count_documents({}) + 1 db["interview_responses"].insert_one(r_dict) - + db["student_profiles"].update_one( - {"user_id": student_id}, - {"$inc": {"interviews_completed": 1}} + {"user_id": student_id}, {"$inc": {"interviews_completed": 1}} ) return to_dict(attempt) + @router.post("/batch") def create_batch_students( - data: dict, - db = Depends(get_db), + data: dict, + db=Depends(get_db), college_scope: int | None = get_college_scope, - current_user = Depends(get_current_user) + current_user=Depends(get_current_user), ): students = data.get("students") or [] department = data.get("department") year = data.get("year") created = 0 default_status = "pending" if current_user.get("role") == "faculty" else "approved" - + for item in students: - email = item.get("email") or f"{item.get('roll') or item.get('studentId')}@upscaler-ai.local" + email = ( + item.get("email") or f"{item.get('roll') or item.get('studentId')}@upscaler-ai.local" + ) if db["users"].count_documents({"email": email.lower()}) > 0: continue - + user_id = db["users"].count_documents({}) + 1 user = { "id": user_id, @@ -362,7 +394,7 @@ def create_batch_students( "created_at": datetime.now(timezone.utc).isoformat(), } db["users"].insert_one(user) - + prof = { "id": db["student_profiles"].count_documents({}) + 1, "user_id": user_id, @@ -370,15 +402,18 @@ def create_batch_students( "year": year or item.get("year"), "tests_completed": 0, "avg_accuracy": 0, - "interviews_completed": 0 + "interviews_completed": 0, } db["student_profiles"].insert_one(prof) created += 1 - - return MessageResponse(message=f"Successfully onboarded {created} students. Status: {default_status}") + + return MessageResponse( + message=f"Successfully onboarded {created} students. Status: {default_status}" + ) + @router.get("/pending") -def list_pending_students(db = Depends(get_db), college_scope: int | None = get_college_scope): +def list_pending_students(db=Depends(get_db), college_scope: int | None = get_college_scope): query = {"role": "student", "status": "pending"} if college_scope: query["college_id"] = college_scope diff --git a/python-service/app/api/v1/tests.py b/python-service/app/api/v1/tests.py index f6859be..1e5fb40 100644 --- a/python-service/app/api/v1/tests.py +++ b/python-service/app/api/v1/tests.py @@ -9,12 +9,12 @@ @router.post("/submit", response_model=AttemptResponse) -def submit(data: TestSubmitRequest, current_user = Depends(get_current_user), db = Depends(get_db)): +def submit(data: TestSubmitRequest, current_user=Depends(get_current_user), db=Depends(get_db)): return submit_test(data, current_user, db) @router.get("/college/results", response_model=list[AttemptResponse]) -def college_results(db = Depends(get_db), college_scope: int | None = get_college_scope): +def college_results(db=Depends(get_db), college_scope: int | None = get_college_scope): query = {} if college_scope: query["college_id"] = int(college_scope) diff --git a/python-service/app/api/v1/users.py b/python-service/app/api/v1/users.py index 576f0b1..6b9719b 100644 --- a/python-service/app/api/v1/users.py +++ b/python-service/app/api/v1/users.py @@ -67,27 +67,29 @@ def to_dict(obj): @router.post("/", response_model=UserResponse) def create_user( - payload: AdminUserCreateRequest, - db = Depends(get_db), - current_user = Depends(get_current_user) + payload: AdminUserCreateRequest, db=Depends(get_db), current_user=Depends(get_current_user) ): """Create a user. Superadmin=anyone, College Admin=faculty+students, Faculty=students only.""" - allowed_roles = [UserRole.SUPER_ADMIN.value, UserRole.COLLEGE_ADMIN.value, UserRole.FACULTY.value] + allowed_roles = [ + UserRole.SUPER_ADMIN.value, + UserRole.COLLEGE_ADMIN.value, + UserRole.FACULTY.value, + ] if current_user.role not in allowed_roles: raise HTTPException(status_code=403, detail="Not authorized to create users") - + # Faculty can only create students in their own college if current_user.role == UserRole.FACULTY.value: if payload.role != UserRole.STUDENT.value: raise HTTPException(status_code=403, detail="Faculty can only create students") payload.college_id = current_user.college_id - + # College admin can create faculty + students in their own college if current_user.role == UserRole.COLLEGE_ADMIN.value: payload.college_id = current_user.college_id if payload.role not in [UserRole.STUDENT.value, UserRole.FACULTY.value]: raise HTTPException(status_code=403, detail="Can only create students or faculty") - + # `find_one` with a projection stops as soon as it finds a match; the old # count_documents had to visit every matching document first. if db["users"].find_one({"email": payload.email.lower()}, {"_id": 1}): @@ -109,7 +111,7 @@ def create_user( "updated_at": datetime.now(timezone.utc).isoformat(), } db["users"].insert_one(user) - + if user["role"] == UserRole.STUDENT.value: sp = { "id": _next_id(db, "student_profiles"), @@ -117,30 +119,31 @@ def create_user( "student_id": payload.student_id, "year": payload.year, "tests_completed": 0, - "avg_accuracy": 0.0 + "avg_accuracy": 0.0, } db["student_profiles"].insert_one(sp) elif user["role"] == UserRole.FACULTY.value: fp = { "id": _next_id(db, "faculty_profiles"), "user_id": user_id, - "department": payload.department + "department": payload.department, } db["faculty_profiles"].insert_one(fp) elif user["role"] == "recruiter": rp = { "id": _next_id(db, "recruiter_profiles"), "user_id": user_id, - "company_name": "New Company" + "company_name": "New Company", } db["recruiter_profiles"].insert_one(rp) return to_dict(user) + @router.get("/", response_model=List[UserResponse]) def get_all_users( - db = Depends(get_db), - current_user = Depends(get_current_user), + db=Depends(get_db), + current_user=Depends(get_current_user), skip: int = 0, limit: int = DEFAULT_PAGE_SIZE, ): @@ -168,10 +171,11 @@ def get_all_users( ) return [to_dict(u) for u in users] + @router.get("/pending", response_model=List[UserResponse]) def get_pending_users( - db = Depends(get_db), - current_user = Depends(get_current_user), + db=Depends(get_db), + current_user=Depends(get_current_user), limit: int = DEFAULT_PAGE_SIZE, ): """Get all users with 'pending' status.""" @@ -194,66 +198,70 @@ def get_pending_users( ) return [to_dict(u) for u in users] + @router.put("/{user_id}/approve", response_model=UserResponse) -def approve_user( - user_id: int, - db = Depends(get_db), - current_user = Depends(superadmin_checker) -): +def approve_user(user_id: int, db=Depends(get_db), current_user=Depends(superadmin_checker)): user = db["users"].find_one({"id": user_id}) if not user: raise NotFoundError("User", str(user_id)) - db["users"].update_one({"id": user_id}, {"$set": {"status": "approved", "updated_at": datetime.now(timezone.utc).isoformat()}}) + db["users"].update_one( + {"id": user_id}, + {"$set": {"status": "approved", "updated_at": datetime.now(timezone.utc).isoformat()}}, + ) user["status"] = "approved" return to_dict(user) + @router.put("/{user_id}/reject", response_model=UserResponse) -def reject_user( - user_id: int, - db = Depends(get_db), - current_user = Depends(superadmin_checker) -): +def reject_user(user_id: int, db=Depends(get_db), current_user=Depends(superadmin_checker)): user = db["users"].find_one({"id": user_id}) if not user: raise NotFoundError("User", str(user_id)) - db["users"].update_one({"id": user_id}, {"$set": {"status": "rejected", "updated_at": datetime.now(timezone.utc).isoformat()}}) + db["users"].update_one( + {"id": user_id}, + {"$set": {"status": "rejected", "updated_at": datetime.now(timezone.utc).isoformat()}}, + ) user["status"] = "rejected" return to_dict(user) + @router.delete("/{user_id}", response_model=dict) -def delete_user( - user_id: int, - db = Depends(get_db), - current_user = Depends(get_current_user) -): +def delete_user(user_id: int, db=Depends(get_db), current_user=Depends(get_current_user)): if current_user.role not in [UserRole.SUPER_ADMIN.value, UserRole.COLLEGE_ADMIN.value]: raise HTTPException(status_code=403, detail="Not authorized") user = db["users"].find_one({"id": user_id}) if not user: raise NotFoundError("User", str(user_id)) - if current_user.role == UserRole.COLLEGE_ADMIN.value and user.get("college_id") != current_user.college_id: + if ( + current_user.role == UserRole.COLLEGE_ADMIN.value + and user.get("college_id") != current_user.college_id + ): raise HTTPException(status_code=403, detail="Can only delete users from your college") db["users"].delete_one({"id": user_id}) return {"message": "User deleted successfully"} + @router.put("/{user_id}", response_model=UserResponse) def update_user( user_id: int, payload: AdminUserUpdateRequest, - db = Depends(get_db), - current_user = Depends(get_current_user) + db=Depends(get_db), + current_user=Depends(get_current_user), ): if current_user.role not in [UserRole.SUPER_ADMIN.value, UserRole.COLLEGE_ADMIN.value]: raise HTTPException(status_code=403, detail="Not authorized") user = db["users"].find_one({"id": user_id}) if not user: raise NotFoundError("User", str(user_id)) - if current_user.role == UserRole.COLLEGE_ADMIN.value and user.get("college_id") != current_user.college_id: + if ( + current_user.role == UserRole.COLLEGE_ADMIN.value + and user.get("college_id") != current_user.college_id + ): raise HTTPException(status_code=403, detail="Can only update users from your college") - + update_data = payload.model_dump(exclude_unset=True) update_data["updated_at"] = datetime.now(timezone.utc).isoformat() db["users"].update_one({"id": user_id}, {"$set": update_data}) - + updated = db["users"].find_one({"id": user_id}) return to_dict(updated) diff --git a/python-service/app/config.py b/python-service/app/config.py index 2f8d99a..d7da3f7 100644 --- a/python-service/app/config.py +++ b/python-service/app/config.py @@ -60,7 +60,7 @@ class Settings(BaseSettings): # not through this class) — it refuses to run if either is unset. SUPER_ADMIN_EMAIL: str = "admin@upscaler-ai.com" SUPER_ADMIN_PASSWORD: str = "" - + # ── AI Keys ────────────────────────────────── GROQ_API_KEY: str | None = None diff --git a/python-service/app/core/exceptions.py b/python-service/app/core/exceptions.py index ec49e23..13e12f2 100644 --- a/python-service/app/core/exceptions.py +++ b/python-service/app/core/exceptions.py @@ -61,7 +61,11 @@ def __init__(self): # ── Authorization Exceptions ──────────────────── class InsufficientPermissionsError(UpScalerAIException): def __init__(self, role: str = ""): - detail = f"Role '{role}' is not authorized for this resource" if role else "Insufficient permissions" + detail = ( + f"Role '{role}' is not authorized for this resource" + if role + else "Insufficient permissions" + ) super().__init__( status_code=status.HTTP_403_FORBIDDEN, detail=detail, diff --git a/python-service/app/core/middleware.py b/python-service/app/core/middleware.py index c4eee5d..af85011 100644 --- a/python-service/app/core/middleware.py +++ b/python-service/app/core/middleware.py @@ -21,7 +21,9 @@ # ── Rate Limiter ───────────────────────────────── -limiter = Limiter(key_func=get_remote_address, default_limits=[f"{settings.RATE_LIMIT_PER_MINUTE}/minute"]) +limiter = Limiter( + key_func=get_remote_address, default_limits=[f"{settings.RATE_LIMIT_PER_MINUTE}/minute"] +) # ── Request Logging Middleware ─────────────────── diff --git a/python-service/app/core/rbac.py b/python-service/app/core/rbac.py index 5e33ba2..1e17d1c 100644 --- a/python-service/app/core/rbac.py +++ b/python-service/app/core/rbac.py @@ -34,7 +34,7 @@ class UserRole(str, Enum): async def get_current_user( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme), - db = Depends(get_db), + db=Depends(get_db), ): """ Extract and validate the current user from the JWT bearer token. @@ -56,6 +56,7 @@ async def get_current_user( raise InvalidTokenError() from app.repositories.base import DotDict + user = DotDict(user_doc) if not user.is_active: @@ -69,7 +70,7 @@ async def get_current_user( async def get_optional_user( credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme), - db = Depends(get_db), + db=Depends(get_db), ): """Get current user if token is provided, otherwise return None.""" if credentials is None: @@ -96,7 +97,7 @@ async def admin_endpoint(): ... def __init__(self, allowed_roles: list[UserRole]): self.allowed_roles = allowed_roles - async def __call__(self, current_user = Depends(get_current_user)): + async def __call__(self, current_user=Depends(get_current_user)): if current_user.role not in [role.value for role in self.allowed_roles]: raise InsufficientPermissionsError(role=current_user.role) return current_user @@ -111,7 +112,9 @@ def require_roles(*roles: UserRole): # Predefined role checkers for common patterns require_super_admin = Depends(RoleChecker([UserRole.SUPER_ADMIN])) require_college_admin = Depends(RoleChecker([UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN])) -require_faculty = Depends(RoleChecker([UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN])) +require_faculty = Depends( + RoleChecker([UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN]) +) require_recruiter = Depends(RoleChecker([UserRole.RECRUITER, UserRole.SUPER_ADMIN])) require_student = Depends(RoleChecker([UserRole.STUDENT])) require_any_authenticated = Depends(get_current_user) @@ -128,7 +131,7 @@ class CollegeScope: async def __call__( self, - current_user = Depends(get_current_user), + current_user=Depends(get_current_user), ) -> Optional[int]: if current_user.role == UserRole.SUPER_ADMIN.value: return None # Super admin sees everything diff --git a/python-service/app/core/websocket_manager.py b/python-service/app/core/websocket_manager.py index 2b90541..a9b4af9 100644 --- a/python-service/app/core/websocket_manager.py +++ b/python-service/app/core/websocket_manager.py @@ -4,6 +4,7 @@ logger = logging.getLogger(__name__) + class WebSocketManager: def __init__(self): # Maps user_id (int) to a list of their active WebSocket connections @@ -14,7 +15,9 @@ async def connect(self, websocket: WebSocket, user_id: int): if user_id not in self.active_connections: self.active_connections[user_id] = [] self.active_connections[user_id].append(websocket) - logger.info(f"User {user_id} connected. Active connections: {len(self.active_connections[user_id])}") + logger.info( + f"User {user_id} connected. Active connections: {len(self.active_connections[user_id])}" + ) def disconnect(self, websocket: WebSocket, user_id: int): if user_id in self.active_connections: @@ -40,4 +43,5 @@ async def broadcast(self, message: dict): except Exception as e: logger.error(f"Error broadcasting message to user {user_id}: {e}") + manager = WebSocketManager() diff --git a/python-service/app/db_indexes.py b/python-service/app/db_indexes.py index 5ac2cb9..bdb38d9 100644 --- a/python-service/app/db_indexes.py +++ b/python-service/app/db_indexes.py @@ -25,62 +25,75 @@ # Application-level integer id used by nearly every route. ("users", [("id", ASCENDING)], {"unique": True, "name": "ux_users_id"}), # get_all_users / get_pending_users scope-then-sort. - ("users", [("college_id", ASCENDING), ("role", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_users_college_role_created"}), - ("users", [("status", ASCENDING), ("college_id", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_users_status_college_created"}), + ( + "users", + [("college_id", ASCENDING), ("role", ASCENDING), ("created_at", DESCENDING)], + {"name": "ix_users_college_role_created"}, + ), + ( + "users", + [("status", ASCENDING), ("college_id", ASCENDING), ("created_at", DESCENDING)], + {"name": "ix_users_status_college_created"}, + ), ("users", [("role", ASCENDING)], {"name": "ix_users_role"}), - # ── student_profiles ───────────────────────────────────────────────── ("student_profiles", [("user_id", ASCENDING)], {"unique": True, "name": "ux_sp_user"}), ("student_profiles", [("id", ASCENDING)], {"name": "ix_sp_id"}), - # ── faculty / recruiter profiles ───────────────────────────────────── ("faculty_profiles", [("user_id", ASCENDING)], {"unique": True, "name": "ux_fp_user"}), ("recruiter_profiles", [("user_id", ASCENDING)], {"unique": True, "name": "ux_rp_user"}), - # ── assessments ────────────────────────────────────────────────────── ("assessments", [("id", ASCENDING)], {"unique": True, "name": "ux_assess_id"}), - ("assessments", [("college_id", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_assess_college_created"}), - + ( + "assessments", + [("college_id", ASCENDING), ("created_at", DESCENDING)], + {"name": "ix_assess_college_created"}, + ), # ── assessment_attempts ────────────────────────────────────────────── # The dashboard's hottest query: student_id + status, newest first. - ("assessment_attempts", [("student_id", ASCENDING), ("status", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_attempt_student_status_created"}), + ( + "assessment_attempts", + [("student_id", ASCENDING), ("status", ASCENDING), ("created_at", DESCENDING)], + {"name": "ix_attempt_student_status_created"}, + ), ("assessment_attempts", [("college_id", ASCENDING)], {"name": "ix_attempt_college"}), ("assessment_attempts", [("assessment_id", ASCENDING)], {"name": "ix_attempt_assessment"}), ("assessment_attempts", [("id", ASCENDING)], {"name": "ix_attempt_id"}), - # ── interviews ─────────────────────────────────────────────────────── - ("interview_attempts", [("student_id", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_interview_student_created"}), + ( + "interview_attempts", + [("student_id", ASCENDING), ("created_at", DESCENDING)], + {"name": "ix_interview_student_created"}, + ), ("interview_responses", [("interview_id", ASCENDING)], {"name": "ix_iresp_interview"}), - # ── placements ─────────────────────────────────────────────────────── ("placements", [("student_id", ASCENDING)], {"name": "ix_placement_student"}), ("placements", [("college_id", ASCENDING)], {"name": "ix_placement_college"}), - # ── resume ─────────────────────────────────────────────────────────── ("resumes", [("user_id", ASCENDING)], {"unique": True, "name": "ux_resume_user"}), - ("resume_versions", [("user_id", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_rversion_user_created"}), + ( + "resume_versions", + [("user_id", ASCENDING), ("created_at", DESCENDING)], + {"name": "ix_rversion_user_created"}, + ), ("resume_versions", [("id", ASCENDING)], {"name": "ix_rversion_id"}), - # ── colleges / departments ─────────────────────────────────────────── ("colleges", [("id", ASCENDING)], {"unique": True, "name": "ux_college_id"}), ("departments", [("college_id", ASCENDING)], {"name": "ix_dept_college"}), - # ── achievements ───────────────────────────────────────────────────── ("achievements", [("student_id", ASCENDING)], {"name": "ix_achv_student"}), - # ── chat ───────────────────────────────────────────────────────────── # History is fetched per conversation pair and rendered oldest-first. - ("messages", [("sender_id", ASCENDING), ("receiver_id", ASCENDING), ("timestamp", ASCENDING)], - {"name": "ix_msg_pair_time"}), - ("messages", [("receiver_id", ASCENDING), ("timestamp", DESCENDING)], - {"name": "ix_msg_receiver_time"}), - + ( + "messages", + [("sender_id", ASCENDING), ("receiver_id", ASCENDING), ("timestamp", ASCENDING)], + {"name": "ix_msg_pair_time"}, + ), + ( + "messages", + [("receiver_id", ASCENDING), ("timestamp", DESCENDING)], + {"name": "ix_msg_receiver_time"}, + ), # ── profile_data ───────────────────────────────────────────────────── ("profile_data", [("user_id", ASCENDING)], {"unique": True, "name": "ux_profile_user"}), ] @@ -105,9 +118,7 @@ def ensure_indexes(db) -> None: # or a unique index cannot be built because the data has duplicates. # Neither should stop the app from booting. skipped += 1 - logger.warning( - "Index %s on %s not created: %s", options.get("name"), collection, exc - ) + logger.warning("Index %s on %s not created: %s", options.get("name"), collection, exc) except PyMongoError as exc: skipped += 1 logger.warning("Index %s on %s failed: %s", options.get("name"), collection, exc) diff --git a/python-service/app/dependencies.py b/python-service/app/dependencies.py index 03ff8b4..c9d5290 100644 --- a/python-service/app/dependencies.py +++ b/python-service/app/dependencies.py @@ -1,6 +1,7 @@ """ UpScaler-AI V2 — FastAPI Dependencies """ + from fastapi import Depends from pymongo.database import Database diff --git a/python-service/app/main.py b/python-service/app/main.py index e2883ed..b7bee78 100644 --- a/python-service/app/main.py +++ b/python-service/app/main.py @@ -1,6 +1,7 @@ """ UpScaler-AI V2 — Main FastAPI Application Entry Point """ + import logging from contextlib import asynccontextmanager @@ -30,6 +31,7 @@ from app.mongodb import connect_to_mongo, close_mongo_connection + @asynccontextmanager async def lifespan(app: FastAPI): """Lifespan events (startup/shutdown).""" @@ -106,4 +108,5 @@ async def upscaler_ai_exception_handler(request: Request, exc: UpScalerAIExcepti def health_check(): """System health check endpoint.""" from datetime import datetime, timezone + return HealthResponse(timestamp=datetime.now(timezone.utc)) diff --git a/python-service/app/mongodb.py b/python-service/app/mongodb.py index c87559b..dce337b 100644 --- a/python-service/app/mongodb.py +++ b/python-service/app/mongodb.py @@ -5,14 +5,17 @@ settings = get_settings() logger = logging.getLogger(__name__) + class MongoDB: client: AsyncIOMotorClient = None db = None + db_client = MongoDB() import certifi + async def connect_to_mongo(): logger.info("Connecting to MongoDB...") db_client.client = AsyncIOMotorClient( @@ -23,10 +26,12 @@ async def connect_to_mongo(): db_client.db = db_client.client[settings.MONGODB_DB_NAME] logger.info(f"Connected to MongoDB database: {settings.MONGODB_DB_NAME}") + async def close_mongo_connection(): if db_client.client: db_client.client.close() logger.info("Closed MongoDB connection") + def get_mongo_db(): return db_client.db diff --git a/python-service/app/repositories/base.py b/python-service/app/repositories/base.py index 1fb379b..fba97f9 100644 --- a/python-service/app/repositories/base.py +++ b/python-service/app/repositories/base.py @@ -1,15 +1,17 @@ class DotDict(dict): """dot.notation access to dictionary attributes""" + __getattr__ = dict.get __setattr__ = dict.__setitem__ __delattr__ = dict.__delitem__ + class BaseRepository: def __init__(self, collection_name: str, db): self.collection_name = collection_name self.db = db self.collection = db[collection_name] - + def _to_obj(self, doc): if not doc: return None diff --git a/python-service/app/repositories/college_repo.py b/python-service/app/repositories/college_repo.py index 9ceb796..436c39e 100644 --- a/python-service/app/repositories/college_repo.py +++ b/python-service/app/repositories/college_repo.py @@ -1,5 +1,6 @@ from app.repositories.base import BaseRepository + class CollegeRepository(BaseRepository): def __init__(self, db): super().__init__("colleges", db) @@ -17,5 +18,9 @@ def get_by_domain(self, domain: str): return self._to_obj(doc) def search(self, query: str, skip: int = 0, limit: int = 100): - docs = self.collection.find({"name": {"$regex": query, "$options": "i"}}).skip(skip).limit(limit) + docs = ( + self.collection.find({"name": {"$regex": query, "$options": "i"}}) + .skip(skip) + .limit(limit) + ) return self._to_objs(docs) diff --git a/python-service/app/repositories/user_repo.py b/python-service/app/repositories/user_repo.py index 4df9715..1c23638 100644 --- a/python-service/app/repositories/user_repo.py +++ b/python-service/app/repositories/user_repo.py @@ -3,6 +3,7 @@ from app.repositories.base import BaseRepository + class UserRepository(BaseRepository): def __init__(self, db): super().__init__("users", db) @@ -10,7 +11,7 @@ def __init__(self, db): def get_by_email(self, email: str): doc = self.collection.find_one({"email": email.lower()}) return self._to_obj(doc) - + def get_by_id(self, user_id): doc = self.collection.find_one({"id": user_id}) if not doc: @@ -21,7 +22,9 @@ def get_by_id(self, user_id): pass return self._to_obj(doc) - def get_by_college(self, college_id: int, role: Optional[str] = None, skip: int = 0, limit: int = 100): + def get_by_college( + self, college_id: int, role: Optional[str] = None, skip: int = 0, limit: int = 100 + ): query = {"college_id": int(college_id) if college_id else None} if role: query["role"] = role @@ -41,16 +44,13 @@ def get_students_by_college(self, college_id: int, department: Optional[str] = N def update_last_login(self, user): now = datetime.now(timezone.utc).isoformat() - self.collection.update_one( - {"id": user.id}, - {"$set": {"last_login_at": now}} - ) + self.collection.update_one({"id": user.id}, {"$set": {"last_login_at": now}}) user.last_login_at = now return user def email_exists(self, email: str) -> bool: return self.collection.count_documents({"email": email.lower()}) > 0 - + def create(self, data: dict): if "id" not in data: last_doc = self.collection.find_one(sort=[("id", -1)]) @@ -70,7 +70,7 @@ def __init__(self, db): def get_by_user_id(self, user_id: int): doc = self.collection.find_one({"user_id": user_id}) return self._to_obj(doc) - + def create(self, data: dict): if "id" not in data: last_doc = self.collection.find_one(sort=[("id", -1)]) @@ -90,13 +90,13 @@ def get_by_jti(self, jti: str): def revoke_token(self, token): self.collection.update_one( {"jti": token.jti}, - {"$set": {"is_revoked": True, "revoked_at": datetime.now(timezone.utc).isoformat()}} + {"$set": {"is_revoked": True, "revoked_at": datetime.now(timezone.utc).isoformat()}}, ) def revoke_all_user_tokens(self, user_id: int) -> int: result = self.collection.update_many( {"user_id": user_id, "is_revoked": False}, - {"$set": {"is_revoked": True, "revoked_at": datetime.now(timezone.utc).isoformat()}} + {"$set": {"is_revoked": True, "revoked_at": datetime.now(timezone.utc).isoformat()}}, ) return result.modified_count @@ -104,7 +104,7 @@ def cleanup_expired(self) -> int: now = datetime.now(timezone.utc).isoformat() result = self.collection.delete_many({"expires_at": {"$lt": now}}) return result.deleted_count - + def create(self, data: dict): if "id" not in data: last_doc = self.collection.find_one(sort=[("id", -1)]) diff --git a/python-service/app/schemas/auth.py b/python-service/app/schemas/auth.py index 515c23d..c266e75 100644 --- a/python-service/app/schemas/auth.py +++ b/python-service/app/schemas/auth.py @@ -2,6 +2,7 @@ UpScaler-AI V2 — Auth Pydantic Schemas Request/Response models for authentication endpoints. """ + from typing import Optional from pydantic import BaseModel, EmailStr, Field, field_validator import re @@ -9,6 +10,7 @@ class RegisterRequest(BaseModel): """User registration request.""" + email: EmailStr password: str = Field(min_length=6, max_length=128) name: str = Field(min_length=2, max_length=255) @@ -43,12 +45,14 @@ def validate_password(cls, v): class LoginRequest(BaseModel): """User login request.""" + email: EmailStr password: str class TokenResponse(BaseModel): """JWT token response.""" + access_token: str refresh_token: str token_type: str = "bearer" @@ -58,11 +62,13 @@ class TokenResponse(BaseModel): class RefreshTokenRequest(BaseModel): """Refresh token request.""" + refresh_token: str class ChangePasswordRequest(BaseModel): """Change password request.""" + current_password: str new_password: str = Field(min_length=6, max_length=128) @@ -78,11 +84,13 @@ def validate_new_password(cls, v): class ForgotPasswordRequest(BaseModel): """- request.""" + email: EmailStr class ResetPasswordRequest(BaseModel): """Reset password with token.""" + token: str new_password: str = Field(min_length=6, max_length=128) @@ -96,6 +104,7 @@ class UpdateProfileRequest(BaseModel): class UserBriefResponse(BaseModel): """Brief user info included in auth responses.""" + id: str | int email: str name: str diff --git a/python-service/app/schemas/college.py b/python-service/app/schemas/college.py index 7553bf2..8d7d0eb 100644 --- a/python-service/app/schemas/college.py +++ b/python-service/app/schemas/college.py @@ -1,6 +1,7 @@ """ UpScaler-AI V2 — College Pydantic Schemas """ + from datetime import datetime from typing import Optional from pydantic import BaseModel, Field diff --git a/python-service/app/schemas/common.py b/python-service/app/schemas/common.py index 5d3c556..38f46c2 100644 --- a/python-service/app/schemas/common.py +++ b/python-service/app/schemas/common.py @@ -2,6 +2,7 @@ UpScaler-AI V2 — Common Pydantic Schemas Shared response models and base schemas. """ + from datetime import datetime from typing import Generic, TypeVar from pydantic import BaseModel @@ -11,12 +12,14 @@ class MessageResponse(BaseModel): """Standard message response.""" + success: bool = True message: str class PaginatedResponse(BaseModel, Generic[T]): """Standard paginated response wrapper.""" + items: list[T] total: int page: int @@ -28,6 +31,7 @@ class PaginatedResponse(BaseModel, Generic[T]): class HealthResponse(BaseModel): """Health check response.""" + status: str = "healthy" version: str = "2.0.0" timestamp: datetime diff --git a/python-service/app/schemas/placement.py b/python-service/app/schemas/placement.py index e403758..b8333fb 100644 --- a/python-service/app/schemas/placement.py +++ b/python-service/app/schemas/placement.py @@ -56,6 +56,7 @@ class JobPostingCreateRequest(BaseModel): min_cgpa: Optional[float] = None application_deadline: Optional[datetime] = None + class JobPostingResponse(JobPostingCreateRequest): id: int recruiter_id: int @@ -66,6 +67,7 @@ class JobPostingResponse(JobPostingCreateRequest): model_config = {"from_attributes": True} + class JobApplicationResponse(BaseModel): id: int job_posting_id: int @@ -75,7 +77,7 @@ class JobApplicationResponse(BaseModel): updated_at: datetime notes: Optional[str] = None interview_scheduled_at: Optional[datetime] = None - + # Nested info we might want student_name: Optional[str] = None student_email: Optional[str] = None diff --git a/python-service/app/schemas/user.py b/python-service/app/schemas/user.py index 914370c..22e48ac 100644 --- a/python-service/app/schemas/user.py +++ b/python-service/app/schemas/user.py @@ -1,6 +1,7 @@ """ UpScaler-AI V2 — User Pydantic Schemas """ + from datetime import datetime, timezone from typing import Optional from pydantic import BaseModel, EmailStr, Field @@ -8,6 +9,7 @@ class UserResponse(BaseModel): """Full user response.""" + id: int email: str name: str @@ -29,6 +31,7 @@ class UserResponse(BaseModel): class StudentProfileResponse(BaseModel): """Student profile response.""" + id: int user_id: int student_id: Optional[str] = None @@ -51,6 +54,7 @@ class StudentProfileResponse(BaseModel): class UserUpdateRequest(BaseModel): """User profile update.""" + name: Optional[str] = Field(None, min_length=2, max_length=255) phone: Optional[str] = None department: Optional[str] = None @@ -59,11 +63,14 @@ class UserUpdateRequest(BaseModel): class StudentProfileUpdateRequest(BaseModel): """Student profile update.""" + student_id: Optional[str] = None year: Optional[int] = None + class AdminUserCreateRequest(BaseModel): """Admin request to create a new user.""" + name: str = Field(..., min_length=2, max_length=255) email: EmailStr role: str @@ -73,8 +80,10 @@ class AdminUserCreateRequest(BaseModel): student_id: Optional[str] = None year: Optional[int] = None + class AdminUserUpdateRequest(BaseModel): """Admin update request to manage user attributes directly.""" + name: Optional[str] = Field(None, min_length=2, max_length=255) email: Optional[EmailStr] = None role: Optional[str] = None @@ -82,4 +91,3 @@ class AdminUserUpdateRequest(BaseModel): department: Optional[str] = None college_id: Optional[int] = None preferences: Optional[dict] = None - diff --git a/python-service/app/services/auth_service.py b/python-service/app/services/auth_service.py index 279d8df..3777428 100644 --- a/python-service/app/services/auth_service.py +++ b/python-service/app/services/auth_service.py @@ -2,6 +2,7 @@ UpScaler-AI V2 — Authentication Service Business logic for registration, login, and token management. """ + from typing import Optional from pymongo.database import Database @@ -20,9 +21,19 @@ verify_refresh_token, ) from app.repositories.base import DotDict -from app.repositories.user_repo import UserRepository, StudentProfileRepository, RefreshTokenRepository +from app.repositories.user_repo import ( + UserRepository, + StudentProfileRepository, + RefreshTokenRepository, +) from app.repositories.college_repo import CollegeRepository -from app.schemas.auth import RegisterRequest, LoginRequest, TokenResponse, UserBriefResponse, ChangePasswordRequest +from app.schemas.auth import ( + RegisterRequest, + LoginRequest, + TokenResponse, + UserBriefResponse, + ChangePasswordRequest, +) settings = get_settings() @@ -56,21 +67,22 @@ def register(self, data: RegisterRequest) -> DotDict: "department": data.department, "status": status, } - + user = self.user_repo.create(user_data) # Create role-specific profile if data.role == "student": - self.student_profile_repo.create({ - "user_id": user.id, - "student_id": data.student_id, - "year": data.year, - }) + self.student_profile_repo.create( + { + "user_id": user.id, + "student_id": data.student_id, + "year": data.year, + } + ) elif data.role == "recruiter" and data.company_name: - self.db["recruiter_profiles"].insert_one({ - "user_id": user.id, - "company_name": data.company_name - }) + self.db["recruiter_profiles"].insert_one( + {"user_id": user.id, "company_name": data.company_name} + ) return user @@ -79,6 +91,7 @@ def _issue_tokens(self, user: DotDict) -> TokenResponse: raise InvalidCredentialsError() if user.status == "pending": from app.core.exceptions import AccountPendingError + raise AccountPendingError() if user.status != "approved": raise InvalidCredentialsError() @@ -92,12 +105,16 @@ def _issue_tokens(self, user: DotDict) -> TokenResponse: refresh_token, jti, expire = create_refresh_token(subject=str(user.id)) # Save refresh token in DB - self.refresh_token_repo.create({ - "user_id": user.id, - "jti": jti, - "token_hash": hash_password(refresh_token), # Optional: hash refresh token for extra security - "expires_at": expire, - }) + self.refresh_token_repo.create( + { + "user_id": user.id, + "jti": jti, + "token_hash": hash_password( + refresh_token + ), # Optional: hash refresh token for extra security + "expires_at": expire, + } + ) return TokenResponse( access_token=access_token, @@ -113,24 +130,26 @@ def login(self, data: LoginRequest) -> TokenResponse: raise InvalidCredentialsError() return self._issue_tokens(user) - def login_with_student_id(self, student_id: str, password: str, college_id: Optional[int] = None) -> TokenResponse: + def login_with_student_id( + self, student_id: str, password: str, college_id: Optional[int] = None + ) -> TokenResponse: profile_query = {"student_id": student_id.upper()} profile_doc = self.db["student_profiles"].find_one(profile_query) if not profile_doc: raise InvalidCredentialsError() - + user_query = {"id": profile_doc["user_id"], "role": "student"} if college_id: user_query["college_id"] = int(college_id) - + user_doc = self.db["users"].find_one(user_query) if not user_doc: raise InvalidCredentialsError() - + user = self.user_repo._to_obj(user_doc) if not verify_password(password, user.password_hash): raise InvalidCredentialsError() - + return self._issue_tokens(user) def refresh_token(self, refresh_token: str) -> TokenResponse: @@ -141,10 +160,10 @@ def refresh_token(self, refresh_token: str) -> TokenResponse: jti = payload.get("jti") user_id_str = payload.get("sub") - + if not jti or not user_id_str: raise InvalidTokenError() - + user_id = int(user_id_str) # Check if token is valid in DB @@ -171,12 +190,14 @@ def refresh_token(self, refresh_token: str) -> TokenResponse: new_refresh_token, new_jti, expire = create_refresh_token(subject=str(user.id)) # Save new refresh token - self.refresh_token_repo.create({ - "user_id": user.id, - "jti": new_jti, - "token_hash": hash_password(new_refresh_token), - "expires_at": expire, - }) + self.refresh_token_repo.create( + { + "user_id": user.id, + "jti": new_jti, + "token_hash": hash_password(new_refresh_token), + "expires_at": expire, + } + ) return TokenResponse( access_token=access_token, @@ -185,7 +206,9 @@ def refresh_token(self, refresh_token: str) -> TokenResponse: user=UserBriefResponse.model_validate(user), ) - def google_login(self, email: str, name: str, role: str, college_id: Optional[int] = None) -> TokenResponse: + def google_login( + self, email: str, name: str, role: str, college_id: Optional[int] = None + ) -> TokenResponse: """Find-or-create a user from a verified Google profile, then issue tokens. Mirrors register()'s approval rule: student accounts are auto-approved, @@ -212,7 +235,9 @@ def google_login(self, email: str, name: str, role: str, college_id: Optional[in } user = self.user_repo.create(user_data) if role == "student": - self.student_profile_repo.create({"user_id": user.id, "student_id": None, "year": None}) + self.student_profile_repo.create( + {"user_id": user.id, "student_id": None, "year": None} + ) return self._issue_tokens(user) @@ -225,6 +250,6 @@ def change_password(self, user_id: int, data: ChangePasswordRequest) -> None: user = self.user_repo.get_by_id(user_id) if not user or not verify_password(data.current_password, user.password_hash): raise InvalidCredentialsError("Incorrect current password") - + new_hash = hash_password(data.new_password) self.user_repo.update(user_id, {"password_hash": new_hash}) diff --git a/python-service/pyproject.toml b/python-service/pyproject.toml index 6aeb87d..a82a16c 100644 --- a/python-service/pyproject.toml +++ b/python-service/pyproject.toml @@ -6,6 +6,13 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [] +[tool.black] +# Matches [tool.ruff] line-length below so the two tools don't disagree about +# where to wrap. +line-length = 100 +target-version = ["py312"] +extend-exclude = "/\\.venv/" + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/python-service/requirements-dev.txt b/python-service/requirements-dev.txt index b329780..ac6b902 100644 --- a/python-service/requirements-dev.txt +++ b/python-service/requirements-dev.txt @@ -1,4 +1,5 @@ -r requirements.txt ruff==0.16.1 +black==26.5.1 pytest==9.1.1 diff --git a/python-service/seed_mongo.py b/python-service/seed_mongo.py index 47420a8..4c0a71c 100644 --- a/python-service/seed_mongo.py +++ b/python-service/seed_mongo.py @@ -10,22 +10,25 @@ load_dotenv() -pwd_context = CryptContext(schemes=['bcrypt'], deprecated='auto') +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # Credentials are read from the environment only. Never hardcode a connection # string here again — a previous version of this file had a live Atlas # password committed in plaintext, which must be treated as compromised and # rotated in the Atlas console regardless of this fix. -MONGODB_URI = os.environ.get('MONGODB_URI') -MONGODB_DB_NAME = os.environ.get('MONGODB_DB_NAME', 'upscaler_ai') -SUPER_ADMIN_EMAIL = os.environ.get('SUPER_ADMIN_EMAIL') -SUPER_ADMIN_PASSWORD = os.environ.get('SUPER_ADMIN_PASSWORD') +MONGODB_URI = os.environ.get("MONGODB_URI") +MONGODB_DB_NAME = os.environ.get("MONGODB_DB_NAME", "upscaler_ai") +SUPER_ADMIN_EMAIL = os.environ.get("SUPER_ADMIN_EMAIL") +SUPER_ADMIN_PASSWORD = os.environ.get("SUPER_ADMIN_PASSWORD") + async def seed(): if not MONGODB_URI: - sys.exit('MONGODB_URI is not set (check your .env) — refusing to run without it.') + sys.exit("MONGODB_URI is not set (check your .env) — refusing to run without it.") if not SUPER_ADMIN_EMAIL or not SUPER_ADMIN_PASSWORD: - sys.exit('SUPER_ADMIN_EMAIL / SUPER_ADMIN_PASSWORD are not set (check your .env) — refusing to run without them.') + sys.exit( + "SUPER_ADMIN_EMAIL / SUPER_ADMIN_PASSWORD are not set (check your .env) — refusing to run without them." + ) client = AsyncIOMotorClient(MONGODB_URI, tlsCAFile=certifi.where()) db = client[MONGODB_DB_NAME] @@ -33,28 +36,33 @@ async def seed(): email = SUPER_ADMIN_EMAIL password = SUPER_ADMIN_PASSWORD - admin = await db.users.find_one({'email': email}) + admin = await db.users.find_one({"email": email}) if not admin: - print('Inserting admin...') - await db.users.insert_one({ - 'email': email, - 'password_hash': pwd_context.hash(password), - 'name': 'UpScaler-AI Super Admin', - 'role': 'super_admin', - 'status': 'approved', - 'is_active': True, - 'is_email_verified': True, - 'created_at': datetime.now(timezone.utc), - 'updated_at': datetime.now(timezone.utc), - 'college_id': None, - 'department': None - }) - print('Admin inserted.') + print("Inserting admin...") + await db.users.insert_one( + { + "email": email, + "password_hash": pwd_context.hash(password), + "name": "UpScaler-AI Super Admin", + "role": "super_admin", + "status": "approved", + "is_active": True, + "is_email_verified": True, + "created_at": datetime.now(timezone.utc), + "updated_at": datetime.now(timezone.utc), + "college_id": None, + "department": None, + } + ) + print("Admin inserted.") else: - print('Updating admin password...') - await db.users.update_one({'email': email}, {'$set': {'password_hash': pwd_context.hash(password)}}) - print('Admin updated.') + print("Updating admin password...") + await db.users.update_one( + {"email": email}, {"$set": {"password_hash": pwd_context.hash(password)}} + ) + print("Admin updated.") client.close() -if __name__ == '__main__': + +if __name__ == "__main__": asyncio.run(seed()) From e9ad48f3638e4600a85a35554fb3198d90e57f9c Mon Sep 17 00:00:00 2001 From: KIVOX-dev <07kaviarasan@gmail.com> Date: Sat, 1 Aug 2026 17:22:20 +0530 Subject: [PATCH 03/24] chore: ignore the black reformat commit in git blame Points git blame past d621b5e (the formatting-only commit) so line history still attributes code to whoever actually wrote it. GitHub picks this file up automatically; locally, enable it with: git config blame.ignoreRevsFile .git-blame-ignore-revs Co-Authored-By: Claude Opus 5 --- .git-blame-ignore-revs | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .git-blame-ignore-revs diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e8b7cd9 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,10 @@ +# Revisions listed here are skipped by `git blame`. +# Enable locally with: +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# (GitHub reads this file automatically.) +# +# Only pure-formatting commits belong here — never anything that changes +# behaviour, or blame will hide real changes. + +# style: apply black formatting across python-service +d621b5ecc01bce9792e452fcc1c5d8a54bb34484 From 638ee21fe6576bcf7723a1b44ac7d72d44f259a4 Mon Sep 17 00:00:00 2001 From: KIVOX-dev <07kaviarasan@gmail.com> Date: Sat, 1 Aug 2026 17:27:11 +0530 Subject: [PATCH 04/24] feat: initialize project configuration in pyproject.toml --- python-service/pyproject.toml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/python-service/pyproject.toml b/python-service/pyproject.toml index a82a16c..3573a87 100644 --- a/python-service/pyproject.toml +++ b/python-service/pyproject.toml @@ -6,6 +6,15 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [] +[tool.pytest.ini_options] +# `pythonpath = ["."]` is required, not cosmetic: the tests import `app.*`, and +# a bare `pytest` (what CI runs) does not put the working directory on +# sys.path — only `python -m pytest` does. Without this the suite collects +# fine locally via `python -m pytest` but fails in CI with +# "ModuleNotFoundError: No module named 'app'". +pythonpath = ["."] +testpaths = ["tests"] + [tool.black] # Matches [tool.ruff] line-length below so the two tools don't disagree about # where to wrap. From 68d7b0e3ad53ed6c3e1bfad6184ed715c5391d6c Mon Sep 17 00:00:00 2001 From: KIVOX-dev <07kaviarasan@gmail.com> Date: Sat, 1 Aug 2026 17:37:11 +0530 Subject: [PATCH 05/24] docs: record third-pass toolchain audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds §12 to SECURITY_AUDIT.md covering this pass: - pydantic duplicate/unpinned declaration found by `safety` (pip-audit missed it) - the bare-`pytest` sys.path defect that would have failed the python-test job - black adoption as an isolated commit + .git-blame-ignore-revs - lock-file inventory: uv.lock is a stub that pins nothing; no poetry/Pipfile - rationale for cross-checking with `safety` manually rather than wiring it into CI (its modern `scan` command requires an API key; `check` is EOL) Co-Authored-By: Claude Opus 5 --- SECURITY_AUDIT.md | 103 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 2c8315d..d589e65 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -437,3 +437,106 @@ Remaining follow-ups are hygiene, not blockers: - `starlette.testclient` warns that `httpx` support is deprecated in favor of `httpx2` — harmless today, but it will need attention when `httpx` is next bumped. - Re-run this audit on a cadence: `npm audit` / `pip-audit` catch *known* CVEs only, not zero-days. + +--- + +## 12. Third pass — full toolchain audit (`safety`, `black`, lock files) + +Re-verified the reported CI failure first. **`pip-audit` already passed with exit code 0** — the +Python scan failure was the one fixed in §3/§4; it is not still failing. This pass therefore +audited the wider toolchain rather than re-fixing resolved CVEs, and turned up two real defects. + +### 12a. Duplicate, unpinned `pydantic` declaration (found by `safety`, missed by `pip-audit`) + +`requirements.txt` declared pydantic twice: + +``` +pydantic==2.11.3 +pydantic[email] <- no version constraint +``` + +`safety` reported *"4 known vulnerabilities match the pydantic versions that could be installed +from your specifiers: `pydantic[email]>=0` (unpinned)"*. pip intersected the two constraints and +resolved 2.11.3 anyway, so **no vulnerable version was ever installed** — but the declaration was +both a duplicate and an open range, and the safety of the result depended on resolver behaviour +rather than on the manifest. Consolidated to a single pinned entry: + +| | Before | After | +|---|---|---| +| pydantic | `pydantic==2.11.3` + bare `pydantic[email]` | `pydantic[email]==2.11.3` | + +`safety` goes from *"0 reported, 4 ignored"* → *"0 reported, 0 ignored"*. This also satisfies the +"remove duplicate packages" requirement — it was the only duplicate in either service. + +### 12b. CRITICAL — the `python-test` CI job would have failed + +The workflow runs `pytest -v`. A **bare `pytest` does not put the working directory on +`sys.path`; only `python -m pytest` does.** Every local verification up to this point had used +`python -m pytest`, which masked the problem. Running the CI command exactly: + +``` +$ pytest -v +tests/test_security.py:8: in + from app.core.security import ( +E ModuleNotFoundError: No module named 'app' +Interrupted: 2 errors during collection +``` + +That job would have gone red on the first push. Fixed in configuration rather than by changing the +CI invocation, so the suite behaves identically either way: + +```toml +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] +``` + +This is the same class of defect as §9a (the undeclared Mongo drivers): **a divergence between the +developer's invocation and CI's**. Both were invisible until the exact production/CI command was +run in a clean environment. Every gate in this pass was subsequently re-run using the **bare +executables in a fresh venv**, not `python -m`. + +### 12c. `black` adopted as a separate commit + +`black --check` failed on 38 of 56 files (the project had never used black). Applying it is a +large, purely cosmetic diff, so — per your decision — it was kept as its own commit rather than +mixed into the security work: + +| Commit | Contents | +|---|---| +| `759183b` | `fix(deps)`: pydantic pin (§12a) | +| `d621b5e` | `style`: black reformat, 38 files — **no logic changes** | +| `e9ad48f` | `chore`: `.git-blame-ignore-revs` pointing at `d621b5e` | +| `638ee21` | `fix(ci)`: pytest `pythonpath` (§12b) | + +`black` is pinned in `requirements-dev.txt` (26.5.1) and enforced by a dedicated `black --check .` +step in the `python-lint` job. Its `line-length` is set to **100** in `pyproject.toml` to match +`[tool.ruff]`, so the two tools cannot disagree about wrapping — verified by running both after +the reformat. `git blame` skips the formatting commit via `.git-blame-ignore-revs`. + +### 12d. Lock files — nothing to regenerate + +| File | Status | +|---|---| +| `requirements.txt` | The real manifest — fully pinned (`==`) on every entry | +| `requirements-dev.txt` | Fully pinned; `-r requirements.txt` | +| `pyproject.toml` | Tool config only (`ruff`, `black`, `pytest`); `dependencies = []` | +| `uv.lock` | **Stub — locks nothing.** Contains exactly one `[[package]]` block: the `backend` project itself, with zero dependencies. A leftover from `uv init`. | +| `poetry.lock` | Does not exist | +| `Pipfile` / `Pipfile.lock` | Do not exist | + +Because `pyproject.toml` declares `dependencies = []`, `uv.lock` pins no third-party package and +**cannot carry a vulnerability**; no scanner reads it, and `uv` is not installed or used anywhere +in the build. There is nothing to regenerate. It is dead weight that misleadingly implies +uv-managed dependencies — worth either deleting or adopting uv properly, but that is a build-system +decision, so it was left in place rather than removed unilaterally. + +### 12e. `safety` not added to CI (your decision) + +`safety scan` (the modern command) **requires authentication** — it prompts for login and needs a +`SAFETY_API_KEY` secret for CI. `safety check` works unauthenticated against the open-source DB but +is officially deprecated and unsupported beyond 2024-06-01. Since `pip-audit` already gates CI +against the same PyPI/OSV advisory data and passes, `safety` was run **once, manually**, as an +independent cross-check (which is how §12a was found) but not wired into the pipeline. The +security policy was not weakened and no scan was disabled — `pip-audit` still fails the build on +any advisory. From b418af2617a9f6c5f8b56a0807b3c763170efa35 Mon Sep 17 00:00:00 2001 From: KIVOX-dev <07kaviarasan@gmail.com> Date: Sat, 1 Aug 2026 19:37:28 +0530 Subject: [PATCH 06/24] feat: implement backend service architecture including auth, dashboard, and placement modules with input validation and security safeguards. --- python-service/.dockerignore | 16 - python-service/.env.example | 48 -- python-service/.python-version | 1 - python-service/Dockerfile | 42 -- python-service/README.md | 46 -- python-service/REQUIREMENTS.md | 19 - python-service/app/__init__.py | 1 - python-service/app/api/__init__.py | 1 - python-service/app/api/router.py | 15 - python-service/app/api/v1/__init__.py | 1 - python-service/app/api/v1/achievements.py | 147 ------ python-service/app/api/v1/ai.py | 66 --- python-service/app/api/v1/assessments.py | 300 ------------ python-service/app/api/v1/auth.py | 271 ----------- python-service/app/api/v1/batches.py | 150 ------ python-service/app/api/v1/chat.py | 121 ----- python-service/app/api/v1/colleges.py | 59 --- python-service/app/api/v1/dashboard.py | 156 ------- python-service/app/api/v1/interviews.py | 146 ------ python-service/app/api/v1/jobs.py | 158 ------- python-service/app/api/v1/persistence.py | 27 -- python-service/app/api/v1/placements.py | 104 ----- python-service/app/api/v1/profile.py | 429 ------------------ python-service/app/api/v1/resume.py | 408 ----------------- python-service/app/api/v1/router.py | 44 -- python-service/app/api/v1/students.py | 421 ----------------- python-service/app/api/v1/tests.py | 22 - python-service/app/api/v1/users.py | 267 ----------- python-service/app/config.py | 92 ---- python-service/app/core/__init__.py | 1 - python-service/app/core/exceptions.py | 111 ----- python-service/app/core/middleware.py | 77 ---- python-service/app/core/rbac.py | 156 ------- python-service/app/core/security.py | 110 ----- python-service/app/core/websocket_manager.py | 47 -- python-service/app/database.py | 14 - python-service/app/db_indexes.py | 138 ------ python-service/app/dependencies.py | 13 - python-service/app/main.py | 112 ----- python-service/app/mongodb.py | 37 -- python-service/app/mongodb_sync.py | 45 -- python-service/app/repositories/__init__.py | 1 - python-service/app/repositories/base.py | 24 - .../app/repositories/college_repo.py | 26 -- python-service/app/repositories/user_repo.py | 114 ----- python-service/app/schemas/__init__.py | 1 - python-service/app/schemas/achievement.py | 21 - python-service/app/schemas/assessment.py | 86 ---- python-service/app/schemas/auth.py | 122 ----- python-service/app/schemas/batch.py | 41 -- python-service/app/schemas/college.py | 55 --- python-service/app/schemas/common.py | 37 -- python-service/app/schemas/interview.py | 63 --- python-service/app/schemas/persistence.py | 12 - python-service/app/schemas/placement.py | 86 ---- python-service/app/schemas/user.py | 93 ---- python-service/app/services/__init__.py | 1 - python-service/app/services/auth_service.py | 255 ----------- python-service/pyproject.toml | 38 -- python-service/requirements-dev.txt | 5 - python-service/requirements.txt | 60 --- python-service/seed_mongo.py | 68 --- python-service/tests/conftest.py | 11 - python-service/tests/test_routes.py | 51 --- python-service/tests/test_security.py | 51 --- python-service/uv.lock | 8 - 66 files changed, 5769 deletions(-) delete mode 100644 python-service/.dockerignore delete mode 100644 python-service/.env.example delete mode 100644 python-service/.python-version delete mode 100644 python-service/Dockerfile delete mode 100644 python-service/README.md delete mode 100644 python-service/REQUIREMENTS.md delete mode 100644 python-service/app/__init__.py delete mode 100644 python-service/app/api/__init__.py delete mode 100644 python-service/app/api/router.py delete mode 100644 python-service/app/api/v1/__init__.py delete mode 100644 python-service/app/api/v1/achievements.py delete mode 100644 python-service/app/api/v1/ai.py delete mode 100644 python-service/app/api/v1/assessments.py delete mode 100644 python-service/app/api/v1/auth.py delete mode 100644 python-service/app/api/v1/batches.py delete mode 100644 python-service/app/api/v1/chat.py delete mode 100644 python-service/app/api/v1/colleges.py delete mode 100644 python-service/app/api/v1/dashboard.py delete mode 100644 python-service/app/api/v1/interviews.py delete mode 100644 python-service/app/api/v1/jobs.py delete mode 100644 python-service/app/api/v1/persistence.py delete mode 100644 python-service/app/api/v1/placements.py delete mode 100644 python-service/app/api/v1/profile.py delete mode 100644 python-service/app/api/v1/resume.py delete mode 100644 python-service/app/api/v1/router.py delete mode 100644 python-service/app/api/v1/students.py delete mode 100644 python-service/app/api/v1/tests.py delete mode 100644 python-service/app/api/v1/users.py delete mode 100644 python-service/app/config.py delete mode 100644 python-service/app/core/__init__.py delete mode 100644 python-service/app/core/exceptions.py delete mode 100644 python-service/app/core/middleware.py delete mode 100644 python-service/app/core/rbac.py delete mode 100644 python-service/app/core/security.py delete mode 100644 python-service/app/core/websocket_manager.py delete mode 100644 python-service/app/database.py delete mode 100644 python-service/app/db_indexes.py delete mode 100644 python-service/app/dependencies.py delete mode 100644 python-service/app/main.py delete mode 100644 python-service/app/mongodb.py delete mode 100644 python-service/app/mongodb_sync.py delete mode 100644 python-service/app/repositories/__init__.py delete mode 100644 python-service/app/repositories/base.py delete mode 100644 python-service/app/repositories/college_repo.py delete mode 100644 python-service/app/repositories/user_repo.py delete mode 100644 python-service/app/schemas/__init__.py delete mode 100644 python-service/app/schemas/achievement.py delete mode 100644 python-service/app/schemas/assessment.py delete mode 100644 python-service/app/schemas/auth.py delete mode 100644 python-service/app/schemas/batch.py delete mode 100644 python-service/app/schemas/college.py delete mode 100644 python-service/app/schemas/common.py delete mode 100644 python-service/app/schemas/interview.py delete mode 100644 python-service/app/schemas/persistence.py delete mode 100644 python-service/app/schemas/placement.py delete mode 100644 python-service/app/schemas/user.py delete mode 100644 python-service/app/services/__init__.py delete mode 100644 python-service/app/services/auth_service.py delete mode 100644 python-service/pyproject.toml delete mode 100644 python-service/requirements-dev.txt delete mode 100644 python-service/requirements.txt delete mode 100644 python-service/seed_mongo.py delete mode 100644 python-service/tests/conftest.py delete mode 100644 python-service/tests/test_routes.py delete mode 100644 python-service/tests/test_security.py delete mode 100644 python-service/uv.lock diff --git a/python-service/.dockerignore b/python-service/.dockerignore deleted file mode 100644 index b11f3af..0000000 --- a/python-service/.dockerignore +++ /dev/null @@ -1,16 +0,0 @@ -.env -.env.* -!.env.example -.venv -__pycache__/ -*.pyc -*.pyo -*.db -uploads/ -.git -.gitignore -README.md -REQUIREMENTS.md -seed_mongo.py -.python-version -.pytest_cache diff --git a/python-service/.env.example b/python-service/.env.example deleted file mode 100644 index cab1d91..0000000 --- a/python-service/.env.example +++ /dev/null @@ -1,48 +0,0 @@ -# ═══════════════════════════════════════════ -# UpScaler-AI V2 — Environment Configuration -# ═══════════════════════════════════════════ - -# --- Application --- -APP_NAME=UpScaler-AI V2 -APP_ENV=development -DEBUG=true -API_V1_PREFIX=/api/v1 - -# --- Database (MongoDB — the only database engine) --- -# Required: the app will not start without MONGODB_URI. -MONGODB_URI=mongodb://localhost:27017 -MONGODB_DB_NAME=upscaler_ai -# Connection pool sizing (optional — see app/mongodb_sync.py for why these matter). -# MONGO_POOL_MAX=50 -# MONGO_POOL_MIN=5 - -# --- JWT --- -JWT_SECRET_KEY=change-this-to-a-random-64-char-string-in-production -JWT_ALGORITHM=HS256 -ACCESS_TOKEN_EXPIRE_MINUTES=30 -REFRESH_TOKEN_EXPIRE_DAYS=7 - -# --- CORS --- -CORS_ORIGINS=http://localhost:3000,http://localhost:3001 - -# --- Rate Limiting --- -RATE_LIMIT_PER_MINUTE=60 - -# --- Email (future) --- -SMTP_HOST= -SMTP_PORT=587 -SMTP_USER= -SMTP_PASSWORD= -EMAIL_FROM=noreply@upscaler-ai.com - -# --- Super Admin Seed (read by seed_mongo.py) --- -SUPER_ADMIN_EMAIL=admin@upscaler-ai.com -SUPER_ADMIN_PASSWORD=change-this-before-seeding - -# --- Google OAuth (Authorization Code flow, redirect-based) --- -# Create an OAuth Client ID (Web application) in Google Cloud Console and add -# GOOGLE_OAUTH_REDIRECT_URI below as an authorized redirect URI. -GOOGLE_CLIENT_ID= -GOOGLE_CLIENT_SECRET= -GOOGLE_OAUTH_REDIRECT_URI=http://localhost:8000/api/v1/auth/google/callback -FRONTEND_URL=http://localhost:3000 diff --git a/python-service/.python-version b/python-service/.python-version deleted file mode 100644 index e4fba21..0000000 --- a/python-service/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.12 diff --git a/python-service/Dockerfile b/python-service/Dockerfile deleted file mode 100644 index b0e39f8..0000000 --- a/python-service/Dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -# Python/FastAPI service image. Build context is this directory (python-service/): -# docker build -t upscaler-ai-python-service -f python-service/Dockerfile python-service/ - -# ---- builder: install deps into a venv; build toolchain never reaches runtime ---- -FROM python:3.12-slim AS builder -WORKDIR /app - -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - libpq-dev \ - && rm -rf /var/lib/apt/lists/* - -RUN python -m venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" - -COPY requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -# ---- runtime: slim final image — venv + app source only, non-root ---- -FROM python:3.12-slim AS runtime -WORKDIR /app - -RUN useradd --create-home --uid 1001 appuser - -COPY --from=builder /opt/venv /opt/venv -ENV PATH="/opt/venv/bin:$PATH" - -# .dockerignore excludes .env, .venv, uploads/, __pycache__ — see that file -# for the full list; none of those should ever reach an image layer. -COPY --chown=appuser:appuser . . -RUN mkdir -p uploads/profile && chown -R appuser:appuser uploads - -USER appuser - -EXPOSE 8000 - -HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ - CMD python -c "import os, urllib.request; urllib.request.urlopen('http://127.0.0.1:' + os.environ.get('PORT', '8000') + '/health', timeout=3)" - -# No --reload: that's a dev-only flag (single worker, file-watcher overhead). -# PORT and WEB_CONCURRENCY are both overridable at deploy time (Cloud Run sets $PORT). -CMD ["sh", "-c", "uvicorn app.main:app --host 0.0.0.0 --port ${PORT:-8000} --workers ${WEB_CONCURRENCY:-2}"] diff --git a/python-service/README.md b/python-service/README.md deleted file mode 100644 index e39f528..0000000 --- a/python-service/README.md +++ /dev/null @@ -1,46 +0,0 @@ -# UpScaler-AI Python Service - -Existing FastAPI backend (UpScaler-AI V2), using MongoDB as its only datastore -(`app/mongodb.py` async / `app/mongodb_sync.py` sync). Lives in its own folder (`python-service/`) -as an independent deployable from the Node API — see [`REQUIREMENTS.md`](REQUIREMENTS.md) for -prerequisites and [`../README.md`](../README.md) for how this fits alongside it. - -## Quick start - -```bash -cd python-service -python -m venv .venv && .venv\Scripts\activate # Windows; use source .venv/bin/activate on macOS/Linux -pip install -r requirements.txt -cp .env.example .env # fill in real values -uvicorn app.main:app --reload -``` - -## Structure (`app/`) - -``` -app/ - api/v1/ one router module per feature (auth, students, placements, tests, colleges, ...) - core/ security, RBAC, middleware, exceptions, websocket manager - schemas/ Pydantic request/response schemas - repositories/ data-access layer (documents are wrapped in DotDict for attribute access) - services/ business logic (auth_service) - config.py settings (pydantic-settings, reads .env) - database.py `get_db` FastAPI dependency — yields the sync MongoDB handle - mongodb.py async MongoDB client (Motor) — used for startup/shutdown - mongodb_sync.py sync MongoDB client (PyMongo) — used by routes/repositories - db_indexes.py idempotent index creation, run at startup - main.py FastAPI app entrypoint — run via `uvicorn app.main:app` -``` - -There is no ORM layer and no `models/` package: MongoDB documents are read and written directly -through the repositories, so Pydantic schemas in `schemas/` are the only declared shapes. - -## Note on `seed_mongo.py` - -This script used to contain a **hardcoded, plaintext MongoDB Atlas connection string with -credentials**. It now reads `MONGODB_URI`/`SUPER_ADMIN_EMAIL`/`SUPER_ADMIN_PASSWORD` from the -environment (`.env`) and refuses to run without them. The previously-hardcoded credential has -been **rotated in the Atlas console** and the old database user is no longer valid. It still -appears in plaintext in this repo's git history (commits `0274160` and `a53a25d`) — since it's -now a dead credential that's a lower-urgency cleanup, but if the repo is ever shared or made -public, purge it from history (e.g. `git filter-repo`) rather than relying on rotation alone. diff --git a/python-service/REQUIREMENTS.md b/python-service/REQUIREMENTS.md deleted file mode 100644 index 70a447b..0000000 --- a/python-service/REQUIREMENTS.md +++ /dev/null @@ -1,19 +0,0 @@ -# Python Service — Requirements & Prerequisites - -| Requirement | Version / Notes | -|---|---| -| Python | 3.12 (see `.python-version`) — 3.14 is not supported yet (pydantic-core build issue) | -| Dependencies | `requirements.txt` (pip) — `pip install -r requirements.txt` (run from inside `python-service/`) | -| Database | MongoDB — the only database engine. Async client `app/mongodb.py` (Motor), sync client `app/mongodb_sync.py` (PyMongo); `app/database.py` exposes the `get_db` dependency | -| Config | `.env` (copy from `.env.example`) — `MONGODB_URI` and `JWT_SECRET_KEY` are required | -| Run | `uvicorn app.main:app --reload` | -| Migrations | None. MongoDB is schemaless; indexes are created idempotently at startup by `app/db_indexes.py`. (The former SQLAlchemy/Alembic setup has been removed — see `../SECURITY_AUDIT.md`.) | -| Lint / test | `pip install -r requirements-dev.txt` then `ruff check .` / `pytest` | -| Docker | `Dockerfile` — build context is this folder: `docker build -f Dockerfile .` from inside `python-service/` | - -## Security note - -`seed_mongo.py` previously contained a hardcoded plaintext MongoDB Atlas password. That credential -has been rotated and the script now loads `MONGODB_URI` from `.env`/Secret Manager instead. The -old (now-invalid) credential still exists in this repo's git history — see `README.md` for the -affected commits and cleanup note. diff --git a/python-service/app/__init__.py b/python-service/app/__init__.py deleted file mode 100644 index 93abb06..0000000 --- a/python-service/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# UpScaler-AI V2 Backend diff --git a/python-service/app/api/__init__.py b/python-service/app/api/__init__.py deleted file mode 100644 index 28b07ef..0000000 --- a/python-service/app/api/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# API package diff --git a/python-service/app/api/router.py b/python-service/app/api/router.py deleted file mode 100644 index cdd7e6d..0000000 --- a/python-service/app/api/router.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -UpScaler-AI V2 — Main API Router -""" - -from fastapi import APIRouter -from app.api.v1.router import api_router as v1_router -from app.config import get_settings - -settings = get_settings() - -api_router = APIRouter() - -# Mount API v1 -api_router.include_router(v1_router, prefix=settings.API_V1_PREFIX) -api_router.include_router(v1_router, prefix="/api") diff --git a/python-service/app/api/v1/__init__.py b/python-service/app/api/v1/__init__.py deleted file mode 100644 index 6c2f33c..0000000 --- a/python-service/app/api/v1/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# API v1 package diff --git a/python-service/app/api/v1/achievements.py b/python-service/app/api/v1/achievements.py deleted file mode 100644 index 95d18f9..0000000 --- a/python-service/app/api/v1/achievements.py +++ /dev/null @@ -1,147 +0,0 @@ -from fastapi import APIRouter, Depends -from datetime import datetime, timezone - -from app.core.rbac import get_college_scope, get_current_user, assert_can_act_on_student -from app.database import get_db - -router = APIRouter(tags=["Achievements"]) - - -def to_dict(obj): - if not obj: - return None - if isinstance(obj, list): - return [to_dict(x) for x in obj] - if isinstance(obj, dict): - obj["id"] = obj.get("id", str(obj.get("_id"))) - obj.pop("_id", None) - for k, v in obj.items(): - if isinstance(v, (dict, list)): - obj[k] = to_dict(v) - return obj - - -@router.get("/students/{student_id}/achievements") -def student_achievements( - student_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope -): - query = {"user_id": student_id} - if college_scope: - query["college_id"] = college_scope - achievements = db["achievements"].find(query).sort("achieved_at", -1) - return [to_dict(doc) for doc in achievements] - - -@router.post("/students/{student_id}/achievements/evaluate") -def evaluate_achievements( - student_id: int, db=Depends(get_db), current_user=Depends(get_current_user) -): - assert_can_act_on_student(current_user, student_id, db) - student = db["users"].find_one({"id": student_id}) - if not student: - return [] - college_id = student.get("college_id") or 1 - - test_count = db["assessment_attempts"].count_documents( - {"student_id": student_id, "status": "completed"} - ) - interview_count = db["interview_attempts"].count_documents({"student_id": student_id}) - placement_count = db["placements"].count_documents({"student_id": student_id}) - - rules = [ - ( - "practice-5", - test_count >= 5, - "Practice Starter", - "Completed 5 tests", - "test", - test_count, - ), - ( - "interview-1", - interview_count >= 1, - "Interview Ready", - "Completed a mock interview", - "interview", - interview_count, - ), - ( - "placed", - placement_count >= 1, - "Placed Talent", - "Submitted a placement record", - "placement", - placement_count, - ), - ] - - created = [] - for ach_type, condition, title, desc, module, value in rules: - exists = db["achievements"].find_one({"user_id": student_id, "achievement_type": ach_type}) - if condition and not exists: - achievement = { - "id": db["achievements"].count_documents({}) + 1, - "user_id": student_id, - "college_id": college_id, - "achievement_type": ach_type, - "title": title, - "description": desc, - "source_module": module, - "metric_value": value, - "achieved_at": datetime.now(timezone.utc).isoformat(), - } - db["achievements"].insert_one(achievement) - created.append(achievement) - - all_ach = db["achievements"].find({"user_id": student_id}).sort("achieved_at", -1) - return [to_dict(doc) for doc in all_ach] - - -@router.get("/leaderboard") -def leaderboard( - scope: str = "national", db=Depends(get_db), current_user=Depends(get_current_user) -): - query = {"role": "student", "status": "approved"} - - if scope == "college": - query["college_id"] = current_user.get("college_id") - - users = list(db["users"].find(query).limit(100)) - rows = [] - for user in users: - profile = db["student_profiles"].find_one({"user_id": user["id"]}) - - tests = profile.get("tests_completed", 0) if profile else 0 - acc = profile.get("avg_accuracy", 0) if profile else 0 - score = int(tests * acc * 10) - - trend = "same" - if tests > 0: - trend = "up" if score % 3 == 0 else "down" if score % 2 == 0 else "same" - - college = db["colleges"].find_one({"id": user.get("college_id")}) - college_name = college["name"] if college else "Independent" - - rows.append( - { - "id": user["id"], - "name": user.get("name", "Student"), - "college": college_name, - "score": score, - "accuracy": acc, - "avatar": user.get("name", "U")[0].upper() if user.get("name") else "U", - "trend": trend, - "rank": 0, - } - ) - - active_rows = [r for r in rows if r["score"] > 0] - if not active_rows and rows: - active_rows = rows - - active_rows.sort(key=lambda item: item["score"], reverse=True) - - for index, row in enumerate(active_rows, start=1): - row["rank"] = index - - return {"success": True, "data": active_rows} diff --git a/python-service/app/api/v1/ai.py b/python-service/app/api/v1/ai.py deleted file mode 100644 index b38c988..0000000 --- a/python-service/app/api/v1/ai.py +++ /dev/null @@ -1,66 +0,0 @@ -import os -from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel -from groq import Groq - -from app.core.rbac import get_current_user -from app.repositories.base import DotDict - -router = APIRouter(prefix="/ai", tags=["AI"]) - - -class ResumeData(BaseModel): - objective: str - education: str - skills: str - experience: str - - -@router.post("/resume/improve") -def improve_resume(data: ResumeData, current_user: DotDict = Depends(get_current_user)): - """ - Improves resume content using Groq LLM to make it more professional and ATS-friendly. - """ - groq_api_key = os.getenv("GROQ_API_KEY") - if not groq_api_key: - # Fallback if no key is provided just return the same text - return { - "objective": data.objective, - "education": data.education, - "skills": data.skills, - "experience": data.experience, - "message": "GROQ_API_KEY not configured. Returning original text.", - } - - try: - client = Groq(api_key=groq_api_key) - - prompt = f""" - You are an expert Resume Writer and Career Coach. - Improve the following resume sections to make them sound professional, impactful, and ATS-friendly. - Do not add new facts, just rewrite the existing information better. - Return the result as a raw JSON object with keys: "objective", "education", "skills", "experience". - Do not include markdown blocks or any other text outside the JSON. - - Objective: {data.objective} - Education: {data.education} - Skills: {data.skills} - Experience: {data.experience} - """ - - completion = client.chat.completions.create( - model="llama-3.3-70b-versatile", - messages=[{"role": "user", "content": prompt}], - temperature=0.7, - max_tokens=1024, - response_format={"type": "json_object"}, - ) - - import json - - result_text = completion.choices[0].message.content - improved_data = json.loads(result_text) - - return improved_data - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to improve resume: {str(e)}") diff --git a/python-service/app/api/v1/assessments.py b/python-service/app/api/v1/assessments.py deleted file mode 100644 index 34c941e..0000000 --- a/python-service/app/api/v1/assessments.py +++ /dev/null @@ -1,300 +0,0 @@ -from datetime import datetime, timedelta, timezone -import json -from groq import Groq - -from fastapi import APIRouter, Depends, HTTPException - -from app.core.exceptions import NotFoundError -from app.core.rbac import UserRole, get_college_scope, get_current_user, require_roles -from app.database import get_db -from app.config import get_settings -from app.schemas.assessment import ( - AssessmentCreateRequest, - AssessmentResponse, - AssessmentUpdateRequest, - AttemptResponse, - TestSubmitRequest, -) - -router = APIRouter(prefix="/assessments", tags=["Assessments"]) - - -def to_dict(obj): - # Convert MongoDB _id to string or map to id if necessary - if obj is None: - return None - obj["id"] = obj.get("id", str(obj.get("_id", ""))) - return obj - - -@router.get("", response_model=list[AssessmentResponse]) -def list_assessments(db=Depends(get_db), college_scope: int | None = get_college_scope): - query = {} - if college_scope: - query["college_id"] = int(college_scope) - cursor = db["assessments"].find(query).sort("created_at", -1) - return [to_dict(doc) for doc in cursor] - - -@router.post( - "", - response_model=AssessmentResponse, - dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], -) -def create_assessment( - data: AssessmentCreateRequest, current_user=Depends(get_current_user), db=Depends(get_db) -): - doc = data.model_dump() - doc["id"] = db["assessments"].count_documents({}) + 1 - doc["college_id"] = current_user.college_id or 1 - doc["created_by"] = current_user.id - doc["shuffle_questions"] = True - doc["shuffle_options"] = False - doc["show_result_immediately"] = True - doc["max_attempts"] = 1 - doc["created_at"] = datetime.now(timezone.utc).isoformat() - doc["updated_at"] = datetime.now(timezone.utc).isoformat() - - db["assessments"].insert_one(doc) - return to_dict(doc) - - -@router.post( - "/generate-questions", - dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], -) -def generate_assessment_questions(data: dict): - title = data.get("title", "Assessment") - type = data.get("type", "general") - difficulty = data.get("difficulty", "medium") - - groq_api_key = get_settings().GROQ_API_KEY - if not groq_api_key: - return { - "questions": [ - { - "question": f"Sample {difficulty} {type} question for {title}", - "options": ["A", "B", "C", "D"], - "correct_answer": "A", - } - ] - } - - try: - client = Groq(api_key=groq_api_key) - prompt = f""" - You are an expert curriculum designer. - Generate exactly 10 multiple-choice questions for an assessment titled "{title}". - Type: {type} - Difficulty: {difficulty} - - Return the result as a raw JSON object with a single key "questions" containing a list of objects. - Each object should have: - "question": string - "options": list of 4 strings - "correct_answer": string (must match one of the options) - - Do not include markdown blocks or any other text outside the JSON. - """ - - completion = client.chat.completions.create( - model="llama-3.3-70b-versatile", - messages=[{"role": "user", "content": prompt}], - temperature=0.7, - max_tokens=2048, - response_format={"type": "json_object"}, - ) - - result_text = completion.choices[0].message.content.strip() - if result_text.startswith("```json"): - result_text = result_text[7:] - if result_text.startswith("```"): - result_text = result_text[3:] - if result_text.endswith("```"): - result_text = result_text[:-3] - result_text = result_text.strip() - data = json.loads(result_text) - return {"questions": data.get("questions", [])} - except Exception as e: - raise HTTPException(status_code=500, detail=f"Failed to generate questions: {str(e)}") - - -@router.get("/overview/stats") -def overview_stats(db=Depends(get_db), college_scope: int | None = get_college_scope): - query = {} - if college_scope: - query["college_id"] = int(college_scope) - - total = db["assessments"].count_documents(query) - attempts = list(db["assessment_attempts"].find(query)) - - avg_score = ( - round(sum(a.get("percentage", 0) for a in attempts) / len(attempts), 2) if attempts else 0 - ) - return { - "success": True, - "data": {"total": total, "attempts": len(attempts), "avg_score": avg_score}, - } - - -def get_assessment_internal(assessment_id: int, db, college_scope: int | None): - query = {"id": assessment_id} - if college_scope: - query["college_id"] = int(college_scope) - assessment = db["assessments"].find_one(query) - if not assessment: - raise NotFoundError("Assessment", str(assessment_id)) - return assessment - - -@router.get("/{assessment_id}", response_model=AssessmentResponse) -def get_assessment( - assessment_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope -): - assessment = get_assessment_internal(assessment_id, db, college_scope) - return to_dict(assessment) - - -@router.put( - "/{assessment_id}", - response_model=AssessmentResponse, - dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], -) -def update_assessment( - assessment_id: int, - data: AssessmentUpdateRequest, - db=Depends(get_db), - college_scope: int | None = get_college_scope, -): - get_assessment_internal( - assessment_id, db, college_scope - ) # existence/scope check; raises if not authorized - - update_data = data.model_dump(exclude_unset=True) - update_data["updated_at"] = datetime.now(timezone.utc).isoformat() - - db["assessments"].update_one({"id": assessment_id}, {"$set": update_data}) - - updated = get_assessment_internal(assessment_id, db, college_scope) - return to_dict(updated) - - -@router.delete("/{assessment_id}") -def delete_assessment( - assessment_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope -): - get_assessment_internal( - assessment_id, db, college_scope - ) # existence/scope check; raises if not authorized - db["assessments"].delete_one({"id": assessment_id}) - return {"success": True, "message": "Assessment deleted"} - - -@router.get("/{assessment_id}/results", response_model=list[AttemptResponse]) -def assessment_results( - assessment_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope -): - query = {"assessment_id": assessment_id} - if college_scope: - query["college_id"] = int(college_scope) - - attempts = db["assessment_attempts"].find(query).sort("created_at", -1) - return [to_dict(doc) for doc in attempts] - - -@router.post("/submit", response_model=AttemptResponse) -def submit_test( - data: TestSubmitRequest, current_user=Depends(get_current_user), db=Depends(get_db) -): - assessment = None - if data.assessment_id: - assessment = db["assessments"].find_one({"id": data.assessment_id}) - - if not assessment: - assessment = { - "id": db["assessments"].count_documents({}) + 1, - "title": "Practice Test", - "assessment_type": "mixed", - "college_id": current_user.college_id or 1, - "created_by": current_user.id, - "duration_minutes": 30, - "total_marks": data.max_score, - "pass_percentage": 40, - "negative_marking": False, - "status": "active", - "difficulty": "medium", - "created_at": datetime.now(timezone.utc).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat(), - } - db["assessments"].insert_one(assessment) - - pct = ( - data.percentage - if data.percentage is not None - else round((data.score / data.max_score) * 100, 2) - ) - attempt_number = ( - db["assessment_attempts"].count_documents( - {"assessment_id": assessment["id"], "student_id": current_user.id} - ) - + 1 - ) - - attempt = { - "id": db["assessment_attempts"].count_documents({}) + 1, - "assessment_id": assessment["id"], - "student_id": current_user.id, - "college_id": current_user.college_id or assessment["college_id"], - "attempt_number": attempt_number, - "score": data.score, - "max_score": data.max_score, - "percentage": pct, - "time_taken_seconds": data.time_taken_seconds, - "passed": pct >= assessment.get("pass_percentage", 40), - "status": "completed", - "completed_at": datetime.now(timezone.utc).isoformat(), - "created_at": datetime.now(timezone.utc).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat(), - "section_scores": data.section_scores, - "weak_areas": data.weak_areas, - } - db["assessment_attempts"].insert_one(attempt) - - # Update profile stats - profile = db["student_profiles"].find_one({"user_id": current_user.id}) - if profile: - tests_completed = profile.get("tests_completed", 0) + 1 - avg_acc = profile.get("avg_accuracy", 0) - new_avg = round(((avg_acc * (tests_completed - 1)) + pct) / tests_completed, 2) - - today_date = datetime.now(timezone.utc).date() - last_test_str = profile.get("last_test_date") - streak = profile.get("streak", 0) - - if last_test_str: - try: - last_test_date = datetime.fromisoformat(last_test_str).date() - if last_test_date == today_date: - pass - elif last_test_date == today_date - timedelta(days=1): - streak += 1 - else: - streak = 1 - except (ValueError, TypeError): - streak = 1 - else: - streak = 1 - - db["student_profiles"].update_one( - {"user_id": current_user.id}, - { - "$set": { - "tests_completed": tests_completed, - "avg_accuracy": new_avg, - "streak": streak, - "last_test_date": today_date.isoformat(), - } - }, - ) - - return to_dict(attempt) diff --git a/python-service/app/api/v1/auth.py b/python-service/app/api/v1/auth.py deleted file mode 100644 index 48305dc..0000000 --- a/python-service/app/api/v1/auth.py +++ /dev/null @@ -1,271 +0,0 @@ -""" -UpScaler-AI V2 — Auth Router -""" - -from urllib.parse import urlencode, parse_qs - -import httpx -from fastapi import APIRouter, Depends, Response, status, HTTPException -from fastapi.responses import RedirectResponse -from app.config import get_settings -from app.schemas.auth import ( - RegisterRequest, - LoginRequest, - RefreshTokenRequest, - UserBriefResponse, - ChangePasswordRequest, -) -from app.schemas.common import MessageResponse -from app.services.auth_service import AuthService -from app.dependencies import get_auth_service -from app.core.rbac import get_current_user -from app.core.exceptions import AccountPendingError -from app.repositories.base import DotDict - -router = APIRouter(prefix="/auth", tags=["Authentication"]) -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" -GOOGLE_ALLOWED_ROLES = {"hr", "student", "college_admin", "faculty"} - - -def _legacy_user(user: UserBriefResponse): - data = user.model_dump() - data["_id"] = str(user.id) - data["collegeId"] = user.college_id - return data - - -def _token_payload(token_response): - user = _legacy_user(token_response.user) - return { - "success": True, - "access_token": token_response.access_token, - "refresh_token": token_response.refresh_token, - "token_type": token_response.token_type, - "expires_in": token_response.expires_in, - "user": token_response.user, - "token": token_response.access_token, - "data": {"user": user, "token": token_response.access_token}, - } - - -def _set_token_cookie(response: Response, token: str, expires_in: int): - response.set_cookie("token", token, httponly=True, samesite="lax", max_age=expires_in, path="/") - - -@router.post("/register", status_code=status.HTTP_201_CREATED) -def register(data: dict, response: Response, auth_service: AuthService = Depends(get_auth_service)): - """Register a new user account.""" - role = data.get("role") or "student" - if role == "hr": - role = "recruiter" - try: - request = RegisterRequest( - email=data.get("email"), - password=data.get("password"), - name=data.get("name"), - role=role, - college_id=data.get("college_id") or data.get("collegeId"), - department=data.get("department"), - student_id=data.get("student_id") or data.get("studentId"), - year=data.get("year"), - company_name=data.get("company_name") or data.get("company"), - ) - except ValueError as e: - # Pydantic ValidationError is a subclass of ValueError in v2, - # but let's import it safely if needed. Or just catch Exception - # We'll use a direct approach since we just want the message. - from pydantic import ValidationError - - if isinstance(e, ValidationError): - raise HTTPException( - status_code=400, detail=e.errors()[0]["msg"].replace("Value error, ", "") - ) - raise HTTPException(status_code=400, detail=str(e)) - user = auth_service.register(request) - - if user.status == "pending": - return { - "success": True, - "message": "Registration successful. Please wait for approval.", - "data": {"user": {"email": user.email, "status": "pending"}}, - } - - token_response = auth_service.login( - LoginRequest(email=request.email, password=request.password) - ) - _set_token_cookie(response, token_response.access_token, token_response.expires_in) - return _token_payload(token_response) - - -@router.post("/login") -def login(data: dict, response: Response, auth_service: AuthService = Depends(get_auth_service)): - """Login and receive access & refresh tokens.""" - if data.get("studentId") and not data.get("email"): - token_response = auth_service.login_with_student_id( - data["studentId"], - data.get("password", ""), - data.get("collegeId") or data.get("college_id"), - ) - else: - token_response = auth_service.login( - LoginRequest(email=data.get("email"), password=data.get("password", "")) - ) - _set_token_cookie(response, token_response.access_token, token_response.expires_in) - return _token_payload(token_response) - - -@router.post("/refresh") -def refresh_token( - data: RefreshTokenRequest, - response: Response, - auth_service: AuthService = Depends(get_auth_service), -): - """Refresh an access token using a valid refresh token (token rotation enabled).""" - token_response = auth_service.refresh_token(data.refresh_token) - _set_token_cookie(response, token_response.access_token, token_response.expires_in) - return _token_payload(token_response) - - -@router.post("/logout", response_model=MessageResponse) -def logout( - current_user: DotDict = Depends(get_current_user), - auth_service: AuthService = Depends(get_auth_service), -): - """Logout the user by revoking all their refresh tokens.""" - auth_service.logout(current_user.id) - return MessageResponse(message="Successfully logged out") - - -@router.get("/me", response_model=UserBriefResponse) -def get_me(current_user: DotDict = Depends(get_current_user)): - """Get the current authenticated user's profile info.""" - return UserBriefResponse.model_validate(current_user) - - -from app.database import get_db -from app.schemas.auth import UpdateProfileRequest - - -@router.put("/update", response_model=UserBriefResponse) -def update_profile( - data: UpdateProfileRequest, - current_user: DotDict = Depends(get_current_user), - db=Depends(get_db), -): - """Update user profile.""" - update_data = {} - if data.name is not None: - update_data["name"] = data.name - current_user.name = data.name - if data.department is not None: - update_data["department"] = data.department - current_user.department = data.department - if data.avatar_url is not None: - update_data["avatar_url"] = data.avatar_url - current_user.avatar_url = data.avatar_url - - if data.company_name is not None and current_user.role == "recruiter": - pass # Handle recruiter profile logic if needed - - if update_data: - db["users"].update_one({"id": current_user.id}, {"$set": update_data}) - - return UserBriefResponse.model_validate(current_user) - - -@router.put("/change-password", response_model=MessageResponse) -def change_password( - data: ChangePasswordRequest, - current_user: DotDict = Depends(get_current_user), - auth_service: AuthService = Depends(get_auth_service), -): - """Change user password.""" - auth_service.change_password(current_user.id, data) - return MessageResponse(message="Password changed successfully") - - -@router.get("/google") -def google_login_redirect(role: str, redirect: str = "/onboarding"): - """Starts the Google OAuth Authorization Code flow for a given portal role. - - The frontend sends the browser here directly (window.location.href); we - redirect it on to Google, then Google redirects to /auth/google/callback. - """ - if role not in GOOGLE_ALLOWED_ROLES: - raise HTTPException(status_code=400, detail=f"Invalid role: {role}") - if not settings.GOOGLE_CLIENT_ID: - raise HTTPException( - status_code=503, detail="Google sign-in is not configured on this server" - ) - - params = { - "client_id": settings.GOOGLE_CLIENT_ID, - "redirect_uri": settings.GOOGLE_OAUTH_REDIRECT_URI, - "response_type": "code", - "scope": "openid email profile", - "access_type": "online", - "prompt": "select_account", - "state": urlencode({"role": role, "redirect": redirect}), - } - return RedirectResponse(f"{GOOGLE_AUTH_URL}?{urlencode(params)}") - - -@router.get("/google/callback") -async def google_login_callback( - code: str, - state: str = "", - auth_service: AuthService = Depends(get_auth_service), -): - """Google's redirect target: exchanges the code, finds/creates the user, - then hands off to the frontend's /oauth/callback page with tokens (or a - pending-approval flag) in the query string — never as an httpOnly cookie, - since the SPA reads the token into localStorage the same way /auth/login does. - """ - parsed_state = {k: v[0] for k, v in parse_qs(state).items()} - role = parsed_state.get("role", "student") - redirect_path = parsed_state.get("redirect", "/onboarding") - - async with httpx.AsyncClient(timeout=10) as client: - token_res = await client.post( - GOOGLE_TOKEN_URL, - data={ - "code": code, - "client_id": settings.GOOGLE_CLIENT_ID, - "client_secret": settings.GOOGLE_CLIENT_SECRET, - "redirect_uri": settings.GOOGLE_OAUTH_REDIRECT_URI, - "grant_type": "authorization_code", - }, - ) - if token_res.status_code >= 400: - raise HTTPException(status_code=502, detail="Google token exchange failed") - google_tokens = token_res.json() - - userinfo_res = await client.get( - GOOGLE_USERINFO_URL, - headers={"Authorization": f"Bearer {google_tokens['access_token']}"}, - ) - if userinfo_res.status_code >= 400: - raise HTTPException(status_code=502, detail="Failed to fetch Google profile") - profile = userinfo_res.json() - - try: - token_response = auth_service.google_login( - email=profile["email"], - name=profile.get("name") or profile["email"], - role=role, - ) - except AccountPendingError: - return RedirectResponse(f"{settings.FRONTEND_URL}{redirect_path}?pending=1") - - query = urlencode( - { - "token": token_response.access_token, - "refresh": token_response.refresh_token, - "redirect": redirect_path, - } - ) - return RedirectResponse(f"{settings.FRONTEND_URL}/oauth/callback?{query}") diff --git a/python-service/app/api/v1/batches.py b/python-service/app/api/v1/batches.py deleted file mode 100644 index c65f53c..0000000 --- a/python-service/app/api/v1/batches.py +++ /dev/null @@ -1,150 +0,0 @@ -from datetime import datetime, timezone -from uuid import uuid4 - -from fastapi import APIRouter, Depends - -from app.core.exceptions import NotFoundError -from app.core.rbac import UserRole, get_college_scope, get_current_user, require_roles -from app.core.security import hash_password -from app.database import get_db -from app.schemas.batch import BatchCreateRequest, BatchResponse, BatchStatusRequest -from app.schemas.common import MessageResponse - -router = APIRouter(prefix="/batches", tags=["Batches"]) - - -def _next_id(db, collection: str) -> int: - last = db[collection].find_one(sort=[("id", -1)], projection={"id": 1}) - return (last["id"] + 1) if last and isinstance(last.get("id"), int) else 1 - - -def _clean(doc): - if doc: - doc.pop("_id", None) - return doc - - -def _now(): - return datetime.now(timezone.utc).isoformat() - - -@router.post( - "", - response_model=BatchResponse, - dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], -) -def create_batch( - data: BatchCreateRequest, current_user=Depends(get_current_user), db=Depends(get_db) -): - batch = { - "id": _next_id(db, "batches"), - "name": data.name, - "batch_code": data.batch_code or f"BAT-{uuid4().hex[:8].upper()}", - "college_id": current_user.college_id or 1, - "faculty_id": current_user.id, - "department": data.department, - "year": data.year, - "status": "active", - "created_at": _now(), - "updated_at": _now(), - } - db["batches"].insert_one(batch) - - for student_data in data.students: - email = ( - student_data.email - or f"{student_data.roll or student_data.student_id}@upscaler-ai.local" - ).lower() - student = db["users"].find_one({"email": email}) - if not student: - user_id = _next_id(db, "users") - student = { - "id": user_id, - "email": email, - "password_hash": hash_password(student_data.password), - "name": student_data.name, - "role": "student", - "college_id": batch["college_id"], - "department": student_data.department or batch["department"], - "status": "approved", - "is_active": True, - "created_at": _now(), - "updated_at": _now(), - } - db["users"].insert_one(student) - db["student_profiles"].insert_one( - { - "id": _next_id(db, "student_profiles"), - "user_id": user_id, - "student_id": (student_data.roll or student_data.student_id or "").upper(), - "year": student_data.year, - "tests_completed": 0, - "avg_accuracy": 0.0, - } - ) - db["batch_students"].insert_one( - { - "id": _next_id(db, "batch_students"), - "batch_id": batch["id"], - "student_id": student["id"], - } - ) - - return _clean(batch) - - -@router.get("/history", response_model=list[BatchResponse]) -def batch_history(db=Depends(get_db), college_scope: int | None = get_college_scope): - query = {} - if college_scope: - query["college_id"] = int(college_scope) - cursor = db["batches"].find(query).sort("created_at", -1) - return [_clean(doc) for doc in cursor] - - -@router.get("/students") -def batch_students(db=Depends(get_db), college_scope: int | None = get_college_scope): - query = {"role": "student"} - if college_scope: - query["college_id"] = int(college_scope) - students = db["users"].find(query).sort("name", 1) - return { - "success": True, - "data": [ - { - "id": s["id"], - "name": s.get("name"), - "email": s.get("email"), - "department": s.get("department"), - } - for s in students - ], - } - - -@router.get("/pending", response_model=list[BatchResponse]) -def pending_batches(db=Depends(get_db), college_scope: int | None = get_college_scope): - query = {"status": "active"} - if college_scope: - query["college_id"] = int(college_scope) - cursor = db["batches"].find(query).sort("created_at", -1) - return [_clean(doc) for doc in cursor] - - -@router.put("/{batch_id}/status", response_model=MessageResponse) -def update_batch_status( - batch_id: int, - data: BatchStatusRequest, - db=Depends(get_db), - college_scope: int | None = get_college_scope, -): - query = {"id": batch_id} - if college_scope: - query["college_id"] = int(college_scope) - batch = db["batches"].find_one(query) - if not batch: - raise NotFoundError("Batch", str(batch_id)) - db["batches"].update_one( - {"id": batch_id}, {"$set": {"status": data.status, "updated_at": _now()}} - ) - return MessageResponse(message="Batch status updated") diff --git a/python-service/app/api/v1/chat.py b/python-service/app/api/v1/chat.py deleted file mode 100644 index ab89326..0000000 --- a/python-service/app/api/v1/chat.py +++ /dev/null @@ -1,121 +0,0 @@ -import json -from datetime import datetime, timezone - -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query, HTTPException -from pymongo.database import Database - -from app.core.websocket_manager import manager -from app.mongodb import get_mongo_db -from app.database import get_db -from app.core.security import verify_access_token -from app.core.rbac import get_current_user -from app.repositories.base import DotDict - -router = APIRouter(prefix="/chat", tags=["Chat"]) - - -# Returns a local DummyUser (below), not a full user record — callers only use -# .id/.name/.role. -async def get_current_user_ws(token: str, db: Database): - try: - payload = verify_access_token(token) - user_id = int(payload.get("sub")) - user = db["users"].find_one({"id": user_id}) - if not user: - raise ValueError("User not found") - - # create a dummy object so user.id works - class DummyUser: - def __init__(self, d): - self.id = d["id"] - self.name = d.get("name", "") - self.role = d.get("role", "") - - return DummyUser(user) - except Exception: - raise ValueError("Invalid token") - - -@router.websocket("/ws") -async def websocket_endpoint( - websocket: WebSocket, token: str = Query(...), db: Database = Depends(get_db) -): - try: - user = await get_current_user_ws(token, db) - except ValueError: - await websocket.close(code=1008) - return - - await manager.connect(websocket, user.id) - mongo_db = get_mongo_db() - - try: - while True: - data = await websocket.receive_text() - try: - message_data = json.loads(data) - except json.JSONDecodeError: - continue - - receiver_id = message_data.get("receiver_id") - content = message_data.get("content") - - if not receiver_id or not content: - continue - - # Construct message document for MongoDB - msg_doc = { - "sender_id": user.id, - "sender_name": user.name, - "sender_role": user.role, - "receiver_id": int(receiver_id), - "content": content, - "timestamp": datetime.now(timezone.utc).isoformat(), - "read": False, - } - - # Save to MongoDB - if mongo_db is not None: - await mongo_db["messages"].insert_one(msg_doc) - msg_doc.pop("_id", None) - - # Send to sender for confirmation - await manager.send_personal_message(msg_doc, user.id) - # Deliver to receiver in real-time - await manager.send_personal_message(msg_doc, int(receiver_id)) - - except WebSocketDisconnect: - manager.disconnect(websocket, user.id) - - -@router.get("/history/{other_user_id}") -async def get_chat_history( - other_user_id: int, limit: int = 50, current_user: DotDict = Depends(get_current_user) -): - """Fetch chat history between the current user and another user.""" - mongo_db = get_mongo_db() - if mongo_db is None: - raise HTTPException(status_code=500, detail="MongoDB not connected") - - cursor = ( - mongo_db["messages"] - .find( - { - "$or": [ - {"sender_id": current_user.id, "receiver_id": other_user_id}, - {"sender_id": other_user_id, "receiver_id": current_user.id}, - ] - } - ) - .sort("timestamp", -1) - .limit(limit) - ) - - messages = await cursor.to_list(length=limit) - # MongoDB returns newest first due to sort(-1), we want chronological order for UI - messages.reverse() - - for msg in messages: - msg["_id"] = str(msg["_id"]) - - return {"messages": messages} diff --git a/python-service/app/api/v1/colleges.py b/python-service/app/api/v1/colleges.py deleted file mode 100644 index df44d87..0000000 --- a/python-service/app/api/v1/colleges.py +++ /dev/null @@ -1,59 +0,0 @@ -from fastapi import APIRouter, Depends -from datetime import datetime, timezone - -from app.core.rbac import UserRole, get_current_user, require_roles -from app.database import get_db -from app.schemas.college import CollegeCreateRequest, CollegeResponse - -router = APIRouter(prefix="/colleges", tags=["Colleges"]) - - -def to_dict(obj): - if not obj: - return None - obj["id"] = obj.get("id", str(obj.get("_id"))) - obj.pop("_id", None) - return obj - - -@router.get("", response_model=list[CollegeResponse]) -def list_colleges(db=Depends(get_db)): - colleges = db["colleges"].find({"is_active": True}).sort("name", 1) - return [to_dict(c) for c in colleges] - - -@router.get("/me", response_model=CollegeResponse) -def current_college(current_user=Depends(get_current_user), db=Depends(get_db)): - college = db["colleges"].find_one({"id": current_user.college_id}) - return to_dict(college) - - -@router.post("", response_model=CollegeResponse, dependencies=[require_roles(UserRole.SUPER_ADMIN)]) -def create_college(data: CollegeCreateRequest, db=Depends(get_db)): - college_id = db["colleges"].count_documents({}) + 1 - college = { - "id": college_id, - "name": data.name, - "short_code": data.short_code.upper(), - "location": data.location or "", - "address": data.address or "", - "contact_email": data.contact_email, - "contact_phone": data.contact_phone, - "website": data.website, - "is_active": True, - "created_at": datetime.now(timezone.utc).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat(), - } - db["colleges"].insert_one(college) - - for dept in data.departments: - db["departments"].insert_one( - { - "id": db["departments"].count_documents({}) + 1, - "college_id": college_id, - "name": dept.name if hasattr(dept, "name") else dept["name"], - "code": dept.code if hasattr(dept, "code") else dept["code"], - } - ) - - return to_dict(college) diff --git a/python-service/app/api/v1/dashboard.py b/python-service/app/api/v1/dashboard.py deleted file mode 100644 index 9100d53..0000000 --- a/python-service/app/api/v1/dashboard.py +++ /dev/null @@ -1,156 +0,0 @@ -from fastapi import APIRouter, Depends -from app.core.rbac import ( - UserRole, - get_college_scope, - require_roles, - get_current_user, - assert_can_act_on_student, -) -from app.database import get_db - -router = APIRouter(prefix="/dashboard", tags=["Dashboard"]) - -# How many recent attempts the UI actually renders in the "recent tests" strip. -RECENT_LIMIT = 5 -# Upper bound on the history array returned to the client. The dashboard charts -# a trend line; it does not need an unbounded lifetime export. -HISTORY_LIMIT = 100 - - -def to_dict(obj): - if not obj: - return None - obj["id"] = obj.get("id", str(obj.get("_id"))) - obj.pop("_id", None) - return obj - - -@router.get("/student/{student_id}") -def student_dashboard(student_id: int, db=Depends(get_db), current_user=Depends(get_current_user)): - """Per-student summary. - - Totals are computed by the database in a single aggregation rather than - streaming every attempt to the app and summing in Python — the previous - approach transferred the student's entire attempt history on every load. - """ - assert_can_act_on_student(current_user, student_id, db) - attempt_filter = {"student_id": student_id, "status": "completed"} - - summary = list( - db["assessment_attempts"].aggregate( - [ - {"$match": attempt_filter}, - { - "$group": { - "_id": None, - "tests_completed": {"$sum": 1}, - "avg_accuracy": {"$avg": {"$ifNull": ["$percentage", 0]}}, - } - }, - ] - ) - ) - - tests_completed = summary[0]["tests_completed"] if summary else 0 - avg_accuracy = round(summary[0]["avg_accuracy"] or 0, 2) if summary else 0 - - # Newest first off the index, capped. `history` is reversed back into - # chronological order so existing chart code keeps working unchanged. - history_desc = list( - db["assessment_attempts"].find(attempt_filter).sort("created_at", -1).limit(HISTORY_LIMIT) - ) - history = [to_dict(a) for a in reversed(history_desc)] - - interviews = db["interview_attempts"].count_documents({"student_id": student_id}) - placements = db["placements"].count_documents({"student_id": student_id}) - - return { - "success": True, - "data": { - "tests_completed": tests_completed, - "avg_accuracy": avg_accuracy, - "interviews_completed": interviews, - "placements": placements, - "recent_tests": history[-RECENT_LIMIT:], - "history": history, - }, - } - - -@router.get( - "/admin", - dependencies=[require_roles(UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], -) -def admin_dashboard(db=Depends(get_db), college_scope: int | None = get_college_scope): - """Institution-wide counters. - - Previously this pulled every attempt row for the college into memory purely - to derive a count and a mean. Both now come back from one aggregation. - """ - user_query = {"role": "student"} - base_query = {} - - if college_scope: - user_query["college_id"] = int(college_scope) - base_query["college_id"] = int(college_scope) - - attempt_summary = list( - db["assessment_attempts"].aggregate( - [ - {"$match": base_query}, - { - "$group": { - "_id": None, - "attempts": {"$sum": 1}, - "avg_score": {"$avg": {"$ifNull": ["$percentage", 0]}}, - } - }, - ] - ) - ) - - attempts = attempt_summary[0]["attempts"] if attempt_summary else 0 - avg = (attempt_summary[0]["avg_score"] or 0) if attempt_summary else 0 - - students = db["users"].count_documents(user_query) - assessments = db["assessments"].count_documents(base_query) - placements = db["placements"].count_documents(base_query) - - return { - "success": True, - "data": { - "students": students, - "assessments": assessments, - "attempts": attempts, - "placements": placements, - "avg_score": round(float(avg), 2), - }, - } - - -@router.get("/super", dependencies=[require_roles(UserRole.SUPER_ADMIN)]) -def super_dashboard(db=Depends(get_db)): - """Platform-wide role counts. - - One grouped pass over `users` replaces four separate count queries, each of - which was its own round trip to the cluster. - """ - rows = list( - db["users"].aggregate( - [ - {"$match": {"role": {"$in": ["student", "faculty", "college_admin", "recruiter"]}}}, - {"$group": {"_id": "$role", "count": {"$sum": 1}}}, - ] - ) - ) - by_role = {row["_id"]: row["count"] for row in rows} - - return { - "success": True, - "data": { - "students": by_role.get("student", 0), - "faculty": by_role.get("faculty", 0), - "admins": by_role.get("college_admin", 0), - "recruiters": by_role.get("recruiter", 0), - }, - } diff --git a/python-service/app/api/v1/interviews.py b/python-service/app/api/v1/interviews.py deleted file mode 100644 index 3098fcf..0000000 --- a/python-service/app/api/v1/interviews.py +++ /dev/null @@ -1,146 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from datetime import datetime, timezone -import json - -from app.core.rbac import get_college_scope, get_current_user -from app.database import get_db -from app.schemas.interview import InterviewSubmitRequest - -router = APIRouter(prefix="/interviews", tags=["Interviews"]) - - -def to_dict(obj): - if not obj: - return None - obj["id"] = obj.get("id", str(obj.get("_id"))) - obj.pop("_id", None) - return obj - - -@router.get("/student/{student_id}") -def list_interviews( - student_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope -): - query = {"student_id": student_id} - if college_scope: - query["college_id"] = college_scope - attempts = db["interview_attempts"].find(query).sort("created_at", -1) - return [to_dict(doc) for doc in attempts] - - -@router.post("/student/{student_id}") -def submit_interview( - student_id: int, - data: InterviewSubmitRequest, - current_user=Depends(get_current_user), - db=Depends(get_db), -): - attempt_number = db["interview_attempts"].count_documents({"student_id": student_id}) + 1 - attempt = { - "id": db["interview_attempts"].count_documents({}) + 1, - "student_id": student_id, - "college_id": current_user.get("college_id") or 1, - "role": data.role, - "category": data.category, - "overall_rating": data.overall_rating, - "strengths": data.strengths, - "improvements": data.improvements, - "duration_seconds": data.duration_seconds, - "attempt_number": attempt_number, - "status": "completed", - "created_at": datetime.now(timezone.utc).isoformat(), - } - db["interview_attempts"].insert_one(attempt) - - for response in data.responses: - r_dict = response.model_dump() - r_dict["attempt_id"] = attempt["id"] - r_dict["id"] = db["interview_responses"].count_documents({}) + 1 - db["interview_responses"].insert_one(r_dict) - - db["student_profiles"].update_one( - {"user_id": student_id}, {"$inc": {"interviews_completed": 1}} - ) - return to_dict(attempt) - - -@router.post("/generate", response_model=list[dict]) -def generate_questions( - role: str, company: str = "general", current_user=Depends(get_current_user), db=Depends(get_db) -): - """ - Generates 10 random questions for the given role and company using Groq LLM. - """ - from groq import Groq - from app.config import get_settings - - groq_api_key = get_settings().GROQ_API_KEY - if not groq_api_key: - import random - - question_pool = [ - "What are the key differences between React and Angular?", - "Explain the concept of closures in JavaScript.", - "How would you optimize a slow-performing database query?", - "Describe a time you had to resolve a conflict within your team.", - "What is the difference between TCP and UDP?", - ] - selected_questions = random.sample(question_pool * 2, 10) - return [ - { - "id": i + 1, - "text": f"[{company.upper()} - {role.upper()}] {q}", - "time_limit_seconds": 60, - "type": "technical", - } - for i, q in enumerate(selected_questions) - ] - - try: - client = Groq(api_key=groq_api_key) - - prompt = f""" - You are an expert technical interviewer at {company} hiring for a {role} position. - Generate exactly 10 interview questions for this specific role and company. - Make them realistic, challenging, and a mix of technical (7) and behavioral (3) questions. - Return the result as a raw JSON object with a single key "questions" containing a list of strings. - Do not include markdown blocks or any other text outside the JSON. - """ - - completion = client.chat.completions.create( - model="llama-3.3-70b-versatile", - messages=[{"role": "user", "content": prompt}], - temperature=0.7, - max_tokens=1024, - response_format={"type": "json_object"}, - ) - - result_text = completion.choices[0].message.content.strip() - if result_text.startswith("```json"): - result_text = result_text[7:] - if result_text.startswith("```"): - result_text = result_text[3:] - if result_text.endswith("```"): - result_text = result_text[:-3] - result_text = result_text.strip() - data = json.loads(result_text) - generated_questions = data.get("questions", []) - - if not generated_questions or len(generated_questions) < 10: - raise ValueError("LLM did not return enough questions") - - formatted_questions = [] - for i, q in enumerate(generated_questions[:10]): - formatted_questions.append( - { - "id": i + 1, - "text": q, - "time_limit_seconds": 60, - "type": "technical" if i < 7 else "behavioral", - } - ) - - return formatted_questions - except Exception as e: - print(f"Failed to generate questions with AI: {e}") - raise HTTPException(status_code=500, detail=f"Failed to generate questions: {str(e)}") diff --git a/python-service/app/api/v1/jobs.py b/python-service/app/api/v1/jobs.py deleted file mode 100644 index d0d6f9a..0000000 --- a/python-service/app/api/v1/jobs.py +++ /dev/null @@ -1,158 +0,0 @@ -from datetime import datetime, timezone - -from fastapi import APIRouter, Depends, HTTPException - -from app.core.rbac import get_college_scope, get_current_user -from app.database import get_db -from app.schemas.placement import ( - JobPostingCreateRequest, - JobPostingResponse, - JobApplicationResponse, -) - -router = APIRouter(prefix="/jobs", tags=["Jobs"]) - - -def _next_id(db, collection: str) -> int: - last = db[collection].find_one(sort=[("id", -1)], projection={"id": 1}) - return (last["id"] + 1) if last and isinstance(last.get("id"), int) else 1 - - -def _clean(doc): - if doc: - doc.pop("_id", None) - return doc - - -def _now(): - return datetime.now(timezone.utc).isoformat() - - -@router.get("", response_model=list[JobPostingResponse]) -def get_jobs(db=Depends(get_db)): - """Get all active job postings.""" - cursor = db["job_postings"].find({"is_active": True}).sort("created_at", -1) - return [_clean(doc) for doc in cursor] - - -@router.get("/me", response_model=list[JobPostingResponse]) -def get_my_jobs(db=Depends(get_db), current_user=Depends(get_current_user)): - """Get jobs posted by the current HR/Recruiter.""" - cursor = db["job_postings"].find({"recruiter_id": current_user.id}).sort("created_at", -1) - return [_clean(doc) for doc in cursor] - - -@router.post("", response_model=JobPostingResponse) -def create_job( - job_data: JobPostingCreateRequest, - db=Depends(get_db), - current_user=Depends(get_current_user), -): - """Post a new job vacancy — a recruiter's open role, or a college's own placement drive.""" - allowed_roles = ["hr", "recruiter", "college_admin", "super_admin"] - if current_user.role not in allowed_roles: - raise HTTPException(status_code=403, detail="Not authorized to post jobs") - - # A college admin's drive is always scoped to their own college, regardless - # of what the client sent — the same pattern users.py uses for created users. - college_id = job_data.college_id - if current_user.role == "college_admin": - college_id = current_user.college_id or 1 - - job = { - "id": _next_id(db, "job_postings"), - "recruiter_id": current_user.id, - "college_id": college_id, - "title": job_data.title, - "description": job_data.description, - "company_name": job_data.company_name or current_user.company_name, - "location": job_data.location, - "job_type": job_data.job_type, - "salary_min_lpa": job_data.salary_min_lpa, - "salary_max_lpa": job_data.salary_max_lpa, - "required_skills": job_data.required_skills, - "eligible_departments": job_data.eligible_departments, - "eligible_years": job_data.eligible_years, - "min_cgpa": job_data.min_cgpa, - "application_deadline": job_data.application_deadline, - "status": "active", - "is_active": True, - "created_at": _now(), - "updated_at": _now(), - } - db["job_postings"].insert_one(job) - return _clean(job) - - -@router.get("/drives") -def college_drives(db=Depends(get_db), college_scope: int | None = get_college_scope): - """Placement drives (job postings) for the caller's college, with applicant counts. - - Used by the college-admin placements dashboard — 'drive' is just the - college-facing name for a job posting scoped to that college. - """ - query = {} - if college_scope: - query["college_id"] = int(college_scope) - postings = list(db["job_postings"].find(query).sort("created_at", -1)) - if not postings: - return [] - - posting_ids = [p["id"] for p in postings] - counts: dict[int, int] = {} - for row in db["job_applications"].aggregate( - [ - {"$match": {"job_posting_id": {"$in": posting_ids}}}, - {"$group": {"_id": "$job_posting_id", "count": {"$sum": 1}}}, - ] - ): - counts[row["_id"]] = row["count"] - - return [ - { - "id": p["id"], - "title": p.get("title"), - "company_name": p.get("company_name"), - "location": p.get("location"), - "job_type": p.get("job_type"), - "status": p.get("status"), - "application_deadline": p.get("application_deadline"), - "created_at": p.get("created_at"), - "applicant_count": counts.get(p["id"], 0), - } - for p in postings - ] - - -@router.get("/applications/me", response_model=list[JobApplicationResponse]) -def get_my_job_applications(db=Depends(get_db), current_user=Depends(get_current_user)): - """Get all applications for jobs posted by the current HR/Recruiter.""" - if current_user.role not in ["hr", "recruiter"]: - raise HTTPException(status_code=403, detail="Only recruiters can view applications") - - jobs = list(db["job_postings"].find({"recruiter_id": current_user.id}, {"id": 1, "title": 1})) - if not jobs: - return [] - job_titles = {job["id"]: job.get("title") for job in jobs} - - applications = db["job_applications"].find({"job_posting_id": {"$in": list(job_titles)}}) - - result = [] - for app in applications: - student = db["users"].find_one({"id": app.get("student_id")}, {"name": 1, "email": 1}) - result.append( - { - "id": app.get("id"), - "job_posting_id": app.get("job_posting_id"), - "student_id": app.get("student_id"), - "status": app.get("status"), - "applied_at": app.get("applied_at"), - "updated_at": app.get("updated_at"), - "notes": app.get("notes"), - "interview_scheduled_at": app.get("interview_scheduled_at"), - "student_name": student.get("name") if student else None, - "student_email": student.get("email") if student else None, - "job_title": job_titles.get(app.get("job_posting_id")), - } - ) - return result diff --git a/python-service/app/api/v1/persistence.py b/python-service/app/api/v1/persistence.py deleted file mode 100644 index e1a0703..0000000 --- a/python-service/app/api/v1/persistence.py +++ /dev/null @@ -1,27 +0,0 @@ -from fastapi import APIRouter, Depends - -from app.core.rbac import get_current_user -from app.database import get_db -from app.schemas.persistence import UserDataRequest, UserDataResponse - -router = APIRouter(prefix="/userdata", tags=["User Data"]) - - -@router.get("", response_model=UserDataResponse) -def load_user_data(current_user=Depends(get_current_user), db=Depends(get_db)): - state = db["user_data_states"].find_one({"user_id": current_user.id}) - if not state: - return UserDataResponse(data={}) - return UserDataResponse(data=state.get("data") or {}) - - -@router.post("", response_model=UserDataResponse) -def save_user_data( - data: UserDataRequest, current_user=Depends(get_current_user), db=Depends(get_db) -): - db["user_data_states"].update_one( - {"user_id": current_user.id}, - {"$set": {"user_id": current_user.id, "data": data.data}}, - upsert=True, - ) - return UserDataResponse(data=data.data) diff --git a/python-service/app/api/v1/placements.py b/python-service/app/api/v1/placements.py deleted file mode 100644 index 9cec854..0000000 --- a/python-service/app/api/v1/placements.py +++ /dev/null @@ -1,104 +0,0 @@ -from datetime import datetime, timezone - -from fastapi import APIRouter, Depends - -from app.core.exceptions import NotFoundError -from app.core.rbac import UserRole, get_college_scope, get_current_user, require_roles -from app.database import get_db -from app.schemas.placement import PlacementCreateRequest, PlacementResponse, PlacementVerifyRequest - -router = APIRouter(prefix="/placements", tags=["Placements"]) - - -def _next_id(db, collection: str) -> int: - last = db[collection].find_one(sort=[("id", -1)], projection={"id": 1}) - return (last["id"] + 1) if last and isinstance(last.get("id"), int) else 1 - - -def _clean(doc): - if doc: - doc.pop("_id", None) - return doc - - -def _now(): - return datetime.now(timezone.utc).isoformat() - - -@router.get("", response_model=list[PlacementResponse]) -def list_placements(db=Depends(get_db), college_scope: int | None = get_college_scope): - query = {} - if college_scope: - query["college_id"] = int(college_scope) - cursor = db["placements"].find(query).sort("created_at", -1) - return [_clean(doc) for doc in cursor] - - -@router.post("", response_model=PlacementResponse) -def create_placement( - data: PlacementCreateRequest, current_user=Depends(get_current_user), db=Depends(get_db) -): - student_id = data.student_id or current_user.id - student = db["users"].find_one({"id": student_id}) - if not student: - raise NotFoundError("Student", str(student_id)) - placement = { - "id": _next_id(db, "placements"), - "student_id": student_id, - "college_id": student.get("college_id") or current_user.college_id or 1, - "company_name": data.company_name, - "role": data.role, - "salary_lpa": data.salary_lpa, - "work_type": data.work_type, - "mode": data.mode, - "location": data.location, - "proof_url": data.proof_url, - "offer_date": None, - "status": "placed", - "verified_by": None, - "verification_status": "pending", - "verified_at": None, - "created_at": _now(), - "updated_at": _now(), - } - db["placements"].insert_one(placement) - db["student_profiles"].update_one( - {"user_id": student_id}, {"$set": {"placement_status": "placed"}} - ) - return _clean(placement) - - -@router.get("/student/{student_id}", response_model=list[PlacementResponse]) -def student_placements( - student_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope -): - query = {"student_id": student_id} - if college_scope: - query["college_id"] = int(college_scope) - cursor = db["placements"].find(query).sort("created_at", -1) - return [_clean(doc) for doc in cursor] - - -@router.put( - "/{placement_id}/verify", - response_model=PlacementResponse, - dependencies=[require_roles(UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN)], -) -def verify_placement( - placement_id: int, - data: PlacementVerifyRequest, - current_user=Depends(get_current_user), - db=Depends(get_db), -): - placement = db["placements"].find_one({"id": placement_id}) - if not placement: - raise NotFoundError("Placement", str(placement_id)) - update = { - "verification_status": data.verification_status, - "verified_by": current_user.id, - "verified_at": _now(), - "updated_at": _now(), - } - db["placements"].update_one({"id": placement_id}, {"$set": update}) - placement.update(update) - return _clean(placement) diff --git a/python-service/app/api/v1/profile.py b/python-service/app/api/v1/profile.py deleted file mode 100644 index 51539b5..0000000 --- a/python-service/app/api/v1/profile.py +++ /dev/null @@ -1,429 +0,0 @@ -""" -UpScaler-AI V2 — Onboarding Profile Router - -Backs the frontend's schema-driven "complete your profile" step -(src/lib/onboarding/*, src/app/onboarding/page.tsx) for the three portals -that require it after first sign-in: hr, institutional (college_admin / -super_admin), faculty. Students land straight on their dashboard and never -hit this router. -""" - -import os -import uuid - -from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile -from app.core.rbac import get_current_user -from app.database import get_db -from app.repositories.base import DotDict - -router = APIRouter(prefix="/profile", tags=["Profile"]) - -ROLE_TO_PORTAL = { - "recruiter": "hr", - "college_admin": "institutional", - "super_admin": "institutional", - "faculty": "faculty", -} - -UPLOAD_DIR = os.path.join("uploads", "profile") - - -def _portal_for(user: DotDict) -> str: - portal = ROLE_TO_PORTAL.get(user.role) - if portal is None: - raise HTTPException( - status_code=400, detail=f"No onboarding profile exists for role '{user.role}'" - ) - return portal - - -_COUNTRY_OPTIONS = [ - {"label": "India", "value": "IN"}, - {"label": "United States", "value": "US"}, - {"label": "United Kingdom", "value": "UK"}, - {"label": "United Arab Emirates", "value": "AE"}, - {"label": "Singapore", "value": "SG"}, -] - -_COMMON_SECTION = { - "id": "preferences", - "title": "Preferences", - "description": "General settings for your account", - "fields": [ - { - "name": "timezone", - "label": "Timezone", - "type": "select", - "required": True, - "options": [ - {"label": "(GMT+5:30) India Standard Time", "value": "Asia/Kolkata"}, - {"label": "(GMT+0:00) UTC", "value": "UTC"}, - {"label": "(GMT-5:00) Eastern Time", "value": "America/New_York"}, - {"label": "(GMT+4:00) Gulf Standard Time", "value": "Asia/Dubai"}, - ], - }, - { - "name": "language", - "label": "Language", - "type": "select", - "required": True, - "options": [ - {"label": "English", "value": "en"}, - {"label": "Hindi", "value": "hi"}, - {"label": "Tamil", "value": "ta"}, - {"label": "Arabic", "value": "ar"}, - ], - }, - { - "name": "profilePhoto", - "label": "Profile Photo", - "type": "file", - "required": False, - "accept": "image/*", - "helpText": "Optional. PNG or JPG, up to 5MB.", - }, - { - "name": "signature", - "label": "Signature Upload", - "type": "file", - "required": False, - "accept": "image/*", - "helpText": "Optional. Used on generated documents.", - }, - ], -} - -_SCHEMAS = { - "hr": { - "portal": "hr", - "portalLabel": "HR Portal", - "sections": [ - { - "id": "employment", - "title": "Employment Details", - "description": "Your role within the organization", - "fields": [ - { - "name": "employeeId", - "label": "Employee ID", - "type": "text", - "required": True, - "placeholder": "EMP-00123", - }, - { - "name": "department", - "label": "Department", - "type": "select", - "required": True, - "options": [ - {"label": "Human Resources", "value": "hr"}, - {"label": "Talent Acquisition", "value": "talent_acquisition"}, - {"label": "Payroll", "value": "payroll"}, - {"label": "Operations", "value": "operations"}, - ], - }, - { - "name": "designation", - "label": "Designation", - "type": "text", - "required": True, - "placeholder": "HR Manager", - }, - { - "name": "yearsOfExperience", - "label": "Years of Experience", - "type": "number", - "required": True, - "validation": {"min": 0, "max": 60}, - }, - { - "name": "linkedinUrl", - "label": "LinkedIn URL", - "type": "url", - "required": False, - "placeholder": "https://linkedin.com/in/yourname", - }, - ], - }, - { - "id": "contact", - "title": "Contact Information", - "fields": [ - { - "name": "officePhone", - "label": "Office Phone", - "type": "tel", - "required": True, - "placeholder": "+91 22 1234 5678", - }, - { - "name": "mobileNumber", - "label": "Mobile Number", - "type": "tel", - "required": True, - "placeholder": "+91 98765 43210", - }, - { - "name": "emergencyContact", - "label": "Emergency Contact", - "type": "tel", - "required": True, - "placeholder": "+91 90000 00000", - }, - ], - }, - { - "id": "location", - "title": "Location", - "fields": [ - { - "name": "country", - "label": "Country", - "type": "select", - "required": True, - "options": _COUNTRY_OPTIONS, - }, - {"name": "state", "label": "State", "type": "text", "required": True}, - {"name": "city", "label": "City", "type": "text", "required": True}, - { - "name": "officeLocation", - "label": "Office Location", - "type": "text", - "required": True, - "placeholder": "HQ - Tower B, 4th Floor", - }, - ], - }, - _COMMON_SECTION, - ], - }, - "institutional": { - "portal": "institutional", - "portalLabel": "Institutional Admin Portal", - "sections": [ - { - "id": "institution", - "title": "Institution Details", - "fields": [ - { - "name": "institutionName", - "label": "Institution Name", - "type": "text", - "required": True, - }, - { - "name": "institutionCode", - "label": "Institution Code", - "type": "text", - "required": True, - "placeholder": "INST-4521", - }, - {"name": "adminId", "label": "Admin ID", "type": "text", "required": True}, - { - "name": "designation", - "label": "Designation", - "type": "text", - "required": True, - "placeholder": "Principal / Registrar", - }, - { - "name": "website", - "label": "Website", - "type": "url", - "required": False, - "placeholder": "https://institution.edu", - }, - ], - }, - { - "id": "contact", - "title": "Contact Information", - "fields": [ - { - "name": "officePhone", - "label": "Office Phone", - "type": "tel", - "required": True, - }, - { - "name": "mobileNumber", - "label": "Mobile Number", - "type": "tel", - "required": True, - }, - ], - }, - { - "id": "location", - "title": "Location", - "fields": [ - { - "name": "country", - "label": "Country", - "type": "select", - "required": True, - "options": _COUNTRY_OPTIONS, - }, - {"name": "state", "label": "State", "type": "text", "required": True}, - {"name": "district", "label": "District", "type": "text", "required": True}, - {"name": "city", "label": "City", "type": "text", "required": True}, - { - "name": "officeAddress", - "label": "Office Address", - "type": "textarea", - "required": True, - }, - ], - }, - _COMMON_SECTION, - ], - }, - "faculty": { - "portal": "faculty", - "portalLabel": "Faculty Portal", - "sections": [ - { - "id": "employment", - "title": "Employment Details", - "fields": [ - {"name": "facultyId", "label": "Faculty ID", "type": "text", "required": True}, - {"name": "department", "label": "Department", "type": "text", "required": True}, - { - "name": "designation", - "label": "Designation", - "type": "text", - "required": True, - "placeholder": "Assistant Professor", - }, - { - "name": "qualification", - "label": "Qualification", - "type": "text", - "required": True, - "placeholder": "Ph.D. in Computer Science", - }, - { - "name": "experience", - "label": "Experience", - "type": "number", - "required": True, - "validation": {"min": 0, "max": 60}, - }, - { - "name": "subjectsHandling", - "label": "Subjects Handling", - "type": "textarea", - "required": True, - "placeholder": "Data Structures, Algorithms", - }, - { - "name": "officeRoomNumber", - "label": "Office Room Number", - "type": "text", - "required": False, - "placeholder": "B-204", - }, - ], - }, - { - "id": "contact", - "title": "Contact Information", - "fields": [ - { - "name": "mobileNumber", - "label": "Mobile Number", - "type": "tel", - "required": True, - }, - { - "name": "alternateNumber", - "label": "Alternate Number", - "type": "tel", - "required": False, - }, - ], - }, - { - "id": "location", - "title": "Location", - "fields": [ - { - "name": "country", - "label": "Country", - "type": "select", - "required": True, - "options": _COUNTRY_OPTIONS, - }, - {"name": "state", "label": "State", "type": "text", "required": True}, - {"name": "district", "label": "District", "type": "text", "required": True}, - {"name": "city", "label": "City", "type": "text", "required": True}, - ], - }, - _COMMON_SECTION, - ], - }, -} - - -@router.get("/schema") -def get_profile_schema(current_user: DotDict = Depends(get_current_user)): - return _SCHEMAS[_portal_for(current_user)] - - -@router.get("/me") -def get_my_profile(current_user: DotDict = Depends(get_current_user), db=Depends(get_db)): - portal = _portal_for(current_user) - record = db["profile_data"].find_one({"user_id": current_user.id}) - return { - "exists": record is not None, - "portal": portal, - "google": None, - "values": (record or {}).get("values", {}), - } - - -async def _extract_values(request: Request) -> dict: - content_type = request.headers.get("content-type", "") - if not content_type.startswith("multipart/form-data"): - return await request.json() - - os.makedirs(UPLOAD_DIR, exist_ok=True) - values: dict = {} - form = await request.form() - for key, value in form.multi_items(): - if isinstance(value, UploadFile): - if not value.filename: - continue - ext = os.path.splitext(value.filename)[1] - stored_name = f"{uuid.uuid4().hex}{ext}" - with open(os.path.join(UPLOAD_DIR, stored_name), "wb") as f: - f.write(await value.read()) - values[key] = f"/uploads/profile/{stored_name}" - else: - values[key] = value - return values - - -async def _save_profile(request: Request, current_user: DotDict, db) -> dict: - portal = _portal_for(current_user) - values = await _extract_values(request) - db["profile_data"].update_one( - {"user_id": current_user.id}, - {"$set": {"user_id": current_user.id, "portal": portal, "values": values}}, - upsert=True, - ) - return {"exists": True, "portal": portal, "google": None, "values": values} - - -@router.post("") -async def create_profile( - request: Request, current_user: DotDict = Depends(get_current_user), db=Depends(get_db) -): - return await _save_profile(request, current_user, db) - - -@router.put("") -async def update_profile( - request: Request, current_user: DotDict = Depends(get_current_user), db=Depends(get_db) -): - return await _save_profile(request, current_user, db) diff --git a/python-service/app/api/v1/resume.py b/python-service/app/api/v1/resume.py deleted file mode 100644 index e3da155..0000000 --- a/python-service/app/api/v1/resume.py +++ /dev/null @@ -1,408 +0,0 @@ -import os -import json -import logging -from datetime import datetime, timezone -from typing import Optional, List, Dict, Any -from fastapi import APIRouter, Depends, HTTPException, status -from pydantic import BaseModel -from groq import Groq - -from app.database import get_db -from app.core.rbac import get_current_user -from app.repositories.base import DotDict - -logger = logging.getLogger("upscaler_ai.resume") -router = APIRouter(prefix="/resume", tags=["Resume"]) - - -class ResumeSaveRequest(BaseModel): - personal: Dict[str, Any] - objective: str - education: List[Dict[str, Any]] - experience: List[Dict[str, Any]] - projects: List[Dict[str, Any]] - skills: List[Dict[str, Any]] - certifications: List[Dict[str, Any]] - internships: List[Dict[str, Any]] - achievements: List[Dict[str, Any]] - hackathons: List[Dict[str, Any]] - publications: List[Dict[str, Any]] - languages: List[Dict[str, Any]] - volunteer: List[Dict[str, Any]] - references: List[Dict[str, Any]] - customSections: List[Dict[str, Any]] - sectionOrder: List[str] - template: str - zoom: Optional[float] = 1.0 - - -class SaveVersionRequest(BaseModel): - name: str - - -class JDMatchRequest(BaseModel): - jd_text: str - - -class AISuggestRequest(BaseModel): - action: str # 'summary' | 'bullet' | 'skills' | 'rewrite' | 'grammar' | 'cover_letter' | 'interview_prep' - section: Optional[str] = None - content: Optional[str] = None - jd_text: Optional[str] = None - - -class ResumeParseRequest(BaseModel): - text: str - - -def get_groq_client(): - groq_api_key = os.getenv("GROQ_API_KEY") - if not groq_api_key: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="GROQ_API_KEY is not configured in environment variables.", - ) - return Groq(api_key=groq_api_key) - - -@router.get("") -def get_resume(db=Depends(get_db), current_user: DotDict = Depends(get_current_user)): - """Fetch the active resume and saved versions for the current student.""" - resume = db["resumes"].find_one({"user_id": current_user.id}) - if not resume: - # Return default structure - resume = { - "user_id": current_user.id, - "personal": { - "name": current_user.name, - "email": current_user.email, - "phone": "", - "linkedin": "", - "github": "", - "portfolio": "", - "address": "", - "role": "", - }, - "objective": "", - "education": [], - "experience": [], - "projects": [], - "skills": [], - "certifications": [], - "internships": [], - "achievements": [], - "hackathons": [], - "publications": [], - "languages": [], - "volunteer": [], - "references": [], - "customSections": [], - "sectionOrder": [ - "personal", - "objective", - "education", - "experience", - "projects", - "skills", - "certifications", - ], - "template": "modern", - "created_at": datetime.now(timezone.utc).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat(), - } - db["resumes"].insert_one(resume) - - # Convert mongo _id - resume["id"] = str(resume["_id"]) - resume.pop("_id", None) - - # Fetch versions - versions = list(db["resume_versions"].find({"user_id": current_user.id}).sort("created_at", -1)) - for v in versions: - v["id"] = str(v["_id"]) - v.pop("_id", None) - - return {"resume": resume, "versions": versions} - - -@router.post("") -def save_resume( - data: ResumeSaveRequest, db=Depends(get_db), current_user: DotDict = Depends(get_current_user) -): - """Save the active resume details (auto-save endpoint).""" - now = datetime.now(timezone.utc).isoformat() - update_doc = data.model_dump() - update_doc["updated_at"] = now - - db["resumes"].update_one({"user_id": current_user.id}, {"$set": update_doc}, upsert=True) - - # Also update the user's main profile skills list if present - skills_list = [s.get("name") for s in data.skills if s.get("name")] - if skills_list: - skills_str = ", ".join(skills_list) - db["student_profiles"].update_one( - {"user_id": current_user.id}, {"$set": {"skills": skills_str}} - ) - - return {"success": True, "updated_at": now} - - -@router.post("/version") -def save_version( - payload: SaveVersionRequest, - db=Depends(get_db), - current_user: DotDict = Depends(get_current_user), -): - """Save the current resume state as a new version.""" - resume = db["resumes"].find_one({"user_id": current_user.id}) - if not resume: - raise HTTPException(status_code=404, detail="No active resume found to version") - - resume.pop("_id", None) - version_doc = { - **resume, - "name": payload.name, - "created_at": datetime.now(timezone.utc).isoformat(), - } - db["resume_versions"].insert_one(version_doc) - return {"success": True, "message": f"Version '{payload.name}' saved successfully"} - - -@router.post("/version/{version_id}/restore") -def restore_version( - version_id: str, db=Depends(get_db), current_user: DotDict = Depends(get_current_user) -): - """Restore a previously saved version as the active resume.""" - from bson import ObjectId - - try: - obj_id = ObjectId(version_id) - except Exception: - raise HTTPException(status_code=400, detail="Invalid version ID format") - - version = db["resume_versions"].find_one({"_id": obj_id, "user_id": current_user.id}) - if not version: - raise HTTPException(status_code=404, detail="Version not found") - - version.pop("_id", None) - version.pop("name", None) - version["updated_at"] = datetime.now(timezone.utc).isoformat() - - db["resumes"].update_one({"user_id": current_user.id}, {"$set": version}, upsert=True) - return {"success": True, "message": "Resume restored to selected version"} - - -@router.delete("/version/{version_id}") -def delete_version( - version_id: str, db=Depends(get_db), current_user: DotDict = Depends(get_current_user) -): - """Delete a saved version.""" - from bson import ObjectId - - try: - obj_id = ObjectId(version_id) - except Exception: - raise HTTPException(status_code=400, detail="Invalid version ID format") - - res = db["resume_versions"].delete_one({"_id": obj_id, "user_id": current_user.id}) - if res.deleted_count == 0: - raise HTTPException(status_code=404, detail="Version not found") - return {"success": True, "message": "Version deleted"} - - -@router.post("/analyze") -def analyze_resume(db=Depends(get_db), current_user: DotDict = Depends(get_current_user)): - """Analyze the student's active resume against ATS standards using Groq.""" - resume = db["resumes"].find_one({"user_id": current_user.id}) - if not resume: - raise HTTPException(status_code=404, detail="Active resume not found") - - client = get_groq_client() - - # Strip unnecessary fields for lower token usage - resume.pop("_id", None) - resume.pop("user_id", None) - resume.pop("created_at", None) - resume.pop("updated_at", None) - resume.pop("sectionOrder", None) - resume.pop("template", None) - resume.pop("zoom", None) - - prompt = f""" - You are an expert ATS Resume Analyzer. - Analyze the following resume and return a highly detailed, professional feedback report. - The response MUST be a valid JSON object only. Do not wrap the JSON in markdown code blocks. - - Required JSON keys: - - "score": integer between 0 and 100 representing the ATS score. - - "readability": integer between 0 and 100 for readability score. - - "section_completeness": list of objects containing "section" (name) and "score" (0-100) and "status" (e.g. "Complete", "Needs Info", "Missing"). - - "keywords_analyzed": integer representing the count of key industry keywords found. - - "missing_keywords": list of strings of important missing keywords based on the candidate's career role. - - "formatting_issues": list of strings describing any layout or formatting issues (e.g., standard margins, page breaks, fonts). - - "contact_validation": list of strings indicating validity of contact info (e.g., email format, LinkedIn URL, GitHub presence). - - "skills_gap": list of strings showing critical tech/soft skills gaps. - - "experience_quality": string evaluating the depth and style of the work experience descriptions. - - "suggestions": list of strings suggesting actionable steps to improve the score. - - Resume Data: - {json.dumps(resume, indent=2)} - """ - - try: - completion = client.chat.completions.create( - model="llama-3.3-70b-versatile", - messages=[{"role": "user", "content": prompt}], - temperature=0.3, - max_tokens=2048, - response_format={"type": "json_object"}, - ) - return json.loads(completion.choices[0].message.content) - except Exception as e: - logger.error(f"ATS Analysis failed: {e}") - raise HTTPException(status_code=500, detail=f"ATS Analysis failed: {str(e)}") - - -@router.post("/match-jd") -def match_job_description( - payload: JDMatchRequest, db=Depends(get_db), current_user: DotDict = Depends(get_current_user) -): - """Compare student resume with a pasted Job Description using Groq.""" - resume = db["resumes"].find_one({"user_id": current_user.id}) - if not resume: - raise HTTPException(status_code=404, detail="Active resume not found") - - client = get_groq_client() - - resume.pop("_id", None) - resume.pop("user_id", None) - - prompt = f""" - You are an expert recruiter. Compare the candidate's resume with the provided Job Description. - Return a detailed compatibility report as a raw JSON object only. Do not wrap in markdown code blocks. - - Required JSON keys: - - "match_percentage": integer from 0 to 100. - - "ats_match_status": string (e.g., "Highly Compatible", "Moderately Compatible", "Low Compatibility"). - - "matching_skills": list of strings of skills present in both. - - "missing_keywords": list of strings of skills/tools mentioned in JD but missing in resume. - - "suggested_skills": list of strings of related technical skills to add. - - "missing_experience": string detailing any work experience gaps for the job. - - "recommended_certifications": list of strings of certifications that would boost the profile for this role. - - "recommended_projects": list of strings suggesting project ideas that match the JD. - - "overall_evaluation": string summarizing the fit. - - Job Description: - {payload.jd_text} - - Candidate Resume: - {json.dumps(resume, indent=2)} - """ - - try: - completion = client.chat.completions.create( - model="llama-3.3-70b-versatile", - messages=[{"role": "user", "content": prompt}], - temperature=0.3, - max_tokens=2048, - response_format={"type": "json_object"}, - ) - return json.loads(completion.choices[0].message.content) - except Exception as e: - logger.error(f"JD Match failed: {e}") - raise HTTPException(status_code=500, detail=f"JD Matching failed: {str(e)}") - - -@router.post("/ai-suggest") -def ai_suggest( - payload: AISuggestRequest, db=Depends(get_db), current_user: DotDict = Depends(get_current_user) -): - """Generates summary, cover letters, rewrites experiences, or suggests skills using Groq.""" - client = get_groq_client() - - system_instruction = ( - "You are a professional ATS resume writer. Help the student optimize their profile." - ) - - if payload.action == "summary": - prompt = f"Based on the following content, write a concise, compelling, and professional resume summary (maximum 3 sentences):\n{payload.content}" - elif payload.action == "rewrite": - prompt = f"Rewrite the following description or bullet point using strong action verbs, professional style, and making it ATS-friendly. Maintain all facts and metrics:\n{payload.content}" - elif payload.action == "skills": - prompt = f"Given the student's department or role '{payload.content}', suggest a structured list of technical skills, tools, and soft skills they should include on their resume. Return a JSON object with keys 'technical', 'tools', 'soft'." - elif payload.action == "grammar": - prompt = f"Correct any grammar or spelling mistakes in the following text. Preserve the original meaning and formatting. Return only the corrected text:\n{payload.content}" - elif payload.action == "cover_letter": - resume = db["resumes"].find_one({"user_id": current_user.id}) - resume_data = json.dumps(resume, default=str) if resume else payload.content - prompt = f"Write a professional, tailored Cover Letter based on this Job Description and the student's resume.\n\nJob Description:\n{payload.jd_text}\n\nStudent Resume:\n{resume_data}" - elif payload.action == "interview_prep": - resume = db["resumes"].find_one({"user_id": current_user.id}) - resume_data = json.dumps(resume, default=str) if resume else payload.content - prompt = f"Generate 5 tailored technical and behavioral interview preparation questions and guidelines based on the student's resume.\n\nStudent Resume:\n{resume_data}" - else: - raise HTTPException(status_code=400, detail="Invalid action type") - - try: - completion = client.chat.completions.create( - model="llama-3.3-70b-versatile", - messages=[ - {"role": "system", "content": system_instruction}, - {"role": "user", "content": prompt}, - ], - temperature=0.5, - max_tokens=1500, - ) - output = completion.choices[0].message.content.strip() - - # If skills action, try to load JSON - if payload.action == "skills": - try: - # Find JSON bounds if the model included conversational wrappers - start = output.find("{") - end = output.rfind("}") + 1 - if start != -1 and end != -1: - output = json.loads(output[start:end]) - except Exception: - pass - - return {"result": output} - except Exception as e: - logger.error(f"AI Suggestion failed: {e}") - raise HTTPException(status_code=500, detail=f"AI suggestion failed: {str(e)}") - - -@router.post("/parse") -def parse_resume_text(payload: ResumeParseRequest, current_user=Depends(get_current_user)): - """Parse raw resume text into structured JSON fields using Groq.""" - client = get_groq_client() - - prompt = f""" - You are an expert resume parsing tool. - Extract the candidate information from the following text into structured JSON fields matching this exact key structure: - - Required JSON keys: - - "personal": {{ "name": "", "email": "", "phone": "", "linkedin": "", "github": "", "portfolio": "", "address": "", "role": "" }} - - "objective": "" - - "education": [ {{ "institution": "", "degree": "", "year": "", "score": "" }} ] - - "experience": [ {{ "role": "", "company": "", "duration": "", "description": "" }} ] - - "projects": [ {{ "title": "", "description": "", "link": "", "technologies": "" }} ] - - "skills": [ {{ "name": "", "category": "technical" | "soft" }} ] - - "certifications": [ {{ "name": "", "issuer": "", "year": "" }} ] - - Text content: - {payload.text} - """ - - try: - completion = client.chat.completions.create( - model="llama-3.3-70b-versatile", - messages=[{"role": "user", "content": prompt}], - temperature=0.1, - max_tokens=2048, - response_format={"type": "json_object"}, - ) - return json.loads(completion.choices[0].message.content) - except Exception as e: - logger.error(f"Resume parsing failed: {e}") - raise HTTPException(status_code=500, detail=f"Resume parsing failed: {str(e)}") diff --git a/python-service/app/api/v1/router.py b/python-service/app/api/v1/router.py deleted file mode 100644 index 186ecca..0000000 --- a/python-service/app/api/v1/router.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -UpScaler-AI V2 — API v1 Router -""" - -from fastapi import APIRouter -from app.api.v1 import ( - achievements, - assessments, - auth, - batches, - colleges, - dashboard, - interviews, - persistence, - placements, - profile, - students, - tests, - jobs, - users, - chat, - ai, - resume, -) - -api_router = APIRouter() - -api_router.include_router(auth.router) -api_router.include_router(profile.router) -api_router.include_router(users.router) -api_router.include_router(colleges.router) -api_router.include_router(students.router) -api_router.include_router(assessments.router) -api_router.include_router(tests.router) -api_router.include_router(batches.router) -api_router.include_router(interviews.router) -api_router.include_router(placements.router) -api_router.include_router(achievements.router) -api_router.include_router(dashboard.router) -api_router.include_router(persistence.router) -api_router.include_router(jobs.router) -api_router.include_router(chat.router) -api_router.include_router(ai.router) -api_router.include_router(resume.router) diff --git a/python-service/app/api/v1/students.py b/python-service/app/api/v1/students.py deleted file mode 100644 index e45a121..0000000 --- a/python-service/app/api/v1/students.py +++ /dev/null @@ -1,421 +0,0 @@ -from fastapi import APIRouter, Depends, Query -from datetime import datetime, timezone, timedelta -from app.core.exceptions import NotFoundError -from app.core.rbac import get_college_scope, get_current_user, assert_can_act_on_student -from app.core.security import hash_password -from app.database import get_db -from app.schemas.interview import InterviewSubmitRequest -from app.schemas.common import MessageResponse -from app.schemas.user import StudentProfileUpdateRequest, UserUpdateRequest - -router = APIRouter(prefix="/students", tags=["Students"]) - - -def to_dict(obj): - if not obj: - return None - if isinstance(obj, list): - return [to_dict(x) for x in obj] - if isinstance(obj, dict): - obj["id"] = obj.get("id", str(obj.get("_id"))) - obj.pop("_id", None) - for k, v in obj.items(): - if isinstance(v, (dict, list)): - obj[k] = to_dict(v) - return obj - - -@router.get("") -def list_students( - search: str | None = None, - department: str | None = None, - year: int | None = None, - limit: int = Query(default=100, ge=1, le=1000), - db=Depends(get_db), - college_scope: int | None = get_college_scope, -): - query = {"role": "student"} - if college_scope: - query["college_id"] = college_scope - if department: - query["department"] = department - if search: - query["$or"] = [ - {"name": {"$regex": search, "$options": "i"}}, - {"email": {"$regex": search, "$options": "i"}}, - ] - - users = list(db["users"].find(query).sort("name", 1).limit(limit)) - - # Attach profiles - for user in users: - prof = db["student_profiles"].find_one({"user_id": user["id"]}) - if prof: - user["student_profile"] = prof - - # Filter by year if needed - if year: - users = [u for u in users if u.get("student_profile", {}).get("year") == year] - - return [to_dict(u) for u in users] - - -@router.post("/identify") -def identify_student(data: dict, db=Depends(get_db)): - college_name = str(data.get("college_id") or data.get("collegeName") or "").strip() - roll_no = str(data.get("roll_no") or data.get("studentId") or "").strip().upper() - - college = db["colleges"].find_one({"name": {"$regex": college_name, "$options": "i"}}) - if not college: - raise NotFoundError("College", college_name) - - profile = db["student_profiles"].find_one({"student_id": roll_no}) - if not profile: - raise NotFoundError("Student", roll_no) - - student = db["users"].find_one({"id": profile["user_id"], "college_id": college["id"]}) - if not student: - raise NotFoundError("Student", roll_no) - - return { - "success": True, - "student": { - "internal_id": student["id"], - "id": profile["student_id"], - "name": student.get("name", ""), - "college": college["name"], - "college_id": college["id"], - "department": student.get("department", ""), - "year": profile.get("year", None), - "stats": { - "tests_completed": profile.get("tests_completed", 0), - "avg_accuracy": profile.get("avg_accuracy", 0), - "interviews_completed": profile.get("interviews_completed", 0), - "streak": profile.get("streak", 0), - }, - "profile_data": {}, - "resumeData": {}, - "jobApplications": [], - "trackProgress": {}, - }, - "history": {"tests": [], "interviews": [], "total_attempts": 0}, - } - - -@router.get("/{student_id}") -def get_student(student_id: int, db=Depends(get_db), college_scope: int | None = get_college_scope): - query = {"id": student_id, "role": "student"} - if college_scope: - query["college_id"] = college_scope - student = db["users"].find_one(query) - if not student: - raise NotFoundError("Student", str(student_id)) - return to_dict(student) - - -@router.put("/{student_id}") -def update_student( - student_id: int, - data: UserUpdateRequest, - db=Depends(get_db), - college_scope: int | None = get_college_scope, -): - get_student(student_id, db, college_scope) # existence/scope check; raises if not authorized - update_data = data.model_dump(exclude_unset=True) - db["users"].update_one({"id": student_id}, {"$set": update_data}) - return to_dict(db["users"].find_one({"id": student_id})) - - -@router.put("/{student_id}/profile") -def update_student_profile( - student_id: int, - data: StudentProfileUpdateRequest, - db=Depends(get_db), - current_user=Depends(get_current_user), -): - assert_can_act_on_student(current_user, student_id, db) - profile = db["student_profiles"].find_one({"user_id": student_id}) - update_data = data.model_dump(exclude_unset=True) - if not profile: - update_data["user_id"] = student_id - update_data["id"] = db["student_profiles"].count_documents({}) + 1 - db["student_profiles"].insert_one(update_data) - else: - db["student_profiles"].update_one({"user_id": student_id}, {"$set": update_data}) - return MessageResponse(message="Student profile updated") - - -@router.get("/{student_id}/dashboard") -def get_student_dashboard( - student_id: int, db=Depends(get_db), current_user=Depends(get_current_user) -): - assert_can_act_on_student(current_user, student_id, db) - profile = db["student_profiles"].find_one({"user_id": student_id}) - if not profile: - profile = { - "id": db["student_profiles"].count_documents({}) + 1, - "user_id": student_id, - "tests_completed": 0, - "avg_accuracy": 0, - "interviews_completed": 0, - "streak": 0, - } - db["student_profiles"].insert_one(profile) - - recent_attempts = list( - db["assessment_attempts"].find({"student_id": student_id}).sort("created_at", -1).limit(5) - ) - recent_activity = [] - for a in recent_attempts: - assessment = db["assessments"].find_one({"id": a["assessment_id"]}) - title = assessment["title"] if assessment else "Practice Test" - recent_activity.append( - { - "id": a["id"], - "title": title, - "score": a.get("score", 0), - "max_score": a.get("max_score", 100), - "percentage": a.get("percentage", 0), - "date": a.get("created_at"), - } - ) - - return { - "tests_completed": profile.get("tests_completed", 0), - "avg_accuracy": round(profile.get("avg_accuracy", 0), 1), - "interviews_completed": profile.get("interviews_completed", 0), - "streak": profile.get("streak", 0), - "national_rank": profile.get("national_rank", "-"), - "placement_status": profile.get("placement_status", None), - "recent_activity": recent_activity, - } - - -@router.get("/{student_id}/tests") -def student_tests(student_id: int, db=Depends(get_db), current_user=Depends(get_current_user)): - query = {"student_id": student_id} - if current_user.role != "super_admin" and current_user.id != student_id: - if current_user.college_id: - query["college_id"] = current_user.college_id - - attempts = db["assessment_attempts"].find(query).sort("created_at", -1) - return [to_dict(a) for a in attempts] - - -@router.post("/{student_id}/tests") -def log_student_test( - student_id: int, data: dict, db=Depends(get_db), current_user=Depends(get_current_user) -): - assert_can_act_on_student(current_user, student_id, db) - student = db["users"].find_one({"id": student_id}) - if not student: - raise NotFoundError("Student", str(student_id)) - - score = int(data.get("score") or 0) - max_score = int(data.get("max_score") or data.get("total") or 100) - pct = float(data.get("percentage") or round((score / max_score) * 100, 2)) - assessment_id = int(data.get("assessment_id") or 0) - - assessment = db["assessments"].find_one({"id": assessment_id}) if assessment_id else None - if not assessment: - assessment = { - "id": db["assessments"].count_documents({}) + 1, - "title": data.get("testName") or data.get("title") or "Practice Test", - "assessment_type": "mixed", - "college_id": student.get("college_id") or 1, - "created_by": student_id, - "duration_minutes": int(data.get("duration") or 30), - "total_marks": max_score, - "pass_percentage": 40, - "negative_marking": False, - "status": "active", - "difficulty": "medium", - "created_at": datetime.now(timezone.utc).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat(), - } - db["assessments"].insert_one(assessment) - - attempt_num = db["assessment_attempts"].count_documents({"student_id": student_id}) + 1 - attempt = { - "id": db["assessment_attempts"].count_documents({}) + 1, - "assessment_id": assessment["id"], - "student_id": student_id, - "college_id": student.get("college_id") or 1, - "attempt_number": attempt_num, - "score": score, - "max_score": max_score, - "percentage": pct, - "status": "completed", - "passed": pct >= 40, - "created_at": datetime.now(timezone.utc).isoformat(), - "completed_at": datetime.now(timezone.utc).isoformat(), - } - db["assessment_attempts"].insert_one(attempt) - - profile = db["student_profiles"].find_one({"user_id": student_id}) - if profile: - tests_completed = profile.get("tests_completed", 0) + 1 - avg_acc = profile.get("avg_accuracy", 0) - new_avg = round(((avg_acc * (tests_completed - 1)) + pct) / tests_completed, 2) - - today_date = datetime.now(timezone.utc).date() - last_test_str = profile.get("last_test_date") - streak = profile.get("streak", 0) - - if last_test_str: - try: - last_test_date = datetime.fromisoformat(last_test_str).date() - if last_test_date == today_date: - pass - elif last_test_date == today_date - timedelta(days=1): - streak += 1 - else: - streak = 1 - except (ValueError, TypeError): - streak = 1 - else: - streak = 1 - - db["student_profiles"].update_one( - {"user_id": student_id}, - { - "$set": { - "tests_completed": tests_completed, - "avg_accuracy": new_avg, - "streak": streak, - "last_test_date": today_date.isoformat(), - } - }, - ) - - return MessageResponse(message="Test attempt logged") - - -@router.get("/{student_id}/tests/analytics") -def student_test_analytics( - student_id: int, db=Depends(get_db), current_user=Depends(get_current_user) -): - assert_can_act_on_student(current_user, student_id, db) - attempts = list(db["assessment_attempts"].find({"student_id": student_id})) - avg = round(sum(a.get("percentage", 0) for a in attempts) / len(attempts), 2) if attempts else 0 - return { - "success": True, - "data": { - "attempts": len(attempts), - "average": avg, - "history": [to_dict(a) for a in attempts], - }, - } - - -@router.get("/{student_id}/interviews") -def student_interviews(student_id: int, db=Depends(get_db), current_user=Depends(get_current_user)): - query = {"student_id": student_id} - if current_user.role != "super_admin" and current_user.id != student_id: - if current_user.college_id: - query["college_id"] = current_user.college_id - - attempts = db["interview_attempts"].find(query).sort("created_at", -1) - return [to_dict(a) for a in attempts] - - -@router.post("/{student_id}/interviews") -def log_student_interview( - student_id: int, - data: InterviewSubmitRequest, - db=Depends(get_db), - current_user=Depends(get_current_user), -): - assert_can_act_on_student(current_user, student_id, db) - student = db["users"].find_one({"id": student_id}) - if not student: - raise NotFoundError("Student", str(student_id)) - - attempt_num = db["interview_attempts"].count_documents({"student_id": student_id}) + 1 - attempt = { - "id": db["interview_attempts"].count_documents({}) + 1, - "student_id": student_id, - "college_id": student.get("college_id") or 1, - "role": data.role, - "category": data.category, - "overall_rating": data.overall_rating, - "strengths": data.strengths, - "improvements": data.improvements, - "duration_seconds": data.duration_seconds, - "attempt_number": attempt_num, - "status": "completed", - "created_at": datetime.now(timezone.utc).isoformat(), - } - db["interview_attempts"].insert_one(attempt) - - for resp in data.responses: - r_dict = resp.model_dump() - r_dict["attempt_id"] = attempt["id"] - r_dict["id"] = db["interview_responses"].count_documents({}) + 1 - db["interview_responses"].insert_one(r_dict) - - db["student_profiles"].update_one( - {"user_id": student_id}, {"$inc": {"interviews_completed": 1}} - ) - return to_dict(attempt) - - -@router.post("/batch") -def create_batch_students( - data: dict, - db=Depends(get_db), - college_scope: int | None = get_college_scope, - current_user=Depends(get_current_user), -): - students = data.get("students") or [] - department = data.get("department") - year = data.get("year") - created = 0 - default_status = "pending" if current_user.get("role") == "faculty" else "approved" - - for item in students: - email = ( - item.get("email") or f"{item.get('roll') or item.get('studentId')}@upscaler-ai.local" - ) - if db["users"].count_documents({"email": email.lower()}) > 0: - continue - - user_id = db["users"].count_documents({}) + 1 - user = { - "id": user_id, - "email": email.lower(), - "password_hash": hash_password(item.get("password") or "student123"), - "name": item.get("name"), - "role": "student", - "college_id": college_scope, - "department": item.get("department") or department, - "status": default_status, - "is_active": True, - "created_at": datetime.now(timezone.utc).isoformat(), - } - db["users"].insert_one(user) - - prof = { - "id": db["student_profiles"].count_documents({}) + 1, - "user_id": user_id, - "student_id": (item.get("roll") or item.get("studentId") or "").upper(), - "year": year or item.get("year"), - "tests_completed": 0, - "avg_accuracy": 0, - "interviews_completed": 0, - } - db["student_profiles"].insert_one(prof) - created += 1 - - return MessageResponse( - message=f"Successfully onboarded {created} students. Status: {default_status}" - ) - - -@router.get("/pending") -def list_pending_students(db=Depends(get_db), college_scope: int | None = get_college_scope): - query = {"role": "student", "status": "pending"} - if college_scope: - query["college_id"] = college_scope - users = db["users"].find(query).sort("created_at", -1) - return [to_dict(u) for u in users] diff --git a/python-service/app/api/v1/tests.py b/python-service/app/api/v1/tests.py deleted file mode 100644 index 1e5fb40..0000000 --- a/python-service/app/api/v1/tests.py +++ /dev/null @@ -1,22 +0,0 @@ -from fastapi import APIRouter, Depends - -from app.api.v1.assessments import submit_test, to_dict -from app.core.rbac import get_college_scope, get_current_user -from app.database import get_db -from app.schemas.assessment import AttemptResponse, TestSubmitRequest - -router = APIRouter(prefix="/tests", tags=["Tests"]) - - -@router.post("/submit", response_model=AttemptResponse) -def submit(data: TestSubmitRequest, current_user=Depends(get_current_user), db=Depends(get_db)): - return submit_test(data, current_user, db) - - -@router.get("/college/results", response_model=list[AttemptResponse]) -def college_results(db=Depends(get_db), college_scope: int | None = get_college_scope): - query = {} - if college_scope: - query["college_id"] = int(college_scope) - attempts = db["assessment_attempts"].find(query).sort("created_at", -1) - return [to_dict(doc) for doc in attempts] diff --git a/python-service/app/api/v1/users.py b/python-service/app/api/v1/users.py deleted file mode 100644 index 6b9719b..0000000 --- a/python-service/app/api/v1/users.py +++ /dev/null @@ -1,267 +0,0 @@ -from fastapi import APIRouter, Depends, HTTPException -from typing import List -from datetime import datetime, timezone - -from app.dependencies import get_db -from app.core.rbac import get_current_user, RoleChecker, UserRole -from app.schemas.user import UserResponse, AdminUserUpdateRequest, AdminUserCreateRequest -from app.core.exceptions import NotFoundError -from app.core.security import hash_password - -router = APIRouter(prefix="/users", tags=["Users"]) -superadmin_checker = RoleChecker([UserRole.SUPER_ADMIN]) - -# Number of users returned in one page. The admin tables render a scrollable -# list, not the entire institution at once, and an unbounded find over a remote -# cluster was a large part of the load time. -DEFAULT_PAGE_SIZE = 200 -MAX_PAGE_SIZE = 1000 - -# Exactly the fields `UserResponse` serialises. Excluding `password_hash` here -# means the hash never leaves the database, and skipping unused fields keeps -# the payload small over the wire. -USER_PROJECTION = { - "_id": 1, - "id": 1, - "email": 1, - "name": 1, - "role": 1, - "college_id": 1, - "department": 1, - "phone": 1, - "avatar_url": 1, - "is_active": 1, - "is_email_verified": 1, - "status": 1, - "last_login_at": 1, - "created_at": 1, - "updated_at": 1, - "preferences": 1, -} - - -def _next_id(db, collection: str) -> int: - """Next sequential id for a profile collection. - - Reads the current maximum off the index instead of counting the whole - collection. Counting was both a full scan and wrong after any delete — - it could hand out an id that already existed. - """ - last = db[collection].find_one(sort=[("id", -1)], projection={"id": 1}) - return (last["id"] + 1) if last and isinstance(last.get("id"), int) else 1 - - -def to_dict(obj): - if not obj: - return None - if isinstance(obj, list): - return [to_dict(x) for x in obj] - if isinstance(obj, dict): - obj["id"] = obj.get("id", str(obj.get("_id"))) - obj.pop("_id", None) - for k, v in obj.items(): - if isinstance(v, (dict, list)): - obj[k] = to_dict(v) - return obj - - -@router.post("/", response_model=UserResponse) -def create_user( - payload: AdminUserCreateRequest, db=Depends(get_db), current_user=Depends(get_current_user) -): - """Create a user. Superadmin=anyone, College Admin=faculty+students, Faculty=students only.""" - allowed_roles = [ - UserRole.SUPER_ADMIN.value, - UserRole.COLLEGE_ADMIN.value, - UserRole.FACULTY.value, - ] - if current_user.role not in allowed_roles: - raise HTTPException(status_code=403, detail="Not authorized to create users") - - # Faculty can only create students in their own college - if current_user.role == UserRole.FACULTY.value: - if payload.role != UserRole.STUDENT.value: - raise HTTPException(status_code=403, detail="Faculty can only create students") - payload.college_id = current_user.college_id - - # College admin can create faculty + students in their own college - if current_user.role == UserRole.COLLEGE_ADMIN.value: - payload.college_id = current_user.college_id - if payload.role not in [UserRole.STUDENT.value, UserRole.FACULTY.value]: - raise HTTPException(status_code=403, detail="Can only create students or faculty") - - # `find_one` with a projection stops as soon as it finds a match; the old - # count_documents had to visit every matching document first. - if db["users"].find_one({"email": payload.email.lower()}, {"_id": 1}): - raise HTTPException(status_code=400, detail="Email already registered") - - last_user = db["users"].find_one(sort=[("id", -1)], projection={"id": 1}) - user_id = (last_user["id"] + 1) if last_user and "id" in last_user else 1 - user = { - "id": user_id, - "email": payload.email.lower(), - "name": payload.name, - "role": payload.role, - "password_hash": hash_password(payload.password), - "status": "approved", - "college_id": payload.college_id, - "department": payload.department, - "is_active": True, - "created_at": datetime.now(timezone.utc).isoformat(), - "updated_at": datetime.now(timezone.utc).isoformat(), - } - db["users"].insert_one(user) - - if user["role"] == UserRole.STUDENT.value: - sp = { - "id": _next_id(db, "student_profiles"), - "user_id": user_id, - "student_id": payload.student_id, - "year": payload.year, - "tests_completed": 0, - "avg_accuracy": 0.0, - } - db["student_profiles"].insert_one(sp) - elif user["role"] == UserRole.FACULTY.value: - fp = { - "id": _next_id(db, "faculty_profiles"), - "user_id": user_id, - "department": payload.department, - } - db["faculty_profiles"].insert_one(fp) - elif user["role"] == "recruiter": - rp = { - "id": _next_id(db, "recruiter_profiles"), - "user_id": user_id, - "company_name": "New Company", - } - db["recruiter_profiles"].insert_one(rp) - - return to_dict(user) - - -@router.get("/", response_model=List[UserResponse]) -def get_all_users( - db=Depends(get_db), - current_user=Depends(get_current_user), - skip: int = 0, - limit: int = DEFAULT_PAGE_SIZE, -): - """Get users. Superadmin sees all. Others see users in their college. - - Paginated and projected: the password hash and other internal fields are - never serialised, and the result set is bounded. Callers that pass no - paging arguments keep working and simply receive the first page. - """ - query = {} - - if current_user.role != UserRole.SUPER_ADMIN.value: - if current_user.college_id is None: - query["id"] = current_user.id - else: - query["college_id"] = current_user.college_id - query["role"] = {"$ne": UserRole.SUPER_ADMIN.value} - - users = ( - db["users"] - .find(query, USER_PROJECTION) - .sort("created_at", -1) - .skip(max(skip, 0)) - .limit(min(max(limit, 1), MAX_PAGE_SIZE)) - ) - return [to_dict(u) for u in users] - - -@router.get("/pending", response_model=List[UserResponse]) -def get_pending_users( - db=Depends(get_db), - current_user=Depends(get_current_user), - limit: int = DEFAULT_PAGE_SIZE, -): - """Get all users with 'pending' status.""" - if current_user.role not in [UserRole.SUPER_ADMIN.value, UserRole.COLLEGE_ADMIN.value]: - raise HTTPException(status_code=403, detail="Not enough permissions") - - query = {"status": "pending"} - if current_user.role == UserRole.COLLEGE_ADMIN.value: - if current_user.college_id is None: - query["id"] = current_user.id - else: - query["college_id"] = current_user.college_id - query["role"] = {"$ne": UserRole.SUPER_ADMIN.value} - - users = ( - db["users"] - .find(query, USER_PROJECTION) - .sort("created_at", -1) - .limit(min(max(limit, 1), MAX_PAGE_SIZE)) - ) - return [to_dict(u) for u in users] - - -@router.put("/{user_id}/approve", response_model=UserResponse) -def approve_user(user_id: int, db=Depends(get_db), current_user=Depends(superadmin_checker)): - user = db["users"].find_one({"id": user_id}) - if not user: - raise NotFoundError("User", str(user_id)) - db["users"].update_one( - {"id": user_id}, - {"$set": {"status": "approved", "updated_at": datetime.now(timezone.utc).isoformat()}}, - ) - user["status"] = "approved" - return to_dict(user) - - -@router.put("/{user_id}/reject", response_model=UserResponse) -def reject_user(user_id: int, db=Depends(get_db), current_user=Depends(superadmin_checker)): - user = db["users"].find_one({"id": user_id}) - if not user: - raise NotFoundError("User", str(user_id)) - db["users"].update_one( - {"id": user_id}, - {"$set": {"status": "rejected", "updated_at": datetime.now(timezone.utc).isoformat()}}, - ) - user["status"] = "rejected" - return to_dict(user) - - -@router.delete("/{user_id}", response_model=dict) -def delete_user(user_id: int, db=Depends(get_db), current_user=Depends(get_current_user)): - if current_user.role not in [UserRole.SUPER_ADMIN.value, UserRole.COLLEGE_ADMIN.value]: - raise HTTPException(status_code=403, detail="Not authorized") - user = db["users"].find_one({"id": user_id}) - if not user: - raise NotFoundError("User", str(user_id)) - if ( - current_user.role == UserRole.COLLEGE_ADMIN.value - and user.get("college_id") != current_user.college_id - ): - raise HTTPException(status_code=403, detail="Can only delete users from your college") - db["users"].delete_one({"id": user_id}) - return {"message": "User deleted successfully"} - - -@router.put("/{user_id}", response_model=UserResponse) -def update_user( - user_id: int, - payload: AdminUserUpdateRequest, - db=Depends(get_db), - current_user=Depends(get_current_user), -): - if current_user.role not in [UserRole.SUPER_ADMIN.value, UserRole.COLLEGE_ADMIN.value]: - raise HTTPException(status_code=403, detail="Not authorized") - user = db["users"].find_one({"id": user_id}) - if not user: - raise NotFoundError("User", str(user_id)) - if ( - current_user.role == UserRole.COLLEGE_ADMIN.value - and user.get("college_id") != current_user.college_id - ): - raise HTTPException(status_code=403, detail="Can only update users from your college") - - update_data = payload.model_dump(exclude_unset=True) - update_data["updated_at"] = datetime.now(timezone.utc).isoformat() - db["users"].update_one({"id": user_id}, {"$set": update_data}) - - updated = db["users"].find_one({"id": user_id}) - return to_dict(updated) diff --git a/python-service/app/config.py b/python-service/app/config.py deleted file mode 100644 index d7da3f7..0000000 --- a/python-service/app/config.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -UpScaler-AI V2 — Application Configuration -Uses Pydantic Settings for type-safe environment variables. -""" - -from functools import lru_cache -from typing import Any - -from pydantic import field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class Settings(BaseSettings): - """Application settings loaded from environment variables.""" - - model_config = SettingsConfigDict( - env_file=".env", - env_file_encoding="utf-8", - case_sensitive=False, - extra="ignore", - ) - - # ── Application ────────────────────────────── - APP_NAME: str = "UpScaler-AI V2" - APP_ENV: str = "development" - # Safe-by-default: DEBUG must be explicitly enabled in a local .env, since - # it also controls whether /docs, /redoc and /openapi.json are public - # (see main.py) — a forgotten override should not expose them. - DEBUG: bool = False - API_V1_PREFIX: str = "/api/v1" - - # ── MongoDB (the only database engine) ─────── - MONGODB_URI: str = "mongodb://localhost:27017" - MONGODB_DB_NAME: str = "upscaler_ai" - - # ── JWT ────────────────────────────────────── - # No default: a placeholder secret here is worse than an app that refuses - # to start. Set a real random 64-char string in .env / Secret Manager. - JWT_SECRET_KEY: str - JWT_ALGORITHM: str = "HS256" - ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 - REFRESH_TOKEN_EXPIRE_DAYS: int = 7 - - # ── CORS ───────────────────────────────────── - CORS_ORIGINS: str = "http://localhost:3000,http://localhost:3001" - - # ── Rate Limiting ──────────────────────────── - RATE_LIMIT_PER_MINUTE: int = 60 - - # ── Email ──────────────────────────────────── - SMTP_HOST: str = "" - SMTP_PORT: int = 587 - SMTP_USER: str = "" - SMTP_PASSWORD: str = "" - EMAIL_FROM: str = "noreply@upscaler-ai.com" - - # ── Super Admin Seed ───────────────────────── - # No password default on purpose — set a real one in .env before seeding. - # Consumed by seed_mongo.py (which reads them from the environment directly, - # not through this class) — it refuses to run if either is unset. - SUPER_ADMIN_EMAIL: str = "admin@upscaler-ai.com" - SUPER_ADMIN_PASSWORD: str = "" - - # ── AI Keys ────────────────────────────────── - GROQ_API_KEY: str | None = None - - # ── Google OAuth ───────────────────────────── - GOOGLE_CLIENT_ID: str = "" - GOOGLE_CLIENT_SECRET: str = "" - GOOGLE_OAUTH_REDIRECT_URI: str = "http://localhost:8000/api/v1/auth/google/callback" - FRONTEND_URL: str = "http://localhost:3000" - - @field_validator("DEBUG", mode="before") - @classmethod - def parse_debug(cls, value: Any) -> Any: - if isinstance(value, str): - normalized = value.strip().lower() - if normalized in {"release", "prod", "production"}: - return False - if normalized in {"dev", "development"}: - return True - return value - - @property - def cors_origins_list(self) -> list[str]: - return [origin.strip() for origin in self.CORS_ORIGINS.split(",")] - - -@lru_cache() -def get_settings() -> Settings: - """Cached settings instance.""" - return Settings() diff --git a/python-service/app/core/__init__.py b/python-service/app/core/__init__.py deleted file mode 100644 index dc0b558..0000000 --- a/python-service/app/core/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Core init diff --git a/python-service/app/core/exceptions.py b/python-service/app/core/exceptions.py deleted file mode 100644 index 13e12f2..0000000 --- a/python-service/app/core/exceptions.py +++ /dev/null @@ -1,111 +0,0 @@ -""" -UpScaler-AI V2 — Custom Exception Classes -Centralized exception hierarchy for consistent error handling. -""" - -from fastapi import HTTPException, status - - -class UpScalerAIException(HTTPException): - """Base exception for all UpScaler-AI errors.""" - - def __init__( - self, - status_code: int = status.HTTP_500_INTERNAL_SERVER_ERROR, - detail: str = "An unexpected error occurred", - ): - super().__init__(status_code=status_code, detail=detail) - - -# ── Authentication Exceptions ──────────────────── -class InvalidCredentialsError(UpScalerAIException): - def __init__(self): - super().__init__( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid email or password", - ) - - -class TokenExpiredError(UpScalerAIException): - def __init__(self): - super().__init__( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Token has expired", - ) - - -class InvalidTokenError(UpScalerAIException): - def __init__(self): - super().__init__( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid or malformed token", - ) - - -class InactiveAccountError(UpScalerAIException): - def __init__(self): - super().__init__( - status_code=status.HTTP_403_FORBIDDEN, - detail="Account is deactivated. Contact your administrator.", - ) - - -class AccountPendingError(UpScalerAIException): - def __init__(self): - super().__init__( - status_code=status.HTTP_403_FORBIDDEN, - detail="Account is pending approval", - ) - - -# ── Authorization Exceptions ──────────────────── -class InsufficientPermissionsError(UpScalerAIException): - def __init__(self, role: str = ""): - detail = ( - f"Role '{role}' is not authorized for this resource" - if role - else "Insufficient permissions" - ) - super().__init__( - status_code=status.HTTP_403_FORBIDDEN, - detail=detail, - ) - - -class CollegeScopeViolationError(UpScalerAIException): - def __init__(self): - super().__init__( - status_code=status.HTTP_403_FORBIDDEN, - detail="You can only access resources within your own college", - ) - - -# ── Resource Exceptions ───────────────────────── -class NotFoundError(UpScalerAIException): - def __init__(self, resource: str = "Resource", identifier: str = ""): - detail = f"{resource} not found" - if identifier: - detail = f"{resource} with id '{identifier}' not found" - super().__init__( - status_code=status.HTTP_404_NOT_FOUND, - detail=detail, - ) - - -class DuplicateError(UpScalerAIException): - def __init__(self, resource: str = "Resource", field: str = ""): - detail = f"{resource} already exists" - if field: - detail = f"{resource} with this {field} already exists" - super().__init__( - status_code=status.HTTP_409_CONFLICT, - detail=detail, - ) - - -class ValidationError(UpScalerAIException): - def __init__(self, detail: str = "Validation failed"): - super().__init__( - status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, - detail=detail, - ) diff --git a/python-service/app/core/middleware.py b/python-service/app/core/middleware.py deleted file mode 100644 index af85011..0000000 --- a/python-service/app/core/middleware.py +++ /dev/null @@ -1,77 +0,0 @@ -""" -UpScaler-AI V2 — Middleware -Request logging, rate limiting, and request timing. -""" - -import time -import logging -from uuid import uuid4 - -from fastapi import FastAPI, Request, Response -from slowapi import Limiter, _rate_limit_exceeded_handler -from slowapi.middleware import SlowAPIMiddleware -from slowapi.util import get_remote_address -from slowapi.errors import RateLimitExceeded -from starlette.middleware.base import BaseHTTPMiddleware - -from app.config import get_settings - -settings = get_settings() -logger = logging.getLogger("upscaler_ai") - - -# ── Rate Limiter ───────────────────────────────── -limiter = Limiter( - key_func=get_remote_address, default_limits=[f"{settings.RATE_LIMIT_PER_MINUTE}/minute"] -) - - -# ── Request Logging Middleware ─────────────────── -class RequestLoggingMiddleware(BaseHTTPMiddleware): - """Log every request with timing, method, path, and status.""" - - async def dispatch(self, request: Request, call_next): - request_id = str(uuid4())[:8] - start_time = time.time() - - # Attach request_id for downstream use - request.state.request_id = request_id - - logger.info( - f"[{request_id}] → {request.method} {request.url.path} " - f"from {request.client.host if request.client else 'unknown'}" - ) - - try: - response: Response = await call_next(request) - except Exception as exc: - duration = time.time() - start_time - logger.error( - f"[{request_id}] ✗ {request.method} {request.url.path} " - f"EXCEPTION in {duration:.3f}s: {exc}" - ) - raise - - duration = time.time() - start_time - logger.info( - f"[{request_id}] ← {request.method} {request.url.path} " - f"{response.status_code} in {duration:.3f}s" - ) - - response.headers["X-Request-ID"] = request_id - response.headers["X-Process-Time"] = f"{duration:.3f}" - return response - - -# ── Setup Function ─────────────────────────────── -def setup_middleware(app: FastAPI): - """Attach all middleware to the FastAPI app.""" - # Rate limiting — app.state.limiter + the exception handler alone don't enforce - # anything; SlowAPIMiddleware is what actually applies default_limits to every - # request (previously missing here, leaving auth/register/refresh unthrottled). - app.state.limiter = limiter - app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) - app.add_middleware(SlowAPIMiddleware) - - # Request logging - app.add_middleware(RequestLoggingMiddleware) diff --git a/python-service/app/core/rbac.py b/python-service/app/core/rbac.py deleted file mode 100644 index 1e17d1c..0000000 --- a/python-service/app/core/rbac.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -UpScaler-AI V2 — Role-Based Access Control -FastAPI dependencies for role enforcement and college scoping. -""" - -from enum import Enum -from typing import Optional - -from fastapi import Depends -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials - -from app.database import get_db -from app.core.security import verify_access_token -from app.core.exceptions import ( - InvalidTokenError, - InsufficientPermissionsError, - InactiveAccountError, - AccountPendingError, -) - - -# ── Role Enum ──────────────────────────────────── -class UserRole(str, Enum): - STUDENT = "student" - FACULTY = "faculty" - COLLEGE_ADMIN = "college_admin" - RECRUITER = "recruiter" - SUPER_ADMIN = "super_admin" - - -# ── Bearer Token Extraction ───────────────────── -security_scheme = HTTPBearer(auto_error=False) - - -async def get_current_user( - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme), - db=Depends(get_db), -): - """ - Extract and validate the current user from the JWT bearer token. - Raises InvalidTokenError if token is missing/invalid. - """ - if credentials is None: - raise InvalidTokenError() - - payload = verify_access_token(credentials.credentials) - if payload is None: - raise InvalidTokenError() - - user_id = payload.get("sub") - if user_id is None: - raise InvalidTokenError() - - user_doc = db["users"].find_one({"id": int(user_id)}) - if user_doc is None: - raise InvalidTokenError() - - from app.repositories.base import DotDict - - user = DotDict(user_doc) - - if not user.is_active: - raise InactiveAccountError() - - if user.status == "pending": - raise AccountPendingError() - - return user - - -async def get_optional_user( - credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_scheme), - db=Depends(get_db), -): - """Get current user if token is provided, otherwise return None.""" - if credentials is None: - return None - try: - return await get_current_user(credentials, db) - except Exception: - return None - - -# ── Role Authorization ────────────────────────── -class RoleChecker: - """ - Dependency class for role-based authorization. - - Usage: - @router.get("/admin-only", dependencies=[Depends(RoleChecker([UserRole.SUPER_ADMIN]))]) - async def admin_endpoint(): ... - - Or as a dependency that also returns the user: - current_user: User = Depends(RoleChecker([UserRole.FACULTY, UserRole.COLLEGE_ADMIN])) - """ - - def __init__(self, allowed_roles: list[UserRole]): - self.allowed_roles = allowed_roles - - async def __call__(self, current_user=Depends(get_current_user)): - if current_user.role not in [role.value for role in self.allowed_roles]: - raise InsufficientPermissionsError(role=current_user.role) - return current_user - - -# ── Convenience Dependencies ───────────────────── -def require_roles(*roles: UserRole): - """Factory for creating role-check dependencies.""" - return Depends(RoleChecker(list(roles))) - - -# Predefined role checkers for common patterns -require_super_admin = Depends(RoleChecker([UserRole.SUPER_ADMIN])) -require_college_admin = Depends(RoleChecker([UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN])) -require_faculty = Depends( - RoleChecker([UserRole.FACULTY, UserRole.COLLEGE_ADMIN, UserRole.SUPER_ADMIN]) -) -require_recruiter = Depends(RoleChecker([UserRole.RECRUITER, UserRole.SUPER_ADMIN])) -require_student = Depends(RoleChecker([UserRole.STUDENT])) -require_any_authenticated = Depends(get_current_user) - - -# ── College Scoping ────────────────────────────── -class CollegeScope: - """ - Ensures users can only access their own college's data. - Super admins bypass this restriction. - - Returns the college_id to scope queries by, or None for super admins. - """ - - async def __call__( - self, - current_user=Depends(get_current_user), - ) -> Optional[int]: - if current_user.role == UserRole.SUPER_ADMIN.value: - return None # Super admin sees everything - return current_user.college_id or -1 - - -get_college_scope = Depends(CollegeScope()) - - -def assert_can_act_on_student(current_user, student_id: int, db) -> None: - """Allow a student to act on their own record, staff (faculty/college_admin) to act on a - student in their own college, and super_admin to act on anyone. Raises otherwise. - """ - if current_user.role == UserRole.SUPER_ADMIN.value: - return - if current_user.id == student_id: - return - if current_user.role in (UserRole.FACULTY.value, UserRole.COLLEGE_ADMIN.value): - student = db["users"].find_one({"id": student_id}) - if student is not None and student.get("college_id") == current_user.college_id: - return - raise InsufficientPermissionsError(role=current_user.role) diff --git a/python-service/app/core/security.py b/python-service/app/core/security.py deleted file mode 100644 index 69eb112..0000000 --- a/python-service/app/core/security.py +++ /dev/null @@ -1,110 +0,0 @@ -""" -UpScaler-AI V2 — Security Module -JWT token creation/verification and password hashing. -""" - -from datetime import datetime, timedelta, timezone -from typing import Optional -from uuid import uuid4 - -import jwt -from jwt import PyJWTError as JWTError -from passlib.context import CryptContext - -from app.config import get_settings - -settings = get_settings() - -# ── Password Hashing ──────────────────────────── -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - - -def hash_password(password: str) -> str: - """Hash a plaintext password.""" - return pwd_context.hash(password) - - -def verify_password(plain_password: str, hashed_password: str) -> bool: - """Verify a plaintext password against a hash.""" - return pwd_context.verify(plain_password, hashed_password) - - -# ── JWT Token Management ──────────────────────── -def create_access_token( - subject: str, - role: str, - college_id: Optional[int] = None, - expires_delta: Optional[timedelta] = None, -) -> str: - """Create a JWT access token.""" - expire = datetime.now(timezone.utc) + ( - expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) - ) - payload = { - "sub": str(subject), - "role": role, - "type": "access", - "exp": expire, - "iat": datetime.now(timezone.utc), - "jti": str(uuid4()), - } - if college_id is not None: - payload["college_id"] = college_id - return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) - - -def create_refresh_token( - subject: str, - expires_delta: Optional[timedelta] = None, -) -> tuple[str, str, datetime]: - """ - Create a JWT refresh token. - Returns: (token_string, jti, expiry_datetime) - """ - jti = str(uuid4()) - expire = datetime.now(timezone.utc) + ( - expires_delta or timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) - ) - payload = { - "sub": str(subject), - "type": "refresh", - "exp": expire, - "iat": datetime.now(timezone.utc), - "jti": jti, - } - token = jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) - return token, jti, expire - - -def decode_token(token: str) -> dict: - """ - Decode and validate a JWT token. - Raises JWTError on invalid/expired tokens. - """ - return jwt.decode( - token, - settings.JWT_SECRET_KEY, - algorithms=[settings.JWT_ALGORITHM], - ) - - -def verify_access_token(token: str) -> Optional[dict]: - """Verify an access token and return payload, or None if invalid.""" - try: - payload = decode_token(token) - if payload.get("type") != "access": - return None - return payload - except JWTError: - return None - - -def verify_refresh_token(token: str) -> Optional[dict]: - """Verify a refresh token and return payload, or None if invalid.""" - try: - payload = decode_token(token) - if payload.get("type") != "refresh": - return None - return payload - except JWTError: - return None diff --git a/python-service/app/core/websocket_manager.py b/python-service/app/core/websocket_manager.py deleted file mode 100644 index a9b4af9..0000000 --- a/python-service/app/core/websocket_manager.py +++ /dev/null @@ -1,47 +0,0 @@ -from typing import Dict, List -from fastapi import WebSocket -import logging - -logger = logging.getLogger(__name__) - - -class WebSocketManager: - def __init__(self): - # Maps user_id (int) to a list of their active WebSocket connections - self.active_connections: Dict[int, List[WebSocket]] = {} - - async def connect(self, websocket: WebSocket, user_id: int): - await websocket.accept() - if user_id not in self.active_connections: - self.active_connections[user_id] = [] - self.active_connections[user_id].append(websocket) - logger.info( - f"User {user_id} connected. Active connections: {len(self.active_connections[user_id])}" - ) - - def disconnect(self, websocket: WebSocket, user_id: int): - if user_id in self.active_connections: - if websocket in self.active_connections[user_id]: - self.active_connections[user_id].remove(websocket) - if not self.active_connections[user_id]: - del self.active_connections[user_id] - logger.info(f"User {user_id} disconnected.") - - async def send_personal_message(self, message: dict, user_id: int): - if user_id in self.active_connections: - for connection in self.active_connections[user_id]: - try: - await connection.send_json(message) - except Exception as e: - logger.error(f"Error sending message to user {user_id}: {e}") - - async def broadcast(self, message: dict): - for user_id, connections in self.active_connections.items(): - for connection in connections: - try: - await connection.send_json(message) - except Exception as e: - logger.error(f"Error broadcasting message to user {user_id}: {e}") - - -manager = WebSocketManager() diff --git a/python-service/app/database.py b/python-service/app/database.py deleted file mode 100644 index 7779832..0000000 --- a/python-service/app/database.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -Database access for the service. - -MongoDB is the only database engine used here. `get_db` is the FastAPI -dependency every route/service takes; it yields the synchronous PyMongo -database handle (see app/mongodb_sync.py). The async Motor client in -app/mongodb.py is used for startup/shutdown and async call sites. -""" - -from app.mongodb_sync import get_sync_mongo_db - - -def get_db(): - yield from get_sync_mongo_db() diff --git a/python-service/app/db_indexes.py b/python-service/app/db_indexes.py deleted file mode 100644 index bdb38d9..0000000 --- a/python-service/app/db_indexes.py +++ /dev/null @@ -1,138 +0,0 @@ -""" -UpScaler-AI V2 — MongoDB index definitions. - -Every query the API issues filters on one of the fields below. Without these -indexes MongoDB performs a full collection scan for each request, which is the -single largest source of latency against a remote Atlas cluster. - -`ensure_indexes()` is idempotent — `create_index` is a no-op when an equivalent -index already exists — so it is safe to run on every startup. -""" - -import logging - -from pymongo import ASCENDING, DESCENDING, TEXT -from pymongo.errors import OperationFailure, PyMongoError - -logger = logging.getLogger("upscaler_ai") - - -# (collection, keys, options) -INDEX_SPECS: list[tuple[str, list[tuple[str, int]], dict]] = [ - # ── users ──────────────────────────────────────────────────────────── - # Login path: find_one({"email": ...}) — unique also prevents duplicates. - ("users", [("email", ASCENDING)], {"unique": True, "name": "ux_users_email"}), - # Application-level integer id used by nearly every route. - ("users", [("id", ASCENDING)], {"unique": True, "name": "ux_users_id"}), - # get_all_users / get_pending_users scope-then-sort. - ( - "users", - [("college_id", ASCENDING), ("role", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_users_college_role_created"}, - ), - ( - "users", - [("status", ASCENDING), ("college_id", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_users_status_college_created"}, - ), - ("users", [("role", ASCENDING)], {"name": "ix_users_role"}), - # ── student_profiles ───────────────────────────────────────────────── - ("student_profiles", [("user_id", ASCENDING)], {"unique": True, "name": "ux_sp_user"}), - ("student_profiles", [("id", ASCENDING)], {"name": "ix_sp_id"}), - # ── faculty / recruiter profiles ───────────────────────────────────── - ("faculty_profiles", [("user_id", ASCENDING)], {"unique": True, "name": "ux_fp_user"}), - ("recruiter_profiles", [("user_id", ASCENDING)], {"unique": True, "name": "ux_rp_user"}), - # ── assessments ────────────────────────────────────────────────────── - ("assessments", [("id", ASCENDING)], {"unique": True, "name": "ux_assess_id"}), - ( - "assessments", - [("college_id", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_assess_college_created"}, - ), - # ── assessment_attempts ────────────────────────────────────────────── - # The dashboard's hottest query: student_id + status, newest first. - ( - "assessment_attempts", - [("student_id", ASCENDING), ("status", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_attempt_student_status_created"}, - ), - ("assessment_attempts", [("college_id", ASCENDING)], {"name": "ix_attempt_college"}), - ("assessment_attempts", [("assessment_id", ASCENDING)], {"name": "ix_attempt_assessment"}), - ("assessment_attempts", [("id", ASCENDING)], {"name": "ix_attempt_id"}), - # ── interviews ─────────────────────────────────────────────────────── - ( - "interview_attempts", - [("student_id", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_interview_student_created"}, - ), - ("interview_responses", [("interview_id", ASCENDING)], {"name": "ix_iresp_interview"}), - # ── placements ─────────────────────────────────────────────────────── - ("placements", [("student_id", ASCENDING)], {"name": "ix_placement_student"}), - ("placements", [("college_id", ASCENDING)], {"name": "ix_placement_college"}), - # ── resume ─────────────────────────────────────────────────────────── - ("resumes", [("user_id", ASCENDING)], {"unique": True, "name": "ux_resume_user"}), - ( - "resume_versions", - [("user_id", ASCENDING), ("created_at", DESCENDING)], - {"name": "ix_rversion_user_created"}, - ), - ("resume_versions", [("id", ASCENDING)], {"name": "ix_rversion_id"}), - # ── colleges / departments ─────────────────────────────────────────── - ("colleges", [("id", ASCENDING)], {"unique": True, "name": "ux_college_id"}), - ("departments", [("college_id", ASCENDING)], {"name": "ix_dept_college"}), - # ── achievements ───────────────────────────────────────────────────── - ("achievements", [("student_id", ASCENDING)], {"name": "ix_achv_student"}), - # ── chat ───────────────────────────────────────────────────────────── - # History is fetched per conversation pair and rendered oldest-first. - ( - "messages", - [("sender_id", ASCENDING), ("receiver_id", ASCENDING), ("timestamp", ASCENDING)], - {"name": "ix_msg_pair_time"}, - ), - ( - "messages", - [("receiver_id", ASCENDING), ("timestamp", DESCENDING)], - {"name": "ix_msg_receiver_time"}, - ), - # ── profile_data ───────────────────────────────────────────────────── - ("profile_data", [("user_id", ASCENDING)], {"unique": True, "name": "ux_profile_user"}), -] - -# Text index for the student search box (`GET /students?search=`). -TEXT_INDEX_SPECS: list[tuple[str, list[tuple[str, str]], dict]] = [ - ("users", [("name", TEXT), ("email", TEXT)], {"name": "tx_users_name_email"}), -] - - -def ensure_indexes(db) -> None: - """Create every index the API relies on. Safe to call repeatedly.""" - created = 0 - skipped = 0 - - for collection, keys, options in INDEX_SPECS: - try: - db[collection].create_index(keys, background=True, **options) - created += 1 - except OperationFailure as exc: - # Most commonly: an equivalent index exists under a different name, - # or a unique index cannot be built because the data has duplicates. - # Neither should stop the app from booting. - skipped += 1 - logger.warning("Index %s on %s not created: %s", options.get("name"), collection, exc) - except PyMongoError as exc: - skipped += 1 - logger.warning("Index %s on %s failed: %s", options.get("name"), collection, exc) - - for collection, keys, options in TEXT_INDEX_SPECS: - try: - db[collection].create_index(keys, background=True, **options) - created += 1 - except OperationFailure as exc: - # A collection may only carry one text index; ignore if one exists. - skipped += 1 - logger.warning("Text index on %s not created: %s", collection, exc) - except PyMongoError as exc: - skipped += 1 - logger.warning("Text index on %s failed: %s", collection, exc) - - logger.info("MongoDB indexes ensured (%d requested, %d skipped)", created, skipped) diff --git a/python-service/app/dependencies.py b/python-service/app/dependencies.py deleted file mode 100644 index c9d5290..0000000 --- a/python-service/app/dependencies.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -UpScaler-AI V2 — FastAPI Dependencies -""" - -from fastapi import Depends -from pymongo.database import Database - -from app.database import get_db -from app.services.auth_service import AuthService - - -def get_auth_service(db: Database = Depends(get_db)) -> AuthService: - return AuthService(db) diff --git a/python-service/app/main.py b/python-service/app/main.py deleted file mode 100644 index b7bee78..0000000 --- a/python-service/app/main.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -UpScaler-AI V2 — Main FastAPI Application Entry Point -""" - -import logging -from contextlib import asynccontextmanager - -import os - -from fastapi import FastAPI, Request -from fastapi.middleware.cors import CORSMiddleware -from fastapi.middleware.gzip import GZipMiddleware -from fastapi.responses import JSONResponse -from fastapi.staticfiles import StaticFiles - -from app.config import get_settings -from app.core.exceptions import UpScalerAIException -from app.core.middleware import setup_middleware -from app.api.router import api_router -from app.schemas.common import HealthResponse - -# Configure structured logging -logging.basicConfig( - level=logging.INFO, - format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", -) -logger = logging.getLogger("upscaler_ai") - -settings = get_settings() - - -from app.mongodb import connect_to_mongo, close_mongo_connection - - -@asynccontextmanager -async def lifespan(app: FastAPI): - """Lifespan events (startup/shutdown).""" - logger.info(f"🚀 Starting {settings.APP_NAME} in {settings.APP_ENV} mode...") - await connect_to_mongo() - - # Ensure query indexes exist. Without them every request is a full - # collection scan against a remote cluster. Index builds are backgrounded - # and idempotent, and a failure here must never block startup. - try: - from anyio import to_thread - - from app.db_indexes import ensure_indexes - from app.mongodb_sync import mongo_db - - await to_thread.run_sync(ensure_indexes, mongo_db) - except Exception as exc: # noqa: BLE001 — startup must survive any index error - logger.warning(f"Skipping index creation: {exc}") - - yield - await close_mongo_connection() - logger.info("🛑 Shutting down application...") - - -# Initialize FastAPI app -app = FastAPI( - title=settings.APP_NAME, - version="2.0.0", - description="Campus Placement & Assessment Management Platform", - docs_url="/docs" if settings.DEBUG else None, - redoc_url="/redoc" if settings.DEBUG else None, - openapi_url="/openapi.json" if settings.DEBUG else None, - lifespan=lifespan, -) - -# ── Response Compression ───────────────────────── -# List endpoints return sizeable JSON; gzip cuts transfer time substantially -# and costs almost nothing at this payload size. Added before CORS so the -# CORS headers survive on the compressed response. -app.add_middleware(GZipMiddleware, minimum_size=1024) - -# ── CORS Middleware ────────────────────────────── -app.add_middleware( - CORSMiddleware, - allow_origins=settings.cors_origins_list, - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# ── Custom Middleware ──────────────────────────── -setup_middleware(app) - - -# ── Global Exception Handler ───────────────────── -@app.exception_handler(UpScalerAIException) -async def upscaler_ai_exception_handler(request: Request, exc: UpScalerAIException): - return JSONResponse( - status_code=exc.status_code, - content={"success": False, "message": exc.detail}, - ) - - -# ── Static Files (onboarding profile photo/signature uploads) ──── -os.makedirs("uploads/profile", exist_ok=True) -app.mount("/uploads", StaticFiles(directory="uploads"), name="uploads") - -# ── Mount Routers ──────────────────────────────── -app.include_router(api_router) - - -# ── Health Check ───────────────────────────────── -@app.get("/health", response_model=HealthResponse, tags=["System"]) -def health_check(): - """System health check endpoint.""" - from datetime import datetime, timezone - - return HealthResponse(timestamp=datetime.now(timezone.utc)) diff --git a/python-service/app/mongodb.py b/python-service/app/mongodb.py deleted file mode 100644 index dce337b..0000000 --- a/python-service/app/mongodb.py +++ /dev/null @@ -1,37 +0,0 @@ -from motor.motor_asyncio import AsyncIOMotorClient -from app.config import get_settings -import logging - -settings = get_settings() -logger = logging.getLogger(__name__) - - -class MongoDB: - client: AsyncIOMotorClient = None - db = None - - -db_client = MongoDB() - -import certifi - - -async def connect_to_mongo(): - logger.info("Connecting to MongoDB...") - db_client.client = AsyncIOMotorClient( - settings.MONGODB_URI, - tlsCAFile=certifi.where(), - serverSelectionTimeoutMS=10000, - ) - db_client.db = db_client.client[settings.MONGODB_DB_NAME] - logger.info(f"Connected to MongoDB database: {settings.MONGODB_DB_NAME}") - - -async def close_mongo_connection(): - if db_client.client: - db_client.client.close() - logger.info("Closed MongoDB connection") - - -def get_mongo_db(): - return db_client.db diff --git a/python-service/app/mongodb_sync.py b/python-service/app/mongodb_sync.py deleted file mode 100644 index 12cc463..0000000 --- a/python-service/app/mongodb_sync.py +++ /dev/null @@ -1,45 +0,0 @@ -import os - -import certifi -from dotenv import load_dotenv -from pymongo import MongoClient - -load_dotenv() - -MONGO_URI = os.getenv("MONGODB_URI") -MONGO_DB_NAME = os.getenv("MONGODB_DB_NAME", "upscaler_ai") - -# Synchronous client for the existing synchronous routes. -# -# Pool sizing matters here: FastAPI runs `def` endpoints in a threadpool -# (40 threads by default), and each concurrent request checks out its own -# connection. A pool smaller than the threadpool makes requests queue behind -# each other, which reads as "the API is slow" even when the queries are fast. -# -# The timeouts are explicit so a network blip surfaces as a fast error rather -# than a request that hangs for the driver's 30s default. -client = MongoClient( - MONGO_URI, - tlsCAFile=certifi.where(), - maxPoolSize=int(os.getenv("MONGO_POOL_MAX", "50")), - minPoolSize=int(os.getenv("MONGO_POOL_MIN", "5")), - # Keep warm sockets so we don't pay TLS handshake cost on every burst. - maxIdleTimeMS=60_000, - serverSelectionTimeoutMS=8_000, - connectTimeoutMS=8_000, - socketTimeoutMS=20_000, - retryWrites=True, - retryReads=True, - # zlib ships with Python, so this needs no extra dependency. Wire-protocol - # compression is a real win against a remote Atlas cluster. - compressors="zlib", - appname="upscaler-ai-api", -) -mongo_db = client[MONGO_DB_NAME] - - -def get_sync_mongo_db(): - try: - yield mongo_db - finally: - pass diff --git a/python-service/app/repositories/__init__.py b/python-service/app/repositories/__init__.py deleted file mode 100644 index 223f339..0000000 --- a/python-service/app/repositories/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Repositories package diff --git a/python-service/app/repositories/base.py b/python-service/app/repositories/base.py deleted file mode 100644 index fba97f9..0000000 --- a/python-service/app/repositories/base.py +++ /dev/null @@ -1,24 +0,0 @@ -class DotDict(dict): - """dot.notation access to dictionary attributes""" - - __getattr__ = dict.get - __setattr__ = dict.__setitem__ - __delattr__ = dict.__delitem__ - - -class BaseRepository: - def __init__(self, collection_name: str, db): - self.collection_name = collection_name - self.db = db - self.collection = db[collection_name] - - def _to_obj(self, doc): - if not doc: - return None - # Convert _id to id if missing - if "_id" in doc and "id" not in doc: - doc["id"] = str(doc["_id"]) - return DotDict(doc) - - def _to_objs(self, docs): - return [self._to_obj(doc) for doc in docs] diff --git a/python-service/app/repositories/college_repo.py b/python-service/app/repositories/college_repo.py deleted file mode 100644 index 436c39e..0000000 --- a/python-service/app/repositories/college_repo.py +++ /dev/null @@ -1,26 +0,0 @@ -from app.repositories.base import BaseRepository - - -class CollegeRepository(BaseRepository): - def __init__(self, db): - super().__init__("colleges", db) - - def exists(self, id: int) -> bool: - # Check integer or string since we migrated from SQL - return self.collection.count_documents({"id": {"$in": [id, int(id), str(id)]}}) > 0 - - def get_by_name(self, name: str): - doc = self.collection.find_one({"name": {"$regex": name, "$options": "i"}}) - return self._to_obj(doc) - - def get_by_domain(self, domain: str): - doc = self.collection.find_one({"domain": domain.lower()}) - return self._to_obj(doc) - - def search(self, query: str, skip: int = 0, limit: int = 100): - docs = ( - self.collection.find({"name": {"$regex": query, "$options": "i"}}) - .skip(skip) - .limit(limit) - ) - return self._to_objs(docs) diff --git a/python-service/app/repositories/user_repo.py b/python-service/app/repositories/user_repo.py deleted file mode 100644 index 1c23638..0000000 --- a/python-service/app/repositories/user_repo.py +++ /dev/null @@ -1,114 +0,0 @@ -from datetime import datetime, timezone -from typing import Optional - -from app.repositories.base import BaseRepository - - -class UserRepository(BaseRepository): - def __init__(self, db): - super().__init__("users", db) - - def get_by_email(self, email: str): - doc = self.collection.find_one({"email": email.lower()}) - return self._to_obj(doc) - - def get_by_id(self, user_id): - doc = self.collection.find_one({"id": user_id}) - if not doc: - # Fallback to int ID lookup just in case - try: - doc = self.collection.find_one({"id": int(user_id)}) - except (ValueError, TypeError): - pass - return self._to_obj(doc) - - def get_by_college( - self, college_id: int, role: Optional[str] = None, skip: int = 0, limit: int = 100 - ): - query = {"college_id": int(college_id) if college_id else None} - if role: - query["role"] = role - docs = self.collection.find(query).skip(skip).limit(limit) - return self._to_objs(docs) - - def get_students_by_college(self, college_id: int, department: Optional[str] = None): - query = { - "college_id": int(college_id) if college_id else None, - "role": "student", - "is_active": True, - } - if department: - query["department"] = department - docs = self.collection.find(query) - return self._to_objs(docs) - - def update_last_login(self, user): - now = datetime.now(timezone.utc).isoformat() - self.collection.update_one({"id": user.id}, {"$set": {"last_login_at": now}}) - user.last_login_at = now - return user - - def email_exists(self, email: str) -> bool: - return self.collection.count_documents({"email": email.lower()}) > 0 - - def create(self, data: dict): - if "id" not in data: - last_doc = self.collection.find_one(sort=[("id", -1)]) - data["id"] = (last_doc["id"] + 1) if last_doc and "id" in last_doc else 1 - data["is_active"] = True - self.collection.insert_one(data) - return self._to_obj(data) - - def update(self, user_id, update_data: dict): - self.collection.update_one({"id": user_id}, {"$set": update_data}) - - -class StudentProfileRepository(BaseRepository): - def __init__(self, db): - super().__init__("student_profiles", db) - - def get_by_user_id(self, user_id: int): - doc = self.collection.find_one({"user_id": user_id}) - return self._to_obj(doc) - - def create(self, data: dict): - if "id" not in data: - last_doc = self.collection.find_one(sort=[("id", -1)]) - data["id"] = (last_doc["id"] + 1) if last_doc and "id" in last_doc else 1 - self.collection.insert_one(data) - return self._to_obj(data) - - -class RefreshTokenRepository(BaseRepository): - def __init__(self, db): - super().__init__("refresh_tokens", db) - - def get_by_jti(self, jti: str): - doc = self.collection.find_one({"jti": jti, "is_revoked": False}) - return self._to_obj(doc) - - def revoke_token(self, token): - self.collection.update_one( - {"jti": token.jti}, - {"$set": {"is_revoked": True, "revoked_at": datetime.now(timezone.utc).isoformat()}}, - ) - - def revoke_all_user_tokens(self, user_id: int) -> int: - result = self.collection.update_many( - {"user_id": user_id, "is_revoked": False}, - {"$set": {"is_revoked": True, "revoked_at": datetime.now(timezone.utc).isoformat()}}, - ) - return result.modified_count - - def cleanup_expired(self) -> int: - now = datetime.now(timezone.utc).isoformat() - result = self.collection.delete_many({"expires_at": {"$lt": now}}) - return result.deleted_count - - def create(self, data: dict): - if "id" not in data: - last_doc = self.collection.find_one(sort=[("id", -1)]) - data["id"] = (last_doc["id"] + 1) if last_doc and "id" in last_doc else 1 - data["is_revoked"] = False - self.collection.insert_one(data) - return self._to_obj(data) diff --git a/python-service/app/schemas/__init__.py b/python-service/app/schemas/__init__.py deleted file mode 100644 index 8d2fd85..0000000 --- a/python-service/app/schemas/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Schemas package diff --git a/python-service/app/schemas/achievement.py b/python-service/app/schemas/achievement.py deleted file mode 100644 index 7a54804..0000000 --- a/python-service/app/schemas/achievement.py +++ /dev/null @@ -1,21 +0,0 @@ -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel - - -class AchievementResponse(BaseModel): - id: int - user_id: int - college_id: int - achievement_type: str - title: str - description: Optional[str] = None - source_module: str - reference_id: Optional[int] = None - metric_value: Optional[float] = None - auto_generated: bool - achieved_at: Optional[datetime] = None - created_at: Optional[datetime] = None - - model_config = {"from_attributes": True} diff --git a/python-service/app/schemas/assessment.py b/python-service/app/schemas/assessment.py deleted file mode 100644 index af2b513..0000000 --- a/python-service/app/schemas/assessment.py +++ /dev/null @@ -1,86 +0,0 @@ -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, Field - - -class AssessmentCreateRequest(BaseModel): - title: str = Field(min_length=2, max_length=255) - description: Optional[str] = None - assessment_type: str = "mixed" - duration_minutes: int = Field(default=30, ge=1) - total_marks: int = Field(default=20, ge=1) - pass_percentage: float = Field(default=40.0, ge=0, le=100) - negative_marking: bool = False - target_departments: Optional[str] = None - target_years: Optional[str] = None - difficulty: str = "medium" - status: str = "active" - scheduled_start: Optional[datetime] = None - scheduled_end: Optional[datetime] = None - - -class AssessmentUpdateRequest(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - duration_minutes: Optional[int] = None - total_marks: Optional[int] = None - pass_percentage: Optional[float] = None - negative_marking: Optional[bool] = None - target_departments: Optional[str] = None - target_years: Optional[str] = None - difficulty: Optional[str] = None - status: Optional[str] = None - scheduled_start: Optional[datetime] = None - scheduled_end: Optional[datetime] = None - - -class AssessmentResponse(BaseModel): - id: int - title: str - description: Optional[str] = None - assessment_type: str - college_id: int - created_by: Optional[int] = None - duration_minutes: int - total_marks: int - pass_percentage: float - negative_marking: bool - target_departments: Optional[str] = None - target_years: Optional[str] = None - difficulty: str - status: str - scheduled_start: Optional[datetime] = None - scheduled_end: Optional[datetime] = None - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} - - -class TestSubmitRequest(BaseModel): - assessment_id: Optional[int] = None - roll_no: Optional[str] = None - score: int = 0 - max_score: int = Field(default=100, ge=1) - percentage: Optional[float] = None - time_taken_seconds: Optional[int] = None - section_scores: Optional[str] = None - weak_areas: Optional[str] = None - - -class AttemptResponse(BaseModel): - id: int - assessment_id: int - student_id: int - college_id: int - score: Optional[int] = None - max_score: Optional[int] = None - percentage: Optional[float] = None - time_taken_seconds: Optional[int] = None - passed: Optional[bool] = None - status: str - completed_at: Optional[datetime] = None - created_at: datetime - - model_config = {"from_attributes": True} diff --git a/python-service/app/schemas/auth.py b/python-service/app/schemas/auth.py deleted file mode 100644 index c266e75..0000000 --- a/python-service/app/schemas/auth.py +++ /dev/null @@ -1,122 +0,0 @@ -""" -UpScaler-AI V2 — Auth Pydantic Schemas -Request/Response models for authentication endpoints. -""" - -from typing import Optional -from pydantic import BaseModel, EmailStr, Field, field_validator -import re - - -class RegisterRequest(BaseModel): - """User registration request.""" - - email: EmailStr - password: str = Field(min_length=6, max_length=128) - name: str = Field(min_length=2, max_length=255) - role: str = Field(default="student") - college_id: Optional[int] = None - department: Optional[str] = None - - # Student-specific - student_id: Optional[str] = None - year: Optional[int] = None - - # Recruiter-specific - company_name: Optional[str] = None - - @field_validator("role") - @classmethod - def validate_role(cls, v): - allowed = {"student", "faculty", "college_admin", "recruiter"} - if v not in allowed: - raise ValueError(f"Role must be one of: {', '.join(allowed)}") - return v - - @field_validator("password") - @classmethod - def validate_password(cls, v): - if not re.search(r"[A-Za-z]", v): - raise ValueError("Password must contain at least one letter") - if not re.search(r"\d", v): - raise ValueError("Password must contain at least one digit") - return v - - -class LoginRequest(BaseModel): - """User login request.""" - - email: EmailStr - password: str - - -class TokenResponse(BaseModel): - """JWT token response.""" - - access_token: str - refresh_token: str - token_type: str = "bearer" - expires_in: int # seconds - user: "UserBriefResponse" - - -class RefreshTokenRequest(BaseModel): - """Refresh token request.""" - - refresh_token: str - - -class ChangePasswordRequest(BaseModel): - """Change password request.""" - - current_password: str - new_password: str = Field(min_length=6, max_length=128) - - @field_validator("new_password") - @classmethod - def validate_new_password(cls, v): - if not re.search(r"[A-Za-z]", v): - raise ValueError("Password must contain at least one letter") - if not re.search(r"\d", v): - raise ValueError("Password must contain at least one digit") - return v - - -class ForgotPasswordRequest(BaseModel): - """- request.""" - - email: EmailStr - - -class ResetPasswordRequest(BaseModel): - """Reset password with token.""" - - token: str - new_password: str = Field(min_length=6, max_length=128) - - -class UpdateProfileRequest(BaseModel): - name: Optional[str] = None - company_name: Optional[str] = None - department: Optional[str] = None - avatar_url: Optional[str] = None - - -class UserBriefResponse(BaseModel): - """Brief user info included in auth responses.""" - - id: str | int - email: str - name: str - role: str - college_id: Optional[str | int] = None - department: Optional[str] = None - avatar_url: Optional[str] = None - company_name: Optional[str] = None - student_id: Optional[str] = None - - model_config = {"from_attributes": True} - - -# Update forward reference -TokenResponse.model_rebuild() diff --git a/python-service/app/schemas/batch.py b/python-service/app/schemas/batch.py deleted file mode 100644 index 0e73295..0000000 --- a/python-service/app/schemas/batch.py +++ /dev/null @@ -1,41 +0,0 @@ -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, EmailStr, Field - - -class BatchStudentInput(BaseModel): - name: str - email: Optional[EmailStr] = None - student_id: Optional[str] = None - roll: Optional[str] = None - department: Optional[str] = None - year: Optional[int] = None - password: str = "student123" - - -class BatchCreateRequest(BaseModel): - name: str = Field(min_length=2, max_length=255) - batch_code: Optional[str] = None - department: str - year: str - students: list[BatchStudentInput] = [] - - -class BatchStatusRequest(BaseModel): - status: str - - -class BatchResponse(BaseModel): - id: int - name: str - batch_code: str - college_id: int - faculty_id: int - department: str - year: str - status: str - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - model_config = {"from_attributes": True} diff --git a/python-service/app/schemas/college.py b/python-service/app/schemas/college.py deleted file mode 100644 index 8d7d0eb..0000000 --- a/python-service/app/schemas/college.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -UpScaler-AI V2 — College Pydantic Schemas -""" - -from datetime import datetime -from typing import Optional -from pydantic import BaseModel, Field - - -class CollegeCreateRequest(BaseModel): - name: str = Field(min_length=3, max_length=255) - short_code: str = Field(min_length=2, max_length=20) - location: Optional[str] = "" - address: Optional[str] = "" - contact_email: Optional[str] = None - contact_phone: Optional[str] = None - website: Optional[str] = None - departments: list[dict] = [] # [{"name": "CSE", "code": "CSE"}] - - -class CollegeUpdateRequest(BaseModel): - name: Optional[str] = None - location: Optional[str] = None - address: Optional[str] = None - contact_email: Optional[str] = None - contact_phone: Optional[str] = None - website: Optional[str] = None - is_active: Optional[bool] = None - - -class CollegeResponse(BaseModel): - id: int - name: str - short_code: str - location: str - address: str - contact_email: Optional[str] = None - contact_phone: Optional[str] = None - website: Optional[str] = None - logo_url: Optional[str] = None - is_active: bool - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} - - -class DepartmentResponse(BaseModel): - id: int - name: str - code: str - is_active: bool - college_id: int - - model_config = {"from_attributes": True} diff --git a/python-service/app/schemas/common.py b/python-service/app/schemas/common.py deleted file mode 100644 index 38f46c2..0000000 --- a/python-service/app/schemas/common.py +++ /dev/null @@ -1,37 +0,0 @@ -""" -UpScaler-AI V2 — Common Pydantic Schemas -Shared response models and base schemas. -""" - -from datetime import datetime -from typing import Generic, TypeVar -from pydantic import BaseModel - -T = TypeVar("T") - - -class MessageResponse(BaseModel): - """Standard message response.""" - - success: bool = True - message: str - - -class PaginatedResponse(BaseModel, Generic[T]): - """Standard paginated response wrapper.""" - - items: list[T] - total: int - page: int - per_page: int - total_pages: int - has_next: bool - has_prev: bool - - -class HealthResponse(BaseModel): - """Health check response.""" - - status: str = "healthy" - version: str = "2.0.0" - timestamp: datetime diff --git a/python-service/app/schemas/interview.py b/python-service/app/schemas/interview.py deleted file mode 100644 index 48b7f0c..0000000 --- a/python-service/app/schemas/interview.py +++ /dev/null @@ -1,63 +0,0 @@ -from datetime import datetime -from typing import Optional, Union -import json - -from pydantic import BaseModel, Field, field_validator - - -class InterviewResponseInput(BaseModel): - question_id: Optional[int] = None - question_text: Optional[str] = None - answer_text: Optional[str] = None - score: Optional[int] = Field(default=None, ge=0) - rating: Optional[int] = Field(default=None, ge=1, le=10) - feedback: Optional[str] = None - time_taken_seconds: Optional[int] = None - - -class InterviewSubmitRequest(BaseModel): - role: str - category: str = "technical" - overall_rating: Optional[float] = None - strengths: Optional[Union[list[str], str]] = None - improvements: Optional[Union[list[str], str]] = None - duration_seconds: Optional[int] = None - responses: list[InterviewResponseInput] = [] - - @field_validator("strengths", "improvements", mode="before") - @classmethod - def coerce_to_json_str(cls, v): - """Store lists as JSON strings in DB.""" - if isinstance(v, list): - return json.dumps(v) - return v - - -class InterviewAttemptResponse(BaseModel): - id: int - student_id: int - college_id: int - role: str - category: str - overall_rating: Optional[float] = None - strengths: Optional[list[str]] = None - improvements: Optional[list[str]] = None - duration_seconds: Optional[int] = None - attempt_number: int - status: str - created_at: Optional[datetime] = None - - model_config = {"from_attributes": True} - - @field_validator("strengths", "improvements", mode="before") - @classmethod - def parse_json_list(cls, v): - """Parse JSON strings back to lists for the response.""" - if isinstance(v, str): - try: - parsed = json.loads(v) - if isinstance(parsed, list): - return parsed - except Exception: - return [v] - return v diff --git a/python-service/app/schemas/persistence.py b/python-service/app/schemas/persistence.py deleted file mode 100644 index e57e5c4..0000000 --- a/python-service/app/schemas/persistence.py +++ /dev/null @@ -1,12 +0,0 @@ -from typing import Any - -from pydantic import BaseModel - - -class UserDataRequest(BaseModel): - data: dict[str, Any] = {} - - -class UserDataResponse(BaseModel): - success: bool = True - data: dict[str, Any] = {} diff --git a/python-service/app/schemas/placement.py b/python-service/app/schemas/placement.py deleted file mode 100644 index b8333fb..0000000 --- a/python-service/app/schemas/placement.py +++ /dev/null @@ -1,86 +0,0 @@ -from datetime import datetime -from typing import Optional - -from pydantic import BaseModel, Field - - -class PlacementCreateRequest(BaseModel): - student_id: Optional[int] = None - company_name: str - role: str - salary_lpa: float = Field(ge=0) - work_type: str = "onsite" - mode: str = "campus" - location: Optional[str] = None - proof_url: Optional[str] = None - - -class PlacementVerifyRequest(BaseModel): - verification_status: str = "verified" - - -class PlacementResponse(BaseModel): - id: int - student_id: int - college_id: int - company_name: str - role: str - salary_lpa: float - work_type: str - mode: str - location: Optional[str] = None - offer_date: Optional[datetime] = None - status: str - proof_url: Optional[str] = None - verified_by: Optional[int] = None - verification_status: str - verified_at: Optional[datetime] = None - created_at: Optional[datetime] = None - updated_at: Optional[datetime] = None - - model_config = {"from_attributes": True} - - -class JobPostingCreateRequest(BaseModel): - college_id: int = 1 # Defaulting for now - title: str - description: Optional[str] = None - company_name: str - location: Optional[str] = None - job_type: str = "full_time" - salary_min_lpa: Optional[float] = None - salary_max_lpa: Optional[float] = None - required_skills: Optional[str] = None - eligible_departments: Optional[str] = None - eligible_years: Optional[str] = None - min_cgpa: Optional[float] = None - application_deadline: Optional[datetime] = None - - -class JobPostingResponse(JobPostingCreateRequest): - id: int - recruiter_id: int - status: str - is_active: bool - created_at: datetime - updated_at: datetime - - model_config = {"from_attributes": True} - - -class JobApplicationResponse(BaseModel): - id: int - job_posting_id: int - student_id: int - status: str - applied_at: datetime - updated_at: datetime - notes: Optional[str] = None - interview_scheduled_at: Optional[datetime] = None - - # Nested info we might want - student_name: Optional[str] = None - student_email: Optional[str] = None - job_title: Optional[str] = None - - model_config = {"from_attributes": True} diff --git a/python-service/app/schemas/user.py b/python-service/app/schemas/user.py deleted file mode 100644 index 22e48ac..0000000 --- a/python-service/app/schemas/user.py +++ /dev/null @@ -1,93 +0,0 @@ -""" -UpScaler-AI V2 — User Pydantic Schemas -""" - -from datetime import datetime, timezone -from typing import Optional -from pydantic import BaseModel, EmailStr, Field - - -class UserResponse(BaseModel): - """Full user response.""" - - id: int - email: str - name: str - role: str - college_id: Optional[int] = None - department: Optional[str] = None - phone: Optional[str] = None - avatar_url: Optional[str] = None - is_active: bool = True - is_email_verified: bool = False - status: str = "approved" - last_login_at: Optional[datetime] = None - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) - preferences: Optional[dict] = None - - model_config = {"from_attributes": True} - - -class StudentProfileResponse(BaseModel): - """Student profile response.""" - - id: int - user_id: int - student_id: Optional[str] = None - year: Optional[int] = None - semester: Optional[int] = None - cgpa: Optional[float] = None - skills: Optional[str] = None - resume_url: Optional[str] = None - linkedin_url: Optional[str] = None - github_url: Optional[str] = None - tests_completed: int = 0 - avg_accuracy: float = 0.0 - interviews_completed: int = 0 - streak: int = 0 - national_rank: Optional[int] = None - placement_status: str = "unplaced" - - model_config = {"from_attributes": True} - - -class UserUpdateRequest(BaseModel): - """User profile update.""" - - name: Optional[str] = Field(None, min_length=2, max_length=255) - phone: Optional[str] = None - department: Optional[str] = None - avatar_url: Optional[str] = None - - -class StudentProfileUpdateRequest(BaseModel): - """Student profile update.""" - - student_id: Optional[str] = None - year: Optional[int] = None - - -class AdminUserCreateRequest(BaseModel): - """Admin request to create a new user.""" - - name: str = Field(..., min_length=2, max_length=255) - email: EmailStr - role: str - password: str - college_id: Optional[int] = None - department: Optional[str] = None - student_id: Optional[str] = None - year: Optional[int] = None - - -class AdminUserUpdateRequest(BaseModel): - """Admin update request to manage user attributes directly.""" - - name: Optional[str] = Field(None, min_length=2, max_length=255) - email: Optional[EmailStr] = None - role: Optional[str] = None - status: Optional[str] = None - department: Optional[str] = None - college_id: Optional[int] = None - preferences: Optional[dict] = None diff --git a/python-service/app/services/__init__.py b/python-service/app/services/__init__.py deleted file mode 100644 index a70b302..0000000 --- a/python-service/app/services/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Services package diff --git a/python-service/app/services/auth_service.py b/python-service/app/services/auth_service.py deleted file mode 100644 index 3777428..0000000 --- a/python-service/app/services/auth_service.py +++ /dev/null @@ -1,255 +0,0 @@ -""" -UpScaler-AI V2 — Authentication Service -Business logic for registration, login, and token management. -""" - -from typing import Optional - -from pymongo.database import Database - -from app.config import get_settings -from app.core.exceptions import ( - DuplicateError, - InvalidCredentialsError, - InvalidTokenError, -) -from app.core.security import ( - hash_password, - verify_password, - create_access_token, - create_refresh_token, - verify_refresh_token, -) -from app.repositories.base import DotDict -from app.repositories.user_repo import ( - UserRepository, - StudentProfileRepository, - RefreshTokenRepository, -) -from app.repositories.college_repo import CollegeRepository -from app.schemas.auth import ( - RegisterRequest, - LoginRequest, - TokenResponse, - UserBriefResponse, - ChangePasswordRequest, -) - -settings = get_settings() - - -class AuthService: - def __init__(self, db: Database): - self.db = db - self.user_repo = UserRepository(db) - self.student_profile_repo = StudentProfileRepository(db) - self.refresh_token_repo = RefreshTokenRepository(db) - self.college_repo = CollegeRepository(db) - - def register(self, data: RegisterRequest) -> DotDict: - """Register a new user and return the user object.""" - if self.user_repo.email_exists(data.email): - raise DuplicateError(resource="User", field="email") - - # Validate college if provided - if data.college_id: - if not self.college_repo.exists(id=data.college_id): - raise ValueError("Invalid college_id") - - # Create core user - status = "approved" if data.role == "student" else "pending" - user_data = { - "email": data.email.lower(), - "password_hash": hash_password(data.password), - "name": data.name, - "role": data.role, - "college_id": data.college_id, - "department": data.department, - "status": status, - } - - user = self.user_repo.create(user_data) - - # Create role-specific profile - if data.role == "student": - self.student_profile_repo.create( - { - "user_id": user.id, - "student_id": data.student_id, - "year": data.year, - } - ) - elif data.role == "recruiter" and data.company_name: - self.db["recruiter_profiles"].insert_one( - {"user_id": user.id, "company_name": data.company_name} - ) - - return user - - def _issue_tokens(self, user: DotDict) -> TokenResponse: - if not user.is_active: - raise InvalidCredentialsError() - if user.status == "pending": - from app.core.exceptions import AccountPendingError - - raise AccountPendingError() - if user.status != "approved": - raise InvalidCredentialsError() - - self.user_repo.update_last_login(user) - access_token = create_access_token( - subject=str(user.id), - role=user.role, - college_id=user.college_id, - ) - refresh_token, jti, expire = create_refresh_token(subject=str(user.id)) - - # Save refresh token in DB - self.refresh_token_repo.create( - { - "user_id": user.id, - "jti": jti, - "token_hash": hash_password( - refresh_token - ), # Optional: hash refresh token for extra security - "expires_at": expire, - } - ) - - return TokenResponse( - access_token=access_token, - refresh_token=refresh_token, - expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, - user=UserBriefResponse.model_validate(user), - ) - - def login(self, data: LoginRequest) -> TokenResponse: - """Authenticate user and return tokens.""" - user = self.user_repo.get_by_email(data.email) - if not user or not verify_password(data.password, user.password_hash): - raise InvalidCredentialsError() - return self._issue_tokens(user) - - def login_with_student_id( - self, student_id: str, password: str, college_id: Optional[int] = None - ) -> TokenResponse: - profile_query = {"student_id": student_id.upper()} - profile_doc = self.db["student_profiles"].find_one(profile_query) - if not profile_doc: - raise InvalidCredentialsError() - - user_query = {"id": profile_doc["user_id"], "role": "student"} - if college_id: - user_query["college_id"] = int(college_id) - - user_doc = self.db["users"].find_one(user_query) - if not user_doc: - raise InvalidCredentialsError() - - user = self.user_repo._to_obj(user_doc) - if not verify_password(password, user.password_hash): - raise InvalidCredentialsError() - - return self._issue_tokens(user) - - def refresh_token(self, refresh_token: str) -> TokenResponse: - """Generate new access token from refresh token.""" - payload = verify_refresh_token(refresh_token) - if not payload: - raise InvalidTokenError() - - jti = payload.get("jti") - user_id_str = payload.get("sub") - - if not jti or not user_id_str: - raise InvalidTokenError() - - user_id = int(user_id_str) - - # Check if token is valid in DB - db_token = self.refresh_token_repo.get_by_jti(jti) - if not db_token or db_token.user_id != user_id: - # Token reuse detected! Revoke all tokens for user. - if db_token and db_token.is_revoked: - self.refresh_token_repo.revoke_all_user_tokens(user_id) - raise InvalidTokenError() - - user = self.user_repo.get_by_id(user_id) - if not user or not user.is_active or user.status != "approved": - raise InvalidTokenError() - - # Revoke old refresh token (token rotation) - self.refresh_token_repo.revoke_token(db_token) - - # Generate new tokens - access_token = create_access_token( - subject=str(user.id), - role=user.role, - college_id=user.college_id, - ) - new_refresh_token, new_jti, expire = create_refresh_token(subject=str(user.id)) - - # Save new refresh token - self.refresh_token_repo.create( - { - "user_id": user.id, - "jti": new_jti, - "token_hash": hash_password(new_refresh_token), - "expires_at": expire, - } - ) - - return TokenResponse( - access_token=access_token, - refresh_token=new_refresh_token, - expires_in=settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, - user=UserBriefResponse.model_validate(user), - ) - - def google_login( - self, email: str, name: str, role: str, college_id: Optional[int] = None - ) -> TokenResponse: - """Find-or-create a user from a verified Google profile, then issue tokens. - - Mirrors register()'s approval rule: student accounts are auto-approved, - every other role starts 'pending' until an admin approves them, so - _issue_tokens will raise AccountPendingError for a freshly created - non-student account — callers should treat that as "check back later", - not as a failure. - """ - email = email.lower() - if role == "hr": - role = "recruiter" - - user = self.user_repo.get_by_email(email) - if user is None: - import secrets - - user_data = { - "email": email, - "password_hash": hash_password(secrets.token_urlsafe(32)), - "name": name, - "role": role, - "college_id": college_id, - "status": "approved" if role == "student" else "pending", - } - user = self.user_repo.create(user_data) - if role == "student": - self.student_profile_repo.create( - {"user_id": user.id, "student_id": None, "year": None} - ) - - return self._issue_tokens(user) - - def logout(self, user_id: int) -> None: - """Revoke all refresh tokens for a user (global logout for simplicity).""" - self.refresh_token_repo.revoke_all_user_tokens(user_id) - - def change_password(self, user_id: int, data: ChangePasswordRequest) -> None: - """Change a user's password.""" - user = self.user_repo.get_by_id(user_id) - if not user or not verify_password(data.current_password, user.password_hash): - raise InvalidCredentialsError("Incorrect current password") - - new_hash = hash_password(data.new_password) - self.user_repo.update(user_id, {"password_hash": new_hash}) diff --git a/python-service/pyproject.toml b/python-service/pyproject.toml deleted file mode 100644 index 3573a87..0000000 --- a/python-service/pyproject.toml +++ /dev/null @@ -1,38 +0,0 @@ -[project] -name = "backend" -version = "0.1.0" -description = "Add your description here" -readme = "README.md" -requires-python = ">=3.12" -dependencies = [] - -[tool.pytest.ini_options] -# `pythonpath = ["."]` is required, not cosmetic: the tests import `app.*`, and -# a bare `pytest` (what CI runs) does not put the working directory on -# sys.path — only `python -m pytest` does. Without this the suite collects -# fine locally via `python -m pytest` but fails in CI with -# "ModuleNotFoundError: No module named 'app'". -pythonpath = ["."] -testpaths = ["tests"] - -[tool.black] -# Matches [tool.ruff] line-length below so the two tools don't disagree about -# where to wrap. -line-length = 100 -target-version = ["py312"] -extend-exclude = "/\\.venv/" - -[tool.ruff] -line-length = 100 -target-version = "py312" -exclude = [".venv"] - -[tool.ruff.lint] -# Defaults (pyflakes + a slice of pycodestyle) only — this project predates -# ruff, so style/modernization rule families (I, UP, B, ...) are left off to -# avoid a repo-wide reformat unrelated to the dependency security audit. -select = ["E4", "E7", "E9", "F"] -# E402: several modules deliberately import after some top-level code runs, -# to break circular-import cycles (see the comments at each site) — a real -# fix means restructuring those modules, which is out of scope here. -ignore = ["E402"] diff --git a/python-service/requirements-dev.txt b/python-service/requirements-dev.txt deleted file mode 100644 index ac6b902..0000000 --- a/python-service/requirements-dev.txt +++ /dev/null @@ -1,5 +0,0 @@ --r requirements.txt - -ruff==0.16.1 -black==26.5.1 -pytest==9.1.1 diff --git a/python-service/requirements.txt b/python-service/requirements.txt deleted file mode 100644 index 1750542..0000000 --- a/python-service/requirements.txt +++ /dev/null @@ -1,60 +0,0 @@ -# ═══════════════════════════════════════════ -# UpScaler-AI V2 — Backend Dependencies -# ═══════════════════════════════════════════ -# -# Python: 3.12. Python 3.14 currently forces pydantic-core to build -# from source with a PyO3 version that does not support that interpreter. - -# --- Core Framework --- -fastapi==0.141.1 -# Pinned explicitly (fastapi allows a wide starlette range): starlette<1.1.0 -# carries an SSRF-via-UNC-path issue in StaticFiles (GHSA-wqp7-x3pw-xc5r), -# which this app's /uploads mount uses. -starlette==1.3.1 -uvicorn[standard]==0.34.3 -python-multipart==0.0.32 - -# --- Database (MongoDB — the only database engine used by this service) --- -# These three were imported at runtime (app/mongodb.py, app/mongodb_sync.py, -# app/db_indexes.py, seed_mongo.py) but were never declared here — the service -# only started locally because they happened to be present in the dev venv. -# A clean `pip install -r requirements.txt` (i.e. the Docker build) produced an -# image that crashed on startup with ModuleNotFoundError. Declared explicitly now. -motor==3.7.1 -pymongo==4.17.0 -# Directly imported for TLS CA bundles when connecting to Atlas; it also arrives -# transitively via httpx, but this service uses it in its own right. -certifi==2026.7.22 - - -# --- Validation & Serialization --- -# Single pinned entry with the [email] extra. This was previously two lines — -# `pydantic==2.11.3` plus a bare, unpinned `pydantic[email]` — a duplicate -# declaration that `safety` flags: the unpinned specifier nominally admits -# ancient pydantic 1.x releases that carry known CVEs. pip intersected the two -# and resolved 2.11.3 anyway, so nothing vulnerable was ever installed, but the -# range is now closed explicitly rather than relying on that interaction. -pydantic[email]==2.11.3 -pydantic-settings==2.9.1 - -# --- Authentication --- -# python-jose was replaced with PyJWT: python-jose unconditionally pulls in -# ecdsa, which has an unfixed Minerva timing-attack CVE the maintainers have -# declined to patch (see SECURITY_AUDIT.md). PyJWT covers the same HS256 -# encode/decode usage in app/core/security.py without that dependency. -pyjwt==2.13.0 -passlib[bcrypt]==1.7.4 -bcrypt==4.3.0 - -# --- Security & Middleware --- -slowapi==0.1.9 -python-dotenv==1.2.2 - -# --- AI --- -# Imported by app/api/v1/{ai,assessments,interviews,resume}.py. Was also -# undeclared here (same startup-crash class of bug as the Mongo drivers above). -groq==1.6.0 - -# --- Utilities --- -email-validator==2.2.0 -httpx==0.27.2 diff --git a/python-service/seed_mongo.py b/python-service/seed_mongo.py deleted file mode 100644 index 4c0a71c..0000000 --- a/python-service/seed_mongo.py +++ /dev/null @@ -1,68 +0,0 @@ -import asyncio -import os -import sys - -from dotenv import load_dotenv -from motor.motor_asyncio import AsyncIOMotorClient -import certifi -from passlib.context import CryptContext -from datetime import datetime, timezone - -load_dotenv() - -pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") - -# Credentials are read from the environment only. Never hardcode a connection -# string here again — a previous version of this file had a live Atlas -# password committed in plaintext, which must be treated as compromised and -# rotated in the Atlas console regardless of this fix. -MONGODB_URI = os.environ.get("MONGODB_URI") -MONGODB_DB_NAME = os.environ.get("MONGODB_DB_NAME", "upscaler_ai") -SUPER_ADMIN_EMAIL = os.environ.get("SUPER_ADMIN_EMAIL") -SUPER_ADMIN_PASSWORD = os.environ.get("SUPER_ADMIN_PASSWORD") - - -async def seed(): - if not MONGODB_URI: - sys.exit("MONGODB_URI is not set (check your .env) — refusing to run without it.") - if not SUPER_ADMIN_EMAIL or not SUPER_ADMIN_PASSWORD: - sys.exit( - "SUPER_ADMIN_EMAIL / SUPER_ADMIN_PASSWORD are not set (check your .env) — refusing to run without them." - ) - - client = AsyncIOMotorClient(MONGODB_URI, tlsCAFile=certifi.where()) - db = client[MONGODB_DB_NAME] - - email = SUPER_ADMIN_EMAIL - password = SUPER_ADMIN_PASSWORD - - admin = await db.users.find_one({"email": email}) - if not admin: - print("Inserting admin...") - await db.users.insert_one( - { - "email": email, - "password_hash": pwd_context.hash(password), - "name": "UpScaler-AI Super Admin", - "role": "super_admin", - "status": "approved", - "is_active": True, - "is_email_verified": True, - "created_at": datetime.now(timezone.utc), - "updated_at": datetime.now(timezone.utc), - "college_id": None, - "department": None, - } - ) - print("Admin inserted.") - else: - print("Updating admin password...") - await db.users.update_one( - {"email": email}, {"$set": {"password_hash": pwd_context.hash(password)}} - ) - print("Admin updated.") - client.close() - - -if __name__ == "__main__": - asyncio.run(seed()) diff --git a/python-service/tests/conftest.py b/python-service/tests/conftest.py deleted file mode 100644 index 505ac85..0000000 --- a/python-service/tests/conftest.py +++ /dev/null @@ -1,11 +0,0 @@ -import os - -# Settings() (see app/config.py) requires JWT_SECRET_KEY with no default and -# reads from a local .env file that is gitignored and won't exist in CI — -# set the required values directly so the test suite doesn't depend on any -# machine-local file. -os.environ.setdefault("JWT_SECRET_KEY", "test-only-secret-key-not-for-production-use-1234") -os.environ.setdefault("MONGODB_URI", "mongodb://127.0.0.1:27017") -os.environ.setdefault("MONGODB_DB_NAME", "upscaler_ai_test") -os.environ.setdefault("SUPER_ADMIN_EMAIL", "admin@example.com") -os.environ.setdefault("SUPER_ADMIN_PASSWORD", "test-only-admin-password") diff --git a/python-service/tests/test_routes.py b/python-service/tests/test_routes.py deleted file mode 100644 index 9e2d364..0000000 --- a/python-service/tests/test_routes.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Route-registration smoke tests. - -These guard against silent routing regressions across FastAPI/Starlette -upgrades. Newer FastAPI stores included routers lazily (as `_IncludedRouter` -entries) instead of eagerly flattening them into `app.routes`, so inspecting -`len(app.routes)` proves nothing — only an actual request does. - -A protected endpoint must answer 401 (route exists, auth rejected the caller), -never 404 (route disappeared). No MongoDB connection is needed: auth rejects -these requests before any query runs. Only genuinely auth-gated paths belong -below — a public route (e.g. GET /api/v1/colleges, used by the registration -dropdown) would reach the database and hang without a live server. -""" - -import pytest -from starlette.testclient import TestClient - -from app.main import app - - -@pytest.fixture(scope="module") -def client(): - # Not used as a context manager on purpose: that would run the lifespan - # handler, which connects to MongoDB. - return TestClient(app) - - -def test_health_endpoint(client): - res = client.get("/health") - assert res.status_code == 200 - assert res.json()["status"] == "healthy" - - -@pytest.mark.parametrize( - "path", - [ - "/api/v1/auth/me", - "/api/v1/users/", - "/api/v1/resume", - "/api/v1/dashboard/admin", - "/api/v1/students", - "/api/v1/placements", - ], -) -def test_protected_routes_are_registered(client, path): - assert client.get(path).status_code == 401, f"{path} should exist but require auth" - - -def test_unknown_route_still_404s(client): - assert client.get("/api/v1/definitely-not-a-route").status_code == 404 diff --git a/python-service/tests/test_security.py b/python-service/tests/test_security.py deleted file mode 100644 index b83c92e..0000000 --- a/python-service/tests/test_security.py +++ /dev/null @@ -1,51 +0,0 @@ -""" -Covers app/core/security.py after the python-jose -> PyJWT migration -(python-jose pulled in ecdsa, which carries an unfixed CVE — see -SECURITY_AUDIT.md). These checks confirm token issuance/verification and -password hashing still round-trip correctly under PyJWT. -""" - -from app.core.security import ( - create_access_token, - create_refresh_token, - hash_password, - verify_access_token, - verify_password, - verify_refresh_token, -) - - -def test_access_token_round_trip(): - token = create_access_token(subject="user-1", role="student", college_id=42) - payload = verify_access_token(token) - - assert payload is not None - assert payload["sub"] == "user-1" - assert payload["role"] == "student" - assert payload["type"] == "access" - assert payload["college_id"] == 42 - - -def test_refresh_token_round_trip(): - token, jti, expiry = create_refresh_token(subject="user-1") - payload = verify_refresh_token(token) - - assert payload is not None - assert payload["type"] == "refresh" - assert payload["jti"] == jti - assert expiry is not None - - -def test_access_token_rejected_as_refresh_token(): - token = create_access_token(subject="user-1", role="student") - assert verify_refresh_token(token) is None - - -def test_invalid_token_returns_none(): - assert verify_access_token("not-a-valid-jwt") is None - - -def test_password_hash_round_trip(): - hashed = hash_password("correct horse battery staple") - assert verify_password("correct horse battery staple", hashed) is True - assert verify_password("wrong password", hashed) is False diff --git a/python-service/uv.lock b/python-service/uv.lock deleted file mode 100644 index d22ee52..0000000 --- a/python-service/uv.lock +++ /dev/null @@ -1,8 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.12" - -[[package]] -name = "backend" -version = "0.1.0" -source = { virtual = "." } From d61230ce4e1f445998ba251be3dabb632f35b201 Mon Sep 17 00:00:00 2001 From: KIVOX-dev <07kaviarasan@gmail.com> Date: Sat, 1 Aug 2026 19:37:45 +0530 Subject: [PATCH 07/24] feat: migrate to Node.js backend, add secure file uploads, validation, and integration testing infrastructure --- .github/workflows/ci.yml | 80 +-------- README.md | 27 +++- REQUIREMENTS.md | 8 +- SECURITY_AUDIT.md | 55 +++++++ node-api/eslint.config.js | 9 +- node-api/jest.config.js | 14 ++ node-api/src/__tests__/helpers/seed.js | 30 ++++ node-api/src/__tests__/helpers/testApp.js | 51 ++++++ .../src/__tests__/integration/auth.test.js | 152 ++++++++++++++++++ .../src/__tests__/integration/hr-jobs.test.js | 83 ++++++++++ .../__tests__/integration/pagination.test.js | 95 +++++++++++ .../src/__tests__/integration/rbac.test.js | 123 ++++++++++++++ .../integration/students-directory.test.js | 111 +++++++++++++ .../src/__tests__/integration/uploads.test.js | 92 +++++++++++ node-api/src/controllers/auth.controller.js | 37 ++++- .../src/controllers/dashboard.controller.js | 4 +- .../src/controllers/leaderboard.controller.js | 2 +- .../src/controllers/placement.controller.js | 10 +- node-api/src/controllers/user.controller.js | 4 +- node-api/src/middlewares/authenticate.js | 5 +- node-api/src/middlewares/errorHandler.js | 34 +++- node-api/src/middlewares/rateLimiter.js | 9 +- node-api/src/middlewares/upload.js | 91 +++++++++-- node-api/src/middlewares/validate.js | 2 +- node-api/src/repositories/BaseRepository.js | 22 ++- .../repositories/institution.repository.js | 8 +- .../src/repositories/student.repository.js | 33 +++- node-api/src/routes/auth.routes.js | 10 ++ node-api/src/routes/index.js | 5 + node-api/src/routes/profile.routes.js | 6 +- node-api/src/routes/user.routes.js | 13 +- node-api/src/services/BaseService.js | 5 +- node-api/src/services/auth.service.js | 31 +++- node-api/src/services/placement.service.js | 19 ++- .../src/services/resumeBuilder.service.js | 2 +- node-api/src/services/student.service.js | 50 +++++- node-api/src/services/user.service.js | 60 ++++++- node-api/src/utils/ApiError.js | 47 ++++-- node-api/src/utils/ApiResponse.js | 19 ++- node-api/src/utils/regex.js | 17 ++ node-api/src/validations/auth.validation.js | 30 +++- .../src/validations/placement.validation.js | 22 ++- node-api/src/websocket/chatServer.js | 2 +- 43 files changed, 1357 insertions(+), 172 deletions(-) create mode 100644 node-api/jest.config.js create mode 100644 node-api/src/__tests__/helpers/seed.js create mode 100644 node-api/src/__tests__/helpers/testApp.js create mode 100644 node-api/src/__tests__/integration/auth.test.js create mode 100644 node-api/src/__tests__/integration/hr-jobs.test.js create mode 100644 node-api/src/__tests__/integration/pagination.test.js create mode 100644 node-api/src/__tests__/integration/rbac.test.js create mode 100644 node-api/src/__tests__/integration/students-directory.test.js create mode 100644 node-api/src/__tests__/integration/uploads.test.js create mode 100644 node-api/src/utils/regex.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33e8209..5ce401b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,6 @@ concurrency: env: NODE_VERSION: "22" - PYTHON_VERSION: "3.12" jobs: # ── Node.js API ───────────────────────────────────────────────────────── @@ -73,68 +72,9 @@ jobs: - name: npm audit (fail on high/critical) run: npm audit --audit-level=high - # ── Python service ────────────────────────────────────────────────────── - python-lint: - name: python-service / lint - runs-on: ubuntu-latest - defaults: - run: - working-directory: python-service - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - cache: "pip" - cache-dependency-path: python-service/requirements-dev.txt - - run: pip install -r requirements-dev.txt - - name: Lint (ruff) - run: ruff check . - # Formatting is checked separately from ruff so a failure says clearly - # which of the two is unhappy. Both are pinned to line-length 100 in - # pyproject.toml, so they cannot disagree about wrapping. - - name: Format check (black) - run: black --check . - - python-test: - name: python-service / test - runs-on: ubuntu-latest - defaults: - run: - working-directory: python-service - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - cache: "pip" - cache-dependency-path: python-service/requirements-dev.txt - - run: pip install -r requirements-dev.txt - - name: Test (pytest) - run: pytest -v - - python-audit: - name: python-service / dependency audit - runs-on: ubuntu-latest - defaults: - run: - working-directory: python-service - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: ${{ env.PYTHON_VERSION }} - cache: "pip" - cache-dependency-path: python-service/requirements.txt - - run: pip install -r requirements.txt - - name: pip-audit (dependency vulnerability scan) - run: | - pip install pip-audit - pip-audit -r requirements.txt - docker-build: - name: Docker build (both services) - needs: [node-lint, node-test, node-audit, python-lint, python-test, python-audit] + name: Docker build (node-api) + needs: [node-lint, node-test, node-audit] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -146,14 +86,7 @@ jobs: push: false tags: node-api:ci - - name: Build python-service image - uses: docker/build-push-action@v5 - with: - context: python-service - push: false - tags: python-service:ci - - # Scan built images for known CVEs before they ever reach a registry. + # Scan the built image for known CVEs before it ever reaches a registry. # Pinned to a commit SHA (not a mutable tag/branch like `@master`) — # third-party actions should be pinned the same way a dependency would # be, since an unpinned ref can start running different code with no @@ -165,13 +98,6 @@ jobs: severity: CRITICAL,HIGH exit-code: "1" - - name: Trivy scan — python-service - uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 - with: - image-ref: python-service:ci - severity: CRITICAL,HIGH - exit-code: "1" - # Deploy is intentionally separate from the CI jobs above and gated on `main` # only, so a red build can never reach Cloud Run. It's commented out end-to-end # because no GCP project/Workload Identity Federation is wired up yet — see the diff --git a/README.md b/README.md index d3ad670..20eab7f 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,23 @@ # UpScaler-AI Backend -This repository hosts **two independent backend services**, each in its own folder with its own -dependency manifest, env files, and `Dockerfile`: +This repository hosts a single backend service: | Folder | Stack | Purpose | |---|---|---| -| [`python-service/`](python-service/README.md) | Python 3.12 / FastAPI / MongoDB | The original UpScaler-AI V2 API | -| [`node-api/`](node-api/README.md) | Node.js / Express / MongoDB | Clean-architecture REST API, RBAC, JWT + Google OAuth | +| [`node-api/`](node-api/README.md) | Node.js / Express / MongoDB | Clean-architecture REST API, RBAC, JWT + Google OAuth — sole source of truth for every route the frontend calls | -Neither service's code was touched by the other's setup — see each folder's own `README.md` for -architecture and `REQUIREMENTS.md` for prerequisites. +See `node-api/README.md` for architecture and `node-api/REQUIREMENTS.md` for prerequisites. + +## Python service removal + +The original `python-service/` (Python 3.12 / FastAPI / MongoDB) has been removed. It predated +`node-api` and the two had drifted into duplicate, inconsistently-behaving implementations of the +same routes (auth, students, placements, etc.) against separate MongoDB databases. `node-api` had +already reached full functional parity with every endpoint the live frontend actually calls (see +`SECURITY_AUDIT.md` for the migration history), so keeping both running was pure duplicated +maintenance and deployment surface with no remaining benefit. Its history is preserved in git; see +the commit that removed it for the full file list. Full migration/removal notes are appended to +`SECURITY_AUDIT.md`. ## Recent updates @@ -48,4 +56,9 @@ architecture and `REQUIREMENTS.md` for prerequisites. |---|---| | Frontend | http://localhost:5173 (or 3000 for the existing Next.js app) | | Node API | http://localhost:5000 | -| Python service | http://localhost:8000 | + +The frontend's `NEXT_PUBLIC_API_URL` should point at `http://localhost:5000/api/v1`. Its +unset-env-var fallback (`src/lib/api.ts`, `D:\Upscaler-Frontend`) still defaults to port 8000 — a +leftover from when python-service owned that port. That fallback only matters if +`NEXT_PUBLIC_API_URL` is unset; update it to 5000 (or set the env var everywhere it's deployed) as +a follow-up. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index dd47106..8c2ccb7 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -1,8 +1,10 @@ # Requirements — Index -This repo has two independent stacks, each with its own requirements doc: +This repo hosts a single backend stack: -- [`python-service/REQUIREMENTS.md`](python-service/REQUIREMENTS.md) — Python 3.12, MongoDB, `pip install -r requirements.txt` -- [`node-api/REQUIREMENTS.md`](node-api/REQUIREMENTS.md) — Node.js 18+, MongoDB, `npm install` +- [`node-api/REQUIREMENTS.md`](node-api/REQUIREMENTS.md) — Node.js 20+, MongoDB, `npm install` + +The legacy `python-service/` (FastAPI) has been removed — node-api is now the sole backend and +single source of truth for every API route the frontend calls. See `README.md` for details. Frontend requirements live in the frontend's own repo: `D:\Upscaler-Frontend\REQUIREMENTS.md`. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index d589e65..25414a8 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -540,3 +540,58 @@ against the same PyPI/OSV advisory data and passes, `safety` was run **once, man independent cross-check (which is how §12a was found) but not wired into the pipeline. The security policy was not weakened and no scan was disabled — `pip-audit` still fails the build on any advisory. + +--- + +## 13. `python-service/` removed (backend stabilization pass) + +**Date:** 2026-08-01. **Scope:** repo-wide — `python-service/`, `.github/workflows/ci.yml`, +`README.md`, `REQUIREMENTS.md`. + +Everything in §1–12 above documents `python-service` as it existed; it is retained as history and +was **not rewritten**. This section records its removal, done as part of a broader backend +stabilization pass (token refresh, HR portal routing, change-password, role/pagination/upload +hardening — see the corresponding commit(s) around this date for the rest of that pass). + +**Why:** `node-api` and `python-service` had drifted into two independent implementations of +largely the same routes (auth, students, placements/jobs, resume, dashboard, etc.) against two +separate MongoDB databases (`upscaler_ai_node` vs `upscaler_ai`) — duplicated business logic, +duplicated auth, and duplicated maintenance/deployment surface (two Dockerfiles, two CI matrices) +for no behavioral benefit, since the live frontend only ever needed one backend to actually answer +its requests. + +**Verification before deletion:** every route prefix the frontend (`D:\Upscaler-Frontend`) calls +was cross-checked against `node-api/src/routes/index.js` and confirmed present and independently +functional — auth, users, institutions, departments, college-admins, companies, hr, faculty, +students(+profile), placements (aliased at `/jobs` too — see the HR portal fix in this same pass), +placement-applications, tests, test-assignments, results, notifications, resume, activity-logs, +user-data, placement-records, profile, ai, interviews, leaderboard, dashboard, batches, chat. +`node-api` already had its own independent MongoDB seed scripts (`db:seed-colleges`, +`db:seed-departments`, `db:seed-practice-tests`, `db:seed-question-bank`) — it was never dependent +on `python-service`'s database or `seed_mongo.py`. + +**What changed:** +- Deleted `python-service/` in full (git history preserves it — recoverable with + `git log --diff-filter=D -- python-service` if ever needed). +- `.github/workflows/ci.yml`: removed the `python-lint`/`python-test`/`python-audit` jobs, the + `PYTHON_VERSION` env var, the python-service Docker build step, and its Trivy scan. `docker-build` + now only builds/scans `node-api` and depends only on the three node-api jobs. + See §12e above — `pip-audit`'s CI gate no longer applies to this repo now that there is no + `requirements.txt` to check; dependency scanning is `node-audit`'s `npm audit` plus the Trivy + scan on the node-api image. +- `README.md` / `REQUIREMENTS.md`: updated to describe a single-service repo. + +**Known follow-ups, not done as part of this removal:** +- `seed_mongo.py` (deleted with the rest of `python-service/`) contained a hardcoded plaintext + MongoDB Atlas password, flagged back in §1 of this audit. Deleting the file does **not** rotate + that credential — it is still recoverable from git history. **Rotate it** if that has not already + happened. +- The frontend's `src/lib/api.ts` (`D:\Upscaler-Frontend`, a separate repo, intentionally not + modified here) falls back to `http://:8000/api/v1` — python-service's old port — when + `NEXT_PUBLIC_API_URL` is unset. This is a documentation/deployment-config gap, not a code gap: + wherever the frontend is actually deployed already sets `NEXT_PUBLIC_API_URL` to node-api's URL + (confirmed locally via `.env.local` → `http://localhost:5000/api/v1`), so this had no runtime + effect at the time of removal. Still worth fixing the fallback value in that repo as a follow-up + so it doesn't quietly point at a dead service. +- The Postgres-era `sql/schema.sql` mentioned in the root `README.md`'s history section was already + superseded before this pass (see that README) and needed no further action here. diff --git a/node-api/eslint.config.js b/node-api/eslint.config.js index b8f6ebf..667dc90 100644 --- a/node-api/eslint.config.js +++ b/node-api/eslint.config.js @@ -27,7 +27,13 @@ module.exports = [ }, }, rules: { - 'no-unused-vars': ['warn', { args: 'none' }], + // ignoreRestSiblings: true — this codebase's standard way to omit + // fields from an entity before returning it is destructuring them out + // (`const { password_hash, ...safe } = user; return safe;`, see + // auth.service.js#sanitizeUser and friends). `password_hash` etc. are + // deliberately unused bindings, not dead code — this option is exactly + // for that pattern, not a suppression of real issues. + 'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }], }, }, { @@ -42,6 +48,7 @@ module.exports = [ afterAll: 'readonly', beforeEach: 'readonly', afterEach: 'readonly', + jest: 'readonly', }, }, }, diff --git a/node-api/jest.config.js b/node-api/jest.config.js new file mode 100644 index 0000000..f05e1e6 --- /dev/null +++ b/node-api/jest.config.js @@ -0,0 +1,14 @@ +module.exports = { + // Jest's default testMatch treats every .js file under __tests__/ as a + // test file, which picks up __tests__/helpers/*.js (shared test utilities, + // no tests of their own) and fails the run ("must contain at least one + // test"). Scope it to *.test.js explicitly instead. + testMatch: ['**/*.test.js'], + // Each integration test file boots its own mongodb-memory-server instance + // (a real mongod process — see __tests__/helpers/testApp.js). Running many + // of those at once contends for CPU/IO badly enough to blow past Jest's + // default 5s per-hook timeout; capping worker concurrency and raising the + // timeout keeps the suite reliable both locally and in CI. + maxWorkers: 2, + testTimeout: 30000, +}; diff --git a/node-api/src/__tests__/helpers/seed.js b/node-api/src/__tests__/helpers/seed.js new file mode 100644 index 0000000..c6651ea --- /dev/null +++ b/node-api/src/__tests__/helpers/seed.js @@ -0,0 +1,30 @@ +// Direct-to-repository seeding for integration tests — bypasses the HTTP +// register flow (which forces new institution_admin/hr accounts to +// `pending`, see auth.service.js#SELF_REGISTERABLE_ROLES) so tests can spin +// up an already-approved staff account without an extra super_admin step. +async function seedInstitution(institutionRepository, overrides = {}) { + return institutionRepository.create({ + name: 'Test Institute', + code: `TI-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + is_active: true, + ...overrides, + }); +} + +async function seedUser(userRepository, hashPassword, { role, institutionId, email, password = 'Sup3rSecret!', ...rest }) { + const password_hash = await hashPassword(password); + const user = await userRepository.create({ + email, + password_hash, + full_name: rest.full_name || 'Test User', + role, + institution_id: institutionId || null, + status: 'approved', + is_active: true, + token_version: 0, + ...rest, + }); + return { user, password }; +} + +module.exports = { seedInstitution, seedUser }; diff --git a/node-api/src/__tests__/helpers/testApp.js b/node-api/src/__tests__/helpers/testApp.js new file mode 100644 index 0000000..2ddbd7d --- /dev/null +++ b/node-api/src/__tests__/helpers/testApp.js @@ -0,0 +1,51 @@ +// Shared integration-test harness: spins up an in-memory MongoDB instance +// (mongodb-memory-server — no real database/network needed) and a fresh +// Express app wired to it. env.js/database.js read process.env at require +// time, so every env var below must be set *before* app.js is required — +// jest.resetModules() ensures each caller gets a clean module registry +// rather than reusing whatever a previous test file's require() cached. +const { MongoMemoryServer } = require('mongodb-memory-server'); + +let mongod; + +async function buildTestApp() { + mongod = await MongoMemoryServer.create(); + + process.env.MONGODB_URI = mongod.getUri(); + process.env.MONGODB_DB_NAME = 'test_upscaler_ai_node'; + process.env.JWT_SECRET = 'test-jwt-secret'; + process.env.JWT_REFRESH_SECRET = 'test-jwt-refresh-secret'; + process.env.JWT_EXPIRES_IN = '15m'; + process.env.JWT_REFRESH_EXPIRES_IN = '7d'; + process.env.FRONTEND_URL = 'http://localhost:3000'; + process.env.CORS_ORIGINS = 'http://localhost:3000'; + process.env.NODE_ENV = 'test'; + // High enough that a single test file's requests never trip the general + // limiter — auth-specific endpoints keep their own tighter limiter + // (20/15min) intentionally, so auth tests stay under that per file. + process.env.RATE_LIMIT_MAX = '1000'; + process.env.AUTH_RATE_LIMIT_MAX = '1000'; + + jest.resetModules(); + const database = require('../../config/database'); + await database.connect(); + const app = require('../../app'); + + // Same module registry as `app` above (both loaded after the + // jest.resetModules() call) — required here rather than re-required per + // test file so seeding writes to the same in-memory DB connection the + // app itself uses, not a second, disconnected instance. + const userRepository = require('../../repositories/user.repository'); + const institutionRepository = require('../../repositories/institution.repository'); + const studentRepository = require('../../repositories/student.repository'); + const { hashPassword } = require('../../utils/password'); + + return { app, database, userRepository, institutionRepository, studentRepository, hashPassword }; +} + +async function teardownTestApp(database) { + if (database) await database.close(); + if (mongod) await mongod.stop(); +} + +module.exports = { buildTestApp, teardownTestApp }; diff --git a/node-api/src/__tests__/integration/auth.test.js b/node-api/src/__tests__/integration/auth.test.js new file mode 100644 index 0000000..47651e8 --- /dev/null +++ b/node-api/src/__tests__/integration/auth.test.js @@ -0,0 +1,152 @@ +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); + +describe('Auth: register / login / refresh / change-password', () => { + let app; + let database; + + beforeAll(async () => { + ({ app, database } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + let emailCounter = 0; + function uniqueEmail(prefix) { + emailCounter += 1; + return `${prefix}-${emailCounter}@example.com`; + } + + // Fresh account per call (rather than one shared account across every + // `it()`) — this suite shares one in-memory DB for the whole file, so a + // reused email would 409 on the second call. + async function registerAndLogin(prefix = 'refresh-user') { + const email = uniqueEmail(prefix); + const password = 'Sup3rSecret!'; + await request(app).post('/api/v1/auth/register').send({ email, password, name: 'Refresh User' }).expect(201); + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data; + } + + it('registers a new student and immediately issues tokens', async () => { + const res = await request(app) + .post('/api/v1/auth/register') + .send({ email: 'newbie@example.com', password: 'Sup3rSecret!', name: 'New Bie' }) + .expect(201); + expect(res.body.success).toBe(true); + expect(res.body.data.accessToken).toBeTruthy(); + expect(res.body.data.refreshToken).toBeTruthy(); + }); + + it('logs in with valid credentials and returns both token casings', async () => { + const data = await registerAndLogin(); + expect(data.accessToken).toBeTruthy(); + expect(data.access_token).toBe(data.accessToken); // legacy-field compat, see auth.controller.js + expect(data.user.email).toBeTruthy(); + }); + + it('rejects login with the wrong password', async () => { + const email = uniqueEmail('wrong-pw'); + await request(app).post('/api/v1/auth/register').send({ email, password: 'Sup3rSecret!', name: 'Wrong Pw' }).expect(201); + await request(app).post('/api/v1/auth/login').send({ email, password: 'wrong-password' }).expect(401); + }); + + // Regression test for C-1: the live frontend's bare-axios refresh call + // sends `refresh_token` (snake_case) and reads access_token/refresh_token + // off the top level of the response body — see api.ts. Both used to be + // silently broken (Joi stripped the unrecognized field; the token fields + // were nested a level too deep). + it('refreshes with a snake_case refresh_token body and flat top-level token fields', async () => { + const { refreshToken } = await registerAndLogin(); + + const res = await request(app) + .post('/api/v1/auth/refresh') + .send({ refresh_token: refreshToken }) + .expect(200); + + expect(res.body.access_token).toBeTruthy(); + expect(res.body.refresh_token).toBeTruthy(); + // Envelope form still present for every other consumer. + expect(res.body.data.accessToken).toBe(res.body.access_token); + }); + + it('also accepts the camelCase refreshToken body', async () => { + const { refreshToken } = await registerAndLogin(); + const res = await request(app).post('/api/v1/auth/refresh').send({ refreshToken }).expect(200); + expect(res.body.access_token).toBeTruthy(); + }); + + it('supports refreshing repeatedly without forcing a re-login', async () => { + const { refreshToken: first } = await registerAndLogin(); + const res1 = await request(app).post('/api/v1/auth/refresh').send({ refresh_token: first }).expect(200); + const second = res1.body.refresh_token; + const res2 = await request(app).post('/api/v1/auth/refresh').send({ refresh_token: second }).expect(200); + expect(res2.body.access_token).toBeTruthy(); + }); + + it('rejects a malformed/invalid refresh token', async () => { + await request(app) + .post('/api/v1/auth/refresh') + .send({ refresh_token: 'not-a-real-token' }) + .expect(401); + }); + + it('rejects an expired refresh token', async () => { + const expired = jwt.sign({ sub: 'someone', tv: 0 }, process.env.JWT_REFRESH_SECRET, { expiresIn: -10 }); + await request(app).post('/api/v1/auth/refresh').send({ refresh_token: expired }).expect(401); + }); + + it('rejects a refresh request with neither field present', async () => { + await request(app).post('/api/v1/auth/refresh').send({}).expect(400); + }); + + // Regression test for C-3. + it('changes password, rejects the wrong current password, and invalidates the old refresh token', async () => { + const email = uniqueEmail('changer'); + await request(app).post('/api/v1/auth/register').send({ email, password: 'OldPass123!', name: 'Changer' }).expect(201); + const login = await request(app).post('/api/v1/auth/login').send({ email, password: 'OldPass123!' }).expect(200); + const { accessToken, refreshToken } = login.body.data; + + await request(app) + .put('/api/v1/auth/change-password') + .set('Authorization', `Bearer ${accessToken}`) + .send({ current_password: 'wrong', new_password: 'NewPass123!' }) + .expect(400); + + const changeRes = await request(app) + .put('/api/v1/auth/change-password') + .set('Authorization', `Bearer ${accessToken}`) + .send({ current_password: 'OldPass123!', new_password: 'NewPass123!' }) + .expect(200); + expect(changeRes.body.data.accessToken).toBeTruthy(); + + // Old refresh token was issued before the password change and must now + // be rejected (token_version bump) — see auth.service.js#changePassword. + await request(app).post('/api/v1/auth/refresh').send({ refresh_token: refreshToken }).expect(401); + + // New credentials work. + await request(app).post('/api/v1/auth/login').send({ email, password: 'NewPass123!' }).expect(200); + }); + + it('rejects change-password to the same password', async () => { + const email = uniqueEmail('samepass'); + await request(app).post('/api/v1/auth/register').send({ email, password: 'Password123!', name: 'Same Pass' }).expect(201); + const login = await request(app).post('/api/v1/auth/login').send({ email, password: 'Password123!' }).expect(200); + + await request(app) + .put('/api/v1/auth/change-password') + .set('Authorization', `Bearer ${login.body.data.accessToken}`) + .send({ current_password: 'Password123!', new_password: 'Password123!' }) + .expect(400); + }); + + it('rejects change-password without authentication', async () => { + await request(app) + .put('/api/v1/auth/change-password') + .send({ current_password: 'a', new_password: 'Password123!' }) + .expect(401); + }); +}); diff --git a/node-api/src/__tests__/integration/hr-jobs.test.js b/node-api/src/__tests__/integration/hr-jobs.test.js new file mode 100644 index 0000000..ab87cdc --- /dev/null +++ b/node-api/src/__tests__/integration/hr-jobs.test.js @@ -0,0 +1,83 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression tests for C-2: the live HR portal (hr/page.tsx) calls /jobs, +// /jobs/me, /jobs/applications/me and POSTs to /jobs — none of which existed +// before /jobs was aliased onto the placement router. +describe('HR portal: /jobs alias and payload shape', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function loginAsHr() { + const institution = await seedInstitution(institutionRepository); + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'hr', + institutionId: institution.id, + email: `hr-${Date.now()}@example.com`, + }); + const res = await request(app).post('/api/v1/auth/login').send({ email: user.email, password }).expect(200); + return res.body.data.accessToken; + } + + it('accepts a vacancy post at /jobs with the frontend\'s actual payload shape (comma-separated strings)', async () => { + const token = await loginAsHr(); + + const res = await request(app) + .post('/api/v1/jobs') + .set('Authorization', `Bearer ${token}`) + .send({ + title: 'Full Stack Developer', + job_type: 'full_time', + application_deadline: new Date(Date.now() + 86400000).toISOString(), + min_cgpa: 7, + eligible_years: '2024, 2025', + description: 'Build things.', + required_skills: 'React, Node.js', + company_name: 'Acme Corp', + }) + .expect(201); + + expect(res.body.data.title).toBe('Full Stack Developer'); + expect(res.body.data.required_skills).toEqual(['React', 'Node.js']); + expect(res.body.data.eligible_years).toEqual([2024, 2025]); + }); + + it('lists postings under /jobs and /placements identically', async () => { + const token = await loginAsHr(); + await request(app) + .post('/api/v1/jobs') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Backend Engineer', company_name: 'Acme' }) + .expect(201); + + const viaJobs = await request(app).get('/api/v1/jobs/me').set('Authorization', `Bearer ${token}`).expect(200); + const viaPlacements = await request(app) + .get('/api/v1/placements/me') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(Array.isArray(viaJobs.body.data)).toBe(true); + expect(viaJobs.body.data.map((p) => p.id).sort()).toEqual(viaPlacements.body.data.map((p) => p.id).sort()); + }); + + it('/jobs/applications/me responds for a recruiter (empty, but not 404)', async () => { + const token = await loginAsHr(); + const res = await request(app) + .get('/api/v1/jobs/applications/me') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(Array.isArray(res.body.data)).toBe(true); + }); +}); diff --git a/node-api/src/__tests__/integration/pagination.test.js b/node-api/src/__tests__/integration/pagination.test.js new file mode 100644 index 0000000..e770efa --- /dev/null +++ b/node-api/src/__tests__/integration/pagination.test.js @@ -0,0 +1,95 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression tests for H-2: BaseService.list/ApiResponse.paginated used to +// report page/limit/total/totalPages with no hasNext/hasPrevious and no +// sort support. +describe('Pagination: GET /institutions', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + + for (let i = 0; i < 25; i += 1) { + await seedInstitution(institutionRepository, { name: `Institute ${String(i).padStart(2, '0')}`, code: `INST-${i}-${Date.now()}` }); + } + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function loginAsSuperAdmin() { + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'super_admin', + email: `super-${Date.now()}-${Math.random()}@example.com`, + }); + const res = await request(app).post('/api/v1/auth/login').send({ email: user.email, password }).expect(200); + return res.body.data.accessToken; + } + + it('returns real pagination metadata, not a silently truncated array', async () => { + const token = await loginAsSuperAdmin(); + const res = await request(app) + .get('/api/v1/institutions') + .query({ page: 1, limit: 10 }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(res.body.data.length).toBe(10); + expect(res.body.meta).toMatchObject({ page: 1, limit: 10 }); + expect(res.body.meta.total).toBeGreaterThanOrEqual(25); + expect(res.body.meta.totalPages).toBeGreaterThanOrEqual(3); + expect(res.body.meta.hasNext).toBe(true); + expect(res.body.meta.hasPrevious).toBe(false); + }); + + it('hasPrevious is true and hasNext is false on the last page', async () => { + const token = await loginAsSuperAdmin(); + const first = await request(app) + .get('/api/v1/institutions') + .query({ page: 1, limit: 10 }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + const lastPage = first.body.meta.totalPages; + + const res = await request(app) + .get('/api/v1/institutions') + .query({ page: lastPage, limit: 10 }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(res.body.meta.hasNext).toBe(false); + expect(res.body.meta.hasPrevious).toBe(true); + }); + + it('supports sortBy/sortOrder', async () => { + const token = await loginAsSuperAdmin(); + const asc = await request(app) + .get('/api/v1/institutions') + .query({ page: 1, limit: 5, sortBy: 'name', sortOrder: 'asc' }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + const desc = await request(app) + .get('/api/v1/institutions') + .query({ page: 1, limit: 5, sortBy: 'name', sortOrder: 'desc' }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(asc.body.data[0].name).not.toBe(desc.body.data[0].name); + }); + + it('ignores an unrecognized sortBy rather than erroring', async () => { + const token = await loginAsSuperAdmin(); + await request(app) + .get('/api/v1/institutions') + .query({ page: 1, limit: 5, sortBy: '$where' }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + }); +}); diff --git a/node-api/src/__tests__/integration/rbac.test.js b/node-api/src/__tests__/integration/rbac.test.js new file mode 100644 index 0000000..067e944 --- /dev/null +++ b/node-api/src/__tests__/integration/rbac.test.js @@ -0,0 +1,123 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +describe('RBAC: self-service updates and institution-scoped approvals', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function login(email, password) { + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data.accessToken; + } + + // Regression test for H-4: Subscription.tsx's "Upgrade to Pro" does + // PUT /users/:id (own id) with a `preferences` patch. This route used to + // be gated to super_admin/institution_admin only, 403ing every student. + it('lets a student self-update their own preferences (the upgrade flow) but not their role', async () => { + const institution = await seedInstitution(institutionRepository); + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: 'student-self@example.com', + }); + const token = await login(user.email, password); + + const res = await request(app) + .put(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ preferences: { plan: 'pro' } }) + .expect(200); + expect(res.body.data.preferences.plan).toBe('pro'); + + // Privilege escalation attempt via the same self-service path must be + // silently dropped, not applied. + const escalate = await request(app) + .put(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ role: 'super_admin', preferences: { plan: 'basic' } }) + .expect(200); + expect(escalate.body.data.role).toBe('student'); + expect(escalate.body.data.preferences.plan).toBe('basic'); + }); + + it('rejects a student updating a different user', async () => { + const institution = await seedInstitution(institutionRepository); + const { user: student, password } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: 'student-a@example.com', + }); + const { user: other } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: 'student-b@example.com', + }); + const token = await login(student.email, password); + + await request(app) + .put(`/api/v1/users/${other.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ preferences: { plan: 'pro' } }) + .expect(403); + }); + + // Regression test for H-3: InstitutionalApproval.tsx (rendered on the + // institution-admin portal) calls PUT /users/:id/approve — this used to be + // super_admin-only, 403ing every institution admin trying to approve their + // own pending HR/faculty signups. + it('lets an institution_admin approve a pending user in their own institution', async () => { + const institution = await seedInstitution(institutionRepository); + const { user: admin, password: adminPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institution.id, + email: 'admin@example.com', + }); + const { user: pendingHr } = await seedUser(userRepository, hashPassword, { + role: 'hr', + institutionId: institution.id, + email: 'pending-hr@example.com', + status: 'pending', + }); + const token = await login(admin.email, adminPassword); + + const res = await request(app) + .put(`/api/v1/users/${pendingHr.id}/approve`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body.data.status).toBe('approved'); + }); + + it('blocks an institution_admin from approving a user in a different institution', async () => { + const institutionA = await seedInstitution(institutionRepository, { code: `A-${Date.now()}` }); + const institutionB = await seedInstitution(institutionRepository, { code: `B-${Date.now()}` }); + const { user: adminA, password: adminPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institutionA.id, + email: 'admin-a@example.com', + }); + const { user: pendingInB } = await seedUser(userRepository, hashPassword, { + role: 'hr', + institutionId: institutionB.id, + email: 'pending-b@example.com', + status: 'pending', + }); + const token = await login(adminA.email, adminPassword); + + await request(app) + .put(`/api/v1/users/${pendingInB.id}/approve`) + .set('Authorization', `Bearer ${token}`) + .expect(403); + }); +}); diff --git a/node-api/src/__tests__/integration/students-directory.test.js b/node-api/src/__tests__/integration/students-directory.test.js new file mode 100644 index 0000000..765defa --- /dev/null +++ b/node-api/src/__tests__/integration/students-directory.test.js @@ -0,0 +1,111 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression tests for H-6: GET /students has no role gate (PlatformChat.tsx +// calls it as a student when GET /users/ 403s them), and used to return every +// raw student field — phone, date_of_birth, gender, address, cgpa — to +// whichever role asked. Non-staff callers should get a minimal directory +// projection instead; staff should still see full records within their own +// institution and never another institution's. +describe('Students: PII-minimized directory for non-staff, full detail for staff', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let studentRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, studentRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function login(email, password) { + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data.accessToken; + } + + async function seedStudent(institutionId, overrides = {}) { + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId, + email: `student-${Date.now()}-${Math.random()}@example.com`, + phone: '555-0100', + ...overrides, + }); + await studentRepository.create({ + user_id: user.id, + institution_id: institutionId, + phone: '555-0100', + date_of_birth: '2000-01-01', + gender: 'female', + address: '123 Secret St', + cgpa: 8.9, + }); + return { user, password }; + } + + it('hides sensitive fields from a student caller but keeps id/name/email', async () => { + const institution = await seedInstitution(institutionRepository); + const { user: viewer, password } = await seedStudent(institution.id); + await seedStudent(institution.id); // a classmate, visible in the directory + + const token = await login(viewer.email, password); + const res = await request(app).get('/api/v1/students').set('Authorization', `Bearer ${token}`).expect(200); + + expect(res.body.data.length).toBeGreaterThanOrEqual(1); + for (const entry of res.body.data) { + expect(entry).not.toHaveProperty('phone'); + expect(entry).not.toHaveProperty('date_of_birth'); + expect(entry).not.toHaveProperty('gender'); + expect(entry).not.toHaveProperty('address'); + expect(entry).not.toHaveProperty('cgpa'); + expect(entry).toHaveProperty('id'); + expect(entry).toHaveProperty('full_name'); + } + }); + + it('gives staff (institution_admin) the full record within their own institution', async () => { + const institution = await seedInstitution(institutionRepository); + await seedStudent(institution.id); + const { user: admin, password: adminPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institution.id, + email: `admin-${Date.now()}@example.com`, + }); + const token = await login(admin.email, adminPassword); + + const res = await request(app) + .get('/api/v1/students') + .query({ search: 'student' }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(res.body.data.length).toBeGreaterThanOrEqual(1); + expect(res.body.data[0]).toHaveProperty('date_of_birth'); + }); + + it('never leaks a student from a different institution into another institution_admin\'s search', async () => { + const institutionA = await seedInstitution(institutionRepository, { code: `SA-${Date.now()}` }); + const institutionB = await seedInstitution(institutionRepository, { code: `SB-${Date.now()}` }); + await seedStudent(institutionB.id, { full_name: 'Cross Tenant Student' }); + const { user: adminA, password: adminPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institutionA.id, + email: `admin-a-${Date.now()}@example.com`, + }); + const token = await login(adminA.email, adminPassword); + + const res = await request(app) + .get('/api/v1/students') + .query({ search: 'Cross Tenant' }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(res.body.data.length).toBe(0); + }); +}); diff --git a/node-api/src/__tests__/integration/uploads.test.js b/node-api/src/__tests__/integration/uploads.test.js new file mode 100644 index 0000000..ca8b785 --- /dev/null +++ b/node-api/src/__tests__/integration/uploads.test.js @@ -0,0 +1,92 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression tests for H-7: profile.routes.js's upload.any() used to accept +// any file with no MIME/extension/content check at all. +describe('Upload security: /profile', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + // /profile is the staff onboarding endpoint (hr/institution_admin/faculty + // — see onboardingSchemas.js#ROLE_TO_PORTAL); students use a separate, + // non-multipart /students/profile endpoint. hr is used here purely as a + // role that's allowed to reach this route at all. + async function loginAsHr() { + const institution = await seedInstitution(institutionRepository); + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'hr', + institutionId: institution.id, + email: `upload-${Date.now()}@example.com`, + }); + const res = await request(app).post('/api/v1/auth/login').send({ email: user.email, password }).expect(200); + return res.body.data.accessToken; + } + + // A real 1x1 PNG's magic bytes. + const REAL_PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'); + + it('rejects a file whose extension/mimetype is not on the image allow-list', async () => { + const token = await loginAsHr(); + await request(app) + .post('/api/v1/profile') + .set('Authorization', `Bearer ${token}`) + .attach('profilePhoto', Buffer.from('#!/bin/sh\necho pwned'), { filename: 'shell.sh', contentType: 'application/x-sh' }) + .expect(400); + }); + + it('rejects a file with a disguised double extension claiming to be an image', async () => { + const token = await loginAsHr(); + await request(app) + .post('/api/v1/profile') + .set('Authorization', `Bearer ${token}`) + .attach('profilePhoto', Buffer.from(''), { + filename: 'avatar.jpg.php', + contentType: 'image/jpeg', + }) + .expect(400); + }); + + it('rejects content whose magic bytes do not match its claimed image MIME type', async () => { + const token = await loginAsHr(); + // Declares image/png + a .png filename, but the actual bytes are plain + // text — this is exactly what a renamed executable/script looks like to + // a check that only trusts the extension or Content-Type header. + await request(app) + .post('/api/v1/profile') + .set('Authorization', `Bearer ${token}`) + .attach('profilePhoto', Buffer.from('not actually a png'), { filename: 'fake.png', contentType: 'image/png' }) + .expect(400); + }); + + it('accepts a real PNG', async () => { + const token = await loginAsHr(); + const res = await request(app) + .post('/api/v1/profile') + .set('Authorization', `Bearer ${token}`) + .attach('profilePhoto', REAL_PNG, { filename: 'avatar.png', contentType: 'image/png' }) + .expect(200); + expect(res.body.data.values.profilePhoto).toMatch(/^\/uploads\/profile\/.+\.png$/); + }); + + it('rejects an oversized file', async () => { + const token = await loginAsHr(); + const oversized = Buffer.concat([REAL_PNG, Buffer.alloc(6 * 1024 * 1024)]); + await request(app) + .post('/api/v1/profile') + .set('Authorization', `Bearer ${token}`) + .attach('profilePhoto', oversized, { filename: 'huge.png', contentType: 'image/png' }) + .expect(400); + }); +}); diff --git a/node-api/src/controllers/auth.controller.js b/node-api/src/controllers/auth.controller.js index f77a11b..b6ae6c7 100644 --- a/node-api/src/controllers/auth.controller.js +++ b/node-api/src/controllers/auth.controller.js @@ -53,8 +53,16 @@ const googleLogin = asyncHandler(async (req, res) => { }); const refresh = asyncHandler(async (req, res) => { - const result = await authService.refresh(req.body.refreshToken); - ApiResponse.ok(res, withLegacyAuthFields(result), 'Token refreshed'); + const token = req.body.refreshToken || req.body.refresh_token; + const result = await authService.refresh(token); + const payload = withLegacyAuthFields(result); + // /auth/refresh is called from a bare axios instance that deliberately + // skips the {success,data} envelope-unwrap interceptor every other call + // site relies on (see api.ts — avoids recursing back into itself on 401). + // It reads access_token/refresh_token straight off the response body, so + // duplicate them at the top level here while keeping the normal envelope + // for every other consumer. + res.status(200).json({ success: true, message: 'Token refreshed', data: payload, ...payload }); }); const me = asyncHandler(async (req, res) => { @@ -79,4 +87,27 @@ const verifyEmail = asyncHandler(async (req, res) => { ApiResponse.ok(res, null, 'Email verified successfully.'); }); -module.exports = { register, login, googleLogin, refresh, me, forgotPassword, resetPassword, changeInitialPassword, verifyEmail }; +// Live frontend sends snake_case (current_password/new_password) — see +// validations/auth.validation.js#changePassword. +const changePassword = asyncHandler(async (req, res) => { + const currentPassword = req.body.currentPassword || req.body.current_password; + const newPassword = req.body.newPassword || req.body.new_password; + const result = await authService.changePassword(req.user.id, currentPassword, newPassword); + // Password change invalidates every other refresh token (see + // authService.js#changePassword) — a fresh pair is returned here so the + // caller's own session can keep going without a forced re-login. + ApiResponse.ok(res, withLegacyAuthFields(result), 'Password changed successfully'); +}); + +module.exports = { + register, + login, + googleLogin, + refresh, + me, + forgotPassword, + resetPassword, + changeInitialPassword, + changePassword, + verifyEmail, +}; diff --git a/node-api/src/controllers/dashboard.controller.js b/node-api/src/controllers/dashboard.controller.js index 5013bcb..4d855d1 100644 --- a/node-api/src/controllers/dashboard.controller.js +++ b/node-api/src/controllers/dashboard.controller.js @@ -4,7 +4,9 @@ const ApiResponse = require('../utils/ApiResponse'); const student = asyncHandler(async (req, res) => { const result = await dashboardService.studentDashboard(req.params.studentId); - ApiResponse.ok(res, result); + // See ApiResponse.okDoubleWrapped — StudentTracking.tsx's "insights" panel + // reads res.data.success/res.data.data off the already-unwrapped response. + ApiResponse.okDoubleWrapped(res, result); }); const admin = asyncHandler(async (req, res) => { diff --git a/node-api/src/controllers/leaderboard.controller.js b/node-api/src/controllers/leaderboard.controller.js index 264f1fe..e41be16 100644 --- a/node-api/src/controllers/leaderboard.controller.js +++ b/node-api/src/controllers/leaderboard.controller.js @@ -4,7 +4,7 @@ const ApiResponse = require('../utils/ApiResponse'); const get = asyncHandler(async (req, res) => { const rows = await achievementService.leaderboard(req.user, req.query.scope); - ApiResponse.ok(res, rows); + ApiResponse.okDoubleWrapped(res, rows); }); module.exports = { get }; diff --git a/node-api/src/controllers/placement.controller.js b/node-api/src/controllers/placement.controller.js index d30d3fc..2dff4d5 100644 --- a/node-api/src/controllers/placement.controller.js +++ b/node-api/src/controllers/placement.controller.js @@ -10,13 +10,19 @@ const create = asyncHandler(async (req, res) => { ApiResponse.created(res, placement); }); +// The frontend consumes both of these as a plain array (no pagination UI — +// see hr/page.tsx), so the response body stays an array; the true total is +// still surfaced via X-Total-Count for any caller that cares whether `rows` +// was truncated (see placement.service.js#listMine/#listDrives). const listMine = asyncHandler(async (req, res) => { - const rows = await placementService.listMine(req.user); + const { rows, total } = await placementService.listMine(req.user); + res.set('X-Total-Count', String(total)); ApiResponse.ok(res, rows); }); const listDrives = asyncHandler(async (req, res) => { - const rows = await placementService.listDrives(req.user); + const { rows, total } = await placementService.listDrives(req.user); + res.set('X-Total-Count', String(total)); ApiResponse.ok(res, rows); }); diff --git a/node-api/src/controllers/user.controller.js b/node-api/src/controllers/user.controller.js index 1e0a0a6..d0fc955 100644 --- a/node-api/src/controllers/user.controller.js +++ b/node-api/src/controllers/user.controller.js @@ -33,12 +33,12 @@ const remove = asyncHandler(async (req, res) => { }); const approve = asyncHandler(async (req, res) => { - const user = await userService.approve(req.params.id); + const user = await userService.approve(req.params.id, req.user); ApiResponse.ok(res, user, 'Approved'); }); const reject = asyncHandler(async (req, res) => { - const user = await userService.reject(req.params.id); + const user = await userService.reject(req.params.id, req.user); ApiResponse.ok(res, user, 'Rejected'); }); diff --git a/node-api/src/middlewares/authenticate.js b/node-api/src/middlewares/authenticate.js index 8f6f7b4..3629d6c 100644 --- a/node-api/src/middlewares/authenticate.js +++ b/node-api/src/middlewares/authenticate.js @@ -17,7 +17,10 @@ module.exports = asyncHandler(async (req, res, next) => { const payload = verifyAccessToken(token); req.user = { id: payload.sub, role: payload.role, institutionId: payload.institutionId }; next(); - } catch (err) { + } catch { + // Deliberately generic — never echo back jwt.verify's own error message + // (expired vs malformed vs bad signature), which would hand an attacker + // a free oracle for probing token validity. throw ApiError.unauthorized('Invalid or expired access token'); } }); diff --git a/node-api/src/middlewares/errorHandler.js b/node-api/src/middlewares/errorHandler.js index 5085c0f..0c8e851 100644 --- a/node-api/src/middlewares/errorHandler.js +++ b/node-api/src/middlewares/errorHandler.js @@ -9,11 +9,26 @@ function notFoundHandler(req, res, next) { // Centralized error handler. MongoDB driver errors are translated into safe, generic // HTTP responses so raw driver details never leak to API clients. function errorHandler(err, req, res, next) { - let { statusCode = 500, message } = err; + let { statusCode = 500, message, code } = err; if (err.code === 11000) { // duplicate key (unique index violation) statusCode = 409; message = 'A record with these details already exists'; + code = 'DUPLICATE_RECORD'; + } else if (err.name === 'MulterError') { + // multer throws its own error class (not ApiError) for upload-limit + // violations — LIMIT_FILE_SIZE (upload.js's 5MB cap), too many files, + // unexpected field name, etc. All of these are the client's fault, not + // a server fault, so this maps them to 400 instead of falling through + // to the generic 500 below. + statusCode = 400; + if (err.code === 'LIMIT_FILE_SIZE') { + message = 'File exceeds the maximum upload size'; + code = 'FILE_TOO_LARGE'; + } else { + message = err.message; + code = 'UPLOAD_ERROR'; + } } else if ( err.name === 'MongoServerSelectionError' || err.name === 'MongoNetworkError' || @@ -21,11 +36,17 @@ function errorHandler(err, req, res, next) { ) { statusCode = 503; message = 'Database is unreachable'; + code = 'SERVICE_UNAVAILABLE'; } else if (!(err instanceof ApiError)) { // Some Node errors (e.g. AggregateError from a failed connection attempt) have an // empty top-level .message — fall back to a generic one instead of surfacing "". message = env.isProduction ? 'Internal server error' : message || 'Internal server error'; + code = 'INTERNAL_ERROR'; } + // Belt-and-suspenders: any status that reached here without a code (e.g. a + // future branch above that forgets to set one) still gets a stable + // fallback derived from the status, rather than `code: undefined`. + code = code || ApiError.codeForStatus(statusCode); if (statusCode >= 500) { logger.error(err.message || message, { stack: err.stack, path: req.originalUrl }); @@ -36,6 +57,17 @@ function errorHandler(err, req, res, next) { res.status(statusCode).json({ success: false, message, + // `detail` duplicates `message` for the many frontend call sites still + // reading FastAPI's HTTPException shape (err.response.data.detail) from + // the python-service days — without it every one of those falls back to + // a generic client-side string instead of this response's real message. + detail: message, + // Stable, machine-readable — see ApiError.js. `message`/`detail` are for + // display and are free to reword; `code` is what frontend logic should + // branch on (e.g. redirecting to login only on 'UNAUTHORIZED', not on + // every 401-shaped string). + code, + timestamp: new Date().toISOString(), ...(err.details ? { details: err.details } : {}), }); } diff --git a/node-api/src/middlewares/rateLimiter.js b/node-api/src/middlewares/rateLimiter.js index 6455e93..e33164c 100644 --- a/node-api/src/middlewares/rateLimiter.js +++ b/node-api/src/middlewares/rateLimiter.js @@ -10,10 +10,15 @@ const apiLimiter = rateLimit({ message: { success: false, message: 'Too many requests, please try again later.' }, }); -// Stricter limiter for login/register/OAuth to blunt credential-stuffing and brute force. +// Stricter limiter for login/register/OAuth to blunt credential-stuffing and +// brute force. Configurable the same way apiLimiter is above — defaults to +// the same 20/15min in every real deployment; only overridden in the +// integration test suite (see __tests__/helpers/testApp.js), where dozens of +// auth calls in one file would otherwise trip it well before any real +// brute-force threshold is relevant. const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, - max: 20, + max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 20, standardHeaders: true, legacyHeaders: false, message: { success: false, message: 'Too many authentication attempts, please try again later.' }, diff --git a/node-api/src/middlewares/upload.js b/node-api/src/middlewares/upload.js index 5f4734d..b637daf 100644 --- a/node-api/src/middlewares/upload.js +++ b/node-api/src/middlewares/upload.js @@ -2,24 +2,93 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); +const ApiError = require('../utils/ApiError'); +const asyncHandler = require('../utils/asyncHandler'); // Mirrors python-service's uploads/profile layout so both the on-disk path and // the public /uploads/profile/ URL shape stay familiar across the migration. const uploadDir = path.join(process.cwd(), 'uploads', 'profile'); fs.mkdirSync(uploadDir, { recursive: true }); -const storage = multer.diskStorage({ - destination: (req, file, cb) => cb(null, uploadDir), - filename: (req, file, cb) => { - const ext = path.extname(file.originalname); - cb(null, `${crypto.randomUUID()}${ext}`); +// Every upload field in the app (onboardingSchemas.js's `profilePhoto` and +// `signature`) is an image — this endpoint has never needed anything else, +// so the allow-list is intentionally image-only rather than a general +// "safe file types" list. Two layers, both required: +// 1. extension + declared Content-Type, checked here (multer fileFilter) — +// cheap, rejects the obvious case before any bytes are read. +// 2. magic-byte signature, checked in verifyAndPersist below, against the +// actual uploaded bytes — the declared MIME type/extension are just +// what the client claims and are trivial to lie about (rename +// shell.php.jpg, or set Content-Type: image/png on an .exe); only the +// file's real header proves what it is. +const ALLOWED_TYPES = { + 'image/jpeg': { extensions: ['.jpg', '.jpeg'], magic: (buf) => buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff }, + 'image/png': { + extensions: ['.png'], + magic: (buf) => buf.slice(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), }, + 'image/gif': { + extensions: ['.gif'], + magic: (buf) => buf.slice(0, 6).equals(Buffer.from('GIF87a', 'ascii')) || buf.slice(0, 6).equals(Buffer.from('GIF89a', 'ascii')), + }, + 'image/webp': { + extensions: ['.webp'], + magic: (buf) => buf.slice(0, 4).toString('ascii') === 'RIFF' && buf.slice(8, 12).toString('ascii') === 'WEBP', + }, + // SVG is deliberately excluded even though it's "an image format" — it can + // embed