pull/24/head
ahmed.mujtaba 2026-08-20 18:22:33 +05:00
parent 2bbdc7b7c4
commit 867fea495a
6 changed files with 68 additions and 31 deletions

View File

@ -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()

View File

@ -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),

View File

@ -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):

View File

@ -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)

View File

@ -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)

View File

@ -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. */