diff --git a/.gitignore b/.gitignore index 5f319a3..27df84e 100644 --- a/.gitignore +++ b/.gitignore @@ -82,4 +82,7 @@ docker.local.frontend/dist/** */ frontend/dist/index.html frontend/dist/index.html tests/** -/backend/tests/** \ No newline at end of file +/backend/tests/** +frontend/dist/** +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 37d5f3a..e596d25 100644 --- a/backend/candidate_forms/app.py +++ b/backend/candidate_forms/app.py @@ -1,17 +1,53 @@ -from datetime import datetime - +from datetime import datetime, date +from dis import Positions +from typing import Type from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import JSONResponse +import uuid from pydantic import BaseModel from sqlalchemy.ext.asyncio import AsyncSession - +from typing import Optional from candidate_forms.plugins import definitions_payload from candidate_forms.views import CandidateForm from db_setup import get_session from users.permissions import PermissionTag, require_permission +from candidate_forms.enums import EmploymentType, Position, ReplacementFor, InternalRecommendate +from candidate_forms.views import RequisitionForm router = APIRouter() +class RequisitionFormCreate(BaseModel): + form_type: str = "requisition" + position:Position + replacement_for:Optional[ReplacementFor] + refferal_by:Optional[InternalRecommendate] + initiated_by:Optional[str] + initiated_date:Optional[date] + recommended_by:Optional[str] + recommended_date:Optional[date] + approved_by_hr:Optional[bool] + approved_by_date_hr:Optional[date] + approved_by_vp:Optional[bool] + approved_by_date_vp:Optional[date] + approved_by_svp:Optional[bool] + approved_by_date_svp:Optional[date] + + +class RequisitionFormUpdate(BaseModel): + position:Optional[Position]=None + replacement_for:Optional[ReplacementFor]=None + refferal_by:Optional[InternalRecommendate]=None + initiated_by:Optional[str]=None + initiated_date:Optional[date]=None + recommended_by:Optional[str]=None + recommended_date:Optional[date]=None + approved_by_hr:Optional[bool]=None + approved_by_date_hr:Optional[date]=None + approved_by_vp:Optional[bool]=None + approved_by_date_vp:Optional[date]=None + approved_by_svp:Optional[bool]=None + approved_by_date_svp:Optional[date]=None + class FormCreate(BaseModel): form_type: str @@ -32,6 +68,85 @@ class FormUpdate(BaseModel): fields: dict | None = None recommendation: str | None = None +@router.get("/forms/requisition/search") +async def search_requisitions( + current_user: dict = Depends( + require_permission( + PermissionTag.REQUISITIONS_VIEW, + PermissionTag.JOB_BOARD_CREATE, + PermissionTag.JOBS_CREATE, + require_all=False, + ) + ), + 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, job_post_id=job_post_id) + return JSONResponse(content={"data": data, "status_code": 200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/forms/requisition/fetch") +async def fetch_requisition_form( + current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_VIEW)), + 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) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.post("/forms/requisition/create") +async def create_requisition_form( + payload: RequisitionFormCreate, + current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_CREATE)), + session:AsyncSession=Depends(get_session), +): + try: + service = RequisitionForm(session=session) + data = await service.create_form(payload.model_dump(exclude_unset=True), current_user) + return JSONResponse(content={"data": data, "status_code": 200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.patch("/forms/requisition/update") +async def update_requisition_form( + payload: RequisitionFormUpdate, + current_user:dict=Depends(require_permission(PermissionTag.REQUISITIONS_EDIT)), + form_id:str=Query(...), + session:AsyncSession=Depends(get_session), +): + try: + service=RequisitionForm(session=session) + data=await service.update_form(form_id,payload.model_dump(exclude_unset=True),current_user) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) @router.get("/forms/definitions") async def fetch_form_definitions( @@ -61,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/enums.py b/backend/candidate_forms/enums.py new file mode 100644 index 0000000..8cede5b --- /dev/null +++ b/backend/candidate_forms/enums.py @@ -0,0 +1,31 @@ +from enum import Enum +from pydantic import BaseModel +from typing import Optional +from datetime import date + +class EmploymentType(str,Enum): + PERMANENT = "permanent" + CONTRACT = "contract" + TEMPORARY = "temporary" + INTERNEE="internee" + +class Position(BaseModel): + department:Optional[str] + title:Optional[str] + date:Optional[date] + date_needed:Optional[date] + type:Optional[EmploymentType] + job_description:Optional[str] + +class InternalRecommendate(BaseModel): + employee_name:Optional[str]=None + employee_department:Optional[str]=None + +class ReplacementFor(BaseModel): + to_replace:Optional[str] + grade:Optional[str] + title:Optional[str] + date_separated:Optional[date] + justification:Optional[str] + budget:Optional[str] + recommended_grade:Optional[str] \ No newline at end of file diff --git a/backend/candidate_forms/models.py b/backend/candidate_forms/models.py index 1071df5..5deb238 100644 --- a/backend/candidate_forms/models.py +++ b/backend/candidate_forms/models.py @@ -1,14 +1,235 @@ import uuid -from datetime import datetime, timezone +from datetime import datetime, date as Date, timezone +from typing import TYPE_CHECKING, Optional -from sqlalchemy import DateTime, JSON, func +from sqlalchemy import DateTime, Enum as SAEnum, JSON, func, or_ from sqlalchemy.ext.asyncio import AsyncSession -from sqlmodel import Field, SQLModel, select +from sqlmodel import Field, Relationship, SQLModel, select + +from candidate_forms.enums import EmploymentType + +if TYPE_CHECKING: + from job.job_post.models import JobPosts def _now() -> datetime: return datetime.now(timezone.utc) +class Requisition(SQLModel, table=True): + __tablename__ = "requisitions" + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + + department: Optional[str] = None + position_title: Optional[str] = None + date: Optional[Date] = None + date_needed: Optional[Date] = None + employment_type: Optional[EmploymentType] = Field( + default=None, + sa_type=SAEnum( + EmploymentType, + name="employmenttype", + schema="app", + native_enum=True, + values_callable=lambda enum: [member.value for member in enum], + ), + ) + job_description: Optional[str] = None + + employee_name: Optional[str] = None + employee_department: Optional[str] = None + + to_replace: Optional[str] = None + grade: Optional[str] = None + recruitment_title: Optional[str] = None + date_separated: Optional[Date] = None + justification: Optional[str] = None + budget: Optional[str] = None + recommended_grade: Optional[str] = None + + initiated_by: Optional[str] = None + initiated_date: Optional[Date] = None + recommended_by: Optional[str] = None + recommended_date: Optional[Date] = None + approved_by_hr: Optional[bool] = None + approved_by_date_hr: Optional[Date] = None + approved_by_vp: Optional[bool] = None + approved_by_date_vp: Optional[Date] = None + approved_by_svp: Optional[bool] = None + approved_by_date_svp: Optional[Date] = None + created_by: Optional[uuid.UUID] = Field(foreign_key="users.id") + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + is_deleted: bool = Field(default=False) + # Optional 1:1: job_posts.requisition_id points here. uselist=False so a + # requisition has at most one job post (enforced in DB by the unique FK). + job_post: Optional["JobPosts"] = Relationship( + back_populates="requisition", + sa_relationship_kwargs={"uselist": False, "lazy": "selectin"}, + ) + + @classmethod + async def get_form_by_id(cls, session: AsyncSession, record_id=None, created_by=None): + qry = select(cls).where(cls.is_deleted == False) # noqa: E712 + if created_by is not None: + qry = qry.where(cls.created_by == created_by) + if record_id not in (None, ""): + + try: + uid = uuid.UUID(str(record_id)) + except (TypeError, ValueError): + return None + + qry = qry.where(cls.id == uid) + qry = qry.order_by(cls.created_at.desc(),cls.id.desc()) + result = await session.execute(qry) + return result.scalars().first() + result = await session.execute(qry.order_by(cls.created_at.desc(),cls.id.desc())) + return list(result.scalars().all()) + + @classmethod + 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}%" + statement = statement.where( + or_(cls.position_title.ilike(like), cls.department.ilike(like)) + ) + limit = max(1, min(int(top or 50), 100)) + statement = statement.order_by(cls.created_at.desc(), cls.id.desc()).limit(limit) + result = await session.execute(statement) + return list(result.scalars().all()) + + @classmethod + async def insert_form(cls, session: AsyncSession, fields: dict): + position = fields.get("position") if fields.get("position") else {} + replacement = fields.get("replacement_for") if fields.get("replacement_for") else {} + referral = fields.get("refferal_by") if fields.get("refferal_by") else {} + row = cls( + department=position.get("department") if position.get("department") else None, + position_title=position.get("title") if position.get("title") else None, + date=position.get("date") if position.get("date") else None, + date_needed=position.get("date_needed") if position.get("date_needed") else None, + employment_type=EmploymentType(position.get("type")) if position.get("type") else None, + job_description=position.get("job_description") if position.get("job_description") else None, + employee_name=referral.get("employee_name") if referral.get("employee_name") else None, + employee_department=referral.get("employee_department") if referral.get("employee_department") else None, + to_replace=replacement.get("to_replace") if replacement.get("to_replace") else None, + grade=replacement.get("grade") if replacement.get("grade") else None, + recruitment_title=replacement.get("title") if replacement.get("title") else None, + date_separated=replacement.get("date_separated") if replacement.get("date_separated") else None, + justification=replacement.get("justification") if replacement.get("justification") else None, + budget=replacement.get("budget") if replacement.get("budget") else None, + recommended_grade=replacement.get("recommended_grade") if replacement.get("recommended_grade") else None, + initiated_by=fields.get("initiated_by") if fields.get("initiated_by") else None, + initiated_date=fields.get("initiated_date") if fields.get("initiated_date") else None, + recommended_by=fields.get("recommended_by") if fields.get("recommended_by") else None, + recommended_date=fields.get("recommended_date") if fields.get("recommended_date") else None, + approved_by_hr=fields.get("approved_by_hr") if fields.get("approved_by_hr") is not None else None, + approved_by_date_hr=fields.get("approved_by_date_hr") if fields.get("approved_by_date_hr") else None, + approved_by_vp=fields.get("approved_by_vp") if fields.get("approved_by_vp") is not None else None, + approved_by_date_vp=fields.get("approved_by_date_vp") if fields.get("approved_by_date_vp") else None, + approved_by_svp=fields.get("approved_by_svp") if fields.get("approved_by_svp") is not None else None, + approved_by_date_svp=fields.get("approved_by_date_svp") if fields.get("approved_by_date_svp") else None, + created_by=fields.get("created_by") if fields.get("created_by") else None, + ) + session.add(row) + await session.commit() + return await cls.get_form_by_id(session, row.id) + + @classmethod + async def update_form(cls, session: AsyncSession, record_id, fields: dict): + row = await cls.get_form_by_id(session, record_id) + if not row: + return None + if "position" in fields: + position = fields.get("position") if fields.get("position") else {} + if "department" in position: + row.department = position.get("department") if position.get("department") else None + if "title" in position: + row.position_title = position.get("title") if position.get("title") else None + if "date" in position: + row.date = position.get("date") if position.get("date") else None + if "date_needed" in position: + row.date_needed = position.get("date_needed") if position.get("date_needed") else None + if "type" in position: + row.employment_type = EmploymentType(position.get("type")) if position.get("type") else None + if "job_description" in position: + row.job_description = position.get("job_description") if position.get("job_description") else None + if "replacement_for" in fields: + replacement = fields.get("replacement_for") if fields.get("replacement_for") else {} + if "to_replace" in replacement: + row.to_replace = replacement.get("to_replace") if replacement.get("to_replace") else None + if "grade" in replacement: + row.grade = replacement.get("grade") if replacement.get("grade") else None + if "title" in replacement: + row.recruitment_title = replacement.get("title") if replacement.get("title") else None + if "date_separated" in replacement: + row.date_separated = replacement.get("date_separated") if replacement.get("date_separated") else None + if "justification" in replacement: + row.justification = replacement.get("justification") if replacement.get("justification") else None + if "budget" in replacement: + row.budget = replacement.get("budget") if replacement.get("budget") else None + if "recommended_grade" in replacement: + row.recommended_grade = replacement.get("recommended_grade") if replacement.get("recommended_grade") else None + if "refferal_by" in fields: + referral = fields.get("refferal_by") if fields.get("refferal_by") else {} + if "employee_name" in referral: + row.employee_name = referral.get("employee_name") if referral.get("employee_name") else None + if "employee_department" in referral: + row.employee_department = referral.get("employee_department") if referral.get("employee_department") else None + if "initiated_by" in fields: + row.initiated_by = fields.get("initiated_by") if fields.get("initiated_by") else None + if "initiated_date" in fields: + row.initiated_date = fields.get("initiated_date") if fields.get("initiated_date") else None + if "recommended_by" in fields: + row.recommended_by = fields.get("recommended_by") if fields.get("recommended_by") else None + if "recommended_date" in fields: + row.recommended_date = fields.get("recommended_date") if fields.get("recommended_date") else None + if "approved_by_hr" in fields: + row.approved_by_hr = fields.get("approved_by_hr") if fields.get("approved_by_hr") is not None else None + if "approved_by_date_hr" in fields: + row.approved_by_date_hr = fields.get("approved_by_date_hr") if fields.get("approved_by_date_hr") else None + if "approved_by_vp" in fields: + row.approved_by_vp = fields.get("approved_by_vp") if fields.get("approved_by_vp") is not None else None + if "approved_by_date_vp" in fields: + row.approved_by_date_vp = fields.get("approved_by_date_vp") if fields.get("approved_by_date_vp") else None + if "approved_by_svp" in fields: + row.approved_by_svp = fields.get("approved_by_svp") if fields.get("approved_by_svp") is not None else None + if "approved_by_date_svp" in fields: + row.approved_by_date_svp = fields.get("approved_by_date_svp") if fields.get("approved_by_date_svp") else None + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row class CandidateForms(SQLModel, table=True): """One digitized hiring form (Annexure A requisition, or one of the two @@ -100,8 +321,20 @@ class CandidateForms(SQLModel, table=True): return list(result.scalars().all()), total @classmethod - async def insert_form(cls, session: AsyncSession, fields: dict): - row = cls(**fields) + async def insert_form(cls, session: AsyncSession, fields: dict): + row = cls( + form_type=fields.get("form_type"), + inbox_id=fields.get("inbox_id"), + manual_upload_candidate_id=fields.get("manual_upload_candidate_id"), + job_post_id=fields.get("job_post_id"), + interviewer_id=fields.get("interviewer_id"), + form_date=fields.get("form_date"), + sections=fields.get("sections"), + fields=fields.get("fields"), + overall_score=fields.get("overall_score"), + recommendation=fields.get("recommendation"), + created_by=fields.get("created_by"), + ) session.add(row) await session.commit() return await cls.get_form_by_id(session, row.id) @@ -111,8 +344,18 @@ class CandidateForms(SQLModel, table=True): row = await cls.get_form_by_id(session, record_id) if not row: return None - for key, value in fields.items(): - setattr(row, key, value) + if "interviewer_id" in fields: + row.interviewer_id = fields.get("interviewer_id") + if "form_date" in fields: + row.form_date = fields.get("form_date") + if "sections" in fields: + row.sections = fields.get("sections") + if "fields" in fields: + row.fields = fields.get("fields") + if "overall_score" in fields: + row.overall_score = fields.get("overall_score") + if "recommendation" in fields: + row.recommendation = fields.get("recommendation") row.updated_at = _now() session.add(row) await session.commit() 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 aa8362b..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,12 +42,74 @@ 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, "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, } + + +def _date(value): + return value.isoformat() if value else None + + +def _enum(value): + if value is None: + return None + return getattr(value, "value", value) + + +def serialize_requisition_option(row) -> dict: + """Compact row for a searchable picker: `{job title} - {department}`.""" + title = (row.position_title or "").strip() + department = (row.department or "").strip() + return { + "id": str(row.id) if row.id else None, + "title": row.position_title, + "department": row.department, + "label": f"{title or 'Untitled'} - {department or '—'}", + } + + +def serialize_requisition(row) -> dict: + return { + "id": str(row.id) if row.id else None, + "position": { + "department": row.department, + "title": row.position_title, + "date": _date(row.date), + "date_needed": _date(row.date_needed), + "type": _enum(row.employment_type), + "job_description": row.job_description, + }, + "replacement_for": { + "to_replace": row.to_replace, + "grade": row.grade, + "title": row.recruitment_title, + "date_separated": _date(row.date_separated), + "justification": row.justification, + "budget": row.budget, + "recommended_grade": row.recommended_grade, + }, + "refferal_by": { + "employee_name": row.employee_name, + "employee_department": row.employee_department, + }, + "initiated_by": row.initiated_by, + "initiated_date": _date(row.initiated_date), + "recommended_by": row.recommended_by, + "recommended_date": _date(row.recommended_date), + "approved_by_hr": row.approved_by_hr, + "approved_by_date_hr": _date(row.approved_by_date_hr), + "approved_by_vp": row.approved_by_vp, + "approved_by_date_vp": _date(row.approved_by_date_vp), + "approved_by_svp": row.approved_by_svp, + "approved_by_date_svp": _date(row.approved_by_date_svp), + "created_by": str(row.created_by) if row.created_by else None, + "created_at": row.created_at.isoformat() if row.created_at else None, + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + } diff --git a/backend/candidate_forms/views.py b/backend/candidate_forms/views.py index b0d93b9..64681aa 100644 --- a/backend/candidate_forms/views.py +++ b/backend/candidate_forms/views.py @@ -5,8 +5,9 @@ from datetime import timezone from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from candidate_forms.models import CandidateForms, _now +from candidate_forms.models import CandidateForms, Requisition, _now from candidate_forms.plugins import ( + FORM_DEFINITIONS, FORM_READY_STATUSES, FORM_TYPES, RECOMMENDATIONS, @@ -14,13 +15,19 @@ from candidate_forms.plugins import ( normalize_fields, normalize_sections, ) -from candidate_forms.serializers import serialize_form +from candidate_forms.serializers import ( + serialize_form, + serialize_requisition, + serialize_requisition_option, +) 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") @@ -53,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 @@ -80,55 +116,28 @@ 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: raise HTTPException(status_code=404, detail="Job post not found") return inbox_id, manual_id, job_post_id, stage - def _normalize_payload(self, form_type, payload): - """Shared create/update normalization. Returns the writable fields dict - for the keys present in `payload`.""" - fields = {} - if "sections" in payload: - try: - sections, overall = normalize_sections(form_type, payload.get("sections")) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) - fields["sections"] = sections - fields["overall_score"] = overall - if "fields" in payload: - try: - fields["fields"] = normalize_fields(form_type, payload.get("fields")) - except ValueError as exc: - raise HTTPException(status_code=422, detail=str(exc)) - if "recommendation" in payload: - recommendation = payload.get("recommendation") or None - if recommendation is not None and recommendation not in RECOMMENDATIONS: - raise HTTPException( - status_code=422, - detail=f"recommendation must be one of {', '.join(RECOMMENDATIONS)}", - ) - fields["recommendation"] = recommendation - if "interviewer_id" in payload: - interviewer_id = _as_uuid(payload.get("interviewer_id")) - if payload.get("interviewer_id") and interviewer_id is None: - raise HTTPException(status_code=422, detail="Invalid interviewer_id") - fields["interviewer_id"] = interviewer_id - if "form_date" in payload: - fields["form_date"] = _aware(payload.get("form_date")) - return fields - async def _context_maps(self, rows): inbox_ids = [r.inbox_id for r in rows if r.inbox_id is not None] manual_ids = [r.manual_upload_candidate_id for r in rows if r.manual_upload_candidate_id] @@ -213,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, @@ -228,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: @@ -250,11 +283,14 @@ 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: - # A scheduled interview also unlocks the forms: the paperwork belongs - # to the interview, not to which kanban column the card sits in. - # (Interview records link only to inbox rows, so manual-upload - # candidates unlock by stage alone.) has_interview = False if inbox_id is not None: rows = await Interviews.get_interviews_by_inbox(self.session, inbox_id) @@ -269,28 +305,29 @@ class CandidateForm: ), ) - fields = { - "inbox_id": inbox_id, - "manual_upload_candidate_id": manual_id, - "job_post_id": job_post_id, - "form_type": form_type, - "created_by": _user_id(current_user), - } - fields.update( - self._normalize_payload( - form_type, - { - key: payload.get(key) - for key in ("sections", "fields", "recommendation", "interviewer_id", "form_date") - }, - ) - ) - if form_type != "requisition" and fields.get("interviewer_id") is None: - fields["interviewer_id"] = _user_id(current_user) - if fields.get("form_date") is None: - fields["form_date"] = _now() + interviewer_id = _as_uuid(payload.get("interviewer_id")) + 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, fields) + row = await CandidateForms.insert_form( + self.session, + { + "form_type": form_type, + "inbox_id": inbox_id, + "manual_upload_candidate_id": manual_id, + "job_post_id": job_post_id, + "interviewer_id": interviewer_id, + "form_date": form_date, + "sections": sections, + "fields": fields, + "overall_score": overall_score, + "recommendation": _recommendation(form_type, payload.get("recommendation")), + "created_by": _user_id(current_user), + }, + ) await HistoryRecorder(self.session).record( HistoryEvent.FORM_CREATED, current_user=current_user, @@ -308,8 +345,27 @@ 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 = self._normalize_payload(row.form_type, payload) + fields = {} + if "interviewer_id" in payload: + fields["interviewer_id"] = _as_uuid(payload.get("interviewer_id")) + if "form_date" in payload: + fields["form_date"] = _aware(payload.get("form_date")) + if "sections" in payload: + 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"] = _score_fields(row.form_type, payload.get("fields")) + if "recommendation" in payload: + fields["recommendation"] = _recommendation(row.form_type, payload.get("recommendation")) if not fields: raise HTTPException(status_code=400, detail="No fields to update") @@ -330,7 +386,57 @@ 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") return {"id": str(row.id), "deleted": True} + +class RequisitionForm: + def __init__(self, session: AsyncSession): + self.session = session + + async def create_form(self, payload, current_user): + payload["created_by"] = _user_id(current_user) + row = await Requisition.insert_form(self.session, payload) + return serialize_requisition(row) + + async def update_form(self, form_id, payload, current_user): + _user_id(current_user) + row = await Requisition.get_form_by_id(self.session, form_id) + if not row: + raise HTTPException(status_code=404, detail="Form not found") + if not payload: + raise HTTPException(status_code=400, detail="No fields to update") + updated = await Requisition.update_form(self.session, form_id, payload) + if not updated: + raise HTTPException(status_code=404, detail="Form not found") + return serialize_requisition(updated) + + + async def get_form_by_id(self, form_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: + raise HTTPException(status_code=404, detail="Form not found") + return serialize_requisition(row) + 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, 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 7d1dd4d..8875179 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 14928a7..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 @@ -140,6 +140,7 @@ class JobUpdate(BaseModel): description: str | None = None current_recruiter_id: UUID | None = None hiring_manager_id: UUID | None = None + requisition_id: UUID | None = None class JobStatusUpdate(BaseModel): @@ -235,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)) @@ -251,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)) @@ -727,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), @@ -864,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), @@ -875,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 @@ -998,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/assignment/views.py b/backend/job/assignment/views.py index e4a01f2..743145d 100644 --- a/backend/job/assignment/views.py +++ b/backend/job/assignment/views.py @@ -50,9 +50,8 @@ class Assignment: async def record_job_owner(self,job_post_id,user_id,assignment_role,assigned_by): """Close the open interval of this role, then open a new one. - user_id None = unassign (hiring_manager cannot be cleared; callers - must not pass None for that role). No-ops when the same person already - holds the open interval. Does not touch job_posts columns. + user_id None = unassign. No-ops when the same person already holds the + open interval. Does not touch job_posts columns. """ role=self._job_role(assignment_role) job_uid=JobAssignments._as_uuid(job_post_id) @@ -63,8 +62,6 @@ class Assignment: self.session,job_uid,current_only=True,assignment_role=role, ) if user_id is None: - if role=="hiring_manager": - raise HTTPException(status_code=422,detail="hiring_manager_id is required") await JobAssignments.close_current(self.session,job_uid,role) return None user_uid=JobAssignments._as_uuid(user_id) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index ee7f6f2..05053f2 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 ea98f48..55fdb54 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -2,13 +2,15 @@ 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 if TYPE_CHECKING: # runtime import would be circular: users.models imports this module + from candidate_forms.models import Requisition from users.models import Users @@ -22,12 +24,7 @@ class JobPosts(SQLModel, table=True): id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) title: str = Field(index=True) - # foreign_keys is required, not decoration: current_recruiter_id and - # hiring_manager_id below are extra FKs into users.id, so the join is - # ambiguous without it and every mapper fails to initialize. `user` is the - # AUTHOR of the post. The recruiter and hiring-manager columns stay bare — - # Users already carries five selectin relations that load on every - # authenticated request. Same pairing as Notes.user / Notes.author. + user: Optional["Users"] = Relationship( back_populates="job_posts", sa_relationship_kwargs={"lazy": "joined", "foreign_keys": "[JobPosts.created_by]"}, @@ -51,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"}) @@ -61,9 +56,19 @@ class JobPosts(SQLModel, table=True): # Who is working the req now (swappable). History lives in job_assignments # with assignment_role=primary_recruiter; this column is the current pointer. current_recruiter_id: uuid.UUID | None = Field(default=None, foreign_key="users.id") - # Who owns the requisition (stable). Required at create. History lives in + # Who owns the requisition (stable). Optional. History lives in # job_assignments with assignment_role=hiring_manager. hiring_manager_id: uuid.UUID | None = Field(default=None, foreign_key="users.id", index=True) + # Annexure A employee requisition this job was opened from (optional 1:1). + # Distinct from requisition_status, which is the hiring lifecycle on this row. + # unique=True so two job posts cannot share one requisition; NULLs stay allowed. + requisition_id: uuid.UUID | None = Field( + default=None, foreign_key="requisitions.id", ondelete="SET NULL", unique=True, index=True, + ) + requisition: Optional["Requisition"] = Relationship( + back_populates="job_post", + sa_relationship_kwargs={"lazy": "selectin"}, + ) created_by: uuid.UUID = Field(foreign_key="users.id") created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) @@ -83,6 +88,20 @@ class JobPosts(SQLModel, table=True): result = await session.execute(select(cls).where(cls.id == uid)) return result.scalars().first() + @classmethod + async def get_by_requisition_id(cls, session: AsyncSession, requisition_id): + """Live job post already opened from this Annexure A requisition, if any.""" + uid = cls._as_uuid(requisition_id) + if uid is None: + return None + result = await session.execute( + select(cls).where( + cls.requisition_id == uid, + cls.is_deleted == False, # noqa: E712 + ) + ) + return result.scalars().first() + @classmethod async def get_active_job_posts(cls, session: AsyncSession): result = await session.execute( @@ -177,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. @@ -195,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.""" @@ -390,6 +629,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() @@ -543,4 +783,7 @@ class SocialPlatform(SQLModel, table=True): return {r.alias: r.buffer_service for r in rows} -import users.models as _users_models \ No newline at end of file +# Requisition must be registered before Users relationships trigger mapper +# configure — JobPosts.requisition_id FKs to app.requisitions. +import candidate_forms.models as _requisition_models # noqa: E402, F401 +import users.models as _users_models # noqa: E402, F401 \ No newline at end of file diff --git a/backend/job/job_post/serializers.py b/backend/job/job_post/serializers.py index 0610900..a490191 100644 --- a/backend/job/job_post/serializers.py +++ b/backend/job/job_post/serializers.py @@ -36,6 +36,7 @@ def serialize_job_post(row) -> dict: "created_by_name": row.user.name if getattr(row, "user", None) else None, "created_at": row.created_at.isoformat() if row.created_at else None, "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "requisition_id": str(row.requisition_id) if getattr(row, "requisition_id", None) else None, } @@ -46,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, @@ -70,6 +72,9 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app "recruiter_name": recruiter_name, "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, @@ -78,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 a7b13dc..f3c29bf 100644 --- a/backend/job/job_post/views.py +++ b/backend/job/job_post/views.py @@ -8,6 +8,7 @@ from uuid import UUID import httpx from dotenv import load_dotenv from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, model_validator from inbox.models import Inbox_Messages @@ -26,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") @@ -64,8 +65,9 @@ class JobPostCreate(BaseModel): scheduler_time: time | None = time(0, 0, 0) scheduler_date: date | None = None due_at: str | None = None - hiring_manager_id: UUID + hiring_manager_id: UUID | None = None current_recruiter_id: UUID | None = None + requisition_id: UUID | None = None @model_validator(mode="after") def validate_mode_and_due_at(self): @@ -146,10 +148,12 @@ class JobPost: fields["platform"]="internal" assignment=Assignment(self.session) - hm=await assignment.require_role( - payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id", - ) - fields["hiring_manager_id"]=hm.id + hm=None + if payload.get("hiring_manager_id"): + hm=await assignment.require_role( + payload.get("hiring_manager_id"),EnumRoles.HIRING_MANAGER,"hiring_manager_id", + ) + fields["hiring_manager_id"]=hm.id rec=None if payload.get("current_recruiter_id"): rec=await assignment.require_role( @@ -157,9 +161,33 @@ class JobPost: ) fields["current_recruiter_id"]=rec.id - row=await JobPosts.insert_job_post(self.session,fields) + if payload.get("requisition_id"): + from candidate_forms.models import Requisition + req=await Requisition.get_form_by_id( + self.session, record_id=str(payload["requisition_id"]), + ) + 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: + raise HTTPException( + status_code=409, + detail="This requisition is already linked to a job post", + ) + fields["requisition_id"]=req.id + + try: + row=await JobPosts.insert_job_post(self.session,fields) + except IntegrityError as e: + orig=str(getattr(e,"orig",e)).lower() + if "requisition" in orig: + raise HTTPException( + status_code=409,detail="This requisition is already linked to a job post", + ) from e + raise assigned_by=current_user.get("id") if isinstance(current_user,dict) else None - await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by) + if hm: + await assignment.record_job_owner(row.id,hm.id,"hiring_manager",assigned_by) if rec: await assignment.record_job_owner(row.id,rec.id,"primary_recruiter",assigned_by) @@ -206,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) @@ -288,11 +338,29 @@ class JobPost: rec_changed=False if "hiring_manager_id" in payload: raw=payload.get("hiring_manager_id") - if not raw: - raise HTTPException(status_code=422,detail="hiring_manager_id is required") - hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id") - fields["hiring_manager_id"]=hm.id - hm_changed=str(existing.hiring_manager_id)!=str(hm.id) + if raw is None or raw=="": + fields["hiring_manager_id"]=None + hm_changed=existing.hiring_manager_id is not None + else: + hm=await assignment.require_role(raw,EnumRoles.HIRING_MANAGER,"hiring_manager_id") + fields["hiring_manager_id"]=hm.id + hm_changed=str(existing.hiring_manager_id)!=str(hm.id) + if "requisition_id" in payload: + raw=payload.get("requisition_id") + if raw is None or raw=="": + fields["requisition_id"]=None + else: + from candidate_forms.models import Requisition + 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") if raw is None or raw=="": @@ -305,7 +373,15 @@ class JobPost: if not fields: raise HTTPException(status_code=400,detail="No fields to update") - row=await JobPosts.update_job_post(self.session,job_post_id,fields) + try: + row=await JobPosts.update_job_post(self.session,job_post_id,fields) + except IntegrityError as e: + orig=str(getattr(e,"orig",e)).lower() + if "requisition" in orig: + raise HTTPException( + status_code=409,detail="This requisition is already linked to a job post", + ) from e + raise if not row: raise HTTPException(status_code=404,detail="Job post not found") if hm_changed: 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/019_requisitions_rbac.sql b/backend/migrations/manual/019_requisitions_rbac.sql new file mode 100644 index 0000000..859cb89 --- /dev/null +++ b/backend/migrations/manual/019_requisitions_rbac.sql @@ -0,0 +1,68 @@ +-- 019_requisitions_rbac.sql +-- Manual one-shot: the `requisitions` permission module (8 tags), a +-- `requisitions_management` bundle holding them, and the bundle attached to +-- the staff roles that fill Employee Requisition forms (Annexure A). Mirrors +-- 007's idempotent pattern; applied automatically at startup by +-- alembic_setup.run_manual_sql() and recorded in manual_migrations. +-- +-- The all_access bundle is a fixed id list seeded before this module existed, +-- so system_administrator gets requisitions access through THIS bundle, not +-- that one. Users must log in again after this applies — permissions are +-- resolved from the DB per request, but the frontend caches the list from +-- /users/me. + +-- ============================================================================= +-- 1. The 8 requisitions.* permission tags +-- ============================================================================= +INSERT INTO app.permission_tags + (tag_name, module, action, description, created_at, updated_at, is_active, is_deleted) +VALUES + ('requisitions.view', 'requisitions', 'view', NULL, NOW(), NOW(), true, false), + ('requisitions.create', 'requisitions', 'create', NULL, NOW(), NOW(), true, false), + ('requisitions.edit', 'requisitions', 'edit', NULL, NOW(), NOW(), true, false), + ('requisitions.delete', 'requisitions', 'delete', NULL, NOW(), NOW(), true, false), + ('requisitions.approve', 'requisitions', 'approve', NULL, NOW(), NOW(), true, false), + ('requisitions.export', 'requisitions', 'export', NULL, NOW(), NOW(), true, false), + ('requisitions.manage', 'requisitions', 'manage', NULL, NOW(), NOW(), true, false), + ('requisitions.configure', 'requisitions', 'configure', NULL, NOW(), NOW(), true, false) +ON CONFLICT (tag_name) DO NOTHING; + +-- ============================================================================= +-- 2. Bundle holding all eight requisitions tags +-- ============================================================================= +INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted) +SELECT + 'requisitions_management', + 'Employee requisition forms: view, create, edit and manage requisitions', + ( + SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) + FROM app.permission_tags + WHERE is_deleted = false + AND module = 'requisitions' + ), + true, + NOW(), + NOW(), + true, + false +WHERE NOT EXISTS ( + SELECT 1 FROM app.permissions WHERE name = 'requisitions_management' +); + +-- ============================================================================= +-- 3. Attach the bundle to the staff roles (idempotent; same role list as 007) +-- ============================================================================= +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 = 'requisitions_management' + AND r.role_name IN ( + 'system_administrator', + 'hr_administrator', + 'recruiter', + 'hiring_manager', + 'department_head', + 'ceo' + ) + AND NOT (COALESCE(r.permissions, '[]'::jsonb) @> jsonb_build_array(p.id)); diff --git a/backend/migrations/manual/020_requisitions.sql b/backend/migrations/manual/020_requisitions.sql new file mode 100644 index 0000000..474bc45 --- /dev/null +++ b/backend/migrations/manual/020_requisitions.sql @@ -0,0 +1,60 @@ +-- 020_requisitions.sql +-- Employee Requisition (Annexure A) as its own table, separate from +-- candidate_forms (Interview Analysis / Cultural Fit). Nested request objects +-- flatten onto columns: position.title → position_title, replacement_for.title +-- → recruitment_title, refferal_by → employee_name / employee_department. +-- employment type is a CHECK over EmploymentType values, not a native PG enum +-- (point releases of the Python enum should not require a type ALTER). +-- +-- "date" and "type" are quoted: both are PostgreSQL keywords. +-- +-- Idempotent, applied automatically at startup by alembic_setup.run_manual_sql() +-- and recorded in manual_migrations. Matches Requisition in +-- backend/candidate_forms/models.py (needed here because prod boots with +-- DB_AUTOGENERATE=false and never autogenerates new tables). + +CREATE TABLE IF NOT EXISTS app.requisitions ( + id uuid PRIMARY KEY, + department varchar, + position_title varchar, + "date" date, + date_needed date, + "type" varchar + CHECK ("type" IS NULL OR "type" IN ( + 'permanent', 'contract', 'temporary', 'internee' + )), + job_description text, + + employee_name varchar, + employee_department varchar, + + to_replace varchar, + grade varchar, + recruitment_title varchar, + date_separated date, + justification text, + budget varchar, + recommended_grade varchar, + + initiated_by varchar, + initiated_date date, + recommended_by varchar, + recommended_date date, + approved_by_hr boolean, + approved_by_date_hr date, + approved_by_vp boolean, + approved_by_date_vp date, + approved_by_svp boolean, + approved_by_date_svp date, + + created_by uuid REFERENCES app.users(id), + created_at timestamptz NOT NULL DEFAULT NOW(), + updated_at timestamptz NOT NULL DEFAULT NOW(), + is_deleted boolean NOT NULL DEFAULT false +); + +CREATE INDEX IF NOT EXISTS ix_requisitions_created_by + ON app.requisitions (created_by); + +CREATE INDEX IF NOT EXISTS ix_requisitions_is_deleted + ON app.requisitions (is_deleted); diff --git a/backend/migrations/manual/021_job_post_requisition.sql b/backend/migrations/manual/021_job_post_requisition.sql new file mode 100644 index 0000000..550d460 --- /dev/null +++ b/backend/migrations/manual/021_job_post_requisition.sql @@ -0,0 +1,14 @@ +-- 021_job_post_requisition.sql +-- Optional link from a job post to the Annexure A employee requisition it +-- was opened from (app.requisitions). Distinct from requisition_status, which +-- is the hiring lifecycle on job_posts. Applied at startup by +-- alembic_setup.run_manual_sql(). Needed because prod boots with +-- DB_AUTOGENERATE=false. + +ALTER TABLE app.job_posts + ADD COLUMN IF NOT EXISTS requisition_id UUID REFERENCES app.requisitions(id) ON DELETE SET NULL; + +-- Unique so one requisition maps to at most one job post. Postgres unique +-- indexes allow multiple NULLs, so unlinked job posts stay valid. +CREATE UNIQUE INDEX IF NOT EXISTS uq_job_posts_requisition_id + ON app.job_posts (requisition_id); diff --git a/backend/migrations/manual/022_job_post_requisition_unique.sql b/backend/migrations/manual/022_job_post_requisition_unique.sql new file mode 100644 index 0000000..cea0c26 --- /dev/null +++ b/backend/migrations/manual/022_job_post_requisition_unique.sql @@ -0,0 +1,11 @@ +-- 022_job_post_requisition_unique.sql +-- 021 originally created a non-unique ix_job_posts_requisition_id. Replace it +-- with a unique index so the optional job_posts.requisition_id link is 1:1. +-- Postgres unique indexes allow multiple NULLs. Applied at startup by +-- alembic_setup.run_manual_sql(). Needed because prod boots with +-- DB_AUTOGENERATE=false. + +DROP INDEX IF EXISTS app.ix_job_posts_requisition_id; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_job_posts_requisition_id + ON app.job_posts (requisition_id); diff --git a/backend/migrations/manual/023_requisition_employmenttype.sql b/backend/migrations/manual/023_requisition_employmenttype.sql new file mode 100644 index 0000000..8723057 --- /dev/null +++ b/backend/migrations/manual/023_requisition_employmenttype.sql @@ -0,0 +1,65 @@ +-- 023_requisition_employmenttype.sql +-- Native PG enum for Annexure A employment type. 020 stored this as a quoted +-- varchar "type" with a CHECK; SQLAlchemy maps EmploymentType to +-- app.employmenttype, which 020 never created. Rename "type" (a PG keyword) +-- to employment_type and convert the column. Applied at startup by +-- alembic_setup.run_manual_sql(). Needed because prod boots with +-- DB_AUTOGENERATE=false. + +DO $$ BEGIN + CREATE TYPE app.employmenttype AS ENUM ( + 'permanent', 'contract', 'temporary', 'internee' + ); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +DECLARE + r record; +BEGIN + FOR r IN + SELECT con.conname + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace + WHERE nsp.nspname = 'app' + AND rel.relname = 'requisitions' + AND con.contype = 'c' + LOOP + EXECUTE format('ALTER TABLE app.requisitions DROP CONSTRAINT IF EXISTS %I', r.conname); + END LOOP; +END $$; + +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'requisitions' + AND column_name = 'type' + ) AND NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'requisitions' + AND column_name = 'employment_type' + ) THEN + ALTER TABLE app.requisitions RENAME COLUMN "type" TO employment_type; + END IF; +END $$; + +DO $$ BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'app' + AND table_name = 'requisitions' + AND column_name = 'employment_type' + AND udt_name <> 'employmenttype' + ) THEN + ALTER TABLE app.requisitions + ALTER COLUMN employment_type TYPE app.employmenttype + USING CASE + WHEN employment_type IS NULL OR btrim(employment_type) = '' THEN NULL + ELSE lower(employment_type)::app.employmenttype + END; + END IF; +END $$; 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/models.py b/backend/users/models.py index a143071..5cdfdd5 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -177,7 +177,7 @@ class Users(SQLModel, table=True): @classmethod async def get_user_by_email(cls, session: AsyncSession, email: str): - statement = select(cls).options(selectinload(cls.role)).where(cls.email == email) + statement = select(cls).options(selectinload(cls.role)).where(cls.email == email,cls.role_id != 8) result = await session.execute(statement) return result.scalars().first() diff --git a/backend/users/permissions.py b/backend/users/permissions.py index d85e61d..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 @@ -40,7 +40,7 @@ class PermissionModule(str, Enum): RBAC_USERS = "rbac_users" TASKS = "tasks" TALENT = "talent" - + REQUISITIONS = "requisitions" class PermissionAction(str, Enum): VIEW = "view" @@ -54,6 +54,15 @@ class PermissionAction(str, Enum): class PermissionTag(str, Enum): + REQUISITIONS_VIEW = "requisitions.view" + REQUISITIONS_CREATE = "requisitions.create" + REQUISITIONS_EDIT = "requisitions.edit" + REQUISITIONS_DELETE = "requisitions.delete" + REQUISITIONS_APPROVE = "requisitions.approve" + REQUISITIONS_EXPORT = "requisitions.export" + REQUISITIONS_MANAGE = "requisitions.manage" + REQUISITIONS_CONFIGURE = "requisitions.configure" + DASHBOARD_VIEW = "dashboard.view" DASHBOARD_CREATE = "dashboard.create" DASHBOARD_EDIT = "dashboard.edit" @@ -194,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 dd9d8de..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)(/|$) { + 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 b8ab919..621f0d6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,7 +12,7 @@ import Signup from './pages/Signup' import ForgotPassword from './pages/ForgotPassword' import ConfirmEmail from './pages/ConfirmEmail' -// Route-level code splitting: putting 23 screens in one bundle would make the +// Route-level code splitting: putting 24 screens in one bundle would make the // first paint pay for every screen a user never opens. const SCREENS = { dashboard: lazy(() => import('./screens/Dashboard')), @@ -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')), @@ -29,6 +30,7 @@ const SCREENS = { tasks: lazy(() => import('./screens/Tasks')), aiassistant: lazy(() => import('./screens/AiAssistant')), interviews: lazy(() => import('./screens/Interviews')), + requisitions: lazy(() => import('./screens/Requisitions')), assessments: lazy(() => import('./screens/Assessments')), offers: lazy(() => import('./screens/Offers')), managers: lazy(() => import('./screens/Managers')), 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 6a21ffc..64ee2c9 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -91,6 +91,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 new file mode 100644 index 0000000..28026e3 --- /dev/null +++ b/frontend/src/api/requisitions.js @@ -0,0 +1,64 @@ +import { request } from '../lib/apiClient' + +/* ============================================================ + requisitions.js — backend/candidate_forms/app.py requisition routes. + + Standalone Annexure A table (app.requisitions), not candidate_forms. + created_by comes from the JWT on the server — do not send it in the body. + ============================================================ */ + +export const EMPLOYMENT_TYPES = [ + { value: 'permanent', label: 'Permanent' }, + { value: 'contract', label: 'Contract' }, + { value: 'temporary', label: 'Temporary' }, + { value: 'internee', label: 'Internee' }, +] + +export const EMPLOYMENT_TYPE_LABEL = Object.fromEntries( + EMPLOYMENT_TYPES.map((t) => [t.value, t.label]), +) + +export function list() { + return request('/forms/requisition/fetch') +} + +export function getById(formId) { + return request('/forms/requisition/fetch', { params: { form_id: formId } }) +} + +/** 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, + job_post_id: jobPostId || undefined, + }, + }) +} + +export function create(body) { + return request('/forms/requisition/create', { method: 'POST', body }) +} + +export function update(formId, body) { + return request('/forms/requisition/update', { + method: 'PATCH', + params: { form_id: formId }, + body, + }) +} + +export function toRows(res) { + const data = res?.data + if (Array.isArray(data)) return data + if (data) return [data] + return [] +} + +export function approvalStatus(row) { + if (row?.approved_by_svp) return { key: 'approved', label: 'Approved' } + if (row?.approved_by_hr || row?.approved_by_vp) return { key: 'review', label: 'In review' } + return { key: 'open', label: 'Open' } +} 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 (