From 867fea495ab5105f1dd9d04efb6a6134c2358cc1 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Thu, 20 Aug 2026 18:22:33 +0500 Subject: [PATCH] m. --- backend/inbox/models.py | 8 +++++++- backend/job/app.py | 20 +++++++++++--------- backend/job/candidate/models.py | 32 +++++++++++++++++++++++++------- backend/job/candidate/views.py | 8 +++++--- backend/job/pipeline/views.py | 8 +++++--- frontend/src/api/candidates.js | 23 +++++++++++++++-------- 6 files changed, 68 insertions(+), 31 deletions(-) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index ea1d579..2ba6092 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -105,9 +105,12 @@ class Inbox(SQLModel, table=True): .outerjoin(AtsResults,cls.ats_id==AtsResults.id) .where(Inbox_Messages.assigned_job_post_id.is_not(None)) .where(Roles.role_name==EnumRoles.CANDIDATE.value) + # Newest-first is the list contract; score is only a tiebreak + # within the same instant. id keeps paging stable. .order_by( - AtsResults.overall_score.desc().nulls_last(), cls.created_at.desc(), + AtsResults.overall_score.desc().nulls_last(), + cls.id.desc(), ) ) if job_post_id: @@ -202,6 +205,9 @@ class Inbox(SQLModel, table=True): qry = qry.where(cls.user_id == user_id) if search: qry = qry.where(cls._candidate_search_filter(search)) + # Most-recent-first is the list contract; id breaks ties so a page + # boundary can't drop or repeat a row when created_at collides. + qry = qry.order_by(cls.created_at.desc(), cls.id.desc()) qry = qry.limit(limit).offset(offset) result = await session.execute(qry) rows = result.scalars().all() diff --git a/backend/job/app.py b/backend/job/app.py index 4e2b225..8502194 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -391,15 +391,17 @@ async def score_inbox_candidates( @router.get("/candidate/scored/fetch") async def fetch_scored_candidates( job_id: str = Query(None), + limit: int = Query(10, ge=1, le=100), + offset: int = Query(0, ge=0), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), session: AsyncSession = Depends(get_session), ): - """Persisted leaderboard: completed by score desc, failures last. Without job_id - returns the whole pool across jobs.""" + """Persisted scored candidates, newest first. Without job_id returns the whole + pool across jobs. `total` is the full result-set size, not the page length.""" try: service=CandidateScoring(session=session) - data=await service.fetch_candidates(job_id) - return JSONResponse(content={"data":data,"total":len(data),"status_code":200}) + data,total=await service.fetch_candidates(job_id,limit=limit,offset=offset) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: raise except Exception as e: @@ -409,7 +411,7 @@ async def fetch_scored_candidates( @router.get("/job/fetch") async def fetch_job_posts( search: str | None = Query(None), - top: int | None = Query(None), + top: int | None = Query(10, ge=1, le=100), skip: int = Query(0, ge=0), ids: str | None = Query(None), active_only: bool = Query(True), @@ -445,7 +447,7 @@ async def fetch_jobs( department: str | None = Query(None), requisition_status: str | None = Query(None), employment_type: str | None = Query(None), - top: int | None = Query(None), + top: int | None = Query(10, ge=1, le=100), skip: int = Query(0, ge=0), # Defaults False, unlike /job/fetch: a requisition list must show CLOSED # requisitions, and those carry is_active = false. Soft-deleted rows are still @@ -486,8 +488,8 @@ async def fetch_candidate_by_id( @router.get("/candidate/fetch") async def fetch_candidate( user_id:str=Query(None), - limit:int=Query(10), - offset:int=Query(0), + limit:int=Query(10,ge=1,le=100), + offset:int=Query(0,ge=0), search:str=Query(None), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), session: AsyncSession = Depends(get_session), @@ -772,7 +774,7 @@ async def change_candidate_stage( @router.get("/pipeline/candidates/fetch") async def fetch_pipeline_candidates( job_post_id:Optional[uuid.UUID]=Query(None), - limit:int=Query(200,ge=1,le=1000), + limit:int=Query(10,ge=1,le=1000), offset:int=Query(0,ge=0), current_user: dict = Depends(require_permission(PermissionTag.PIPELINE_VIEW)), session: AsyncSession = Depends(get_session), diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 6ec9676..92e9107 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -90,9 +90,12 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): &(AtsResults.job_post_id==cls.job_post_id) &(AtsResults.is_current==True), # noqa: E712 ) + # Newest-first is the list contract; score is only a tiebreak + # within the same instant. id keeps paging stable. .order_by( - AtsResults.overall_score.desc().nulls_last(), cls.created_at.desc(), + AtsResults.overall_score.desc().nulls_last(), + cls.id.desc(), ) ) if job_post_id: @@ -278,25 +281,40 @@ class Candidates(SQLModel, table=True): return result.scalars().first() @classmethod - async def get_candidates_by_job(cls, session: AsyncSession, job_id: str | None = None): - """Leaderboard order: completed by score desc, failures last, ties stable. + async def get_candidates_by_job( + cls, + session: AsyncSession, + job_id: str | None = None, + limit: int | None = None, + offset: int = 0, + ): + """Most-recent-first list, paged. Score is only a tiebreak within an instant. job_id=None returns the whole pool across jobs (same ordering) for the - frontend's unscoped Candidates/Talent Pool views. + frontend's unscoped Candidates/Talent Pool views. Returns (rows, total) so + the caller can page without a second count query of its own. """ statement = select(cls) if job_id is not None: uid = cls._as_uuid(job_id) if uid is None: - return [] + return [], 0 statement = statement.where(cls.job_id == uid) + total = ( + await session.execute(select(func.count()).select_from(statement.subquery())) + ).scalar_one() statement = statement.order_by( + cls.created_at.desc(), cls.status.asc(), # "completed" < "failed" cls.match_score.desc().nulls_last(), - cls.created_at.asc(), + cls.id.desc(), ) + if offset: + statement = statement.offset(offset) + if limit is not None: + statement = statement.limit(limit) result = await session.execute(statement) - return result.scalars().all() + return list(result.scalars().all()), total @classmethod async def get_completed_by_email_job(cls, session: AsyncSession, email, job_id): diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 3c0fa40..eeb29d5 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -350,14 +350,16 @@ class CandidateScoring: raise HTTPException(status_code=400,detail="No attachments found for the given message(s)") return await self._score_and_persist(job_id,sources,"inbox",current_user) - async def fetch_candidates(self,job_id=None): + async def fetch_candidates(self,job_id=None,limit=10,offset=0): # job_id omitted -> the whole pool across jobs (frontend Candidates/TalentPool). if job_id is not None: job=await JobPosts.get_job_post_by_id(self.session,job_id) if job is None or job.is_deleted: raise HTTPException(status_code=404,detail="Job post not found") - rows=await Candidates.get_candidates_by_job(self.session,job_id) - return [serialize_candidate(row) for row in rows] + rows,total=await Candidates.get_candidates_by_job( + self.session,job_id,limit=limit,offset=offset, + ) + return [serialize_candidate(row) for row in rows],total async def fetch_candidate_by_id(self,candidate_id): row=await Candidates.get_candidate_by_id(self.session,candidate_id) diff --git a/backend/job/pipeline/views.py b/backend/job/pipeline/views.py index 48ef30d..1db7ed4 100644 --- a/backend/job/pipeline/views.py +++ b/backend/job/pipeline/views.py @@ -13,9 +13,11 @@ class Pipeline: def __init__(self,session:AsyncSession): self.session=session - async def get_all(self,job_post_id=None,limit=None,offset=0): - # limit/offset are per-source, not a merged page: two tables, no common - # order key. limit=200 returns up to 200 inbox AND up to 200 manual rows. + async def get_all(self,job_post_id=None,limit=10,offset=0): + # limit/offset are per-source, not a merged page: two tables that cannot be + # paged as one. limit=10 returns up to 10 inbox AND up to 10 manual rows, + # each newest-first by created_at. `counts`/`total` stay full-set sizes so + # the caller can drive paging off them. try: inbox_data=await Inbox.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset) manual_upload_data=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_id=job_post_id,limit=limit,offset=offset) diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 72d4c1b..b2a6ed0 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -14,18 +14,25 @@ import { downloadFile, request } from '../lib/apiClient' -/** Active job posts for pickers. Needs job_board.view OR candidates.view. */ -export function listJobs() { - return request('/job/fetch') +/** Active job posts for pickers. Needs job_board.view OR candidates.view. + * + * `top` is explicit because /job/fetch now defaults to 10 — a picker dropdown + * that silently showed only the 10 newest jobs would hide the rest. + */ +export function listJobs({ top = 100 } = {}) { + return request('/job/fetch', { params: { top } }) } /** - * Persisted scoring leaderboard. Needs candidates.view. - * Omit jobId for the whole pool across jobs; rows are ordered completed-by- - * score-desc, then failed rows. + * Persisted scored candidates. Needs candidates.view. + * Omit jobId for the whole pool across jobs. Rows come back newest-first by + * created_at and PAGED (limit defaults to 10 server-side); `total` in the + * envelope is the full result-set size, not the page length. */ -export function listCandidates({ jobId } = {}) { - return request('/candidate/scored/fetch', { params: { job_id: jobId } }) +export function listCandidates({ jobId, limit, offset } = {}) { + return request('/candidate/scored/fetch', { + params: { job_id: jobId, limit, offset }, + }) } /** One scored candidate row by id. Needs candidates.view. 404s on unknown ids. */