From 8c5549a20149d0011dbc200269bce548dbca7d43 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 2 Sep 2026 13:33:11 +0500 Subject: [PATCH] Add Progress bar --- .gitignore | 3 +- backend/candidate_forms/app.py | 8 +- backend/candidate_forms/models.py | 23 +- backend/candidate_forms/plugins.py | 58 ++- backend/candidate_forms/serializers.py | 25 +- backend/candidate_forms/views.py | 116 ++++- .../application_default_credentials.json | 4 +- backend/inbox/models.py | 9 +- backend/job/app.py | 81 +++- backend/job/candidate/models.py | 9 +- backend/job/candidate/serializers.py | 21 + backend/job/candidate/views.py | 113 ++++- backend/job/job_post/models.py | 228 +++++++++- backend/job/job_post/serializers.py | 27 ++ backend/job/job_post/views.py | 30 +- backend/job/notes/views.py | 13 +- .../manual/024_manager_candidates_rbac.sql | 40 ++ .../025_manager_role_candidates_rbac.sql | 34 ++ backend/users/permissions.py | 33 +- frontend/nginx.conf | 27 +- frontend/src/App.jsx | 1 + frontend/src/__smoke__/entry.jsx | 3 +- frontend/src/api/candidates.js | 9 + frontend/src/api/jobStats.js | 52 +++ frontend/src/api/jobs.js | 6 + frontend/src/api/requisitions.js | 11 +- frontend/src/app/Sidebar.jsx | 9 +- frontend/src/app/routes.js | 1 + frontend/src/auth/permissions.js | 14 + frontend/src/lib/queryKeys.js | 4 +- frontend/src/pages/Login.jsx | 10 +- frontend/src/screens/CandidateForms.jsx | 81 ++-- frontend/src/screens/CandidateProfile.jsx | 254 +---------- frontend/src/screens/Candidates.jsx | 133 +++++- frontend/src/screens/Dashboard.jsx | 9 +- frontend/src/screens/Jobs.jsx | 65 ++- frontend/src/screens/Progress.jsx | 410 ++++++++++++++++++ frontend/src/styles/styles.css | 110 ++++- 38 files changed, 1744 insertions(+), 340 deletions(-) create mode 100644 backend/migrations/manual/024_manager_candidates_rbac.sql create mode 100644 backend/migrations/manual/025_manager_role_candidates_rbac.sql create mode 100644 frontend/src/api/jobStats.js create mode 100644 frontend/src/screens/Progress.jsx diff --git a/.gitignore b/.gitignore index 8cf8c35..27df84e 100644 --- a/.gitignore +++ b/.gitignore @@ -84,4 +84,5 @@ frontend/dist/index.html tests/** /backend/tests/** frontend/dist/** -nginx.conf \ No newline at end of file +nginx.conf +smoke.test.mjs \ No newline at end of file diff --git a/backend/candidate_forms/app.py b/backend/candidate_forms/app.py index e22d9bf..e596d25 100644 --- a/backend/candidate_forms/app.py +++ b/backend/candidate_forms/app.py @@ -80,15 +80,18 @@ async def search_requisitions( ), q: str | None = Query(None), top: int = Query(50, ge=1, le=100), + job_post_id: uuid.UUID | None = Query(None), session: AsyncSession = Depends(get_session), ): """Searchable picker for job create: `{position_title} - {department}`. `q` matches either field (ilike). Empty `q` returns recent rows. + Linked requisitions are omitted (1:1 with job posts). Pass `job_post_id` + on edit so the job's current requisition remains selectable until unlinked. """ try: service = RequisitionForm(session=session) - data = await service.search(q, top=top) + data = await service.search(q, top=top, job_post_id=job_post_id) return JSONResponse(content={"data": data, "status_code": 200}) except HTTPException: raise @@ -102,6 +105,8 @@ async def fetch_requisition_form( form_id:str=Query(None), session:AsyncSession=Depends(get_session), ): + """Requisition table. Admins get every non-deleted row (job link does not + hide anything). Other roles stay scoped to created_by.""" try: service=RequisitionForm(session=session) data=await service.get_form_by_id(form_id,current_user) @@ -171,6 +176,7 @@ async def fetch_forms( service = CandidateForm(session=session) data, summary, total = await service.get_forms( form_id, inbox_id, manual_upload_candidate_id, job_post_id, form_type, top, skip, + current_user=current_user, ) return JSONResponse( content={"data": data, "summary": summary, "total": total, "status_code": 200} diff --git a/backend/candidate_forms/models.py b/backend/candidate_forms/models.py index fd7612c..5deb238 100644 --- a/backend/candidate_forms/models.py +++ b/backend/candidate_forms/models.py @@ -87,14 +87,35 @@ class Requisition(SQLModel, table=True): return list(result.scalars().all()) @classmethod - async def search(cls, session: AsyncSession, q: str | None = None, *, top: int = 50): + async def search( + cls, + session: AsyncSession, + q: str | None = None, + *, + top: int = 50, + job_post_id=None, + ): """Dropdown rows: match position_title or department (either side). Empty `q` returns the most recent non-deleted rows so the picker has a list before the user types. Not scoped to created_by — job creators need the org-wide list, not only requisitions they opened themselves. + + job_posts.requisition_id is 1:1. Hide requisitions already linked to a + live job post. Pass `job_post_id` when editing so that job's current + requisition stays in the list until the link is cleared. """ + from job.job_post.models import JobPosts + statement = select(cls).where(cls.is_deleted == False) # noqa: E712 + held = select(JobPosts.requisition_id).where( + JobPosts.requisition_id.is_not(None), + JobPosts.is_deleted == False, # noqa: E712 + ) + except_uid = JobPosts._as_uuid(job_post_id) if job_post_id else None + if except_uid is not None: + held = held.where(JobPosts.id != except_uid) + statement = statement.where(cls.id.notin_(held)) term = (q or "").strip() if term: like = f"%{term}%" diff --git a/backend/candidate_forms/plugins.py b/backend/candidate_forms/plugins.py index 9ab696d..3d0addc 100644 --- a/backend/candidate_forms/plugins.py +++ b/backend/candidate_forms/plugins.py @@ -9,16 +9,17 @@ into every saved row so historical records survive future renames. FORM_TYPES = ("requisition", "interview_analysis", "cultural_fit") -RATING_MIN = 1 -RATING_MAX = 4 +RATING_POINTS = (25, 50, 75, 100) +# Paper ticks used to be 1–4; coerce those to the matching percentage. +_LEGACY_TICK = {1: 25, 2: 50, 3: 75, 4: 100} RATING_LABELS = { - 1: "Below Average (1)", - 2: "Average (2)", - 3: "Good (3)", - 4: "Excellent (4)", + 25: "Below Average (25%)", + 50: "Average (50%)", + 75: "Good (75%)", + 100: "Excellent (100%)", } RATING_SCALE_NOTE = ( - "Rating Scale: 1 = Below Average | 2 = Average | 3 = Good | 4 = Excellent. " + "Rating Scale: Below Average = 25% | Average = 50% | Good = 75% | Excellent = 100%. " "Tick the box that applies for each criterion." ) @@ -194,6 +195,7 @@ def definitions_payload() -> dict: "form_types": list(FORM_TYPES), "forms": FORM_DEFINITIONS, "rating_labels": {str(k): v for k, v in RATING_LABELS.items()}, + "rating_points": list(RATING_POINTS), "recommendations": list(RECOMMENDATIONS), "recommendation_labels": dict(RECOMMENDATION_LABELS), "employment_types": list(EMPLOYMENT_TYPES), @@ -211,9 +213,10 @@ def _coerce_rating(value): raise ValueError(f"rating must be a number, got {value!r}") if number != int(number): raise ValueError(f"rating must be a whole number, got {value!r}") - rating = int(number) - if rating < RATING_MIN or rating > RATING_MAX: - raise ValueError(f"rating must be between {RATING_MIN} and {RATING_MAX}, got {rating}") + rating = _LEGACY_TICK.get(int(number), int(number)) + if rating not in RATING_POINTS: + allowed = ", ".join(str(p) for p in RATING_POINTS) + raise ValueError(f"rating must be one of {allowed}, got {rating}") return rating @@ -224,15 +227,34 @@ def _mean(values, digits=2): return round(sum(values) / len(values), digits) +def to_percent(score): + """Keep derived scores on 0–100. + + New ticks are 25/50/75/100 and averages are already percentages. Legacy + 1–4 ticks or means (0, 4] convert once via (score / 4) × 100. + """ + if score is None: + return None + try: + number = float(score) + except (TypeError, ValueError): + return None + if 0 < number <= 4: + return round((number / 4) * 100, 2) + return round(number, 2) + + def normalize_sections(form_type: str, sections): """Validate submitted rated sections against the form definition and recompute all derived numbers. Returns (normalized_sections, overall_score). Every definition section is emitted in definition order with denormalized labels; submitted per-criterion ratings are merged in; client-sent averages - are discarded and recomputed (mean of the non-null ratings, 2 dp). The - overall score is the mean of the section averages. Raises ValueError on - unknown section/criterion keys or out-of-range ratings (422 material). + are discarded and recomputed. Criterion ticks are 25/50/75/100. A section + average is the mean of those percentages; the overall score is the mean of + the section averages. Legacy 1–4 ticks are coerced to the matching percent + before averaging. Raises ValueError on unknown section/criterion keys or + ratings outside the scale (422 material). """ definition = FORM_DEFINITIONS.get(form_type) if definition is None: @@ -278,7 +300,7 @@ def normalize_sections(form_type: str, sections): } for c in section_def["criteria"] ] - average = _mean([c["rating"] for c in criteria]) + average = to_percent(_mean([c["rating"] for c in criteria])) if average is not None: section_averages.append(average) normalized.append( @@ -333,8 +355,10 @@ def combined_summary(rows): `rows` are candidate_forms records (attribute access: form_type, created_at, sections). The latest interview_analysis row supplies the technical and behavioral averages, the latest cultural_fit row the cultural average. - The combined overall (mean of the three section averages, 2 dp) appears - only once all three exist. Returns None when neither evaluation exists. + The combined overall (mean of the three section averages, 2 dp, already + ranged onto 0–100) appears only once all three exist. Returns None when + neither evaluation exists. Legacy 1–4 section averages are converted + through to_percent so mixed old/new rows stay comparable. """ latest = {} for row in rows: @@ -351,7 +375,7 @@ def combined_summary(rows): for section in row.sections or []: key = section.get("key") if key in averages: - averages[key] = section.get("average") + averages[key] = to_percent(section.get("average")) complete = all(v is not None for v in averages.values()) return { diff --git a/backend/candidate_forms/serializers.py b/backend/candidate_forms/serializers.py index e465a1c..b621ed3 100644 --- a/backend/candidate_forms/serializers.py +++ b/backend/candidate_forms/serializers.py @@ -1,3 +1,24 @@ +from candidate_forms.plugins import to_percent + + +def _sections_as_percent(sections): + if not sections: + return list(sections) if sections else None + out = [] + for section in sections: + item = dict(section) + if "average" in item: + item["average"] = to_percent(item.get("average")) + criteria = item.get("criteria") + if criteria: + item["criteria"] = [ + {**c, "rating": to_percent(c.get("rating"))} if isinstance(c, dict) else c + for c in criteria + ] + out.append(item) + return out + + def serialize_form( row, *, @@ -21,9 +42,9 @@ def serialize_form( "interviewer_id": str(row.interviewer_id) if row.interviewer_id else None, "interviewer_name": interviewer_name, "form_date": row.form_date.isoformat() if row.form_date else None, - "sections": list(row.sections) if row.sections else None, + "sections": _sections_as_percent(row.sections), "fields": dict(row.fields) if row.fields else {}, - "overall_score": row.overall_score, + "overall_score": to_percent(row.overall_score), "recommendation": row.recommendation, "created_by": str(row.created_by) if row.created_by else None, "created_by_name": created_by_name, diff --git a/backend/candidate_forms/views.py b/backend/candidate_forms/views.py index 7dda488..64681aa 100644 --- a/backend/candidate_forms/views.py +++ b/backend/candidate_forms/views.py @@ -7,9 +7,13 @@ from sqlalchemy.ext.asyncio import AsyncSession from candidate_forms.models import CandidateForms, Requisition, _now from candidate_forms.plugins import ( + FORM_DEFINITIONS, FORM_READY_STATUSES, FORM_TYPES, + RECOMMENDATIONS, combined_summary, + normalize_fields, + normalize_sections, ) from candidate_forms.serializers import ( serialize_form, @@ -18,10 +22,12 @@ from candidate_forms.serializers import ( ) from inbox.models import Inbox from job.candidate.models import Interviews, Manual_UPLOAD_CANDIDATE +from job.candidate.views import assert_manager_candidate_access from job.history.enums import HistoryEvent from job.history.views import HistoryRecorder from job.job_post.models import JobPosts from users.models import Users +from users.permissions import is_admin, is_hiring_manager logger = logging.getLogger("candidate_forms") @@ -54,6 +60,35 @@ def _stage_value(status) -> str: return str(getattr(status, "value", status) or "").upper() +def _recommendation(form_type, value): + definition = FORM_DEFINITIONS.get(form_type) or {} + if not definition.get("has_recommendation"): + return None + if value in (None, ""): + return None + value = str(value).strip() + if value not in RECOMMENDATIONS: + raise HTTPException( + status_code=422, + detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}", + ) + return value + + +def _score_sections(form_type, sections): + try: + return normalize_sections(form_type, sections) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + +def _score_fields(form_type, fields): + try: + return normalize_fields(form_type, fields) + except ValueError as exc: + raise HTTPException(status_code=422, detail=str(exc)) + + class CandidateForm: def __init__(self, session: AsyncSession): self.session = session @@ -81,16 +116,22 @@ class CandidateForm: stage = _stage_value( link.messages.application_status if link.messages is not None else None ) + app_job = ( + link.messages.assigned_job_post_id if link.messages is not None else None + ) else: inbox_id = None manual = await Manual_UPLOAD_CANDIDATE.get_by_id(self.session, manual_id) if manual is None: raise HTTPException(status_code=404, detail="Manual upload candidate not found") stage = _stage_value(manual.status) + app_job = manual.job_post_id job_post_id = _as_uuid(payload.get("job_post_id")) if payload.get("job_post_id") and job_post_id is None: raise HTTPException(status_code=422, detail="Invalid job_post_id") + if job_post_id is None: + job_post_id = app_job if job_post_id is not None: post = await JobPosts.get_job_post_by_id(self.session, str(job_post_id)) if not post or post.is_deleted: @@ -181,11 +222,27 @@ class CandidateForm: form_type=None, top=None, skip=0, + current_user=None, ): if form_type and form_type not in FORM_TYPES: raise HTTPException( status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}" ) + if is_hiring_manager(current_user) and not ( + form_id or inbox_id is not None or manual_upload_candidate_id or job_post_id + ): + raise HTTPException( + status_code=403, + detail="Hiring managers can only load forms for candidates on their requisitions", + ) + if inbox_id is not None or manual_upload_candidate_id is not None or job_post_id: + await assert_manager_candidate_access( + self.session, + current_user, + job_post_id=job_post_id, + inbox_id=inbox_id, + manual_id=manual_upload_candidate_id, + ) rows, total = await CandidateForms.fetch_forms( self.session, form_id=form_id, @@ -196,6 +253,14 @@ class CandidateForm: top=top, skip=skip or 0, ) + if form_id and rows: + await assert_manager_candidate_access( + self.session, + current_user, + job_post_id=rows[0].job_post_id, + inbox_id=rows[0].inbox_id, + manual_id=rows[0].manual_upload_candidate_id, + ) summary = None if inbox_id is not None or manual_upload_candidate_id is not None: @@ -218,6 +283,13 @@ class CandidateForm: status_code=422, detail=f"form_type must be one of {', '.join(FORM_TYPES)}" ) inbox_id, manual_id, job_post_id, stage = await self._validate_link(payload) + await assert_manager_candidate_access( + self.session, + current_user, + job_post_id=job_post_id, + inbox_id=inbox_id, + manual_id=manual_id, + ) if stage not in FORM_READY_STATUSES: has_interview = False if inbox_id is not None: @@ -237,6 +309,8 @@ class CandidateForm: if form_type != "requisition" and interviewer_id is None: interviewer_id = _user_id(current_user) form_date = _aware(payload.get("form_date")) or _now() + sections, overall_score = _score_sections(form_type, payload.get("sections")) + fields = _score_fields(form_type, payload.get("fields")) row = await CandidateForms.insert_form( self.session, @@ -247,9 +321,10 @@ class CandidateForm: "job_post_id": job_post_id, "interviewer_id": interviewer_id, "form_date": form_date, - "sections": payload.get("sections"), - "fields": payload.get("fields"), - "recommendation": payload.get("recommendation"), + "sections": sections, + "fields": fields, + "overall_score": overall_score, + "recommendation": _recommendation(form_type, payload.get("recommendation")), "created_by": _user_id(current_user), }, ) @@ -270,6 +345,13 @@ class CandidateForm: row = await CandidateForms.get_form_by_id(self.session, form_id) if not row: raise HTTPException(status_code=404, detail="Form not found") + await assert_manager_candidate_access( + self.session, + current_user, + job_post_id=row.job_post_id, + inbox_id=row.inbox_id, + manual_id=row.manual_upload_candidate_id, + ) fields = {} if "interviewer_id" in payload: @@ -277,11 +359,13 @@ class CandidateForm: if "form_date" in payload: fields["form_date"] = _aware(payload.get("form_date")) if "sections" in payload: - fields["sections"] = payload.get("sections") + sections, overall_score = _score_sections(row.form_type, payload.get("sections")) + fields["sections"] = sections + fields["overall_score"] = overall_score if "fields" in payload: - fields["fields"] = payload.get("fields") + fields["fields"] = _score_fields(row.form_type, payload.get("fields")) if "recommendation" in payload: - fields["recommendation"] = payload.get("recommendation") + fields["recommendation"] = _recommendation(row.form_type, payload.get("recommendation")) if not fields: raise HTTPException(status_code=400, detail="No fields to update") @@ -302,6 +386,16 @@ class CandidateForm: async def delete_form(self, form_id, current_user): _user_id(current_user) + row = await CandidateForms.get_form_by_id(self.session, form_id) + if not row: + raise HTTPException(status_code=404, detail="Form not found") + await assert_manager_candidate_access( + self.session, + current_user, + job_post_id=row.job_post_id, + inbox_id=row.inbox_id, + manual_id=row.manual_upload_candidate_id, + ) row = await CandidateForms.soft_delete_form(self.session, form_id) if not row: raise HTTPException(status_code=404, detail="Form not found") @@ -330,7 +424,9 @@ class RequisitionForm: async def get_form_by_id(self, form_id, current_user): - created_by = _user_id(current_user) + # Admins see the full table. Managers still only see rows they opened. + # Job-post linkage is ignored here — that filter is search/picker only. + created_by = None if is_admin(current_user) else _user_id(current_user) if form_id: row = await Requisition.get_form_by_id(self.session, record_id=form_id, created_by=created_by) if not row: @@ -339,6 +435,8 @@ class RequisitionForm: rows = await Requisition.get_form_by_id(self.session, created_by=created_by) return [serialize_requisition(r) for r in rows] - async def search(self, q, top=50): - rows = await Requisition.search(self.session, q, top=top) + async def search(self, q, top=50, job_post_id=None): + rows = await Requisition.search( + self.session, q, top=top, job_post_id=job_post_id, + ) return [serialize_requisition_option(r) for r in rows] \ No newline at end of file diff --git a/backend/credentials/application_default_credentials.json b/backend/credentials/application_default_credentials.json index dfb1d51..4de621d 100644 --- a/backend/credentials/application_default_credentials.json +++ b/backend/credentials/application_default_credentials.json @@ -5,7 +5,7 @@ "refresh_token": "1//03C8LMkk-9mSyCgYIARAAGAMSNwF-L9IrAcPhShsp3cDeprSlqI-P6lArpmbyzu-PcKiLfQ5gC3H_MEk930IaKfewy3cxP3T0Oo8", "universe_domain": "googleapis.com", "account": "ahmed.mujtaba@utopiabrands.com", - "token": "ya29.a0AdMD6Eg_6meQs84gTmiyhzZp7C-JlZeJU6-ECm6twwAcMqvfvRvyvs5LQGbAhHarHzZF-jiU-sicebJmxXIN4l6hNDXoaHcrojuhq--hj2oSBWojiEKaGIgLKPM8frdspz_wVANrwkFwpIKhN3RpWID9mJCt7N6IFaNrZtgStakdF0sVCKKVttE7qWK0vIvJT3HZHpVbaCgYKAX8SARASFQHGX2MiQvJqWStYQDgYFK4E6GJ9Zw0207", - "expiry": "2026-08-31T09:52:09Z", + "token": "ya29.a0AdMD6EgILeNb9UszC7bQJbAcqX709J5ky3eM8MEuQayGwhDStmfnR5t7o192x-FPdt53Q29rYL69zqYrgofUqpwxoI_sPjBsb0wrLqYDo6zwJMTx4P5svM4jZJd9nrXzUEyp3uI81e9DQ3z6lIDKtm6aUTPWQ3fm33i2hxJM7i-svhi3OwjnLtpOUHDyw--v8rQLPHdfaCgYKATkSARASFQHGX2MiXDnutGLllH9DnNCBUKWi4Q0207", + "expiry": "2026-09-02T08:29:45Z", "quota_project_id": "hrms-ats-portal" } diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 5fec8ce..6ae81df 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -74,7 +74,7 @@ class Inbox(SQLModel, table=True): ) @classmethod - async def get_all(cls,session:AsyncSession,job_post_id=None,limit=None,offset=0): + async def get_all(cls,session:AsyncSession,job_post_id=None,job_post_ids=None,limit=None,offset=0): try: from job.job_post.models import JobPosts qry=( @@ -116,7 +116,12 @@ class Inbox(SQLModel, table=True): cls.id.desc(), ) ) - if job_post_id: + if job_post_ids is not None: + ids=list(job_post_ids) + if not ids: + return [] + qry=qry.where(Inbox_Messages.assigned_job_post_id.in_(ids)) + elif job_post_id: qry=qry.where(Inbox_Messages.assigned_job_post_id==job_post_id) if limit is not None: qry=qry.limit(limit).offset(offset) diff --git a/backend/job/app.py b/backend/job/app.py index 376e17a..c10794c 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -12,7 +12,7 @@ from job.history.views import HistoryRecorder from job.assignment.views import Assignment from job.cost.views import HiringCost from sqlalchemy.ext.asyncio import AsyncSession -from users.permissions import PermissionTag, require_permission +from users.permissions import PermissionTag, is_hiring_manager, require_permission from job.job_post.views import JobPost,JobPostCreate from job_assist.execute_agent import run_field_assist from job.job_post.export import build_jobs_workbook @@ -236,9 +236,16 @@ async def fetch_users( session: AsyncSession = Depends(get_session), ): try: + if is_hiring_manager(current_user): + raise HTTPException( + status_code=403, + detail="Hiring managers can only list candidates on their requisitions", + ) service=User(session=session) data=await service.get_users(role_id=role_id,top=top,skip=skip) return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) @@ -252,9 +259,16 @@ async def count_candidate_users( ): """Total matching users for the Candidates pager. Called once on page open.""" try: + if is_hiring_manager(current_user): + raise HTTPException( + status_code=403, + detail="Hiring managers can only list candidates on their requisitions", + ) service=User(session=session) total=await service.count_users(search=search,role_id=role_id) return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200}) + except HTTPException: + raise except Exception as e: raise HTTPException(status_code=500,detail=str(e)) @@ -728,6 +742,46 @@ async def fetch_job_posts( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/job/stats/fetch") +async def fetch_job_stats( + job_post_id: Optional[uuid.UUID] = Query(None), + search: str | None = Query(None), + ids: str | None = Query(None), + top: int | None = Query(10, ge=1, le=500), + skip: int = Query(0, ge=0), + active_only: bool = Query(False), + current_user: dict = Depends( + require_permission( + PermissionTag.JOBS_VIEW, + PermissionTag.PIPELINE_VIEW, + require_all=False, + ) + ), + session: AsyncSession = Depends(get_session), +): + """Live pipeline-stage counts per job post (inbox + manual upload). + + Omit job_post_id for a paged list (optional search / ids). Pass job_post_id + for a single object. Counts are aggregated, not stored. + """ + try: + service=JobPost(session=session) + id_list=[x.strip() for x in (ids or "").split(",") if x.strip()] or None + data,total=await service.fetch_job_stats( + job_post_id=job_post_id, + search=search, + ids=id_list, + top=top, + skip=skip, + active_only=active_only, + ) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/job/departments/fetch") async def fetch_job_departments( active_only: bool = Query(False), @@ -865,6 +919,25 @@ async def fetch_candidate_by_id( raise HTTPException(status_code=500,detail=str(e)) +@router.get("/candidate/manager/fetch") +async def fetch_manager_candidates( + limit:int=Query(50,ge=1,le=200), + offset:int=Query(0,ge=0), + current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + session: AsyncSession = Depends(get_session), +): + """Candidates allocated to jobs opened from this user's requisitions + (or where they are the assigned hiring manager).""" + try: + service=CandidateView(session=session) + data,total=await service.list_manager_candidates(current_user,limit=limit,offset=offset) + return JSONResponse(content={"data":data,"total":total,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/candidate/fetch") async def fetch_candidate( user_id:str=Query(None), @@ -876,7 +949,9 @@ async def fetch_candidate( ): try: service=CandidateView(session=session) - data=await service.get_candidate(user_id=user_id,limit=limit,offset=offset,search=search) + data=await service.get_candidate( + user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user, + ) total=await service.count_candidates(user_id=user_id,search=search) if isinstance(data,list) else 1 @@ -999,7 +1074,7 @@ async def fetch_notes( ): try: service=Note(session=session) - data=await service.get_note(note_id=note_id,user_id=user_id) + data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user) total=1 if isinstance(data,dict) else len(data) return JSONResponse(content={"data":data,"total":total,"status_code":200}) except HTTPException: diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 4f03884..f33ad88 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -62,7 +62,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) @classmethod - async def get_all(cls, session: AsyncSession, job_post_id=None, limit=None, offset=0): + async def get_all(cls, session: AsyncSession, job_post_id=None, job_post_ids=None, limit=None, offset=0): try: from inbox.models import AtsResults from users.models import Users @@ -111,7 +111,12 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): cls.id.desc(), ) ) - if job_post_id: + if job_post_ids is not None: + ids=list(job_post_ids) + if not ids: + return [] + qry=qry.where(cls.job_post_id.in_(ids)) + elif job_post_id: qry=qry.where(cls.job_post_id==job_post_id) if limit is not None: qry=qry.limit(limit).offset(offset) diff --git a/backend/job/candidate/serializers.py b/backend/job/candidate/serializers.py index 2c47e40..d2d4c58 100644 --- a/backend/job/candidate/serializers.py +++ b/backend/job/candidate/serializers.py @@ -220,3 +220,24 @@ def serialize_manual_candidate_profile(row, user, job_post) -> Dict[str, Any]: "summary_critique": None, "scored_at": None, } + + +def serialize_manager_candidate(row, *, source) -> dict: + """One application on a hiring-manager's job — list row, not the profile.""" + inbox_id = row.get("inbox_id") + manual_id = row.get("id") if source == "manual" else None + job_post_id = row.get("assigned_job_post_id") or row.get("job_post_id") + user_id = row.get("user_id") + return { + "id": user_id or (f"inbox:{inbox_id}" if inbox_id is not None else f"manual:{manual_id}"), + "user_id": user_id, + "name": row.get("name"), + "email": row.get("email") or row.get("candidate_email"), + "job_post_id": job_post_id, + "job_title": row.get("title"), + "application_status": row.get("application_status"), + "inbox_id": inbox_id, + "manual_upload_candidate_id": str(manual_id) if manual_id else None, + "created_at": row.get("created_at"), + "source": source, + } diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 4fa0488..124138c 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -23,7 +23,7 @@ from job.candidate.plugins import ( get_scoring_settings, normalize_spaced_text, ) -from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate +from job.candidate.serializers import serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate from job.job_post.models import JobPosts from job.job_post.serializers import serialize_job_post from job.candidate.models import Notes,Manual_UPLOAD_CANDIDATE @@ -32,6 +32,7 @@ from job.history.views import HistoryRecorder from job.notes.serializers import serialize_note from job.candidate.plugins import extract_candidate_email from users.models import Users +from users.permissions import is_hiring_manager from employment_agent.plugins import parse_phone load_dotenv() @@ -40,6 +41,62 @@ CV_QUEUE_NAME=os.getenv("TASKIQ_CV_QUEUE_NAME","cv_upload") MANUAL_UPLOAD_TO_ADDRESS=os.getenv( "MANUAL_UPLOAD_TO_ADDRESS","manual-cv-upload@hr-ats.local" ) +MANAGER_SCOPE_DETAIL="You can only access candidates allocated to jobs opened from your requisitions" + + +async def assigned_job_ids_for_user(session,user_id): + """Job posts this candidate is allocated to (inbox assignment + manual upload).""" + ids=set() + if not user_id: + return ids + rows=await Inbox.get_candidate_profile(session=session,user_id=user_id,limit=1000,offset=0) + records=rows if isinstance(rows,list) else ([rows] if rows else []) + for rec in records: + msg=getattr(rec,"messages",None) + jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None + if jid: + ids.add(jid) + manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(session,user_id) + if manual and manual.job_post_id: + ids.add(manual.job_post_id) + return ids + + +async def job_id_for_application(session,inbox_id=None,manual_id=None): + if inbox_id is not None: + link=await Inbox.get_inbox_with_message(session,inbox_id) + if link is None: + return None,None + msg=link.messages + return (msg.assigned_job_post_id if msg is not None else None),link.user_id + if manual_id is not None: + manual=await Manual_UPLOAD_CANDIDATE.get_by_id(session,manual_id) + if manual is None: + return None,None + return manual.job_post_id,manual.user_id + return None,None + + +async def assert_manager_candidate_access( + session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None, +): + """Hiring managers may only touch applications on jobs they own.""" + if not is_hiring_manager(current_user): + return + owned=set(await JobPosts.ids_for_manager(session,current_user.get("id"))) + if not owned: + raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) + job_id=JobPosts._as_uuid(job_post_id) if job_post_id is not None else None + uid=user_id + if job_id is None and (inbox_id is not None or manual_id is not None): + job_id,uid=await job_id_for_application(session,inbox_id=inbox_id,manual_id=manual_id) + if job_id is None and uid is not None: + candidate_jobs=await assigned_job_ids_for_user(session,uid) + if candidate_jobs & owned: + return + raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) + if job_id is None or job_id not in owned: + raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) async def parse_linkedin_url_from_cv(resume_text) -> str | None: @@ -697,14 +754,62 @@ class CandidateView: logger.exception("manual candidate rollback failed for %s",getattr(row,"id",None)) raise HTTPException(status_code=500,detail=str(e)) - async def get_candidate(self,user_id=None,limit=10,offset=0,search=None): + async def list_manager_candidates(self,current_user,limit=50,offset=0): + """Candidates allocated to jobs this manager owns (requisition → job post).""" + job_ids=await JobPosts.ids_for_manager(self.session,current_user.get("id")) + if not job_ids: + return [],0 + inbox_rows=await Inbox.get_all(self.session,job_post_ids=job_ids) + manual_rows=await Manual_UPLOAD_CANDIDATE.get_all(self.session,job_post_ids=job_ids) + merged=[] + seen=set() + for row in inbox_rows: + uid=row.get("user_id") + payload=serialize_manager_candidate(row,source="inbox") + if uid and uid not in seen: + seen.add(uid) + merged.append(payload) + elif not uid: + merged.append(payload) + for row in manual_rows: + uid=row.get("user_id") + if uid and uid in seen: + continue + payload=serialize_manager_candidate(row,source="manual") + if uid: + seen.add(uid) + merged.append(payload) + merged.sort(key=lambda r: r.get("created_at") or "",reverse=True) + total=len(merged) + start=max(0,int(offset or 0)) + cap=max(1,int(limit or 50)) + return merged[start:start+cap],total + + async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None): try: + if not user_id and is_hiring_manager(current_user): + raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) + if user_id and is_hiring_manager(current_user): + await assert_manager_candidate_access( + self.session,current_user,user_id=user_id, + ) detail=bool(user_id) # Detail mode must see every application for the candidate, not one page. fetch_limit=1000 if detail else limit rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search) if detail: records=rows if isinstance(rows,list) else ([rows] if rows else []) + if records and is_hiring_manager(current_user): + owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id"))) + kept=[] + for rec in records: + msg=getattr(rec,"messages",None) + jid=getattr(msg,"assigned_job_post_id",None) if msg is not None else None + if jid and jid in owned: + kept.append(rec) + if kept: + return await self.attach_profile_detail(kept) + records=[] if records: return await self.attach_profile_detail(rows) # Manual uploads create users + manual_upload_candidate but no inbox @@ -712,6 +817,10 @@ class CandidateView: manual=await Manual_UPLOAD_CANDIDATE.get_by_user_id(self.session,user_id) if not manual: return [] + if is_hiring_manager(current_user): + owned=set(await JobPosts.ids_for_manager(self.session,current_user.get("id"))) + if not manual.job_post_id or manual.job_post_id not in owned: + raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) user=await Users.get_user_by_id(self.session,user_id) job_post=None if manual.job_post_id: diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index f1e7194..cd9598d 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -2,8 +2,9 @@ import uuid from datetime import datetime, timezone from typing import TYPE_CHECKING, Optional -from sqlalchemy import DateTime, JSON, Index, func, or_ +from sqlalchemy import DateTime, JSON, Index, String, case, cast, func, or_, union_all from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import aliased from sqlmodel import Field, Relationship, SQLModel, select from job.job_post.enums import RequisitionStatus @@ -47,9 +48,7 @@ class JobPosts(SQLModel, table=True): buffer_sent_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) status: str = Field(default="draft") buffer_error: str | None = Field(default=None) - # requisition_status is the hiring lifecycle (RequisitionStatus). Distinct from - # `status`, which tracks Buffer publishing (draft/scheduled/published/failed). - # server_default is load-bearing: this column arrives as an ALTER on a populated table. + requisition_status: str = Field(default="open", sa_column_kwargs={"server_default": "open"}) department: str = Field(default="", sa_column_kwargs={"server_default": ""}) vacancies: int = Field(default=1, sa_column_kwargs={"server_default": "1"}) @@ -197,6 +196,193 @@ class JobPosts(SQLModel, table=True): result = await session.execute(statement) return list(result.scalars().all()), total + @classmethod + async def fetch_job_stats( + cls, + session: AsyncSession, + *, + job_post_id=None, + search: str | None = None, + ids: list[str] | None = None, + top: int | None = None, + skip: int = 0, + active_only: bool = False, + ): + """Per-job pipeline stage counts for every applicant assigned to the job. + + Inbox, manual-upload / Add Candidate / CV-bank, and unpromoted sheet + rows. Duplicate emails (case-insensitive) count once per job — the + furthest pipeline stage is kept. Flagged is_duplicate rows are skipped. + Rows with no email still count, each as themselves. Jobs with zero + applicants still appear (LEFT JOIN). + """ + from g_sheet.models import FormData + from inbox.models import Inbox_Messages + from job.candidate.models import Manual_UPLOAD_CANDIDATE + from users.models import Users + + job_uids = [] + if job_post_id is not None: + uid = job_post_id if isinstance(job_post_id, uuid.UUID) else cls._as_uuid(job_post_id) + if uid is None: + return [], 0 + job_uids = [uid] + elif ids: + for raw in ids: + uid = cls._as_uuid(raw) + if uid is not None: + job_uids.append(uid) + if not job_uids: + return [], 0 + + def dup_key(email_col, row_id): + # Same person = lower(trim(email)). No address -> unique per row + # so blank emails do not collapse into one applicant. + return func.coalesce( + func.nullif(func.lower(func.btrim(email_col)), ""), + func.concat("noid:", cast(row_id, String)), + ) + + inbox_q = ( + select( + Inbox_Messages.assigned_job_post_id.label("job_post_id"), + dup_key(Inbox_Messages.message_from, Inbox_Messages.id).label("dup_key"), + cast(Inbox_Messages.application_status, String).label("stage"), + ) + .where(Inbox_Messages.assigned_job_post_id.is_not(None)) + .where(Inbox_Messages.is_duplicate == False) # noqa: E712 + ) + manual_stage = func.coalesce( + func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.status), ""), + "PENDING", + ) + manual_email = func.coalesce( + func.nullif(func.btrim(Manual_UPLOAD_CANDIDATE.candidate_email), ""), + Users.email, + ) + manual_q = ( + select( + Manual_UPLOAD_CANDIDATE.job_post_id.label("job_post_id"), + dup_key(manual_email, Manual_UPLOAD_CANDIDATE.id).label("dup_key"), + manual_stage.label("stage"), + ) + .select_from(Manual_UPLOAD_CANDIDATE) + .outerjoin(Users, Users.id == Manual_UPLOAD_CANDIDATE.user_id) + .where(Manual_UPLOAD_CANDIDATE.job_post_id.is_not(None)) + ) + # Unpromoted sheet applicants only — promoted rows already live on + # manual_upload_candidate (manual_upload_candidate_id set). + form_stage = case( + (FormData.processing_state == "rejected", "REJECTED"), + else_="PENDING", + ) + form_q = ( + select( + FormData.job_post_id.label("job_post_id"), + dup_key(FormData.candidate_email, FormData.id).label("dup_key"), + form_stage.label("stage"), + ) + .where(FormData.job_post_id.is_not(None)) + .where(FormData.manual_upload_candidate_id.is_(None)) + .where(FormData.is_duplicate == False) # noqa: E712 + ) + if job_uids: + inbox_q = inbox_q.where(Inbox_Messages.assigned_job_post_id.in_(job_uids)) + manual_q = manual_q.where(Manual_UPLOAD_CANDIDATE.job_post_id.in_(job_uids)) + form_q = form_q.where(FormData.job_post_id.in_(job_uids)) + + apps = union_all(inbox_q, manual_q, form_q).subquery("applications") + stage_rank = case( + (apps.c.stage == "HIRED", 9), + (apps.c.stage == "APPROVED", 8), + (apps.c.stage == "OFFER", 7), + (apps.c.stage == "INTERVIEW", 6), + (apps.c.stage == "ASSESSMENT", 5), + (apps.c.stage.in_(["SCREENING", "PROCESS"]), 4), + (apps.c.stage == "PENDING", 3), + (apps.c.stage == "ONHOLD", 2), + (apps.c.stage.in_(["REJECTED", "CLOSED"]), 1), + else_=0, + ) + unique_apps = ( + select(apps.c.job_post_id, apps.c.dup_key, apps.c.stage) + .distinct(apps.c.job_post_id, apps.c.dup_key) + .order_by(apps.c.job_post_id, apps.c.dup_key, stage_rank.desc()) + .subquery("unique_applicants") + ) + stage = unique_apps.c.stage + + def stage_count(*values): + return func.coalesce(func.sum(case((stage.in_(list(values)), 1), else_=0)), 0) + + stats = ( + select( + unique_apps.c.job_post_id, + func.count().label("total_applicants"), + stage_count("PENDING").label("shortlisting"), + stage_count("SCREENING", "PROCESS").label("screened"), + stage_count("ASSESSMENT").label("assessment"), + stage_count("INTERVIEW").label("interviewed"), + stage_count("OFFER").label("offered"), + stage_count("ONHOLD").label("on_hold"), + stage_count("REJECTED", "CLOSED").label("rejected"), + stage_count("APPROVED").label("approved"), + stage_count("HIRED").label("hired"), + ) + .select_from(unique_apps) + .group_by(unique_apps.c.job_post_id) + .subquery("job_stage_stats") + ) + + # Alias so this join does not collide with the Users join inside + # the manual-upload subquery above. + Recruiter=aliased(Users) + statement = ( + select( + cls.id.label("job_post_id"), + cls.title, + cls.department, + cls.location, + cls.requisition_status, + cls.current_recruiter_id, + Recruiter.name.label("recruiter_name"), + func.coalesce(stats.c.total_applicants, 0).label("total_applicants"), + func.coalesce(stats.c.shortlisting, 0).label("shortlisting"), + func.coalesce(stats.c.screened, 0).label("screened"), + func.coalesce(stats.c.assessment, 0).label("assessment"), + func.coalesce(stats.c.interviewed, 0).label("interviewed"), + func.coalesce(stats.c.offered, 0).label("offered"), + func.coalesce(stats.c.on_hold, 0).label("on_hold"), + func.coalesce(stats.c.rejected, 0).label("rejected"), + func.coalesce(stats.c.approved, 0).label("approved"), + func.coalesce(stats.c.hired, 0).label("hired"), + ) + .select_from(cls) + .outerjoin(stats, stats.c.job_post_id == cls.id) + .outerjoin(Recruiter, Recruiter.id == cls.current_recruiter_id) + .where(cls.is_deleted == False) # noqa: E712 + ) + if active_only: + statement = statement.where(cls.is_active == True) # noqa: E712 + if job_uids: + statement = statement.where(cls.id.in_(job_uids)) + if search: + like = f"%{search.strip()}%" + statement = statement.where( + or_(cls.title.ilike(like), cls.location.ilike(like), cls.department.ilike(like)) + ) + + count_statement = select(func.count()).select_from(statement.subquery()) + total = (await session.execute(count_statement)).scalar_one() + statement = statement.order_by(cls.created_at.desc()) + if job_post_id is None: + if skip: + statement = statement.offset(skip) + if top is not None: + statement = statement.limit(top) + result = await session.execute(statement) + return list(result.mappings().all()), int(total or 0) + @classmethod async def list_departments(cls, session: AsyncSession, *, active_only: bool = False): """Distinct non-empty departments on non-deleted job posts. @@ -215,6 +401,39 @@ class JobPosts(SQLModel, table=True): result = await session.execute(statement) return list(result.scalars().all()) + @classmethod + async def ids_for_manager(cls, session: AsyncSession, user_id): + """Job posts this user owns: assigned hiring_manager, or opened from + a requisition they created. The manager Candidates list and form + scope both follow this chain.""" + uid = cls._as_uuid(user_id) + if uid is None: + return [] + from candidate_forms.models import Requisition + + assigned = await session.execute( + select(cls.id).where( + cls.hiring_manager_id == uid, + cls.is_deleted == False, # noqa: E712 + ) + ) + via_req = await session.execute( + select(cls.id) + .join(Requisition, cls.requisition_id == Requisition.id) + .where( + Requisition.created_by == uid, + Requisition.is_deleted == False, # noqa: E712 + cls.is_deleted == False, # noqa: E712 + ) + ) + seen: set[uuid.UUID] = set() + out: list[uuid.UUID] = [] + for row_id in list(assigned.scalars().all()) + list(via_req.scalars().all()): + if row_id not in seen: + seen.add(row_id) + out.append(row_id) + return out + @classmethod async def count_open_reqs_by_hiring_managers(cls, session: AsyncSession, user_ids): """Open requisitions per hiring manager, keyed by users.id.""" @@ -397,6 +616,7 @@ class JobPosts(SQLModel, table=True): return None row.is_deleted = True row.is_active = False + row.requisition_id = None row.updated_at = _now() session.add(row) await session.commit() diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index bf165fe..a490191 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -47,6 +47,7 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app inbox, candidate and matching paths. department is the one shared field — talent-pool filters key off it on attached job_posts. """ + req = getattr(row, "requisition", None) return { "id": str(row.id), "title": row.title, @@ -72,6 +73,8 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app "hiring_manager_id": str(row.hiring_manager_id) if row.hiring_manager_id else None, "hiring_manager_name": hiring_manager_name, "requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None, + "requisition_title": req.position_title if req else None, + "requisition_department": req.department if req else None, "applicant_count": applicant_count, "created_by": str(row.created_by) if row.created_by else None, "created_by_name": row.user.name if getattr(row, "user", None) else None, @@ -80,6 +83,30 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app } +def serialize_job_stats(row) -> dict: + """One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats.""" + recruiter_id=row.get("current_recruiter_id") + return { + "job_post_id": str(row["job_post_id"]), + "title": row["title"], + "department": row["department"] or None, + "location": row["location"], + "requisition_status": row["requisition_status"], + "current_recruiter_id": str(recruiter_id) if recruiter_id else None, + "recruiter_name": row.get("recruiter_name") or None, + "total_applicants": int(row["total_applicants"] or 0), + "shortlisting": int(row["shortlisting"] or 0), + "screened": int(row["screened"] or 0), + "assessment": int(row["assessment"] or 0), + "interviewed": int(row["interviewed"] or 0), + "offered": int(row["offered"] or 0), + "on_hold": int(row["on_hold"] or 0), + "rejected": int(row["rejected"] or 0), + "approved": int(row["approved"] or 0), + "hired": int(row["hired"] or 0), + } + + def serialize_status_history(row, *, changed_by_name=None) -> dict: return { "id": str(row.id), diff --git a/backend/job/job_post/views.py b/backend/job/job_post/views.py index 0b4aec6..f3c29bf 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -27,7 +27,7 @@ from job.job_post.plugins import ( render_job_post, resolve_channel, ) -from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_status_history +from job.job_post.serializers import serialize_job_post, serialize_job_row, serialize_job_stats, serialize_status_history load_dotenv() logger=logging.getLogger("job.job_post") @@ -234,6 +234,28 @@ class JobPost: ) return [serialize_job_post(r) for r in rows],total + async def fetch_job_stats(self,job_post_id=None,search=None,ids=None,top=None,skip=0,active_only=False): + uid=None + if job_post_id not in (None,""): + uid=JobPosts._as_uuid(job_post_id) + if uid is None: + raise HTTPException(status_code=422,detail="job_post_id must be a UUID") + rows,total=await JobPosts.fetch_job_stats( + self.session, + job_post_id=uid, + search=search, + ids=ids, + top=top, + skip=skip, + active_only=active_only, + ) + data=[serialize_job_stats(r) for r in rows] + if uid is not None: + if not data: + raise HTTPException(status_code=404,detail="Job post not found") + return data[0],1 + return data,total + async def fetch_departments(self,active_only=False): return await JobPosts.list_departments(self.session,active_only=active_only) @@ -332,6 +354,12 @@ class JobPost: req=await Requisition.get_form_by_id(self.session,record_id=str(raw)) if not req: raise HTTPException(status_code=404,detail="Requisition not found") + held=await JobPosts.get_by_requisition_id(self.session, req.id) + if held and str(held.id)!=str(existing.id): + raise HTTPException( + status_code=409, + detail="This requisition is already linked to a job post", + ) fields["requisition_id"]=req.id if "current_recruiter_id" in payload: raw=payload.get("current_recruiter_id") diff --git a/backend/job/notes/views.py b/backend/job/notes/views.py index 77923a5..b9a5b3d 100644 --- a/backend/job/notes/views.py +++ b/backend/job/notes/views.py @@ -2,6 +2,7 @@ from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from job.candidate.models import Notes +from job.candidate.views import assert_manager_candidate_access from job.history.enums import HistoryEvent from job.history.views import HistoryRecorder from job.notes.serializers import serialize_note @@ -14,17 +15,21 @@ class Note: async def _load(self,record_id): return await Notes.get_note_by_id(self.session,record_id) - async def get_note(self,note_id=None,user_id=None): + async def get_note(self,note_id=None,user_id=None,current_user=None): if note_id: row=await self._load(note_id) if not row: raise HTTPException(status_code=404,detail="Note not found") + await assert_manager_candidate_access( + self.session,current_user,user_id=row.user_id, + ) return serialize_note(row) if not user_id: raise HTTPException(status_code=400,detail="note_id or user_id is required") uid=Notes._as_uuid(user_id) if uid is None: raise HTTPException(status_code=400,detail="Invalid user_id") + await assert_manager_candidate_access(self.session,current_user,user_id=uid) rows=await Notes.get_notes_by_user(self.session,uid) return [serialize_note(r) for r in rows] @@ -36,6 +41,9 @@ class Note: } if not fields["user_id"]: raise HTTPException(status_code=400,detail="user_id is required") + await assert_manager_candidate_access( + self.session,current_user,user_id=fields["user_id"], + ) row=await Notes.insert_note(self.session,fields) await HistoryRecorder(self.session).record( HistoryEvent.NOTE_CREATED.value, @@ -53,6 +61,9 @@ class Note: before=await self._load(note_id) if not before: raise HTTPException(status_code=404,detail="Note not found") + await assert_manager_candidate_access( + self.session,current_user,user_id=before.user_id, + ) old_note=before.note or "" row=await Notes.update_note(self.session,note_id,fields) if not row: diff --git a/backend/migrations/manual/024_manager_candidates_rbac.sql b/backend/migrations/manual/024_manager_candidates_rbac.sql new file mode 100644 index 0000000..abaee29 --- /dev/null +++ b/backend/migrations/manual/024_manager_candidates_rbac.sql @@ -0,0 +1,40 @@ +-- 024_manager_candidates_rbac.sql +-- Manual one-shot: hiring_manager can list candidates on their requisition +-- jobs and write notes on those profiles. Form fill already comes from +-- hiring_forms (interviews.create/edit) + analytics_dashboard (interviews.view). +-- Applied at startup by alembic_setup.run_manual_sql(). +-- +-- Users must log in again after this applies — the frontend caches /users/me. + +-- ============================================================================= +-- 1. Bundle: candidates.view / create / edit (list + notes) +-- ============================================================================= +INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted) +SELECT + 'manager_candidates', + 'Hiring manager: list candidates on own requisition jobs, view profiles, write notes', + ( + SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) + FROM app.permission_tags + WHERE is_deleted = false + AND tag_name IN ('candidates.view', 'candidates.create', 'candidates.edit') + ), + true, + NOW(), + NOW(), + true, + false +WHERE NOT EXISTS ( + SELECT 1 FROM app.permissions WHERE name = 'manager_candidates' +); + +-- ============================================================================= +-- 2. Attach the bundle to hiring_manager only +-- ============================================================================= +UPDATE app.roles r +SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id), + updated_at = NOW() +FROM app.permissions p +WHERE p.name = 'manager_candidates' + AND r.role_name = 'hiring_manager' + AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id)); diff --git a/backend/migrations/manual/025_manager_role_candidates_rbac.sql b/backend/migrations/manual/025_manager_role_candidates_rbac.sql new file mode 100644 index 0000000..e1a1b5d --- /dev/null +++ b/backend/migrations/manual/025_manager_role_candidates_rbac.sql @@ -0,0 +1,34 @@ +-- 025_manager_role_candidates_rbac.sql +-- The Access Control role named Manager (Ahmed Baig) is not the seeded +-- hiring_manager role. 024 only attached manager_candidates to hiring_manager, +-- so Manager had 0 candidates.* tags and the Candidates nav item never appeared +-- (routes.js permission is candidates.view). Same hole broke Calendar: +-- GET /interview/fetch is gated on candidates.view, not interviews.view. +-- Applied at startup by alembic_setup.run_manual_sql(). Log in again after. + +INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted) +SELECT + 'manager_candidates', + 'Hiring manager: list candidates on own requisition jobs, view profiles, write notes', + ( + SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) + FROM app.permission_tags + WHERE is_deleted = false + AND tag_name IN ('candidates.view', 'candidates.create', 'candidates.edit') + ), + true, + NOW(), + NOW(), + true, + false +WHERE NOT EXISTS ( + SELECT 1 FROM app.permissions WHERE name = 'manager_candidates' +); + +UPDATE app.roles r +SET permissions = COALESCE(r.permissions, '[]'::jsonb) || jsonb_build_array(p.id), + updated_at = NOW() +FROM app.permissions p +WHERE p.name = 'manager_candidates' + AND lower(r.role_name) IN ('hiring_manager', 'manager') + AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id)); diff --git a/backend/users/permissions.py b/backend/users/permissions.py index 29ff809..e420768 100644 --- a/backend/users/permissions.py +++ b/backend/users/permissions.py @@ -16,7 +16,7 @@ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.ext.asyncio import AsyncSession from db_setup import get_session -from role.models import Roles +from role.models import EnumRoles, Roles from users.models import Users from users.plugins import decode_token from users.serializers import serialize_user @@ -203,6 +203,37 @@ def _assert_vocabulary_complete() -> None: _assert_vocabulary_complete() +def is_hiring_manager(current_user: dict | None) -> bool: + """Hiring-manager portal: seeded hiring_manager, or a custom Manager role. + + Ahmed Baig's Access Control role is named Manager (not hiring_manager). + Matching is case-insensitive so the sidebar and API scope agree. + """ + name = ((current_user or {}).get("role_name") or "").strip().lower() + return name in {EnumRoles.HIRING_MANAGER.value, "manager"} + + +_ADMIN_ROLES = { + EnumRoles.SYSTEM_ADMINISTRATOR.value, + EnumRoles.HR_ADMINISTRATOR.value, + "admin", +} + + +def is_admin(current_user: dict | None) -> bool: + """Org-wide staff: seeded admin roles, a custom Admin role, or requisitions.manage. + + Managers keep a created_by-scoped requisition list. Admins see every + non-deleted requisition, linked to a job post or not. + """ + user = current_user or {} + name = (user.get("role_name") or "").strip().lower() + if name in _ADMIN_ROLES: + return True + granted = user.get("permissions") or [] + return PermissionTag.REQUISITIONS_MANAGE.value in granted + + def has_permission( granted: set[str] | list[str] | tuple[str, ...], *required: PermissionTag, diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 182fefa..d5182fc 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -37,7 +37,7 @@ server { } # API-only prefixes (no SPA page at the bare path). - location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms|requisitions)(/|$) { + location ~ ^/(health|users|roles|permissions|permission-tags|email|job|candidate|notes|interview|feedback|activity|org-settings|saved-searches|search|documents|sheet|s3|forms)(/|$) { proxy_pass http://backend-api:8000; proxy_http_version 1.1; proxy_set_header Host $host; @@ -57,18 +57,39 @@ server { try_files $uri $uri/ /index.html; } - # Hashed filenames, so they can be cached hard. + # Hashed filenames, so they can be cached hard. A miss after a rebuild is a + # stale tab (old import() hash) — 404 + immutable would pin that miss for a + # year, so JS falls through to a one-shot reload of index.html. location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header Referrer-Policy strict-origin-when-cross-origin always; + + location ~ \.js$ { + expires 1y; + add_header Cache-Control "public, immutable"; + add_header X-Content-Type-Options nosniff always; + add_header X-Frame-Options DENY always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + try_files $uri @stale_js; + } + } + + location @stale_js { + default_type application/javascript; + add_header Cache-Control "no-store" always; + add_header X-Content-Type-Options nosniff always; + return 200 "try{if(!sessionStorage.getItem('tf-chunk-reload')){sessionStorage.setItem('tf-chunk-reload','1');location.reload();}else{sessionStorage.removeItem('tf-chunk-reload');}}catch(e){location.reload();}"; } # index.html must never be cached, or a redeploy keeps serving the old asset hashes. location = /index.html { - add_header Cache-Control "no-store"; + etag off; + if_modified_since off; + add_header Cache-Control "no-store, no-cache, must-revalidate, max-age=0" always; + add_header Pragma "no-cache" always; add_header X-Content-Type-Options nosniff always; add_header X-Frame-Options DENY always; add_header Referrer-Policy strict-origin-when-cross-origin always; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e63ce75..621f0d6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -22,6 +22,7 @@ const SCREENS = { candidates: lazy(() => import('./screens/Candidates')), talentpool: lazy(() => import('./screens/TalentPool')), pipeline: lazy(() => import('./screens/Pipeline')), + progress: lazy(() => import('./screens/Progress')), import: lazy(() => import('./screens/CvImport')), jobboard: lazy(() => import('./screens/JobBoard')), recruiterhub: lazy(() => import('./screens/RecruiterHub')), diff --git a/frontend/src/__smoke__/entry.jsx b/frontend/src/__smoke__/entry.jsx index e7c4822..bec997e 100644 --- a/frontend/src/__smoke__/entry.jsx +++ b/frontend/src/__smoke__/entry.jsx @@ -37,6 +37,7 @@ import Talent from '../screens/Talent' import Tasks from '../screens/Tasks' import AiAssistant from '../screens/AiAssistant' import Interviews from '../screens/Interviews' +import Requisitions from '../screens/Requisitions' import Assessments from '../screens/Assessments' import Offers from '../screens/Offers' import Managers from '../screens/Managers' @@ -53,7 +54,7 @@ const SCREENS = { dashboard: Dashboard, inbox: Inbox, matching: Matching, jobs: Jobs, candidates: Candidates, talentpool: TalentPool, pipeline: Pipeline, import: CvImport, jobboard: JobBoard, recruiterhub: RecruiterHub, talent: Talent, tasks: Tasks, aiassistant: AiAssistant, - interviews: Interviews, assessments: Assessments, offers: Offers, + interviews: Interviews, requisitions: Requisitions, assessments: Assessments, offers: Offers, managers: Managers, calendar: Calendar, reports: Reports, analytics: Analytics, aistudio: AiStudio, notifications: Notifications, rbac: Rbac, settings: Settings, help: Help, diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 759274e..48aeca4 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -259,6 +259,15 @@ export function getByUserId(userId) { return request('/candidate/fetch', { params: { user_id: userId } }) } +/** + * Candidates allocated to jobs this hiring manager owns — requisitions they + * created (or are assigned on) → linked job posts → applications. + * Needs candidates.view. Server-scoped; recruiters should not use this. + */ +export function listForManager({ limit = 50, offset = 0 } = {}) { + return request('/candidate/manager/fetch', { params: { limit, offset } }) +} + /** `data` is a list on the list path and a bare object on the by-id path. */ export function toRows(res) { if (Array.isArray(res?.data)) return res.data diff --git a/frontend/src/api/jobStats.js b/frontend/src/api/jobStats.js new file mode 100644 index 0000000..525e845 --- /dev/null +++ b/frontend/src/api/jobStats.js @@ -0,0 +1,52 @@ +/* ============================================================ + jobStats.js — per-job pipeline stage counts (GET /job/stats/fetch). + + Live aggregation over inbox + manual upload + unpromoted sheet rows, + deduped by email. Needs jobs.view or pipeline.view. + ============================================================ */ + +import { request } from '../lib/apiClient' +import { REQUISITION_STATUSES } from './jobs' + +const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label])) + +/** + * List or single-job stage counts. + * Omit jobPostId for a paged list; pass jobPostId for one object. + */ +export function list({ jobPostId, search, ids, top, skip, activeOnly } = {}) { + return request('/job/stats/fetch', { + params: { + job_post_id: jobPostId, + search, + ids: Array.isArray(ids) ? ids.join(',') : ids, + top, + skip, + active_only: activeOnly, + }, + }) +} + +/** API row -> what Progress cards and the table render. */ +export function toJobStatsView(row) { + return { + id: row.job_post_id, + title: row.title || 'Untitled role', + department: row.department || null, + location: row.location || null, + status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status ?? '—', + requisitionStatus: row.requisition_status, + recruiterId: row.current_recruiter_id || null, + recruiterName: row.recruiter_name || null, + total: Number(row.total_applicants) || 0, + shortlist: Number(row.shortlisting) || 0, + screened: Number(row.screened) || 0, + assessment: Number(row.assessment) || 0, + interviewed: Number(row.interviewed) || 0, + offered: Number(row.offered) || 0, + onHold: Number(row.on_hold) || 0, + rejected: Number(row.rejected) || 0, + approved: Number(row.approved) || 0, + hired: Number(row.hired) || 0, + } +} diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index 0b5b0c5..8f9d418 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -76,6 +76,12 @@ export function toJobView(row) { skills: row.requirements ?? [], optionalSkills: row.optional_skills ?? [], description: row.description, + requisitionId: row.requisition_id || null, + requisitionTitle: row.requisition_title || '', + requisitionDepartment: row.requisition_department || '', + requisitionLabel: row.requisition_id + ? `${(row.requisition_title || 'Untitled').trim() || 'Untitled'} - ${(row.requisition_department || '—').trim() || '—'}` + : null, } } diff --git a/frontend/src/api/requisitions.js b/frontend/src/api/requisitions.js index 25bf7b1..28026e3 100644 --- a/frontend/src/api/requisitions.js +++ b/frontend/src/api/requisitions.js @@ -26,10 +26,15 @@ export function getById(formId) { return request('/forms/requisition/fetch', { params: { form_id: formId } }) } -/** Searchable picker — GET /forms/requisition/search. `q` matches title or department. */ -export function search({ q, top } = {}) { +/** Searchable picker — GET /forms/requisition/search. `q` matches title or department. + * `jobPostId` keeps the job's current requisition in the list while editing. */ +export function search({ q, top, jobPostId } = {}) { return request('/forms/requisition/search', { - params: { q: q || undefined, top }, + params: { + q: q || undefined, + top, + job_post_id: jobPostId || undefined, + }, }) } diff --git a/frontend/src/app/Sidebar.jsx b/frontend/src/app/Sidebar.jsx index 7aede97..1bf3e62 100644 --- a/frontend/src/app/Sidebar.jsx +++ b/frontend/src/app/Sidebar.jsx @@ -1,15 +1,20 @@ import { NavLink } from 'react-router-dom' import { NAV_GROUPS, ROUTES } from './routes' import { useAuth } from '../auth/AuthContext' +import { HIRING_MANAGER_NAV, isHiringManager } from '../auth/permissions' import Icon from '../ui/icons' import { BrandGlyph } from '../components/BrandMark' export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badges }) { - const { can } = useAuth() + const { can, user } = useAuth() // A group heading renders only if something under it survived the permission // filter — otherwise a low-privilege user sees orphaned section labels. - const visible = ROUTES.filter((r) => can(r.permission)) + const visible = ROUTES.filter((r) => { + if (!can(r.permission)) return false + if (isHiringManager(user) && !HIRING_MANAGER_NAV.has(r.path)) return false + return true + }) return (