cronjjob implementedd

pull/75/head
ahmed.mujtaba 2026-09-07 14:56:03 +05:00
parent 53b1d4d238
commit 82686f0bdf
16 changed files with 1067 additions and 46 deletions

View File

@ -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

View File

@ -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()

View File

@ -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}

View File

@ -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)

View File

@ -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:

View File

@ -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,
}

View File

@ -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"]))
pid=payload.get("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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()) == []

View File

@ -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))]

View File

@ -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

View File

@ -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())

View File

@ -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

View File

@ -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,