filter appliede
CI / checks (push) Failing after 1m57s Details
CI / checks (pull_request) Failing after 1m53s Details

pull/79/head
ahmed.mujtaba 2026-09-07 18:42:15 +05:00
parent 9036a48098
commit d00ebcf6f5
13 changed files with 394 additions and 94 deletions

View File

@ -209,6 +209,8 @@ async def fetch_form_data(
has_linkedin: bool | None = Query(None),
has_resume: bool | None = Query(None),
city: str | None = Query(None),
source: str | None = Query(None),
assigned: bool | None = Query(None),
no_suggestions: bool | None = Query(None),
offset: int = Query(0,ge=0),
# Caller-chosen page size (Inbox sends 10/25/50/100). None = unpaged.
@ -222,7 +224,8 @@ async def fetch_form_data(
sheet=sheet,search=search,offset=offset,limit=limit,
processing_state=processing_state,is_duplicate=is_duplicate,
has_linkedin=has_linkedin,has_resume=has_resume,
city=_city_values(city),no_suggestions=no_suggestions,
city=_city_values(city),source=(source or "").strip() or None,
assigned=assigned,no_suggestions=no_suggestions,
)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException:
@ -242,6 +245,8 @@ async def fetch_form_data_counts(
has_linkedin: bool | None = Query(None),
has_resume: bool | None = Query(None),
city: str | None = Query(None),
source: str | None = Query(None),
assigned: bool | None = Query(None),
current_user: dict = Depends(_FORM_DATA_READ),
session: AsyncSession = Depends(get_session),
):
@ -249,7 +254,7 @@ async def fetch_form_data_counts(
service=SheetFormData(session=session)
data=await service.get_counts(
sheet=sheet,search=search,has_linkedin=has_linkedin,has_resume=has_resume,
city=_city_values(city),
city=_city_values(city),source=(source or "").strip() or None,assigned=assigned,
)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
except HTTPException:

View File

@ -129,7 +129,8 @@ class FormData(SQLModel, table=True):
@classmethod
def _filters(
cls, *, sheet=None, search=None, processing_state=None, is_duplicate=None,
has_linkedin=None, has_resume=None, city=None, no_suggestions=None, inbox_filter=None,
has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
no_suggestions=None, inbox_filter=None,
):
filters = []
if sheet:
@ -161,6 +162,16 @@ class FormData(SQLModel, table=True):
if cities:
city_col = func.lower(func.coalesce(cls.city, cls.residing_city))
filters.append(city_col.in_([c.lower() for c in cities]))
if source:
text = source.strip()
lowered = text.lower()
if lowered not in ("google sheet", "google_sheet", "sheet"):
filters.append(cls.source_of_application.ilike(f"%{text}%"))
if assigned is True:
filters.append(or_(cls.assigned_job_post_id.is_not(None), cls.job_post_id.is_not(None)))
elif assigned is False:
filters.append(cls.assigned_job_post_id.is_(None))
filters.append(cls.job_post_id.is_(None))
if no_suggestions is True:
filters.append(cls._no_suggested_jobs())
if inbox_filter == "matched":
@ -346,7 +357,8 @@ class FormData(SQLModel, table=True):
async def fetch_form_data(
cls, session: AsyncSession, *, sheet=None, search=None,
processing_state=None, is_duplicate=None, has_linkedin=None,
has_resume=None, city=None, no_suggestions=None, inbox_filter=None,
has_resume=None, city=None, source=None, assigned=None,
no_suggestions=None, inbox_filter=None,
offset=0, limit=None,
):
statement = select(cls).order_by(cls.created_at.desc(), cls.id.desc())
@ -354,7 +366,8 @@ class FormData(SQLModel, table=True):
sheet=sheet, search=search,
processing_state=processing_state, is_duplicate=is_duplicate,
has_linkedin=has_linkedin, has_resume=has_resume,
city=city, no_suggestions=no_suggestions, inbox_filter=inbox_filter,
city=city, source=source, assigned=assigned,
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
):
statement = statement.where(clause)
if offset:
@ -441,14 +454,16 @@ class FormData(SQLModel, table=True):
async def count_form_data(
cls, session: AsyncSession, *, sheet=None, search=None,
processing_state=None, is_duplicate=None, has_linkedin=None,
has_resume=None, city=None, no_suggestions=None, inbox_filter=None,
has_resume=None, city=None, source=None, assigned=None,
no_suggestions=None, inbox_filter=None,
):
statement = select(func.count()).select_from(cls)
for clause in cls._filters(
sheet=sheet, search=search,
processing_state=processing_state, is_duplicate=is_duplicate,
has_linkedin=has_linkedin, has_resume=has_resume,
city=city, no_suggestions=no_suggestions, inbox_filter=inbox_filter,
city=city, source=source, assigned=assigned,
no_suggestions=no_suggestions, inbox_filter=inbox_filter,
):
statement = statement.where(clause)
result = await session.execute(statement)
@ -457,7 +472,7 @@ class FormData(SQLModel, table=True):
@classmethod
async def count_processing(
cls, session: AsyncSession, *, sheet=None, search=None,
has_linkedin=None, has_resume=None, city=None,
has_linkedin=None, has_resume=None, city=None, source=None, assigned=None,
):
"""Tab badge counts for the Sheet Forms channel.
@ -483,6 +498,7 @@ class FormData(SQLModel, table=True):
for clause in cls._filters(
sheet=sheet, search=search,
has_linkedin=has_linkedin, has_resume=has_resume, city=city,
source=source, assigned=assigned,
):
statement = statement.where(clause)
row = (await session.execute(statement)).one()
@ -512,6 +528,16 @@ class FormData(SQLModel, table=True):
)
return [text.strip() for text in result.scalars().all() if (text or "").strip()]
@classmethod
async def distinct_sources(cls, session: AsyncSession):
"""Non-blank source_of_application values. Distinct only within form_data."""
result = await session.execute(
select(cls.source_of_application)
.where(cls.source_of_application.is_not(None), cls.source_of_application != "")
.distinct()
)
return [text.strip() for text in result.scalars().all() if (text or "").strip()]
@classmethod
async def delete_by_sheet(cls, session: AsyncSession, sheet: str, *, commit: bool = True):
count_result = await session.execute(

View File

@ -436,20 +436,20 @@ class SheetFormData(Sheet):
async def get_form_data(
self,sheet=None,search=None,offset=0,limit=None,
processing_state=None,is_duplicate=None,has_linkedin=None,has_resume=None,
city=None,no_suggestions=None,
city=None,source=None,assigned=None,no_suggestions=None,
):
session=self._require_session()
rows=await FormData.fetch_form_data(
session,sheet=sheet,search=search,offset=offset,limit=limit,
processing_state=processing_state,is_duplicate=is_duplicate,
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
no_suggestions=no_suggestions,
source=source,assigned=assigned,no_suggestions=no_suggestions,
)
total=await FormData.count_form_data(
session,sheet=sheet,search=search,
processing_state=processing_state,is_duplicate=is_duplicate,
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
no_suggestions=no_suggestions,
source=source,assigned=assigned,no_suggestions=no_suggestions,
)
items=await self._hydrate_job_posts([serialize_form_data(row) for row in rows])
from job.candidate.views import CandidateView
@ -600,10 +600,11 @@ class SheetFormData(Sheet):
raise HTTPException(status_code=404,detail="Form data not found")
return await self.get_form_data_by_id(record_id)
async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None):
async def get_counts(self,sheet=None,search=None,has_linkedin=None,has_resume=None,city=None,source=None,assigned=None):
return await FormData.count_processing(
self._require_session(),sheet=sheet,search=search,
has_linkedin=has_linkedin,has_resume=has_resume,city=city,
source=source,assigned=assigned,
)
async def count_rows(self,sheet=None,search=None,has_linkedin=None,has_resume=None):

View File

@ -26,10 +26,12 @@ def _city_values(city: str | None):
return parts or None
def _apps_payload(items,total,cities=None):
def _apps_payload(items,total,cities=None,sources=None):
body={"data":items,"total":total,"status_code":200}
if cities is not None:
body["cities"]=cities
if sources is not None:
body["sources"]=sources
return JSONResponse(content=body)
@ -97,6 +99,8 @@ class ReadAllBody(BaseModel):
is_duplicate: bool | None = None
no_suggestions: bool | None = None
processing_state: str | None = None
city: str | None = None
source: str | None = None
class TriageOverrideBody(BaseModel):
@ -303,6 +307,8 @@ async def mark_all_inbox_read(
is_duplicate=payload.is_duplicate,
no_suggestions=payload.no_suggestions,
processing_state=payload.processing_state,
city=_city_values(payload.city),
source=(payload.source or "").strip() or None,
)
return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200})
except HTTPException:
@ -338,6 +344,7 @@ async def get_all_applications(
processing_state: str | None = Query(default=None),
search: str | None = Query(None),
city: str | None = Query(None),
source: str | None = Query(None),
city_list: bool = Query(default=False),
# Caller-chosen page size (Inbox / Job Matching send 10/25/50/100). None = unpaged.
top: int | None = Query(None, ge=1, le=500),
@ -348,23 +355,25 @@ async def get_all_applications(
try:
service=Email(session=session)
city_values=_city_values(city)
source_value=(source or "").strip() or None
cities=await service.list_cities() if city_list else None
sources=await service.list_sources() if city_list else None
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values)
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values)
return _apps_payload(items,total,cities)
items=await service.get_all_applications(top, skip, search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
total=await service.count_inbox_messages(search, application_status=application_status, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
return _apps_payload(items,total,cities,sources)
if isread==False:
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values)
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values)
return _apps_payload(items,total,cities)
items=await service.get_all_applications(top, skip, search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
total=await service.count_inbox_messages(search, isread=False, assigned=assigned, is_duplicate=is_duplicate, no_suggestions=no_suggestions, processing_state=processing_state, city=city_values, source=source_value)
return _apps_payload(items,total,cities,sources)
if record_id:
item=await service.get_application_by_id(record_id)
return _apps_payload(item,1,cities)
return _apps_payload(item,1,cities,sources)
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values)
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values)
return _apps_payload(items,total,cities)
items=await service.get_all_applications(top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value)
total=await service.count_inbox_messages(search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city_values,source=source_value)
return _apps_payload(items,total,cities,sources)
except HTTPException:
raise
except Exception as e:

View File

@ -9,7 +9,7 @@ from dotenv import load_dotenv
from fastapi import HTTPException
from inbox.enums import Candidate_application_Status
from role.models import EnumRoles, Roles
from sqlalchemy import Column, DateTime, case, func, or_, update
from sqlalchemy import Column, DateTime, case, false, func, or_, update
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@ -1031,6 +1031,7 @@ class Inbox_Messages(SQLModel, table=True):
no_suggestions: bool | None=None,
processing_state: str | None=None,
city=None,
source: str | None=None,
inbox_filter: str | None=None,
):
"""The one WHERE chain shared by the list, the count and the bulk read UPDATE.
@ -1061,6 +1062,23 @@ class Inbox_Messages(SQLModel, table=True):
cities = [c.strip() for c in (city or []) if (c or "").strip()]
if cities:
statement = statement.where(func.lower(cls.city).in_([c.lower() for c in cities]))
if source:
text = source.strip()
lowered = text.lower()
if lowered in ("google sheet", "google_sheet", "sheet"):
statement = statement.where(false())
else:
key = lowered.replace(" ", "_")
channel_ids = select(SourceChannels.id).where(
or_(
func.lower(SourceChannels.label) == lowered,
SourceChannels.key == key,
)
)
statement = statement.where(or_(
cls.source_channel_id.in_(channel_ids),
cls.message_to.ilike(f"%{text}%"),
))
if inbox_filter == "matched":
statement = statement.where(cls.match_status == "matched")
elif inbox_filter == "unassigned":
@ -1076,12 +1094,12 @@ class Inbox_Messages(SQLModel, table=True):
@classmethod
async def get_inbox_messages(
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, light: bool=False
cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None, light: bool=False
):
statement = cls._apply_filters(
select(cls).order_by(cls.created_at.desc(), cls.id.desc()),
search, isread, application_status, assigned, is_duplicate,
no_suggestions, processing_state, city,
no_suggestions, processing_state, city, source,
)
if skip:
statement = statement.offset(skip)
@ -1162,11 +1180,11 @@ class Inbox_Messages(SQLModel, table=True):
return {str(job_id): int(n) for job_id, n in result.all()}
@classmethod
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None):
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None, is_duplicate: bool | None=None, no_suggestions: bool | None=None, processing_state: str | None=None, city=None, source=None):
statement = cls._apply_filters(
select(func.count()).select_from(cls),
search, isread, application_status, assigned, is_duplicate,
no_suggestions, processing_state, city,
no_suggestions, processing_state, city, source,
)
result = await session.execute(statement)
return result.scalar_one()
@ -1283,6 +1301,8 @@ class Inbox_Messages(SQLModel, table=True):
is_duplicate: bool | None=None,
processing_state: str | None=None,
no_suggestions: bool | None=None,
city=None,
source: str | None=None,
) -> int:
"""Mark every row matching a list filter. Returns rows actually CHANGED.
@ -1291,7 +1311,10 @@ class Inbox_Messages(SQLModel, table=True):
was already read. It also keeps read_overridden_at off rows nobody decided
anything about, so the Outlook sweep keeps its reach over untouched mail.
"""
statement=cls._apply_filters(update(cls),search,isread,application_status,assigned,is_duplicate,no_suggestions,processing_state)
statement=cls._apply_filters(
update(cls),search,isread,application_status,assigned,is_duplicate,
no_suggestions,processing_state,city,source,
)
statement=statement.where(cls.message_read!=bool(read))
result=await session.execute(
statement.values(message_read=bool(read),read_overridden_at=_now())
@ -1834,6 +1857,49 @@ class AtsResults(SQLModel, table=True):
)
return result.scalars().first()
@classmethod
async def get_latest_by_job_for_inbox(cls, session: AsyncSession, inbox_id):
"""Newest score per job_post_id for one inbox row (current or superseded)."""
if inbox_id is None:
return []
result = await session.execute(
select(cls)
.where(cls.inbox_id == int(inbox_id))
.order_by(cls.computed_at.desc())
)
by_job = {}
for row in result.scalars():
jid = str(row.job_post_id) if row.job_post_id else None
if jid and jid not in by_job:
by_job[jid] = row
return list(by_job.values())
@classmethod
async def get_latest_by_job_for_messages(cls, session: AsyncSession, message_ids) -> dict:
"""{str(message_id): [newest score per job]} for a page of inbox messages."""
keys = []
for raw in message_ids or []:
uid = cls._as_uuid(raw)
if uid is not None:
keys.append(uid)
if not keys:
return {}
result = await session.execute(
select(cls, Inbox.message_id)
.join(Inbox, cls.inbox_id == Inbox.id)
.where(Inbox.message_id.in_(keys))
.order_by(cls.computed_at.desc())
)
grouped = {}
for row, mid in result.all():
if mid is None:
continue
bucket = grouped.setdefault(mid, {})
jid = str(row.job_post_id) if row.job_post_id else None
if jid and jid not in bucket:
bucket[jid] = row
return {str(mid): list(jobs.values()) for mid, jobs in grouped.items()}
@classmethod
async def get_for_form_job(cls, session: AsyncSession, form_data_id, job_post_id):
"""Any score for this form_data row against this job — current or superseded."""

View File

@ -82,6 +82,18 @@ def serialize_message(message: Inbox_Messages, *, linkedin_url=None) -> dict:
"match_error": message.match_error,
"matched_at": message.matched_at.isoformat() if message.matched_at else None,
"resume_text": message.resume_text,
"ats_score": message.ats_score,
"ats_band": message.ats_band or None,
}
def serialize_ats_result(row) -> dict:
"""One inbox ats_results row — same keys the Sheet Forms cards paint."""
return {
"job_post_id": str(row.job_post_id) if row.job_post_id else None,
"overall_score": row.overall_score,
"band": row.band or None,
"computed_at": row.computed_at.isoformat() if row.computed_at else None,
}

View File

@ -39,6 +39,8 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
except ValueError:
raise PermanentTaskError("record_id and job_id must be uuids")
already=False
results=[]
async with session_scope() as session:
link=await Inbox.get_inbox_by_message_id(session,mid)
if link is not None:
@ -46,7 +48,8 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
# a user, so already_scored must not depend on it.
existing=await AtsResults.get_for_inbox_job(session,link.id,jid)
if existing is not None:
return {"status":"already_scored"}
already=True
if not already:
job=await JobPosts.get_job_post_by_id(session,job_id)
if job is None or job.is_deleted:
raise PermanentTaskError("job post missing or deleted")
@ -59,9 +62,51 @@ async def score_message_against_job(record_id:str,job_id:str) -> dict:
# 400/404 from score_inbox are permanent (no attachment, bad ids);
# retrying cannot fix them.
raise PermanentTaskError(str(exc.detail)) from exc
if already:
# Assigning a job already scored as a suggestion must still flip the
# denormed chip from max-of-suggestions to that job.
await denorm_message_ats_score(record_id)
return {"status":"already_scored"}
await denorm_message_ats_score(record_id)
return {"status":"scored","results":len(results)}
async def denorm_message_ats_score(record_id:str) -> None:
"""Stamp inbox_messages.ats_score: assigned job if set, else max of suggestions."""
async with session_scope() as session:
msg=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
if msg is None:
return
link=await Inbox.get_inbox_by_message_id(session,record_id)
if link is None:
return
rows=await AtsResults.get_latest_by_job_for_inbox(session,link.id)
if not rows:
return
chosen=None
assigned=msg.assigned_job_post_id
if assigned:
chosen=next((r for r in rows if str(r.job_post_id)==str(assigned)),None)
if chosen is None:
chosen=max(rows,key=lambda r:float(r.overall_score or 0))
await Inbox_Messages.set_ats_score(session,record_id,chosen.overall_score,chosen.band)
async def score_message_against_jobs(record_id:str,job_ids) -> None:
"""Score one inbox CV against each job. Failures do not abort the rest."""
seen=set()
for raw in job_ids or []:
job_id=str(raw or "").strip()
if not job_id or job_id in seen:
continue
seen.add(job_id)
try:
outcome=await score_message_against_job(record_id,job_id)
logger.info("ats auto-score %s vs %s: %s",record_id,job_id,outcome.get("status"))
except Exception as exc:
logger.warning("ats auto-score failed for %s vs %s: %s",record_id,job_id,exc)
@broker.task(
task_name="g_sheet.score_form",
retry_on_error=True,
@ -166,23 +211,18 @@ async def match_inbox_message(record_id:str,force:bool=False) -> dict:
status=status,
error=result.get("error") or "",
)
# Auto-score: the match just paired this CV with jobs, so run the ATS on the
# spot — assigned job first, else the agent's top suggestion. Scoring failures
# must not fail the match; the match result is already committed above.
# Auto-score every suggested job. The list chip is the max until a recruiter
# assigns one; assignment then re-runs ATS against that job_post_id.
# Scoring failures must not fail the match; the match result is committed above.
suggested=[str(j) for j in (result.get("suggested_job_post_ids") or []) if j]
score_job_id=None
score_job_ids=list(suggested)
async with session_scope() as session:
fresh=await Inbox_Messages.get_inbox_message_by_id(session,record_id)
if fresh is not None and fresh.assigned_job_post_id:
score_job_id=str(fresh.assigned_job_post_id)
if score_job_id is None and suggested:
score_job_id=suggested[0]
if score_job_id:
try:
outcome=await score_message_against_job(record_id,score_job_id)
logger.info("ats auto-score %s vs %s: %s",record_id,score_job_id,outcome.get("status"))
except Exception as exc:
logger.warning("ats auto-score failed for %s vs %s: %s",record_id,score_job_id,exc)
assigned=str(fresh.assigned_job_post_id)
if assigned not in score_job_ids:
score_job_ids.append(assigned)
await score_message_against_jobs(record_id,score_job_ids)
return {
"status":status,

View File

@ -4,9 +4,9 @@ import uuid
import httpx,os
from fastapi import HTTPException
from inbox.enums import Candidate_application_Status
from inbox.models import Inbox_Messages,Inbox_Message_Triage,MailboxSyncRun,Inbox
from inbox.models import Inbox_Messages,Inbox_Message_Triage,MailboxSyncRun,Inbox,SourceChannels,AtsResults
from inbox.file_decoder import extract_pdf_attachments
from inbox.serializers import serialize_application, serialize_message, serialize_triage, serialize_mailbox_sync_run
from inbox.serializers import serialize_application, serialize_message, serialize_triage, serialize_mailbox_sync_run, serialize_ats_result
from inbox.plugins import (
EMAIL_API_TOKEN,
attach_email_pdfs_to_s3,
@ -272,15 +272,16 @@ class Email:
item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id)
else:
item["assigned_job_post"]=None
return await cv.attach_application_history(item)
items=await self._paint_inbox_ats([item])
return await cv.attach_application_history(items[0])
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None):
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None):
if application_status in (Candidate_application_Status.PROCESS, Candidate_application_Status.REJECTED, Candidate_application_Status.SCREENING, Candidate_application_Status.ASSESSMENT, Candidate_application_Status.INTERVIEW, Candidate_application_Status.OFFER, Candidate_application_Status.HIRED) or processing_state:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True)
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
elif isread==False:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True)
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
else:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,light=True)
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source,light=True)
urls=await Inbox.linkedin_urls_by_message_ids(self.session,[m.id for m in messages])
items=[serialize_application(m,linkedin_url=urls.get(m.id),light=True) for m in messages]
items=await self._attach_job_posts(items)
@ -323,6 +324,46 @@ class Email:
else:
suggested.append(dict(payload))
item["suggested_job_posts"]=suggested
items=await self._paint_inbox_ats(items)
return items
async def _paint_inbox_ats(self,items):
"""Attach per-job ATS scores onto suggested/assigned posts and stamp max.
Unassigned rows show the highest suggestion score; assigned rows show
the score against assigned_job_post_id same rule as Sheet Forms.
"""
if not items:
return items
by_msg=await AtsResults.get_latest_by_job_for_messages(
self.session,[item.get("id") for item in items],
)
for item in items:
rows=by_msg.get(str(item.get("id") or "")) or []
scores=[serialize_ats_result(r) for r in rows]
item["ats_results"]=scores
score_by_job={
str(s["job_post_id"]):s for s in scores if s.get("job_post_id")
}
for post in item.get("suggested_job_posts") or []:
hit=score_by_job.get(str(post.get("id")))
if hit:
post["overall_score"]=hit.get("overall_score")
post["band"]=hit.get("band")
assigned=item.get("assigned_job_post")
if assigned:
hit=score_by_job.get(str(assigned.get("id")))
if hit:
assigned["overall_score"]=hit.get("overall_score")
assigned["band"]=hit.get("band")
aid=item.get("assigned_job_post_id")
assigned_score=score_by_job.get(str(aid)) if aid else None
if assigned_score and assigned_score.get("overall_score") is not None:
item["ats_score"]=round(float(assigned_score.get("overall_score")))
else:
nums=[s.get("overall_score") for s in scores if s.get("overall_score") is not None]
if nums:
item["ats_score"]=round(float(max(nums)))
return items
async def get_application_by_id(self,record_id):
@ -478,13 +519,13 @@ class Email:
results.append({"email":email,"sent":False})
return results
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None):
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,city=None,source=None):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED or processing_state:
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city)
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
elif isread==False:
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city)
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
else:
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city)
return await Inbox_Messages.count_inbox_messages(self.session,search,assigned=assigned,is_duplicate=is_duplicate,no_suggestions=no_suggestions,processing_state=processing_state,city=city,source=source)
async def list_cities(self):
"""Distinct cities from inbox_messages and form_data, merged in Python."""
@ -493,6 +534,15 @@ class Email:
forms=await FormData.distinct_cities(self.session)
return Reapplied(session=self.session).merge_cities(inbox,forms)
async def list_sources(self):
"""Source / platform labels: seeded channels, Google Sheet, form sources."""
from g_sheet.models import FormData
channels=await SourceChannels.list_active(self.session)
forms=await FormData.distinct_sources(self.session)
return Reapplied(session=self.session).merge_cities(
[c.label for c in channels],["Google Sheet"],forms,
)
async def assign_job_post(self,record_id,job_post_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message:
@ -550,7 +600,8 @@ class Email:
async def set_read_all(self,read,search=None,isread:bool=True,
application_status:Candidate_application_Status=Candidate_application_Status.CLOSED,
assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None):
assigned=None,is_duplicate=None,no_suggestions=None,processing_state=None,
city=None,source=None):
"""Mark every row the SAME filter set would have listed.
The filter arguments are the caller's current view, not a free-form query: the
@ -561,6 +612,7 @@ class Email:
self.session,read,search=search,isread=isread,
application_status=application_status,assigned=assigned,is_duplicate=is_duplicate,
no_suggestions=no_suggestions,processing_state=processing_state,
city=city,source=source,
)
logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s dup=%s",
updated,bool(read),isread,assigned,getattr(application_status,"value",application_status),is_duplicate)

View File

@ -20,7 +20,7 @@ export function listMessages() {
* `assigned` is tri-valued: omit for no filter, true for rows with an
* assigned_job_post_id, false for the Job Matching queue.
*/
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, cityList } = {}) {
export function listApplications({ search, top, skip, recordId, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source, cityList } = {}) {
return request('/inbox/all-applications', {
// `isread` is tri-valued on the wire: omit it for every tab (server defaults
// to true = no filter), send false for the Unread tab only. buildUrl drops
@ -31,8 +31,8 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
// `no_suggestions`: Inbox On-Hold tab — no suggested job post linked.
// `processing_state`: Processed / Rejected tabs (Move to Shortlist writes
// processed, not application_status PROCESS).
// `city`: optional comma-separated list. `city_list`: include merged distinct
// cities from inbox_messages and form_data on the same response.
// `city`: optional comma-separated list. `source`: channel / platform label.
// `city_list`: include merged distinct cities and sources on the same response.
params: {
search,
top,
@ -45,6 +45,7 @@ export function listApplications({ search, top, skip, recordId, isread, applicat
no_suggestions: noSuggestions,
processing_state: processingState,
city,
source,
city_list: cityList,
},
})
@ -128,7 +129,7 @@ export function bulkSetRead(recordIds, read) {
* Resolves to `{updated, read}`, where `updated` counts rows that actually
* CHANGED state, so it is safe to show in a toast.
*/
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState } = {}) {
export function setReadAll({ read, search, isread, applicationStatus, assigned, isDuplicate, noSuggestions, processingState, city, source } = {}) {
return request('/inbox/read-all', {
method: 'PATCH',
body: {
@ -140,6 +141,8 @@ export function setReadAll({ read, search, isread, applicationStatus, assigned,
is_duplicate: isDuplicate,
no_suggestions: noSuggestions,
processing_state: processingState,
city,
source,
},
})
}

View File

@ -24,12 +24,12 @@ export function listFormDataSheets() {
*/
export function listFormData({
sheet, search, offset = 0, limit, processing_state, is_duplicate,
hasLinkedin, hasResume, city, no_suggestions,
hasLinkedin, hasResume, city, source, assigned, no_suggestions,
} = {}) {
return request('/sheet/form-data/fetch', {
params: {
sheet, search, offset, limit, processing_state, is_duplicate,
has_linkedin: hasLinkedin, has_resume: hasResume, city, no_suggestions,
has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned, no_suggestions,
},
})
}
@ -47,9 +47,9 @@ export function countFormData({ sheet } = {}) {
* processing_state or is_duplicate: those two ARE the tabs, and passing them
* would make every badge report the tab the user is already on.
*/
export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city } = {}) {
export function fetchFormCounts({ sheet, search, hasLinkedin, hasResume, city, source, assigned } = {}) {
return request('/sheet/form-data/counts', {
params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city },
params: { sheet, search, has_linkedin: hasLinkedin, has_resume: hasResume, city, source, assigned },
})
}

View File

@ -11,6 +11,7 @@ export default function Sidebar({ collapsed, mobileOpen, onToggleCollapse, badge
// A group heading renders only if something under it survived the permission
// filter otherwise a low-privilege user sees orphaned section labels.
const visible = ROUTES.filter((r) => {
if (r.hidden) return false
if (!can(r.permission)) return false
if (isHiringManager(user) && !HIRING_MANAGER_NAV.has(r.path)) return false
return true

View File

@ -18,7 +18,7 @@ export const ROUTES = [
// --- Workspace ---
{ path: 'dashboard', title: 'Dashboard', icon: 'dashboard', group: 'Workspace', permission: 'dashboard.view' },
{ path: 'inbox', title: 'Recruitment Inbox', icon: 'inbox', group: 'Workspace', permission: 'inbox.view', badge: 'inbox' },
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching' },
{ path: 'matching', title: 'Job Matching', icon: 'target', group: 'Workspace', permission: 'candidates.view', badge: 'matching', hidden: true },
{ path: 'jobs', title: 'Jobs', icon: 'briefcase', group: 'Workspace', permission: 'jobs.view', badge: 'jobs' },
{ path: 'candidates', title: 'Candidates', icon: 'users', group: 'Workspace', permission: 'candidates.view' },
// Replaces Talent Pool. That screen browsed candidate accounts over a seed

View File

@ -72,7 +72,7 @@ const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS }
const SEARCH_DEBOUNCE_MS = 300
/** Inbox list filters. '' = any. LinkedIn / Resume only apply on Sheet Forms. */
const EMPTY_INBOX_FILTERS = { location: '', hasLinkedin: '', hasResume: '' }
const EMPTY_INBOX_FILTERS = { location: '', source: '', assigned: '', hasLinkedin: '', hasResume: '' }
/** "Updated 4 min ago" under the search box — the honest label for a cached list. */
function agoLabel(ts) {
@ -254,6 +254,22 @@ function asAtsScore(value) {
return Number.isFinite(n) ? n : null
}
/** Assigned score if set, else max of per-job suggestion scores. */
function emailAtsScore(row) {
const assignedId = row?.assigned_job_post_id || row?.assignedId
const results = Array.isArray(row?.ats_results) ? row.ats_results : []
const posts = Array.isArray(row?.suggested_job_posts)
? row.suggested_job_posts
: (Array.isArray(row?.suggestedPosts) ? row.suggestedPosts : [])
const assignedScore = results.find((s) => String(s.job_post_id) === String(assignedId))
const fromResults = results.map((s) => asAtsScore(s.overall_score)).filter((n) => n != null)
const fromJobs = posts.map((p) => asAtsScore(p.overall_score)).filter((n) => n != null)
return asAtsScore(row?.ats_score)
?? asAtsScore(assignedScore?.overall_score)
?? (fromResults.length ? Math.max(...fromResults) : null)
?? (fromJobs.length ? Math.max(...fromJobs) : null)
}
/** Assigned job title for export — list rows may carry the post or only an id + jobPosts. */
function assignedJobTitle(row) {
const fromPost = row?.assignedPost?.title
@ -493,6 +509,8 @@ async function fetchMessageDetail(recordId) {
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null,
atsResults: Array.isArray(row.ats_results) ? row.ats_results : [],
atsScore: emailAtsScore(row),
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
@ -536,7 +554,7 @@ async function fetchApplications(params) {
linkedinUrl: row.linkedin_url || '',
// resume_text is not on the list payload (serialize_application
// light=True). The detail query fetches it for the open row.
atsScore: asAtsScore(row.ats_score),
atsScore: emailAtsScore(row),
phone: row.phone,
experience: row.experience,
recruiter: row.recruiter,
@ -549,6 +567,7 @@ async function fetchApplications(params) {
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null,
atsResults: Array.isArray(row.ats_results) ? row.ats_results : [],
isReapplicant: Boolean(row.is_reapplicant),
previousApplications: Array.isArray(row.previous_applications) ? row.previous_applications : [],
}
@ -892,10 +911,12 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit,
* `minmax(280px, 34%)`. On any wide screen it would try to fit four columns into
* about four hundred pixels. This stacks instead, like the Progress sidebar.
*
* Location is on every channel. LinkedIn / Resume stay Sheet Forms only those
* columns do not exist on email rows. Collapsed by default because extra selects
* eat scarce vertical space above the queue, but the toggle carries the active
* count so a collapsed panel cannot silently hide a filter.
* City and Source / Platform are on every channel. Assigned job is too
* email and form rows both carry an assigned_job_post_id. LinkedIn / Resume
* stay Sheet Forms only those columns do not exist on email rows. Collapsed
* by default because extra selects eat scarce vertical space above the queue,
* but the toggle carries the active count so a collapsed panel cannot silently
* hide a filter.
*
* (`Facet` in Candidates.jsx and CvBank.jsx is the same idea in a wider column.
* Both files are mid-edit elsewhere, so this is deliberately a local copy rather
@ -904,9 +925,10 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit,
/** Junk location tokens — hide from the dropdown, not from the list query. */
const HIDDEN_LOCATION = /^(KA|KAR|KARA|WAH)$/i
function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle, onChange, onClear }) {
function InboxFilters({ filters, cities, sources, showLinkFilters, active, open, onToggle, onChange, onClear }) {
const locations = (cities || []).filter((name) => !HIDDEN_LOCATION.test(String(name).trim()))
const locationOk = filters.location && !HIDDEN_LOCATION.test(String(filters.location).trim())
const platforms = sources || []
return (
<div className="inbox-filters">
<button
@ -925,9 +947,9 @@ function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle
{open && (
<div className="inbox-filter-stack">
<div className="form-field">
<label htmlFor="inbox-f-location">Location</label>
<label htmlFor="inbox-f-city">City</label>
<select
id="inbox-f-location"
id="inbox-f-city"
value={filters.location}
onChange={(e) => onChange('location', e.target.value)}
>
@ -940,6 +962,34 @@ function InboxFilters({ filters, cities, showLinkFilters, active, open, onToggle
))}
</select>
</div>
<div className="form-field">
<label htmlFor="inbox-f-source">Source / Platform</label>
<select
id="inbox-f-source"
value={filters.source || ''}
onChange={(e) => onChange('source', e.target.value)}
>
<option value="">Any</option>
{filters.source && !platforms.includes(filters.source) && (
<option value={filters.source}>{filters.source}</option>
)}
{platforms.map((name) => (
<option key={name} value={name}>{name}</option>
))}
</select>
</div>
<div className="form-field">
<label htmlFor="inbox-f-assigned">Assigned job</label>
<select
id="inbox-f-assigned"
value={filters.assigned || ''}
onChange={(e) => onChange('assigned', e.target.value)}
>
<option value="">Any</option>
<option value="yes">Assigned</option>
<option value="no">Unassigned</option>
</select>
</div>
{showLinkFilters && (
<>
<div className="form-field">
@ -1060,6 +1110,12 @@ export default function Inbox() {
setSelectedId(null)
}, [])
const city = inboxFilters.location
const source = inboxFilters.source
const assignedParams = inboxFilters.assigned === 'yes'
? { assigned: true }
: inboxFilters.assigned === 'no'
? { assigned: false }
: {}
const deepOpen = searchParams.get('open')
const deepKind = searchParams.get('kind')
@ -1085,6 +1141,8 @@ export default function Inbox() {
const pageLimit = pageSize === 'all' ? undefined : pageSize
const activeInboxFilters = [
inboxFilters.location,
inboxFilters.source,
inboxFilters.assigned,
...(isForms ? [inboxFilters.hasLinkedin, inboxFilters.hasResume] : []),
].filter(Boolean).length
@ -1097,7 +1155,9 @@ export default function Inbox() {
skip: pageLimit == null ? 0 : skip,
...(search ? { search } : {}),
...(city ? { city } : {}),
}), [tabFilter, skip, pageLimit, search, city])
...(source ? { source } : {}),
...assignedParams,
}), [tabFilter, skip, pageLimit, search, city, source, assignedParams])
/**
* Sheet Forms only. On the All channel these rows are merged with email ones,
@ -1119,18 +1179,28 @@ export default function Inbox() {
...formTabFilter,
...(search ? { search } : {}),
...(city ? { city } : {}),
...(source ? { source } : {}),
...assignedParams,
...linkFilters,
}), [formSheet, skip, pageLimit, search, city, formTabFilter, isAllChannel, linkFilters])
}), [formSheet, skip, pageLimit, search, city, source, assignedParams, formTabFilter, isAllChannel, linkFilters])
const citiesQuery = useQuery({
queryKey: qk.mailbox.cities(),
queryFn: async () => {
const res = await inboxApi.listApplications({ cityList: true, top: 1 })
return Array.isArray(res?.cities) ? res.cities : []
return {
cities: Array.isArray(res?.cities) ? res.cities : [],
sources: Array.isArray(res?.sources) ? res.sources : [],
}
},
staleTime: 5 * 60 * 1000,
})
const cities = citiesQuery.data ?? []
const cities = Array.isArray(citiesQuery.data)
? citiesQuery.data
: (citiesQuery.data?.cities ?? [])
const sources = Array.isArray(citiesQuery.data)
? []
: (citiesQuery.data?.sources ?? [])
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(listParams),
@ -1172,8 +1242,10 @@ export default function Inbox() {
sheet: formCountsSheet,
...(search ? { search } : {}),
...(city ? { city } : {}),
...(source ? { source } : {}),
...assignedParams,
...linkFilters,
}), [formCountsSheet, search, city, linkFilters])
}), [formCountsSheet, search, city, source, assignedParams, linkFilters])
const formCountsQuery = useQuery({
queryKey: qk.mailbox.formCounts(formCountsParams),
queryFn: async () => {
@ -1288,7 +1360,7 @@ export default function Inbox() {
const searchTotal = isAllChannel
? (Number(applicationsQuery.data?.total ?? 0) + Number(formQuery.data?.total ?? 0))
: (activeQuery.data?.total ?? 0)
const total = (search || city)
const total = (search || city || source || inboxFilters.assigned)
? searchTotal
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
// 'all' = unpaged: the fetch omits top/limit (the endpoints treat a missing
@ -1336,6 +1408,7 @@ export default function Inbox() {
...selectedRow,
...(detailQuery.data ?? {}),
received: selectedRow?.received ?? detailQuery.data?.received ?? null,
atsScore: asAtsScore(detailQuery.data?.atsScore) ?? asAtsScore(selectedRow?.atsScore) ?? null,
}
: null
@ -1385,7 +1458,12 @@ export default function Inbox() {
setQ('')
setSearch('') // clear the committed term too, or the new channel's first
// fetch carries the old channel's search for 300ms
setInboxFilters((f) => ({ ...EMPTY_INBOX_FILTERS, location: f.location }))
setInboxFilters((f) => ({
...EMPTY_INBOX_FILTERS,
location: f.location,
source: f.source,
assigned: f.assigned,
}))
// Unread is email-only; leave it behind when opening Sheet Forms or All.
if (next !== 'email' && tab === 'Unread') setTab('All Applications')
selection.clear()
@ -1407,7 +1485,13 @@ export default function Inbox() {
if (SERVER_SCOPED_TABS.has(tab)) {
setReadAll.mutate({
read,
filter: { ...tabFilter, ...(search ? { search } : {}) },
filter: {
...tabFilter,
...(search ? { search } : {}),
...(city ? { city } : {}),
...(source ? { source } : {}),
...assignedParams,
},
// sheet rows carry no mailbox read state email rows only
ids: list.filter((i) => i.kind !== 'form').map((i) => i.id),
})
@ -1701,6 +1785,7 @@ export default function Inbox() {
<InboxFilters
filters={inboxFilters}
cities={cities}
sources={sources}
showLinkFilters={isForms}
active={activeInboxFilters}
open={showInboxFilters}
@ -2467,7 +2552,7 @@ function ApplicationDetail({
{i.atsScore != null && (
<div style={{ textAlign: 'center' }}>
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{Math.round(i.atsScore)}</div></div>
</div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div>