Merge pull request 'approved permission' (#70) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 32s
Details
Deploy to S3 / deploy (push) Successful in 32s
Details
Reviewed-on: #70pull/71/head^2
commit
14a2817afd
|
|
@ -736,6 +736,7 @@ async def fetch_job_posts(
|
||||||
skip=skip,
|
skip=skip,
|
||||||
ids=id_list,
|
ids=id_list,
|
||||||
active_only=active_only,
|
active_only=active_only,
|
||||||
|
current_user=current_user,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -865,7 +866,7 @@ async def fetch_jobs(
|
||||||
data,total=await service.fetch_jobs(
|
data,total=await service.fetch_jobs(
|
||||||
search=search,department=department,requisition_status=requisition_status,
|
search=search,department=department,requisition_status=requisition_status,
|
||||||
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
|
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
|
||||||
top=top,skip=skip,active_only=active_only,
|
top=top,skip=skip,active_only=active_only,current_user=current_user,
|
||||||
)
|
)
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -891,7 +892,7 @@ async def export_jobs(
|
||||||
data,_=await service.fetch_jobs(
|
data,_=await service.fetch_jobs(
|
||||||
search=search,department=department,requisition_status=requisition_status,
|
search=search,department=department,requisition_status=requisition_status,
|
||||||
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
|
employment_type=employment_type,hiring_manager_id=hiring_manager_id,
|
||||||
top=None,skip=0,active_only=active_only,
|
top=None,skip=0,active_only=active_only,current_user=current_user,
|
||||||
)
|
)
|
||||||
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
|
filename=f"jobs-export-{datetime.now(timezone.utc).strftime('%Y-%m-%d')}.xlsx"
|
||||||
return Response(
|
return Response(
|
||||||
|
|
@ -947,18 +948,19 @@ async def fetch_candidate(
|
||||||
assigned_job_post_id:UUID=Query(None),
|
assigned_job_post_id:UUID=Query(None),
|
||||||
offset:int=Query(0,ge=0),
|
offset:int=Query(0,ge=0),
|
||||||
search:str=Query(None),
|
search:str=Query(None),
|
||||||
|
created_by:Optional[bool]=Query(False),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=CandidateView(session=session)
|
service=CandidateView(session=session)
|
||||||
data=await service.get_candidate(
|
data=await service.get_candidate(
|
||||||
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,
|
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
total=await service.count_candidates(
|
total=await service.count_candidates(
|
||||||
user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,
|
user_id=user_id,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||||
) if isinstance(data,list) else 1
|
) if isinstance(data,list) else 1
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -971,12 +973,13 @@ async def fetch_candidate(
|
||||||
async def update_candidate(
|
async def update_candidate(
|
||||||
user_id:str=Query(...),
|
user_id:str=Query(...),
|
||||||
payload:CandidateUpdate=...,
|
payload:CandidateUpdate=...,
|
||||||
|
created_by:Optional[bool]=Query(False),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=CandidateView(session=session)
|
service=CandidateView(session=session)
|
||||||
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user)
|
data=await service.update_candidate(user_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -1074,12 +1077,13 @@ async def update_interview(
|
||||||
async def fetch_notes(
|
async def fetch_notes(
|
||||||
note_id:str=Query(None),
|
note_id:str=Query(None),
|
||||||
user_id:str=Query(None),
|
user_id:str=Query(None),
|
||||||
|
created_by:Optional[bool]=Query(False),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Note(session=session)
|
service=Note(session=session)
|
||||||
data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user)
|
data=await service.get_note(note_id=note_id,user_id=user_id,current_user=current_user,created_by=created_by)
|
||||||
total=1 if isinstance(data,dict) else len(data)
|
total=1 if isinstance(data,dict) else len(data)
|
||||||
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
return JSONResponse(content={"data":data,"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
|
@ -1091,12 +1095,13 @@ async def fetch_notes(
|
||||||
@router.post("/notes/create")
|
@router.post("/notes/create")
|
||||||
async def create_note(
|
async def create_note(
|
||||||
payload:NoteCreate,
|
payload:NoteCreate,
|
||||||
|
created_by:Optional[bool]=Query(False),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Note(session=session)
|
service=Note(session=session)
|
||||||
data=await service.create_note(payload.model_dump(exclude_unset=True),current_user)
|
data=await service.create_note(payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -1108,12 +1113,13 @@ async def create_note(
|
||||||
async def update_note(
|
async def update_note(
|
||||||
note_id:str=Query(...),
|
note_id:str=Query(...),
|
||||||
payload:NoteUpdate=...,
|
payload:NoteUpdate=...,
|
||||||
|
created_by:Optional[bool]=Query(False),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
service=Note(session=session)
|
service=Note(session=session)
|
||||||
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user)
|
data=await service.update_note(note_id,payload.model_dump(exclude_unset=True),current_user,created_by=created_by)
|
||||||
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
return JSONResponse(content={"data":data,"total":1,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ from job.history.views import HistoryRecorder
|
||||||
from job.notes.serializers import serialize_note
|
from job.notes.serializers import serialize_note
|
||||||
from job.candidate.plugins import extract_candidate_email
|
from job.candidate.plugins import extract_candidate_email
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
from users.permissions import is_hiring_manager,sees_all_candidates
|
from users.permissions import is_hiring_manager,sees_all_candidates,scopes_to_own_requisitions
|
||||||
from employment_agent.plugins import parse_phone
|
from employment_agent.plugins import parse_phone
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
@ -78,23 +78,26 @@ async def job_id_for_application(session,inbox_id=None,manual_id=None):
|
||||||
return None,None
|
return None,None
|
||||||
|
|
||||||
|
|
||||||
async def owned_job_ids_for_candidate_scope(session,current_user):
|
async def owned_job_ids_for_candidate_scope(session,current_user,created_by=False):
|
||||||
"""Job ids this user may see, or None when the list is unscoped.
|
"""Job ids this user may see, or None when the list is unscoped.
|
||||||
|
|
||||||
Hiring manager → requisition / assigned-manager jobs.
|
requisitions.configure (or hiring-manager portal) → jobs on their requisitions
|
||||||
candidates.manage or admin → None (all applications).
|
/ assigned hiring_manager_id. Recruiter assignment on the job does not hide
|
||||||
Otherwise → job_posts.created_by = this user. Never role_id.
|
those candidates. candidates.manage or admin → None (all applications).
|
||||||
|
Otherwise → current_recruiter_id when set, else created_by. Never role_id.
|
||||||
|
created_by=True skips current_recruiter_id and matches job_posts.created_by
|
||||||
|
to the session user (ignored when the user is requisition-scoped).
|
||||||
"""
|
"""
|
||||||
if is_hiring_manager(current_user):
|
if scopes_to_own_requisitions(current_user):
|
||||||
return await JobPosts.ids_for_manager(session,current_user.get("id"))
|
return await JobPosts.ids_for_manager(session,current_user.get("id"))
|
||||||
if sees_all_candidates(current_user):
|
if sees_all_candidates(current_user):
|
||||||
return None
|
return None
|
||||||
return await JobPosts.ids_for_creator(session,current_user.get("id"))
|
return await JobPosts.ids_for_creator(session,current_user.get("id"),created_by=created_by)
|
||||||
|
|
||||||
|
|
||||||
async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None):
|
async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post_id=None,created_by=False):
|
||||||
"""None = unscoped list. [] = nothing visible. Else UUID list for the query."""
|
"""None = unscoped list. [] = nothing visible. Else UUID list for the query."""
|
||||||
owned=await owned_job_ids_for_candidate_scope(session,current_user)
|
owned=await owned_job_ids_for_candidate_scope(session,current_user,created_by=created_by)
|
||||||
requested=JobPosts._as_uuid(assigned_job_post_id) if assigned_job_post_id is not None else None
|
requested=JobPosts._as_uuid(assigned_job_post_id) if assigned_job_post_id is not None else None
|
||||||
if owned is None:
|
if owned is None:
|
||||||
return [requested] if requested else None
|
return [requested] if requested else None
|
||||||
|
|
@ -104,18 +107,18 @@ async def job_post_ids_for_candidate_list(session,current_user,assigned_job_post
|
||||||
|
|
||||||
|
|
||||||
def _scope_detail(current_user):
|
def _scope_detail(current_user):
|
||||||
if is_hiring_manager(current_user):
|
if scopes_to_own_requisitions(current_user):
|
||||||
return MANAGER_SCOPE_DETAIL
|
return MANAGER_SCOPE_DETAIL
|
||||||
return CREATOR_SCOPE_DETAIL
|
return CREATOR_SCOPE_DETAIL
|
||||||
|
|
||||||
|
|
||||||
async def assert_manager_candidate_access(
|
async def assert_manager_candidate_access(
|
||||||
session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None,
|
session,current_user,*,user_id=None,job_post_id=None,inbox_id=None,manual_id=None,created_by=False,
|
||||||
):
|
):
|
||||||
"""Row access: hiring-manager jobs, unscoped (manage/admin), or jobs this user created."""
|
"""Row access: requisition-owned jobs, unscoped (manage/admin), or jobs this user created."""
|
||||||
if not is_hiring_manager(current_user) and sees_all_candidates(current_user):
|
if sees_all_candidates(current_user) and not scopes_to_own_requisitions(current_user):
|
||||||
return
|
return
|
||||||
owned=await owned_job_ids_for_candidate_scope(session,current_user)
|
owned=await owned_job_ids_for_candidate_scope(session,current_user,created_by=created_by)
|
||||||
owned=set(owned or [])
|
owned=set(owned or [])
|
||||||
detail=_scope_detail(current_user)
|
detail=_scope_detail(current_user)
|
||||||
if not owned:
|
if not owned:
|
||||||
|
|
@ -819,19 +822,19 @@ class CandidateView:
|
||||||
cap=max(1,int(limit or 50))
|
cap=max(1,int(limit or 50))
|
||||||
return merged[start:start+cap],total
|
return merged[start:start+cap],total
|
||||||
|
|
||||||
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None):
|
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
|
||||||
try:
|
try:
|
||||||
if not user_id and is_hiring_manager(current_user):
|
if not user_id and is_hiring_manager(current_user):
|
||||||
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
||||||
if user_id:
|
if user_id:
|
||||||
await assert_manager_candidate_access(
|
await assert_manager_candidate_access(
|
||||||
self.session,current_user,user_id=user_id,
|
self.session,current_user,user_id=user_id,created_by=created_by,
|
||||||
)
|
)
|
||||||
detail=bool(user_id)
|
detail=bool(user_id)
|
||||||
list_job_ids=None
|
list_job_ids=None
|
||||||
if not detail:
|
if not detail:
|
||||||
list_job_ids=await job_post_ids_for_candidate_list(
|
list_job_ids=await job_post_ids_for_candidate_list(
|
||||||
self.session,current_user,assigned_job_post_id=assigned_job_post_id,
|
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||||
)
|
)
|
||||||
if list_job_ids is not None and not list_job_ids:
|
if list_job_ids is not None and not list_job_ids:
|
||||||
return []
|
return []
|
||||||
|
|
@ -841,7 +844,7 @@ class CandidateView:
|
||||||
)
|
)
|
||||||
if detail:
|
if detail:
|
||||||
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
||||||
owned=await owned_job_ids_for_candidate_scope(self.session,current_user)
|
owned=await owned_job_ids_for_candidate_scope(self.session,current_user,created_by=created_by)
|
||||||
if records and owned is not None:
|
if records and owned is not None:
|
||||||
owned_set=set(owned)
|
owned_set=set(owned)
|
||||||
kept=[]
|
kept=[]
|
||||||
|
|
@ -947,12 +950,12 @@ class CandidateView:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None):
|
async def count_candidates(self,user_id=None,search=None,current_user=None,assigned_job_post_id=None,created_by=False):
|
||||||
try:
|
try:
|
||||||
job_post_ids=None
|
job_post_ids=None
|
||||||
if not user_id:
|
if not user_id:
|
||||||
job_post_ids=await job_post_ids_for_candidate_list(
|
job_post_ids=await job_post_ids_for_candidate_list(
|
||||||
self.session,current_user,assigned_job_post_id=assigned_job_post_id,
|
self.session,current_user,assigned_job_post_id=assigned_job_post_id,created_by=created_by,
|
||||||
)
|
)
|
||||||
return await Inbox.count_candidate_profiles(
|
return await Inbox.count_candidate_profiles(
|
||||||
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,
|
session=self.session,user_id=user_id,search=search,job_post_ids=job_post_ids,
|
||||||
|
|
@ -962,11 +965,11 @@ class CandidateView:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
async def update_candidate(self,user_id,payload,current_user=None):
|
async def update_candidate(self,user_id,payload,current_user=None,created_by=False):
|
||||||
try:
|
try:
|
||||||
if not user_id:
|
if not user_id:
|
||||||
raise HTTPException(status_code=400,detail="user_id is required")
|
raise HTTPException(status_code=400,detail="user_id is required")
|
||||||
await assert_manager_candidate_access(self.session,current_user,user_id=user_id)
|
await assert_manager_candidate_access(self.session,current_user,user_id=user_id,created_by=created_by)
|
||||||
fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None}
|
fields={k:v for k,v in (payload or {}).items() if k in ("favorite","rating") and v is not None}
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(status_code=400,detail="favorite or rating is required")
|
raise HTTPException(status_code=400,detail="favorite or rating is required")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import TYPE_CHECKING, Optional
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
from sqlalchemy import DateTime, JSON, Index, String, case, cast, func, or_, union_all
|
from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, func, or_, union_all
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import aliased
|
from sqlalchemy.orm import aliased
|
||||||
from sqlmodel import Field, Relationship, SQLModel, select
|
from sqlmodel import Field, Relationship, SQLModel, select
|
||||||
|
|
@ -209,6 +209,7 @@ class JobPosts(SQLModel, table=True):
|
||||||
requisition_status: str | None = None,
|
requisition_status: str | None = None,
|
||||||
employment_type: str | None = None,
|
employment_type: str | None = None,
|
||||||
hiring_manager_id: uuid.UUID | None = None,
|
hiring_manager_id: uuid.UUID | None = None,
|
||||||
|
restrict_ids: list | None = None,
|
||||||
):
|
):
|
||||||
if ids:
|
if ids:
|
||||||
rows = await cls.get_by_ids(session, ids, active_only=active_only)
|
rows = await cls.get_by_ids(session, ids, active_only=active_only)
|
||||||
|
|
@ -219,6 +220,15 @@ class JobPosts(SQLModel, table=True):
|
||||||
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
|
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
|
||||||
elif not include_deleted:
|
elif not include_deleted:
|
||||||
statement = statement.where(cls.is_deleted == False) # noqa: E712
|
statement = statement.where(cls.is_deleted == False) # noqa: E712
|
||||||
|
if restrict_ids is not None:
|
||||||
|
uids = []
|
||||||
|
for raw in restrict_ids:
|
||||||
|
uid = raw if isinstance(raw, uuid.UUID) else cls._as_uuid(raw)
|
||||||
|
if uid is not None:
|
||||||
|
uids.append(uid)
|
||||||
|
if not uids:
|
||||||
|
return [], 0
|
||||||
|
statement = statement.where(cls.id.in_(uids))
|
||||||
if search:
|
if search:
|
||||||
like = f"%{search.strip()}%"
|
like = f"%{search.strip()}%"
|
||||||
statement = statement.where(
|
statement = statement.where(
|
||||||
|
|
@ -482,15 +492,27 @@ class JobPosts(SQLModel, table=True):
|
||||||
return out
|
return out
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def ids_for_creator(cls, session: AsyncSession, user_id):
|
async def ids_for_creator(cls, session: AsyncSession, user_id, created_by=False):
|
||||||
"""Job posts this user created. Recruiter Candidates scope (when they
|
"""Jobs this recruiter should see on Candidates (when they lack
|
||||||
lack candidates.manage) follows job_posts.created_by, not role_id."""
|
candidates.manage). created_by=True → created_by = session user only.
|
||||||
|
Otherwise: current_recruiter_id when set, else created_by."""
|
||||||
uid = cls._as_uuid(user_id)
|
uid = cls._as_uuid(user_id)
|
||||||
if uid is None:
|
if uid is None:
|
||||||
return []
|
return []
|
||||||
|
if created_by:
|
||||||
|
result = await session.execute(
|
||||||
|
select(cls.id).where(
|
||||||
|
cls.created_by == uid,
|
||||||
|
cls.is_deleted == False, # noqa: E712
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(cls.id).where(
|
select(cls.id).where(
|
||||||
cls.created_by == uid,
|
or_(
|
||||||
|
and_(cls.current_recruiter_id.is_not(None), cls.current_recruiter_id == uid),
|
||||||
|
and_(cls.current_recruiter_id.is_(None), cls.created_by == uid),
|
||||||
|
),
|
||||||
cls.is_deleted == False, # noqa: E712
|
cls.is_deleted == False, # noqa: E712
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,24 @@ class JobPost:
|
||||||
except (httpx.HTTPError,BufferError,RuntimeError) as e:
|
except (httpx.HTTPError,BufferError,RuntimeError) as e:
|
||||||
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e
|
raise HTTPException(status_code=502,detail="Failed to list Buffer channels") from e
|
||||||
|
|
||||||
async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True):
|
async def _restrict_ids_for_requisition_scope(self,current_user):
|
||||||
|
"""None = unscoped. Empty list = no jobs. Else owned job-post ids."""
|
||||||
|
from users.permissions import scopes_to_own_requisitions
|
||||||
|
if not scopes_to_own_requisitions(current_user):
|
||||||
|
return None
|
||||||
|
return await JobPosts.ids_for_manager(self.session,current_user.get("id") if current_user else None)
|
||||||
|
|
||||||
|
async def fetch_job_posts(self,search=None,top=None,skip=0,ids=None,active_only=True,current_user=None):
|
||||||
|
restrict=await self._restrict_ids_for_requisition_scope(current_user)
|
||||||
|
if restrict is not None:
|
||||||
|
owned={str(i) for i in restrict}
|
||||||
|
if ids:
|
||||||
|
ids=[i for i in ids if str(i) in owned]
|
||||||
|
if not ids:
|
||||||
|
return [],0
|
||||||
|
restrict=None
|
||||||
|
elif not restrict:
|
||||||
|
return [],0
|
||||||
rows,total=await JobPosts.fetch_job_posts(
|
rows,total=await JobPosts.fetch_job_posts(
|
||||||
self.session,
|
self.session,
|
||||||
search=search,
|
search=search,
|
||||||
|
|
@ -231,6 +248,7 @@ class JobPost:
|
||||||
skip=skip,
|
skip=skip,
|
||||||
ids=ids,
|
ids=ids,
|
||||||
active_only=active_only,
|
active_only=active_only,
|
||||||
|
restrict_ids=restrict,
|
||||||
)
|
)
|
||||||
return [serialize_job_post(r) for r in rows],total
|
return [serialize_job_post(r) for r in rows],total
|
||||||
|
|
||||||
|
|
@ -274,7 +292,11 @@ class JobPost:
|
||||||
]
|
]
|
||||||
|
|
||||||
async def fetch_jobs(self,search=None,department=None,requisition_status=None,
|
async def fetch_jobs(self,search=None,department=None,requisition_status=None,
|
||||||
employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True):
|
employment_type=None,hiring_manager_id=None,top=None,skip=0,active_only=True,
|
||||||
|
current_user=None):
|
||||||
|
restrict=await self._restrict_ids_for_requisition_scope(current_user)
|
||||||
|
if restrict is not None and not restrict:
|
||||||
|
return [],0
|
||||||
hm_uid=None
|
hm_uid=None
|
||||||
if hiring_manager_id:
|
if hiring_manager_id:
|
||||||
hm_uid=JobPosts._as_uuid(hiring_manager_id)
|
hm_uid=JobPosts._as_uuid(hiring_manager_id)
|
||||||
|
|
@ -284,6 +306,7 @@ class JobPost:
|
||||||
self.session,search=search,top=top,skip=skip,active_only=active_only,
|
self.session,search=search,top=top,skip=skip,active_only=active_only,
|
||||||
department=department,requisition_status=requisition_status,
|
department=department,requisition_status=requisition_status,
|
||||||
employment_type=employment_type,hiring_manager_id=hm_uid,
|
employment_type=employment_type,hiring_manager_id=hm_uid,
|
||||||
|
restrict_ids=restrict,
|
||||||
)
|
)
|
||||||
names=await Users.names_by_ids(
|
names=await Users.names_by_ids(
|
||||||
self.session,
|
self.session,
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,13 @@ class Note:
|
||||||
async def _load(self,record_id):
|
async def _load(self,record_id):
|
||||||
return await Notes.get_note_by_id(self.session,record_id)
|
return await Notes.get_note_by_id(self.session,record_id)
|
||||||
|
|
||||||
async def get_note(self,note_id=None,user_id=None,current_user=None):
|
async def get_note(self,note_id=None,user_id=None,current_user=None,created_by=False):
|
||||||
if note_id:
|
if note_id:
|
||||||
row=await self._load(note_id)
|
row=await self._load(note_id)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404,detail="Note not found")
|
raise HTTPException(status_code=404,detail="Note not found")
|
||||||
await assert_manager_candidate_access(
|
await assert_manager_candidate_access(
|
||||||
self.session,current_user,user_id=row.user_id,
|
self.session,current_user,user_id=row.user_id,created_by=created_by,
|
||||||
)
|
)
|
||||||
return serialize_note(row)
|
return serialize_note(row)
|
||||||
if not user_id:
|
if not user_id:
|
||||||
|
|
@ -29,11 +29,11 @@ class Note:
|
||||||
uid=Notes._as_uuid(user_id)
|
uid=Notes._as_uuid(user_id)
|
||||||
if uid is None:
|
if uid is None:
|
||||||
raise HTTPException(status_code=400,detail="Invalid user_id")
|
raise HTTPException(status_code=400,detail="Invalid user_id")
|
||||||
await assert_manager_candidate_access(self.session,current_user,user_id=uid)
|
await assert_manager_candidate_access(self.session,current_user,user_id=uid,created_by=created_by)
|
||||||
rows=await Notes.get_notes_by_user(self.session,uid)
|
rows=await Notes.get_notes_by_user(self.session,uid)
|
||||||
return [serialize_note(r) for r in rows]
|
return [serialize_note(r) for r in rows]
|
||||||
|
|
||||||
async def create_note(self,payload,current_user):
|
async def create_note(self,payload,current_user,created_by=False):
|
||||||
fields={
|
fields={
|
||||||
"note":payload.get("note") or "",
|
"note":payload.get("note") or "",
|
||||||
"user_id":payload.get("user_id"),
|
"user_id":payload.get("user_id"),
|
||||||
|
|
@ -42,7 +42,7 @@ class Note:
|
||||||
if not fields["user_id"]:
|
if not fields["user_id"]:
|
||||||
raise HTTPException(status_code=400,detail="user_id is required")
|
raise HTTPException(status_code=400,detail="user_id is required")
|
||||||
await assert_manager_candidate_access(
|
await assert_manager_candidate_access(
|
||||||
self.session,current_user,user_id=fields["user_id"],
|
self.session,current_user,user_id=fields["user_id"],created_by=created_by,
|
||||||
)
|
)
|
||||||
row=await Notes.insert_note(self.session,fields)
|
row=await Notes.insert_note(self.session,fields)
|
||||||
await HistoryRecorder(self.session).record(
|
await HistoryRecorder(self.session).record(
|
||||||
|
|
@ -54,7 +54,7 @@ class Note:
|
||||||
row=await self._load(row.id)
|
row=await self._load(row.id)
|
||||||
return serialize_note(row)
|
return serialize_note(row)
|
||||||
|
|
||||||
async def update_note(self,note_id,payload,current_user=None):
|
async def update_note(self,note_id,payload,current_user=None,created_by=False):
|
||||||
fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
|
fields={k:v for k,v in payload.items() if v is not None and k in ("note",)}
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(status_code=400,detail="No fields to update")
|
raise HTTPException(status_code=400,detail="No fields to update")
|
||||||
|
|
@ -62,7 +62,7 @@ class Note:
|
||||||
if not before:
|
if not before:
|
||||||
raise HTTPException(status_code=404,detail="Note not found")
|
raise HTTPException(status_code=404,detail="Note not found")
|
||||||
await assert_manager_candidate_access(
|
await assert_manager_candidate_access(
|
||||||
self.session,current_user,user_id=before.user_id,
|
self.session,current_user,user_id=before.user_id,created_by=created_by,
|
||||||
)
|
)
|
||||||
old_note=before.note or ""
|
old_note=before.note or ""
|
||||||
row=await Notes.update_note(self.session,note_id,fields)
|
row=await Notes.update_note(self.session,note_id,fields)
|
||||||
|
|
|
||||||
|
|
@ -247,6 +247,25 @@ def sees_all_candidates(current_user: dict | None) -> bool:
|
||||||
return PermissionTag.CANDIDATES_MANAGE.value in granted
|
return PermissionTag.CANDIDATES_MANAGE.value in granted
|
||||||
|
|
||||||
|
|
||||||
|
def scopes_to_own_requisitions(current_user: dict | None) -> bool:
|
||||||
|
"""Jobs and candidates limited to requisitions this user created (or is assigned).
|
||||||
|
|
||||||
|
Opt-in from Access Control: tick Requisitions → Configure
|
||||||
|
(`requisitions.configure`). That is independent of Create (who may open a
|
||||||
|
requisition) and of Manage (which already means admin and unscopes).
|
||||||
|
|
||||||
|
Hiring-manager portal roles still use this scope so the locked sidebar
|
||||||
|
keeps a matching candidate list. candidates.manage / admin still see every
|
||||||
|
job. Do not key custom roles off a name such as AI_TEAM_MANAGER.
|
||||||
|
"""
|
||||||
|
if is_hiring_manager(current_user):
|
||||||
|
return True
|
||||||
|
if is_admin(current_user) or sees_all_candidates(current_user):
|
||||||
|
return False
|
||||||
|
granted = (current_user or {}).get("permissions") or []
|
||||||
|
return PermissionTag.REQUISITIONS_CONFIGURE.value in granted
|
||||||
|
|
||||||
|
|
||||||
def has_permission(
|
def has_permission(
|
||||||
granted: set[str] | list[str] | tuple[str, ...],
|
granted: set[str] | list[str] | tuple[str, ...],
|
||||||
*required: PermissionTag,
|
*required: PermissionTag,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
/**
|
||||||
|
* scopesToOwnRequisitions — mirrors backend users.permissions.scopes_to_own_requisitions.
|
||||||
|
*
|
||||||
|
* node permissions-scope.test.mjs
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
isAdmin,
|
||||||
|
isHiringManager,
|
||||||
|
seesAllCandidates,
|
||||||
|
scopesToOwnRequisitions,
|
||||||
|
} from './src/auth/permissions.js'
|
||||||
|
|
||||||
|
let failed = 0
|
||||||
|
function ok(name, cond, extra) {
|
||||||
|
if (cond) {
|
||||||
|
console.log(`ok ${name}`)
|
||||||
|
if (extra) console.log(` ${extra}`)
|
||||||
|
} else {
|
||||||
|
failed += 1
|
||||||
|
console.log(`FAIL ${name}`)
|
||||||
|
if (extra) console.log(` ${extra}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const recruiter = {
|
||||||
|
id: 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||||
|
role_name: 'recruiter',
|
||||||
|
permissions: ['candidates.view', 'jobs.view'],
|
||||||
|
}
|
||||||
|
ok('recruiter is not requisition-scoped', !scopesToOwnRequisitions(recruiter))
|
||||||
|
ok('recruiter does not see all candidates', !seesAllCandidates(recruiter))
|
||||||
|
|
||||||
|
const custom = {
|
||||||
|
...recruiter,
|
||||||
|
role_name: 'AI_TEAM_MANAGER',
|
||||||
|
permissions: ['candidates.view', 'jobs.view', 'requisitions.create'],
|
||||||
|
}
|
||||||
|
ok('requisitions.create alone does not scope jobs/candidates', !scopesToOwnRequisitions(custom))
|
||||||
|
ok('custom role is not hiring-manager portal', !isHiringManager(custom))
|
||||||
|
ok('custom role is not admin', !isAdmin(custom))
|
||||||
|
|
||||||
|
ok(
|
||||||
|
'Access Control requisitions.configure enables the scope',
|
||||||
|
scopesToOwnRequisitions({
|
||||||
|
...custom,
|
||||||
|
permissions: ['candidates.view', 'jobs.view', 'requisitions.create', 'requisitions.configure'],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const manage = {
|
||||||
|
...custom,
|
||||||
|
permissions: ['candidates.view', 'candidates.manage', 'requisitions.configure'],
|
||||||
|
}
|
||||||
|
ok(
|
||||||
|
'candidates.manage wins over requisitions.configure',
|
||||||
|
!scopesToOwnRequisitions(manage) && seesAllCandidates(manage),
|
||||||
|
)
|
||||||
|
|
||||||
|
ok(
|
||||||
|
'admin is not requisition-scoped',
|
||||||
|
!scopesToOwnRequisitions({ role_name: 'admin', permissions: ['requisitions.create'] }) &&
|
||||||
|
isAdmin({ role_name: 'admin' }),
|
||||||
|
)
|
||||||
|
|
||||||
|
ok(
|
||||||
|
'hiring_manager stays portal-locked and requisition-scoped',
|
||||||
|
isHiringManager({ role_name: 'hiring_manager' }) &&
|
||||||
|
scopesToOwnRequisitions({ role_name: 'hiring_manager', permissions: ['candidates.view'] }),
|
||||||
|
)
|
||||||
|
|
||||||
|
ok(
|
||||||
|
'requisitions.manage is admin, not this scope',
|
||||||
|
isAdmin({ role_name: 'ops_lead', permissions: ['requisitions.manage'] }) &&
|
||||||
|
!scopesToOwnRequisitions({ role_name: 'ops_lead', permissions: ['requisitions.manage'] }),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (failed) {
|
||||||
|
console.log(`\n${failed} failed`)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
console.log('\nall passed')
|
||||||
|
|
@ -62,3 +62,15 @@ export function seesAllCandidates(user) {
|
||||||
if (isAdmin(user)) return true
|
if (isAdmin(user)) return true
|
||||||
return (user?.permissions || []).includes('candidates.manage')
|
return (user?.permissions || []).includes('candidates.manage')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Jobs/candidates limited to requisitions this user created (or is assigned).
|
||||||
|
|
||||||
|
Matches backend `scopes_to_own_requisitions`. Custom roles opt in from
|
||||||
|
Access Control by ticking Requisitions → Configure (`requisitions.configure`),
|
||||||
|
not Create. `requisitions.manage` already means admin. Do not expand
|
||||||
|
`isHiringManager` for this; that helper still locks the sidebar. */
|
||||||
|
export function scopesToOwnRequisitions(user) {
|
||||||
|
if (isHiringManager(user)) return true
|
||||||
|
if (isAdmin(user) || seesAllCandidates(user)) return false
|
||||||
|
return (user?.permissions || []).includes('requisitions.configure')
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@
|
||||||
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
|
Recruiter rows come from GET /candidate/fetch (inbox + manual), one row
|
||||||
per application, so score / stage / job / recruiter have a source.
|
per application, so score / stage / job / recruiter have a source.
|
||||||
Without candidates.manage the server scopes that list to jobs the user
|
Without candidates.manage the server scopes that list to jobs the user
|
||||||
created (job_posts.created_by). Hiring managers use GET
|
owns as recruiter (or created). Tick Requisitions → Configure in Access
|
||||||
/candidate/manager/fetch (jobs on their requisitions). Adding a candidate
|
Control to see candidates on jobs opened from that user's requisitions;
|
||||||
|
recruiter assignment on the job does not hide them. Hiring managers use GET
|
||||||
|
/candidate/manager/fetch (same job chain). Adding a candidate
|
||||||
still goes through CV Import or the Add Candidate modal — both run the CV
|
still goes through CV Import or the Add Candidate modal — both run the CV
|
||||||
through persisted ATS scoring.
|
through persisted ATS scoring.
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
@ -20,7 +22,7 @@ import PageHeader from '../ui/PageHeader'
|
||||||
import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
import { Avatar, Badge, EmptyState, FieldError, Icon, ScoreChip, SkeletonRows } from '../ui/primitives'
|
||||||
import { useToast } from '../ui/Toast'
|
import { useToast } from '../ui/Toast'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { isHiringManager, seesAllCandidates } from '../auth/permissions'
|
import { isHiringManager, seesAllCandidates, scopesToOwnRequisitions } from '../auth/permissions'
|
||||||
import CandidateProfile from './CandidateProfile'
|
import CandidateProfile from './CandidateProfile'
|
||||||
import { useJobTitles } from './ScoredCandidateProfile'
|
import { useJobTitles } from './ScoredCandidateProfile'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
|
|
@ -297,6 +299,7 @@ function RecruiterCandidates() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
const updateCandidates = useSeedMutation('candidates')
|
const updateCandidates = useSeedMutation('candidates')
|
||||||
const unscoped = seesAllCandidates(user)
|
const unscoped = seesAllCandidates(user)
|
||||||
|
const requisitionScoped = scopesToOwnRequisitions(user)
|
||||||
|
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [jobId, setJobId] = useState('')
|
const [jobId, setJobId] = useState('')
|
||||||
|
|
@ -331,8 +334,12 @@ function RecruiterCandidates() {
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
const jobsQuery = useQuery({
|
const jobsQuery = useQuery({
|
||||||
queryKey: qk.jobPosts.list({ createdBy: unscoped ? 'all' : (user?.id ?? null) }),
|
queryKey: qk.jobPosts.list({
|
||||||
queryFn: () => fetchJobs({ createdBy: unscoped ? undefined : user?.id }),
|
createdBy: unscoped ? 'all' : requisitionScoped ? 'requisition' : (user?.id ?? null),
|
||||||
|
}),
|
||||||
|
queryFn: () => fetchJobs({
|
||||||
|
createdBy: unscoped || requisitionScoped ? undefined : user?.id,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
const candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data])
|
const candidates = useMemo(() => candidatesQuery.data?.rows ?? [], [candidatesQuery.data])
|
||||||
const jobsById = useMemo(
|
const jobsById = useMemo(
|
||||||
|
|
@ -629,7 +636,9 @@ function RecruiterCandidates() {
|
||||||
? 'Try a different search, job, stage, or band filter.'
|
? 'Try a different search, job, stage, or band filter.'
|
||||||
: unscoped
|
: unscoped
|
||||||
? 'Import a CV or add a candidate to see score, stage, and recruiter on this table.'
|
? 'Import a CV or add a candidate to see score, stage, and recruiter on this table.'
|
||||||
: 'Candidates appear here when they are allocated to a job you created.'}
|
: requisitionScoped
|
||||||
|
? 'Candidates appear here when they are allocated to jobs opened from your requisitions.'
|
||||||
|
: 'Candidates appear here when they are allocated to a job you created.'}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,16 @@ function humanise(slug) {
|
||||||
.join(' ')
|
.join(' ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extra tooltip copy for tags whose matrix cell is an opt-in, not a screen. */
|
||||||
|
const TAG_HELP = {
|
||||||
|
'requisitions.configure':
|
||||||
|
'Limit Jobs and Candidates to requisitions this user created. Independent of Create. Untick to use the default recruiter list.',
|
||||||
|
'candidates.manage':
|
||||||
|
'See every candidate, not only jobs this user owns.',
|
||||||
|
'requisitions.manage':
|
||||||
|
'Org-wide requisition list (admin).',
|
||||||
|
}
|
||||||
|
|
||||||
export default function Rbac() {
|
export default function Rbac() {
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { can } = useAuth()
|
const { can } = useAuth()
|
||||||
|
|
@ -286,7 +296,9 @@ export default function Rbac() {
|
||||||
{role.bundles?.length
|
{role.bundles?.length
|
||||||
? ` (currently ${role.bundles.map((b) => b.name ?? b).join(', ')})`
|
? ` (currently ${role.bundles.map((b) => b.name ?? b).join(', ')})`
|
||||||
: ''}
|
: ''}
|
||||||
. Shared system bundles are not rewritten.
|
. Shared system bundles are not rewritten.{' '}
|
||||||
|
<b>Requisitions → Configure</b> limits Jobs and Candidates to
|
||||||
|
requisitions that user created; it is not implied by Create.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{tagsQuery.isPending && (
|
{tagsQuery.isPending && (
|
||||||
|
|
@ -319,12 +331,13 @@ export default function Rbac() {
|
||||||
return <td key={action}><span className="text-muted">·</span></td>
|
return <td key={action}><span className="text-muted">·</span></td>
|
||||||
}
|
}
|
||||||
const on = draft.has(tag)
|
const on = draft.has(tag)
|
||||||
|
const help = TAG_HELP[tag]
|
||||||
return (
|
return (
|
||||||
<td key={action}>
|
<td key={action}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={`perm-check${on ? ' on' : ''}`}
|
className={`perm-check${on ? ' on' : ''}`}
|
||||||
title={tag}
|
title={help ? `${tag} — ${help}` : tag}
|
||||||
disabled={!canEdit || saveMatrix.isPending}
|
disabled={!canEdit || saveMatrix.isPending}
|
||||||
aria-pressed={on}
|
aria-pressed={on}
|
||||||
aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`}
|
aria-label={`${humanise(mod)} ${humanise(action)}: ${on ? 'granted' : 'not granted'}`}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue