cronjjob implementedd
parent
53b1d4d238
commit
82686f0bdf
|
|
@ -25,6 +25,11 @@ CALENDAR_API_TOKEN=
|
|||
EMAIL_SYNC_FOLDER=inbox
|
||||
EMAIL_SYNC_SINCE=
|
||||
EMAIL_SYNC_CRON=* * * * *
|
||||
# Daily Sync Inbox (POST /email/sync) at 05:00 AM PKT. Token must match the
|
||||
# Bearer the cron sends; leave blank to skip the tick with a warning.
|
||||
INBOX_SYNC_CRON=0 5 * * *
|
||||
INBOX_SYNC_CRON_TZ=Asia/Karachi
|
||||
CRON_INBOX_SYNC_TOKEN=
|
||||
|
||||
JWT_SECRET_KEY=
|
||||
JWT_ALGORITHM=HS256
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
"""HTTP client for scheduled system jobs — call the portal API, do not import views.
|
||||
|
||||
The daily inbox sync goes through POST /email/sync so enqueue, coalescing, and
|
||||
the mailbox_sync worker stay on one code path with the UI Sync Inbox button.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
INBOX_SYNC_PATH="/email/sync"
|
||||
INBOX_SYNC_TIMEOUT=float(os.getenv("INBOX_SYNC_TIMEOUT_SECONDS","30"))
|
||||
|
||||
|
||||
def _backend_url() -> str:
|
||||
return (os.getenv("BACKEND_URL") or "http://localhost:8000").rstrip("/")
|
||||
|
||||
|
||||
def _cron_token() -> str:
|
||||
return (os.getenv("CRON_INBOX_SYNC_TOKEN") or "").strip()
|
||||
|
||||
|
||||
async def call_inbox_sync_api(*, top=100, skip=0, test_on=True) -> dict:
|
||||
"""POST /email/sync on this service -> the JSON envelope.
|
||||
|
||||
Uses CRON_INBOX_SYNC_TOKEN as Bearer. The route accepts that shared secret
|
||||
in place of a recruiter JWT so the 05:00 PKT scheduler can enqueue a run.
|
||||
"""
|
||||
token=_cron_token()
|
||||
if not token:
|
||||
raise RuntimeError("CRON_INBOX_SYNC_TOKEN is not set")
|
||||
params={
|
||||
"top":int(top if top is not None else 100),
|
||||
"skip":int(skip if skip is not None else 0),
|
||||
"test_on":bool(test_on) if test_on is not None else True,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=INBOX_SYNC_TIMEOUT) as client:
|
||||
response=await client.post(
|
||||
f"{_backend_url()}{INBOX_SYNC_PATH}",
|
||||
params=params,
|
||||
headers={"Authorization":f"Bearer {token}"},
|
||||
)
|
||||
if response.status_code>=400:
|
||||
raise httpx.HTTPStatusError(
|
||||
response.text,
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
return response.json()
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"""Daily Sync Inbox cron — 05:00 AM PKT via httpx POST /email/sync.
|
||||
|
||||
Worker: taskiq worker taskiq_management.broker_setup:broker cron_schdule.tasks
|
||||
Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler cron_schdule.tasks
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from cron_schdule.plugins import call_inbox_sync_api
|
||||
from taskiq_management.broker_setup import broker
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger=logging.getLogger("cron_schdule.inbox_sync")
|
||||
|
||||
# 05:00 Asia/Karachi (PKT, UTC+5, no DST). Override the expression or zone in .env.
|
||||
INBOX_SYNC_CRON=os.getenv("INBOX_SYNC_CRON","0 5 * * *")
|
||||
INBOX_SYNC_CRON_TZ=os.getenv("INBOX_SYNC_CRON_TZ","Asia/Karachi")
|
||||
INBOX_SYNC_TOP=int(os.getenv("INBOX_SYNC_TOP","100"))
|
||||
INBOX_SYNC_SKIP=int(os.getenv("INBOX_SYNC_SKIP","0"))
|
||||
INBOX_SYNC_TEST_ON=os.getenv("INBOX_SYNC_TEST_ON","true").strip().lower() not in ("0","false","no")
|
||||
|
||||
|
||||
@broker.task(
|
||||
task_name="cron_schdule.sync_inbox",
|
||||
schedule=[{"cron":INBOX_SYNC_CRON,"cron_offset":INBOX_SYNC_CRON_TZ}],
|
||||
)
|
||||
async def sync_inbox_daily() -> dict:
|
||||
"""Enqueue Outlook mailbox sync the same way the Inbox UI button does."""
|
||||
try:
|
||||
payload=await call_inbox_sync_api(
|
||||
top=INBOX_SYNC_TOP,
|
||||
skip=INBOX_SYNC_SKIP,
|
||||
test_on=INBOX_SYNC_TEST_ON,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
logger.warning("daily inbox sync skipped: %s",e)
|
||||
return {"error":"not_configured","detail":str(e)}
|
||||
except httpx.ConnectError as e:
|
||||
logger.warning("daily inbox sync unreachable: %s",e)
|
||||
return {"error":"unreachable","detail":str(e)}
|
||||
except httpx.HTTPStatusError as e:
|
||||
status=e.response.status_code if e.response is not None else None
|
||||
logger.warning("daily inbox sync HTTP %s",status)
|
||||
return {"error":"http_error","status_code":status}
|
||||
data=payload.get("data") if isinstance(payload,dict) else None
|
||||
return {"status":"ok","data":data}
|
||||
|
|
@ -1,16 +1,49 @@
|
|||
import hmac
|
||||
import os
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter,Depends, Query
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import HTTPException
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pydantic import BaseModel
|
||||
from db_setup import get_session
|
||||
from inbox.enums import Candidate_application_Status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from inbox.views import Email
|
||||
from users.permissions import PermissionTag, require_permission
|
||||
from users.permissions import PermissionTag, get_current_user, require_permission
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv()
|
||||
|
||||
router = APIRouter()
|
||||
_optional_bearer=HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def _cron_inbox_sync_token_ok(provided: str) -> bool:
|
||||
expected=(os.getenv("CRON_INBOX_SYNC_TOKEN") or "").strip()
|
||||
token=(provided or "").strip()
|
||||
if not expected or not token or len(expected)!=len(token):
|
||||
return False
|
||||
return hmac.compare_digest(token, expected)
|
||||
|
||||
|
||||
async def inbox_sync_caller(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_optional_bearer)],
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""JWT with inbox.edit, or CRON_INBOX_SYNC_TOKEN for the daily scheduler."""
|
||||
token=credentials.credentials if credentials else ""
|
||||
if _cron_inbox_sync_token_ok(token):
|
||||
return None
|
||||
if credentials is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate":"Bearer"},
|
||||
)
|
||||
current_user=await get_current_user(credentials,session)
|
||||
checker=require_permission(PermissionTag.INBOX_EDIT)
|
||||
return await checker(current_user)
|
||||
|
||||
|
||||
class AssignJobPostBody(BaseModel):
|
||||
|
|
@ -104,13 +137,14 @@ async def start_email_sync(
|
|||
top:int=Query(100,ge=1,le=100),
|
||||
skip:int=Query(0,ge=0),
|
||||
test_on: bool = Query(True),
|
||||
current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)),
|
||||
current_user: dict | None = Depends(inbox_sync_caller),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
"""Enqueue mailbox sync on the dedicated mailbox_sync Taskiq queue.
|
||||
|
||||
Returns immediately with a run id. Poll GET /email/sync/fetch until completed.
|
||||
Closing the browser does not cancel the worker.
|
||||
Closing the browser does not cancel the worker. The daily cron uses the same
|
||||
route with CRON_INBOX_SYNC_TOKEN instead of a recruiter JWT.
|
||||
"""
|
||||
try:
|
||||
service=Email(session=session)
|
||||
|
|
|
|||
|
|
@ -162,7 +162,12 @@ class Inbox(SQLModel, table=True):
|
|||
|
||||
@classmethod
|
||||
async def list_applications_by_emails(cls, session: AsyncSession, emails):
|
||||
"""Inbox applications whose user or sender address is in `emails`."""
|
||||
"""Every inbox_messages row from these senders — linked or not.
|
||||
|
||||
The applications list hides body-only mail (`attachment == True`). History
|
||||
still needs those rows: a later proper CV is a new attempt, and the first
|
||||
one must remain visible if the system dropped it for format.
|
||||
"""
|
||||
from job.job_post.models import JobPosts
|
||||
|
||||
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||
|
|
@ -170,36 +175,47 @@ class Inbox(SQLModel, table=True):
|
|||
return []
|
||||
result = await session.execute(
|
||||
select(
|
||||
cls.id.label("inbox_id"),
|
||||
cls.user_id,
|
||||
cls.message_id,
|
||||
Users.email,
|
||||
Inbox.id.label("inbox_id"),
|
||||
Inbox_Messages.id.label("message_pk"),
|
||||
Inbox_Messages.message_id.label("upstream_id"),
|
||||
Inbox_Messages.message_from,
|
||||
Users.email.label("user_email"),
|
||||
Inbox_Messages.assigned_job_post_id,
|
||||
Inbox_Messages.application_status,
|
||||
Inbox_Messages.message_received_time,
|
||||
cls.created_at,
|
||||
Inbox_Messages.created_at,
|
||||
Inbox_Messages.attachment,
|
||||
Inbox_Messages.match_status,
|
||||
JobPosts.title,
|
||||
)
|
||||
.join(Users, cls.user_id == Users.id)
|
||||
.join(Inbox_Messages, cls.message_id == Inbox_Messages.id)
|
||||
.select_from(Inbox_Messages)
|
||||
.outerjoin(Inbox, Inbox.message_id == Inbox_Messages.id)
|
||||
.outerjoin(Users, Inbox.user_id == Users.id)
|
||||
.outerjoin(JobPosts, Inbox_Messages.assigned_job_post_id == JobPosts.id)
|
||||
.where(or_(
|
||||
func.lower(Users.email).in_(lowers),
|
||||
func.lower(Inbox_Messages.message_from).in_(lowers),
|
||||
func.lower(Users.email).in_(lowers),
|
||||
))
|
||||
.order_by(cls.created_at.desc())
|
||||
.order_by(Inbox_Messages.created_at.desc())
|
||||
)
|
||||
rows = []
|
||||
seen = set()
|
||||
for row in result.mappings().all():
|
||||
email = (row["email"] or row["message_from"] or "").strip().lower() or None
|
||||
mid = row["message_pk"]
|
||||
if mid in seen:
|
||||
continue
|
||||
seen.add(mid)
|
||||
sender = (row["message_from"] or "").strip().lower() or None
|
||||
user_email = (row["user_email"] or "").strip().lower() or None
|
||||
email = sender if sender in lowers else (user_email if user_email in lowers else sender)
|
||||
status = row["application_status"]
|
||||
applied = row["message_received_time"] or row["created_at"]
|
||||
rows.append({
|
||||
"source": "inbox",
|
||||
"email": email,
|
||||
"inbox_id": row["inbox_id"],
|
||||
"message_id": str(row["message_id"]) if row["message_id"] else None,
|
||||
"message_id": str(mid) if mid else None,
|
||||
"upstream_id": str(row["upstream_id"]) if row["upstream_id"] else None,
|
||||
"manual_upload_candidate_id": None,
|
||||
"form_data_id": None,
|
||||
"candidate_id": None,
|
||||
|
|
@ -207,6 +223,8 @@ class Inbox(SQLModel, table=True):
|
|||
"job_title": row["title"] or None,
|
||||
"status": status.value if status else None,
|
||||
"applied_at": applied.isoformat() if hasattr(applied, "isoformat") else (applied or None),
|
||||
"attachment": bool(row["attachment"]),
|
||||
"match_status": row["match_status"] or None,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
|
@ -1529,6 +1547,51 @@ class Inbox_Message_Triage(SQLModel, table=True):
|
|||
await session.refresh(row)
|
||||
return row
|
||||
|
||||
@classmethod
|
||||
async def list_filtered_by_emails(cls, session: AsyncSession, emails):
|
||||
"""Non-ingested classifier rows for these senders.
|
||||
|
||||
The inbox never listed these — either the CV could not be read or the
|
||||
gate kept the mail out. History still needs the attempt, labelled as
|
||||
rejected for format rather than dropped.
|
||||
"""
|
||||
from inbox_classifier.enums import Triage_Reason_Code
|
||||
|
||||
lowers = sorted({(e or "").strip().lower() for e in (emails or []) if (e or "").strip()})
|
||||
if not lowers:
|
||||
return []
|
||||
result = await session.execute(
|
||||
select(cls)
|
||||
.where(func.lower(cls.message_from).in_(lowers))
|
||||
.where(cls.ingested == False) # noqa: E712
|
||||
.where(or_(
|
||||
cls.attachment == True, # noqa: E712
|
||||
cls.reason_code == Triage_Reason_Code.JOB_APPLICATION.value,
|
||||
))
|
||||
.order_by(cls.classified_at.desc())
|
||||
)
|
||||
rows = []
|
||||
for rec in result.scalars().all():
|
||||
applied = rec.message_received_time or rec.classified_at
|
||||
rows.append({
|
||||
"source": "filtered",
|
||||
"email": (rec.message_from or "").strip().lower() or None,
|
||||
"inbox_id": None,
|
||||
"message_id": rec.message_id or None,
|
||||
"upstream_id": rec.message_id or None,
|
||||
"manual_upload_candidate_id": None,
|
||||
"form_data_id": None,
|
||||
"candidate_id": None,
|
||||
"job_post_id": None,
|
||||
"job_title": None,
|
||||
"status": "WRONG_FORMAT",
|
||||
"applied_at": applied.isoformat() if hasattr(applied, "isoformat") else (applied or None),
|
||||
"attachment": bool(rec.attachment),
|
||||
"match_status": None,
|
||||
"rejection_reason": "wrong_format",
|
||||
})
|
||||
return rows
|
||||
|
||||
@classmethod
|
||||
def _triage_filter(cls, statement, is_application, status, search):
|
||||
if is_application is not None:
|
||||
|
|
|
|||
|
|
@ -335,8 +335,51 @@ def serialize_manager_candidate(row, *, source) -> dict:
|
|||
}
|
||||
|
||||
|
||||
_WRONG_FORMAT_MATCH = frozenset({"no_text", "failed", "dlq"})
|
||||
|
||||
|
||||
def is_assigned_application(row) -> bool:
|
||||
"""True when the row is an application to a real job, not an unassigned email.
|
||||
|
||||
Reapplied means they applied to a role before. Another inbox mail with no
|
||||
job post is still history; it is not a reapplication. Sheet forms name a
|
||||
role in job_title even before a job post is linked.
|
||||
"""
|
||||
if not isinstance(row, dict):
|
||||
return False
|
||||
if row.get("job_post_id"):
|
||||
return True
|
||||
if row.get("source") == "form" and row.get("job_title"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def rejection_reason(row) -> str | None:
|
||||
"""Why an unassigned attempt never reached a job — or None if it is still open.
|
||||
|
||||
Wrong format: no CV, unreadable PDF, matcher failed, or the classifier
|
||||
kept the mail out of the inbox. Assigned rows keep their pipeline status.
|
||||
"""
|
||||
if not isinstance(row, dict) or is_assigned_application(row):
|
||||
return None
|
||||
if row.get("rejection_reason") == "wrong_format":
|
||||
return "wrong_format"
|
||||
if row.get("source") == "filtered":
|
||||
return "wrong_format"
|
||||
match = str(row.get("match_status") or "").strip().lower()
|
||||
if match in _WRONG_FORMAT_MATCH:
|
||||
return "wrong_format"
|
||||
if row.get("source") == "inbox" and row.get("attachment") is False:
|
||||
return "wrong_format"
|
||||
return None
|
||||
|
||||
|
||||
def serialize_application_history_item(row) -> dict:
|
||||
"""One prior application / score / sheet row for a reapplicant lookup."""
|
||||
reason = rejection_reason(row)
|
||||
status = row.get("status")
|
||||
if reason == "wrong_format":
|
||||
status = "WRONG_FORMAT"
|
||||
return {
|
||||
"source": row.get("source"),
|
||||
"inbox_id": row.get("inbox_id"),
|
||||
|
|
@ -346,8 +389,11 @@ def serialize_application_history_item(row) -> dict:
|
|||
"candidate_id": row.get("candidate_id"),
|
||||
"job_post_id": row.get("job_post_id"),
|
||||
"job_title": row.get("job_title"),
|
||||
"status": row.get("status"),
|
||||
"status": status,
|
||||
"applied_at": row.get("applied_at"),
|
||||
"rejection_reason": reason,
|
||||
"match_status": row.get("match_status"),
|
||||
"attachment": row.get("attachment"),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -362,6 +408,6 @@ def serialize_application_history(email, *, user=None, present_in=None, applicat
|
|||
{"id": str(user.id), "name": user.name, "email": user.email}
|
||||
if user is not None else None
|
||||
),
|
||||
"is_reapplicant": len(items) > 0,
|
||||
"is_reapplicant": any(is_assigned_application(item) for item in items),
|
||||
"applications": items,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from app.core.errors import ATSError,ErrorCode
|
|||
from app.models.scoring import CompletedCandidate
|
||||
from app.services.pdf import extract_resume,sanitize_filename
|
||||
from app.services.scoring import score_batch
|
||||
from inbox.models import Inbox_Messages,Inbox,AtsResults
|
||||
from inbox.models import Inbox_Messages,Inbox,Inbox_Message_Triage,AtsResults
|
||||
from job.candidate.models import Candidates
|
||||
from job.candidate.plugins import (
|
||||
FILE_NOT_FOUND,
|
||||
|
|
@ -25,7 +25,7 @@ from job.candidate.plugins import (
|
|||
normalize_spaced_text,
|
||||
)
|
||||
from g_sheet.models import FormData
|
||||
from job.candidate.serializers import serialize_application_history,serialize_candidate,serialize_candidate_profile,serialize_manual_candidate_profile,serialize_manual_upload_candidate,serialize_matching_candidate,serialize_manager_candidate
|
||||
from job.candidate.serializers import is_assigned_application,serialize_application_history,serialize_application_history_item,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
|
||||
|
|
@ -73,17 +73,23 @@ def _is_current_application(item,payload):
|
|||
if not isinstance(item,dict) or not isinstance(payload,dict):
|
||||
return False
|
||||
source=item.get("source")
|
||||
if source=="inbox":
|
||||
if source in ("inbox","filtered"):
|
||||
if payload.get("inbox_id") is not None and item.get("inbox_id") is not None:
|
||||
try:
|
||||
if int(payload["inbox_id"])==int(item["inbox_id"]):
|
||||
return True
|
||||
except (TypeError,ValueError):
|
||||
pass
|
||||
pid=payload.get("message_id")
|
||||
if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None:
|
||||
pid=payload.get("id")
|
||||
return bool(pid and item.get("message_id") and str(pid)==str(item["message_id"]))
|
||||
if pid and item.get("message_id") and str(pid)==str(item["message_id"]):
|
||||
return True
|
||||
graph=payload.get("message_id")
|
||||
if graph:
|
||||
if item.get("upstream_id") and str(graph)==str(item["upstream_id"]):
|
||||
return True
|
||||
if item.get("message_id") and str(graph)==str(item["message_id"]):
|
||||
return True
|
||||
return False
|
||||
if source=="manual":
|
||||
pid=payload.get("manual_upload_candidate_id")
|
||||
if not pid and payload.get("inbox_id") is None and payload.get("sheet") is None:
|
||||
|
|
@ -1615,6 +1621,7 @@ class CandidateView:
|
|||
users=await Users.get_users_by_emails(self.session,lowers)
|
||||
user_by_email={_norm_email(u.email):u for u in users}
|
||||
inbox_rows=await Inbox.list_applications_by_emails(self.session,lowers)
|
||||
filtered_rows=await Inbox_Message_Triage.list_filtered_by_emails(self.session,lowers)
|
||||
manual_rows=await Manual_UPLOAD_CANDIDATE.list_by_emails(self.session,lowers)
|
||||
form_rows=await FormData.list_by_emails(self.session,lowers)
|
||||
ats_rows=await Candidates.list_by_emails(self.session,lowers)
|
||||
|
|
@ -1623,10 +1630,21 @@ class CandidateView:
|
|||
if email in packed:
|
||||
packed[email]["present_in"].append("users")
|
||||
packed[email]["user"]=user
|
||||
ingested_upstream={
|
||||
str(row.get("upstream_id")) for row in inbox_rows if row.get("upstream_id")
|
||||
}
|
||||
for row in inbox_rows:
|
||||
email=_norm_email(row.get("email"))
|
||||
if email in packed:
|
||||
packed[email]["applications"].append(row)
|
||||
for row in filtered_rows:
|
||||
email=_norm_email(row.get("email"))
|
||||
if email not in packed:
|
||||
continue
|
||||
upstream=str(row.get("upstream_id") or row.get("message_id") or "")
|
||||
if upstream and upstream in ingested_upstream:
|
||||
continue
|
||||
packed[email]["applications"].append(row)
|
||||
for row in manual_rows:
|
||||
email=_norm_email(row.get("email"))
|
||||
if email not in packed:
|
||||
|
|
@ -1697,19 +1715,8 @@ class CandidateView:
|
|||
for row in pack.get("applications") or []:
|
||||
if _is_current_application(row,payload):
|
||||
continue
|
||||
previous.append({
|
||||
"source":row.get("source"),
|
||||
"inbox_id":row.get("inbox_id"),
|
||||
"message_id":row.get("message_id"),
|
||||
"manual_upload_candidate_id":row.get("manual_upload_candidate_id"),
|
||||
"form_data_id":row.get("form_data_id"),
|
||||
"candidate_id":row.get("candidate_id"),
|
||||
"job_post_id":row.get("job_post_id"),
|
||||
"job_title":row.get("job_title"),
|
||||
"status":row.get("status"),
|
||||
"applied_at":row.get("applied_at"),
|
||||
})
|
||||
previous.append(serialize_application_history_item(row))
|
||||
payload["present_in"]=list(pack.get("present_in") or [])
|
||||
payload["is_reapplicant"]=bool(previous)
|
||||
payload["is_reapplicant"]=any(is_assigned_application(item) for item in previous)
|
||||
payload["previous_applications"]=previous
|
||||
return records[0] if single else records
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Taskiq broker — Redis Streams + smart retry + DLQ.
|
||||
|
||||
Worker: taskiq worker taskiq_management.broker_setup:broker inbox.tasks inbox.sync_tasks taskiq_management.tasks job.candidate.bank_tasks
|
||||
Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler
|
||||
Worker: taskiq worker taskiq_management.broker_setup:broker inbox.tasks inbox.sync_tasks cron_schdule.tasks taskiq_management.tasks job.candidate.bank_tasks
|
||||
Scheduler: taskiq scheduler taskiq_management.broker_setup:scheduler inbox.sync_tasks cron_schdule.tasks
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
"""Reapplicant history — badge vs kept attempts.
|
||||
|
||||
No database: the helpers decide whether a prior row counts as Reapplied
|
||||
and how a system-dropped attempt is labelled.
|
||||
"""
|
||||
from job.candidate.serializers import (
|
||||
is_assigned_application,
|
||||
rejection_reason,
|
||||
serialize_application_history,
|
||||
serialize_application_history_item,
|
||||
)
|
||||
from job.candidate.views import _is_current_application
|
||||
|
||||
|
||||
def test_unassigned_inbox_is_not_a_reapplication():
|
||||
row = {
|
||||
"source": "inbox",
|
||||
"job_post_id": None,
|
||||
"job_title": None,
|
||||
"status": "CLOSED",
|
||||
"attachment": True,
|
||||
"match_status": "matched",
|
||||
}
|
||||
assert is_assigned_application(row) is False
|
||||
assert rejection_reason(row) is None
|
||||
|
||||
|
||||
def test_assigned_inbox_is_a_reapplication():
|
||||
row = {"source": "inbox", "job_post_id": "job-1", "job_title": "Engineer"}
|
||||
assert is_assigned_application(row) is True
|
||||
assert rejection_reason(row) is None
|
||||
|
||||
|
||||
def test_form_role_name_counts_without_job_post():
|
||||
row = {"source": "form", "job_post_id": None, "job_title": "Data Analyst"}
|
||||
assert is_assigned_application(row) is True
|
||||
|
||||
|
||||
def test_unreadable_cv_is_wrong_format():
|
||||
row = {
|
||||
"source": "inbox",
|
||||
"job_post_id": None,
|
||||
"attachment": True,
|
||||
"match_status": "no_text",
|
||||
"status": "CLOSED",
|
||||
}
|
||||
assert rejection_reason(row) == "wrong_format"
|
||||
item = serialize_application_history_item(row)
|
||||
assert item["status"] == "WRONG_FORMAT"
|
||||
assert item["rejection_reason"] == "wrong_format"
|
||||
|
||||
|
||||
def test_body_only_mail_is_wrong_format():
|
||||
row = {"source": "inbox", "job_post_id": None, "attachment": False, "status": "CLOSED"}
|
||||
assert rejection_reason(row) == "wrong_format"
|
||||
|
||||
|
||||
def test_filtered_classifier_row_is_wrong_format():
|
||||
row = {"source": "filtered", "job_post_id": None, "status": "WRONG_FORMAT"}
|
||||
assert rejection_reason(row) == "wrong_format"
|
||||
history = serialize_application_history("a@x.com", applications=[row])
|
||||
assert history["is_reapplicant"] is False
|
||||
assert history["applications"][0]["status"] == "WRONG_FORMAT"
|
||||
|
||||
|
||||
def test_history_reapplicant_needs_an_assigned_job():
|
||||
unassigned = {"source": "inbox", "job_post_id": None, "status": "CLOSED", "attachment": True}
|
||||
assigned = {"source": "inbox", "job_post_id": "job-1", "job_title": "Engineer", "status": "PENDING"}
|
||||
only_mail = serialize_application_history("a@x.com", applications=[unassigned])
|
||||
assert only_mail["is_reapplicant"] is False
|
||||
assert len(only_mail["applications"]) == 1
|
||||
both = serialize_application_history("a@x.com", applications=[unassigned, assigned])
|
||||
assert both["is_reapplicant"] is True
|
||||
assert len(both["applications"]) == 2
|
||||
|
||||
|
||||
def test_current_inbox_row_matches_message_pk():
|
||||
item = {"source": "inbox", "message_id": "11111111-1111-1111-1111-111111111111"}
|
||||
payload = {"id": "11111111-1111-1111-1111-111111111111", "email": "a@x.com"}
|
||||
assert _is_current_application(item, payload) is True
|
||||
assert _is_current_application(item, {"id": "other"}) is False
|
||||
|
||||
|
||||
def test_filtered_row_matches_graph_id_on_detail_payload():
|
||||
item = {"source": "filtered", "message_id": "AAMkGraph", "upstream_id": "AAMkGraph"}
|
||||
payload = {"message_id": "AAMkGraph", "fromEmail": "a@x.com"}
|
||||
assert _is_current_application(item, payload) is True
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
"""cron_schdule.plugins.call_inbox_sync_api — httpx POST /email/sync, no live network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from cron_schdule import plugins
|
||||
|
||||
|
||||
class _FakeClient:
|
||||
def __init__(self, response, calls):
|
||||
self._response=response
|
||||
self.calls=calls
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return None
|
||||
|
||||
async def post(self, url, **kwargs):
|
||||
self.calls.append((url, kwargs))
|
||||
return self._response
|
||||
|
||||
|
||||
async def test_call_inbox_sync_api_posts_email_sync(monkeypatch):
|
||||
monkeypatch.setenv("BACKEND_URL","http://backend-api:8000")
|
||||
monkeypatch.setenv("CRON_INBOX_SYNC_TOKEN","cron-secret")
|
||||
calls=[]
|
||||
response=httpx.Response(
|
||||
200,
|
||||
json={"data":{"status":"queued","id":"run-1"},"total":1,"status_code":200},
|
||||
request=httpx.Request("POST","http://backend-api:8000/email/sync"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins.httpx,
|
||||
"AsyncClient",
|
||||
lambda *a, **k: _FakeClient(response, calls),
|
||||
)
|
||||
payload=await plugins.call_inbox_sync_api(top=50, skip=0, test_on=True)
|
||||
assert payload["data"]["status"]=="queued"
|
||||
assert calls[0][0]=="http://backend-api:8000/email/sync"
|
||||
assert calls[0][1]["headers"]["Authorization"]=="Bearer cron-secret"
|
||||
assert calls[0][1]["params"]["top"]==50
|
||||
assert calls[0][1]["params"]["test_on"] is True
|
||||
|
||||
|
||||
async def test_call_inbox_sync_api_requires_token(monkeypatch):
|
||||
monkeypatch.setenv("BACKEND_URL","http://backend-api:8000")
|
||||
monkeypatch.delenv("CRON_INBOX_SYNC_TOKEN", raising=False)
|
||||
try:
|
||||
await plugins.call_inbox_sync_api()
|
||||
raise AssertionError("expected RuntimeError")
|
||||
except RuntimeError as e:
|
||||
assert "CRON_INBOX_SYNC_TOKEN" in str(e)
|
||||
|
||||
|
||||
async def test_call_inbox_sync_api_raises_on_http_error(monkeypatch):
|
||||
monkeypatch.setenv("BACKEND_URL","http://backend-api:8000")
|
||||
monkeypatch.setenv("CRON_INBOX_SYNC_TOKEN","cron-secret")
|
||||
request=httpx.Request("POST","http://backend-api:8000/email/sync")
|
||||
response=httpx.Response(401, text="unauthorized", request=request)
|
||||
monkeypatch.setattr(
|
||||
plugins.httpx,
|
||||
"AsyncClient",
|
||||
lambda *a, **k: _FakeClient(response, []),
|
||||
)
|
||||
try:
|
||||
await plugins.call_inbox_sync_api()
|
||||
raise AssertionError("expected HTTPStatusError")
|
||||
except httpx.HTTPStatusError as e:
|
||||
assert e.response.status_code==401
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
"""Unit tests for @extract_drive_cvs. No Google, no DB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from g_sheet.decorators import (
|
||||
build_extracted_data,
|
||||
extract_drive_cvs,
|
||||
make_job_temp_dir,
|
||||
remove_job_temp_dir,
|
||||
_should_ingest,
|
||||
)
|
||||
|
||||
|
||||
def test_build_extracted_data_json_shape():
|
||||
payload = build_extracted_data(
|
||||
status="completed",
|
||||
resume_link="https://drive.google.com/open?id=abc",
|
||||
file_id="abc",
|
||||
filename="cv.pdf",
|
||||
mime_type="application/pdf",
|
||||
text="Ada Lovelace",
|
||||
page_count=2,
|
||||
truncated=False,
|
||||
)
|
||||
assert payload["status"] == "completed"
|
||||
assert payload["file_id"] == "abc"
|
||||
assert payload["text"] == "Ada Lovelace"
|
||||
assert payload["char_count"] == len("Ada Lovelace")
|
||||
assert payload["error_code"] is None
|
||||
assert payload["extracted_at"]
|
||||
|
||||
|
||||
def test_should_ingest_skips_enqueue_and_empty():
|
||||
assert _should_ingest({"status": "queued", "tab": "x"}) is False
|
||||
assert _should_ingest({"tab": "x", "rows_read": 0, "inserted": 0}) is False
|
||||
assert _should_ingest({"tab": "x", "error": "boom"}) is False
|
||||
assert _should_ingest(object()) is False
|
||||
assert _should_ingest({"tab": "x", "rows_read": 4, "inserted": 4}) is True
|
||||
|
||||
|
||||
async def test_extract_drive_cvs_runs_after_import(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_ingest(session, sheet, credentials):
|
||||
calls.append((sheet, credentials))
|
||||
return {"extracted": 2, "extract_failed": 1}
|
||||
|
||||
monkeypatch.setattr("g_sheet.decorators.ingest_form_resume_links", fake_ingest)
|
||||
monkeypatch.setattr("g_sheet.decorators._prepare_drive_credentials", lambda service: "creds")
|
||||
|
||||
class Service:
|
||||
session = object()
|
||||
spreadsheet_id = "sheet-id"
|
||||
credentials = None
|
||||
credentials_path = None
|
||||
scopes = None
|
||||
|
||||
@extract_drive_cvs
|
||||
async def import_sheet(self, tab):
|
||||
return {"tab": tab, "rows_read": 3, "inserted": 3}
|
||||
|
||||
out = await Service().import_sheet("Form Responses")
|
||||
assert calls == [("Form Responses", "creds")]
|
||||
assert out["extracted"] == 2
|
||||
assert out["extract_failed"] == 1
|
||||
|
||||
|
||||
async def test_extract_drive_cvs_http_enqueue_does_not_ingest(monkeypatch):
|
||||
calls = []
|
||||
|
||||
async def fake_ingest(*args, **kwargs):
|
||||
calls.append(True)
|
||||
return {"extracted": 1, "extract_failed": 0}
|
||||
|
||||
monkeypatch.setattr("g_sheet.decorators.ingest_form_resume_links", fake_ingest)
|
||||
monkeypatch.setattr("g_sheet.decorators._prepare_drive_credentials", lambda service: "creds")
|
||||
|
||||
@extract_drive_cvs
|
||||
async def import_all_sheets(tab=None, session=None):
|
||||
return object()
|
||||
|
||||
result = await import_all_sheets(tab="Form Responses", session=object())
|
||||
assert result is not None
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_job_temp_dir_removed_after_cleanup(tmp_path):
|
||||
job_dir = make_job_temp_dir(tmp_path)
|
||||
leftover = job_dir / "abc.pdf"
|
||||
leftover.write_bytes(b"%PDF-1.4 leftover")
|
||||
assert leftover.is_file()
|
||||
remove_job_temp_dir(job_dir)
|
||||
assert not job_dir.exists()
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
async def test_ingest_removes_job_dir_when_finished(monkeypatch, tmp_path):
|
||||
from g_sheet.decorators import ingest_form_resume_links
|
||||
|
||||
async def fake_links(session, sheet):
|
||||
return [("id-1", "https://drive.google.com/open?id=abc")]
|
||||
|
||||
async def fake_extract(credentials, resume_link, dest_dir, max_chars=None, max_bytes=None):
|
||||
path = Path(dest_dir) / "abc.pdf"
|
||||
path.write_bytes(b"%PDF-fake")
|
||||
path.unlink()
|
||||
return build_extracted_data(
|
||||
status="completed",
|
||||
resume_link=resume_link,
|
||||
file_id="abc",
|
||||
text="ok",
|
||||
page_count=1,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
async def fake_set(session, record_id, payload, *, commit=True):
|
||||
return object()
|
||||
|
||||
monkeypatch.setattr("g_sheet.decorators.FormData.fetch_resume_links", fake_links)
|
||||
monkeypatch.setattr("g_sheet.decorators.extract_one_resume", fake_extract)
|
||||
monkeypatch.setattr("g_sheet.decorators.FormData.set_extracted_data", fake_set)
|
||||
|
||||
stats = await ingest_form_resume_links(object(), "tab", "creds", temp_root=tmp_path)
|
||||
assert stats["extracted"] == 1
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
async def test_ingest_rolls_back_after_persist_failure(monkeypatch, tmp_path):
|
||||
from g_sheet.decorators import ingest_form_resume_links
|
||||
|
||||
class Session:
|
||||
def __init__(self):
|
||||
self.rolled = 0
|
||||
|
||||
async def rollback(self):
|
||||
self.rolled += 1
|
||||
|
||||
async def fake_links(session, sheet):
|
||||
return [("id-1", "https://drive.google.com/open?id=abc")]
|
||||
|
||||
async def fake_extract(credentials, resume_link, dest_dir, max_chars=None, max_bytes=None):
|
||||
return build_extracted_data(
|
||||
status="completed",
|
||||
resume_link=resume_link,
|
||||
file_id="abc",
|
||||
text="ok",
|
||||
page_count=1,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
async def fake_set(session, record_id, payload, *, commit=True):
|
||||
raise RuntimeError("column type mismatch")
|
||||
|
||||
monkeypatch.setattr("g_sheet.decorators.FormData.fetch_resume_links", fake_links)
|
||||
monkeypatch.setattr("g_sheet.decorators.extract_one_resume", fake_extract)
|
||||
monkeypatch.setattr("g_sheet.decorators.FormData.set_extracted_data", fake_set)
|
||||
|
||||
session = Session()
|
||||
stats = await ingest_form_resume_links(session, "tab", "creds", temp_root=tmp_path)
|
||||
assert stats["extracted"] == 0
|
||||
assert stats["extract_failed"] == 1
|
||||
assert session.rolled == 1
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
"""Unit tests for form ATS scoring helpers. No OpenAI, no DB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from g_sheet.scoring import (
|
||||
enqueue_form_scores,
|
||||
resume_text_from_extracted,
|
||||
serialize_form_ats,
|
||||
_band,
|
||||
)
|
||||
|
||||
|
||||
def test_resume_text_from_extracted_requires_completed_text():
|
||||
assert resume_text_from_extracted(None) is None
|
||||
assert resume_text_from_extracted("x") is None
|
||||
assert resume_text_from_extracted({"status": "failed", "text": "Ada"}) is None
|
||||
assert resume_text_from_extracted({"status": "completed", "text": " "}) is None
|
||||
assert resume_text_from_extracted({"status": "completed", "text": " Ada Lovelace "}) == "Ada Lovelace"
|
||||
|
||||
|
||||
def test_band_thresholds():
|
||||
assert _band(None) == ""
|
||||
assert _band(50) == "Weak Match"
|
||||
assert _band(65) == "Potential Match"
|
||||
assert _band(82) == "Strong Match"
|
||||
|
||||
|
||||
def test_serialize_form_ats_shape():
|
||||
job_id = uuid.uuid4()
|
||||
|
||||
class Row:
|
||||
job_post_id = job_id
|
||||
overall_score = 81.0
|
||||
band = "Potential Match"
|
||||
computed_at = None
|
||||
|
||||
assert serialize_form_ats(Row()) == {
|
||||
"job_post_id": str(job_id),
|
||||
"overall_score": 81.0,
|
||||
"band": "Potential Match",
|
||||
"computed_at": None,
|
||||
}
|
||||
|
||||
|
||||
async def test_enqueue_form_scores_queues_each_job(monkeypatch):
|
||||
queued = []
|
||||
|
||||
class Kicker:
|
||||
def with_labels(self, **kwargs):
|
||||
return self
|
||||
|
||||
async def kiq(self, form_id, job_id):
|
||||
queued.append((form_id, job_id))
|
||||
|
||||
class Task:
|
||||
def kicker(self):
|
||||
return Kicker()
|
||||
|
||||
monkeypatch.setattr("inbox.tasks.score_form_data", Task())
|
||||
form_id = uuid.uuid4()
|
||||
job_a = uuid.uuid4()
|
||||
job_b = uuid.uuid4()
|
||||
await enqueue_form_scores(form_id, [job_a, str(job_b), job_a, None])
|
||||
assert queued == [(str(form_id), str(job_a)), (str(form_id), str(job_b))]
|
||||
|
||||
|
||||
async def test_enqueue_form_row_scores_assigned_skips_suggested(monkeypatch):
|
||||
queued = []
|
||||
|
||||
class Kicker:
|
||||
def with_labels(self, **kwargs):
|
||||
return self
|
||||
|
||||
async def kiq(self, form_id, job_id):
|
||||
queued.append((form_id, job_id))
|
||||
|
||||
class Task:
|
||||
def kicker(self):
|
||||
return Kicker()
|
||||
|
||||
monkeypatch.setattr("inbox.tasks.score_form_data", Task())
|
||||
|
||||
class Row:
|
||||
id = uuid.uuid4()
|
||||
assigned_job_post_id = uuid.uuid4()
|
||||
job_post_id = None
|
||||
suggested_job_post_ids = [str(uuid.uuid4()), str(uuid.uuid4())]
|
||||
|
||||
from g_sheet.scoring import enqueue_form_row_scores
|
||||
await enqueue_form_row_scores(Row())
|
||||
assert queued == [(str(Row.id), str(Row.assigned_job_post_id))]
|
||||
|
||||
|
||||
async def test_enqueue_form_row_scores_suggested_when_unassigned(monkeypatch):
|
||||
queued = []
|
||||
|
||||
class Kicker:
|
||||
def with_labels(self, **kwargs):
|
||||
return self
|
||||
|
||||
async def kiq(self, form_id, job_id):
|
||||
queued.append((form_id, job_id))
|
||||
|
||||
class Task:
|
||||
def kicker(self):
|
||||
return Kicker()
|
||||
|
||||
monkeypatch.setattr("inbox.tasks.score_form_data", Task())
|
||||
job_a = uuid.uuid4()
|
||||
job_b = uuid.uuid4()
|
||||
|
||||
class Row:
|
||||
id = uuid.uuid4()
|
||||
assigned_job_post_id = None
|
||||
job_post_id = None
|
||||
suggested_job_post_ids = [str(job_a), str(job_b)]
|
||||
|
||||
from g_sheet.scoring import enqueue_form_row_scores
|
||||
row = Row()
|
||||
await enqueue_form_row_scores(row)
|
||||
assert queued == [(str(row.id), str(job_a)), (str(row.id), str(job_b))]
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
"""employment_agent.plugins parse_phone — stacked clamp_phone + prefer_extracted_phone."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from employment_agent.plugins import parse_phone, prefer_full_phone
|
||||
|
||||
|
||||
def _digits(value):
|
||||
return "".join(c for c in (value or "") if c.isdigit())
|
||||
|
||||
|
||||
def test_pk_local_keeps_all_eleven_digits():
|
||||
fields=parse_phone({},"Ali 0321-5551234 Engineer")
|
||||
assert _digits(fields["phone"])=="03215551234"
|
||||
|
||||
|
||||
def test_plus_ninety_two_keeps_last_group():
|
||||
fields=parse_phone({},"Phone: +92 333 123 4567")
|
||||
digits=_digits(fields["phone"])
|
||||
assert digits.endswith("1234567")
|
||||
assert len(digits)>=12
|
||||
|
||||
|
||||
def test_pdf_wrapped_last_three_digits_are_kept():
|
||||
fields=parse_phone({},"Mobile: 0300-1234\n567")
|
||||
assert _digits(fields["phone"])=="03001234567"
|
||||
|
||||
|
||||
def test_does_not_swallow_a_year_after_the_number():
|
||||
fields=parse_phone({},"0321-5551234 2018 — 2024 Engineer")
|
||||
assert _digits(fields["phone"])=="03215551234"
|
||||
|
||||
|
||||
def test_four_three_four_grouping_is_complete():
|
||||
fields=parse_phone({},"Cell: 0301 234 5678")
|
||||
assert _digits(fields["phone"])=="03012345678"
|
||||
|
||||
|
||||
def test_form_phone_without_resume_is_kept_when_complete():
|
||||
fields=parse_phone({"phone":"0321-5551234"},"")
|
||||
assert _digits(fields["phone"])=="03215551234"
|
||||
|
||||
|
||||
def test_prefer_full_phone_picks_the_longer_complete_number():
|
||||
assert prefer_full_phone("0300-1234","0300-1234567")=="0300-1234567"
|
||||
assert prefer_full_phone(None,"0321-5551234")=="0321-5551234"
|
||||
assert prefer_full_phone("0300-123",None) is None
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
"""Requisition-owner job/candidate scope — unit + live DB checks.
|
||||
|
||||
Run from the API container (PYTHONPATH=/app):
|
||||
|
||||
python tests/test_requisition_scope.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from job.candidate.views import owned_job_ids_for_candidate_scope
|
||||
from job.job_post.models import JobPosts
|
||||
from job.job_post.views import JobPost
|
||||
from role.models import Roles
|
||||
from users.models import Users
|
||||
from users.permissions import (
|
||||
scopes_to_own_requisitions,
|
||||
sees_all_candidates,
|
||||
is_hiring_manager,
|
||||
is_admin,
|
||||
)
|
||||
|
||||
|
||||
def _ok(name: str, cond: bool, extra: str = "") -> None:
|
||||
global failed
|
||||
if cond:
|
||||
print(f"ok {name}" + (f" {extra}" if extra else ""))
|
||||
else:
|
||||
failed += 1
|
||||
print(f"FAIL {name}" + (f" {extra}" if extra else ""))
|
||||
|
||||
|
||||
failed = 0
|
||||
|
||||
|
||||
def test_helper_unit() -> None:
|
||||
recruiter = {
|
||||
"id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
||||
"role_name": "recruiter",
|
||||
"permissions": ["candidates.view", "jobs.view"],
|
||||
}
|
||||
_ok("recruiter is not requisition-scoped", not scopes_to_own_requisitions(recruiter))
|
||||
_ok("recruiter does not see all candidates", not sees_all_candidates(recruiter))
|
||||
|
||||
custom = {
|
||||
**recruiter,
|
||||
"role_name": "AI_TEAM_MANAGER",
|
||||
"permissions": ["candidates.view", "jobs.view", "requisitions.create"],
|
||||
}
|
||||
_ok("requisitions.create alone does not scope jobs/candidates", not scopes_to_own_requisitions(custom))
|
||||
_ok("custom role is not hiring-manager portal", not is_hiring_manager(custom))
|
||||
_ok("custom role is not admin", not is_admin(custom))
|
||||
_ok("custom role does not see all candidates", not sees_all_candidates(custom))
|
||||
|
||||
configured = {
|
||||
**custom,
|
||||
"permissions": ["candidates.view", "jobs.view", "requisitions.create", "requisitions.configure"],
|
||||
}
|
||||
_ok("Access Control requisitions.configure enables the scope", scopes_to_own_requisitions(configured))
|
||||
|
||||
manage = {
|
||||
**custom,
|
||||
"permissions": ["candidates.view", "candidates.manage", "requisitions.configure"],
|
||||
}
|
||||
_ok(
|
||||
"candidates.manage wins over requisitions.configure",
|
||||
not scopes_to_own_requisitions(manage) and sees_all_candidates(manage),
|
||||
)
|
||||
|
||||
admin = {
|
||||
"id": "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
||||
"role_name": "admin",
|
||||
"permissions": ["requisitions.create", "requisitions.manage"],
|
||||
}
|
||||
_ok("admin is not requisition-scoped", not scopes_to_own_requisitions(admin) and is_admin(admin))
|
||||
|
||||
hm = {
|
||||
"id": "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
||||
"role_name": "hiring_manager",
|
||||
"permissions": ["candidates.view"],
|
||||
}
|
||||
_ok("hiring_manager is requisition-scoped", scopes_to_own_requisitions(hm) and is_hiring_manager(hm))
|
||||
|
||||
manager_named = {**hm, "role_name": "Manager"}
|
||||
_ok("Manager role name is requisition-scoped", scopes_to_own_requisitions(manager_named))
|
||||
|
||||
req_manage = {
|
||||
"id": "dddddddd-dddd-dddd-dddd-dddddddddddd",
|
||||
"role_name": "ops_lead",
|
||||
"permissions": ["requisitions.manage", "candidates.view"],
|
||||
}
|
||||
_ok(
|
||||
"requisitions.manage is admin, not this scope",
|
||||
is_admin(req_manage) and not scopes_to_own_requisitions(req_manage),
|
||||
)
|
||||
|
||||
|
||||
async def test_live_db() -> None:
|
||||
from candidate_forms.models import Requisition
|
||||
from db_setup import session_scope
|
||||
from inbox.models import Inbox_Messages
|
||||
from job.candidate.views import CandidateView
|
||||
|
||||
async with session_scope() as session:
|
||||
# Job opened from a requisition this user created, but job_posts.created_by
|
||||
# is someone else: recruiter/creator scope hides it; requisition scope
|
||||
# must still show it (recruiter assignment on the job is irrelevant).
|
||||
stmt = (
|
||||
select(JobPosts, Requisition)
|
||||
.join(Requisition, JobPosts.requisition_id == Requisition.id)
|
||||
.where(
|
||||
JobPosts.is_deleted == False, # noqa: E712
|
||||
Requisition.is_deleted == False, # noqa: E712
|
||||
Requisition.created_by != JobPosts.created_by,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
row = (await session.execute(stmt)).first()
|
||||
if not row:
|
||||
print("skip live job whose requisition creator is not job_posts.created_by")
|
||||
else:
|
||||
job, req = row
|
||||
owner = {
|
||||
"id": str(req.created_by),
|
||||
"role_name": "AI_TEAM_MANAGER",
|
||||
"permissions": ["candidates.view", "jobs.view", "requisitions.configure"],
|
||||
}
|
||||
recruiter_only = {
|
||||
"id": str(req.created_by),
|
||||
"role_name": "recruiter",
|
||||
"permissions": ["candidates.view", "jobs.view"],
|
||||
}
|
||||
manage_all = {
|
||||
"id": str(req.created_by),
|
||||
"role_name": "AI_TEAM_MANAGER",
|
||||
"permissions": ["candidates.view", "candidates.manage"],
|
||||
}
|
||||
owned = await owned_job_ids_for_candidate_scope(session, owner)
|
||||
creator = await owned_job_ids_for_candidate_scope(session, recruiter_only)
|
||||
unscoped = await owned_job_ids_for_candidate_scope(session, manage_all)
|
||||
_ok(
|
||||
"requisition owner sees job they did not create as job_posts.created_by",
|
||||
job.id in set(owned or []),
|
||||
f"job={job.title!r} req_owner={req.created_by} job_created_by={job.created_by} recruiter={job.current_recruiter_id}",
|
||||
)
|
||||
_ok(
|
||||
"without requisitions.create, recruiter/creator filter hides that job",
|
||||
job.id not in set(creator or []),
|
||||
)
|
||||
_ok("candidates.manage leaves the list unscoped", unscoped is None)
|
||||
|
||||
applicants = await Inbox_Messages.counts_by_job_post_ids(session, [job.id])
|
||||
n = applicants.get(str(job.id), 0)
|
||||
print(f"info applicants on that job: {n}")
|
||||
|
||||
service = JobPost(session)
|
||||
jobs_rows, jobs_total = await service.fetch_jobs(
|
||||
active_only=False, top=500, skip=0, current_user=owner
|
||||
)
|
||||
job_ids = {str(r.get("id") or "") for r in jobs_rows}
|
||||
_ok(
|
||||
"/jobs/fetch for requisition owner includes the job",
|
||||
str(job.id) in job_ids,
|
||||
f"total={jobs_total}",
|
||||
)
|
||||
_, all_total = await service.fetch_jobs(
|
||||
active_only=False, top=500, skip=0, current_user=manage_all
|
||||
)
|
||||
_ok(
|
||||
"candidates.manage /jobs/fetch is wider than requisition scope",
|
||||
all_total > jobs_total,
|
||||
f"scoped={jobs_total} unscoped={all_total}",
|
||||
)
|
||||
|
||||
cand_view = CandidateView(session)
|
||||
scoped_rows = await cand_view.get_candidate(
|
||||
current_user=owner, limit=100, offset=0
|
||||
)
|
||||
all_cand = await cand_view.get_candidate(
|
||||
current_user=manage_all, limit=100, offset=0
|
||||
)
|
||||
scoped_n = len(scoped_rows) if isinstance(scoped_rows, list) else 0
|
||||
all_n = len(all_cand) if isinstance(all_cand, list) else 0
|
||||
_ok(
|
||||
"candidate list for requisition owner is non-empty when the job has applicants",
|
||||
(n == 0) or scoped_n > 0,
|
||||
f"scoped_candidates={scoped_n}",
|
||||
)
|
||||
_ok(
|
||||
"candidates.manage sees at least the requisition-scoped rows",
|
||||
all_n >= scoped_n,
|
||||
f"scoped={scoped_n} unscoped={all_n}",
|
||||
)
|
||||
|
||||
user = await Users.get_user_by_email(session, "new@utopiabrands.com")
|
||||
if not user:
|
||||
print("skip live new@utopiabrands.com (user missing)")
|
||||
return
|
||||
tags = await Roles.resolve_tags(session, user.role)
|
||||
role = getattr(user, "role", None)
|
||||
live = {
|
||||
"id": str(user.id),
|
||||
"role_name": getattr(role.role_name, "value", role.role_name) if role is not None else None,
|
||||
"permissions": list(tags),
|
||||
}
|
||||
print(
|
||||
f"info live user {user.email} role={live['role_name']!r} "
|
||||
f"requisitions.create={'requisitions.create' in tags} "
|
||||
f"requisitions.configure={'requisitions.configure' in tags} "
|
||||
f"candidates.manage={'candidates.manage' in tags}"
|
||||
)
|
||||
_ok(
|
||||
"live AI Team Manager is not hiring-manager portal",
|
||||
not is_hiring_manager(live),
|
||||
f"role={live['role_name']!r}",
|
||||
)
|
||||
scoped = scopes_to_own_requisitions(live)
|
||||
print(f"info scopes_to_own_requisitions={scoped}")
|
||||
owned = await owned_job_ids_for_candidate_scope(session, live)
|
||||
if owned is None:
|
||||
print("info live candidate list is unscoped (admin or candidates.manage)")
|
||||
else:
|
||||
print(f"info live owned job ids: {len(owned)}")
|
||||
if "requisitions.configure" not in tags and not scoped:
|
||||
print(
|
||||
"info tick Requisitions → Configure on this role in Access Control "
|
||||
"to enable requisition-owner job/candidate scope"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
test_helper_unit()
|
||||
asyncio.run(test_live_db())
|
||||
if failed:
|
||||
print(f"\n{failed} failed")
|
||||
return 1
|
||||
print("\nall passed")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -234,6 +234,7 @@ services:
|
|||
"taskiq_management.broker_setup:broker",
|
||||
"inbox.tasks",
|
||||
"inbox.sync_tasks",
|
||||
"cron_schdule.tasks",
|
||||
"taskiq_management.tasks",
|
||||
"--workers",
|
||||
"1",
|
||||
|
|
@ -253,6 +254,7 @@ services:
|
|||
"scheduler",
|
||||
"taskiq_management.broker_setup:scheduler",
|
||||
"inbox.sync_tasks",
|
||||
"cron_schdule.tasks",
|
||||
]
|
||||
environment:
|
||||
<<: *backend-env
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const SOURCE_LABEL = {
|
|||
manual: 'Manual',
|
||||
form: 'Form',
|
||||
ats: 'ATS',
|
||||
filtered: 'Email',
|
||||
}
|
||||
|
||||
const STAGE_BADGE = {
|
||||
|
|
@ -19,9 +20,18 @@ const STAGE_BADGE = {
|
|||
Hired: 'b-green',
|
||||
'On Hold': 'b-amber',
|
||||
Rejected: 'b-gray',
|
||||
'Rejected — wrong format': 'b-red',
|
||||
'No job assigned': 'b-gray',
|
||||
}
|
||||
|
||||
export function applicationStatusLabel(status) {
|
||||
export function applicationStatusLabel(status, item) {
|
||||
if (item?.rejection_reason === 'wrong_format' || String(status || '').toUpperCase() === 'WRONG_FORMAT') {
|
||||
return 'Rejected — wrong format'
|
||||
}
|
||||
const assigned = Boolean(item?.job_post_id || item?.jobPostId || (item?.source === 'form' && (item.job_title || item.jobTitle)))
|
||||
if (!assigned && (status == null || status === '' || String(status).toUpperCase() === 'CLOSED')) {
|
||||
return 'No job assigned'
|
||||
}
|
||||
if (status == null || status === '') return 'Shortlist'
|
||||
const key = String(status).toUpperCase()
|
||||
if (STAGE_FROM_STATUS[key]) return STAGE_FROM_STATUS[key]
|
||||
|
|
@ -44,25 +54,32 @@ export function previousApplicationsOf(row) {
|
|||
return []
|
||||
}
|
||||
|
||||
function hasAssignedJob(item) {
|
||||
if (!item) return false
|
||||
if (item.job_post_id || item.jobPostId) return true
|
||||
return item.source === 'form' && Boolean(item.job_title || item.jobTitle)
|
||||
}
|
||||
|
||||
export function isReapplicant(row) {
|
||||
if (!row) return false
|
||||
if (row.isReapplicant === true || row.is_reapplicant === true) return true
|
||||
return previousApplicationsOf(row).length > 0
|
||||
const previous = previousApplicationsOf(row)
|
||||
if (previous.length) return previous.some(hasAssignedJob)
|
||||
return row.isReapplicant === true || row.is_reapplicant === true
|
||||
}
|
||||
|
||||
export function previousApplicationsTip(row) {
|
||||
const items = previousApplicationsOf(row)
|
||||
if (!items.length) return 'Applied before'
|
||||
return items.map((item) => {
|
||||
const job = item.job_title || item.jobTitle || 'Unassigned job'
|
||||
return `${job} — ${applicationStatusLabel(item.status)}`
|
||||
const job = item.job_title || item.jobTitle || 'No job assigned'
|
||||
return `${job} — ${applicationStatusLabel(item.status, item)}`
|
||||
}).join('\n')
|
||||
}
|
||||
|
||||
/** Compact chip for tables, kanban cards, and inbox rows. */
|
||||
export function ReappliedBadge({ row, className = '' }) {
|
||||
if (!isReapplicant(row)) return null
|
||||
const count = previousApplicationsOf(row).length
|
||||
const count = previousApplicationsOf(row).filter(hasAssignedJob).length
|
||||
return (
|
||||
<span
|
||||
className={`badge b-amber badge-plain ${className}`.trim()}
|
||||
|
|
@ -102,7 +119,7 @@ export function PreviousApplications({ row, title = 'Previous applications' }) {
|
|||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{items.map((item, idx) => {
|
||||
const stage = applicationStatusLabel(item.status)
|
||||
const stage = applicationStatusLabel(item.status, item)
|
||||
const key = [
|
||||
item.source,
|
||||
item.inbox_id,
|
||||
|
|
|
|||
Loading…
Reference in New Issue