pull/24/head
parent
2bbdc7b7c4
commit
867fea495a
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in New Issue