s3 lionk done
CI / checks (push) Failing after 2m9s Details
CI / checks (pull_request) Failing after 2m4s Details

pull/79/head
ahmed.mujtaba 2026-09-07 19:32:49 +05:00
parent 7c9e18741d
commit ad6f8ba121
8 changed files with 133 additions and 62 deletions

View File

@ -340,23 +340,23 @@ class SheetFormData(Sheet):
"""FormData DB mirror — query / delete only (no Google client)."""
async def _hydrate_job_posts(self,items):
"""Attach suggested job_posts, assigned_job_post, and per-job ATS scores.
"""Attach suggested job titles, assigned_job_post, and per-job ATS scores.
Preferred source is suggested_job_post_ids (ILIKE matches stored on
import). Legacy rows without that list still title-match. ATS is one
current score per (form, job).
current score per (form, job). Full JD loads when a card is expanded.
"""
if not items:
return items
from g_sheet.scoring import serialize_form_ats
from inbox.models import AtsResults
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
from job.job_post.serializers import serialize_job_post_title
session=self._require_session()
def _job_payload(post):
payload=serialize_job_post(post)
payload=serialize_job_post_title(post)
if post.is_deleted or not post.is_active:
payload={**payload,"unavailable":True}
return payload
@ -374,7 +374,7 @@ class SheetFormData(Sheet):
wanted=list(dict.fromkeys([*suggested_ids,*assigned_ids]))
by_id={}
if wanted:
for post in await JobPosts.get_by_ids(session,wanted,active_only=False):
for post in await JobPosts.titles_by_ids(session,wanted,active_only=False):
by_id[str(post.id)]=_job_payload(post)
titles=[(item.get("position_applied_for") or "").strip() for item in items]

View File

@ -3,7 +3,6 @@
from __future__ import annotations
import asyncio
import base64
import logging
import os
import uuid
@ -138,6 +137,7 @@ def load_file_bytes(path_or_url: str) -> bytes | None:
def load_message_files(message:Inbox_Messages) -> list[dict]:
"""Filename + public URL only. Open-resume uses the S3 link; do not pull bytes."""
if not message.file_path:
return []
names=[n.strip() for n in (message.file_name or "").split(",") if n.strip()]
@ -147,22 +147,6 @@ def load_message_files(message:Inbox_Messages) -> list[dict]:
entry={"file_name":name or "resume.pdf","url":None,"content_base64":None,"size":0}
if path_str.lower().startswith("http://") or path_str.lower().startswith("https://"):
entry["url"]=path_str
raw=load_file_bytes(path_str)
if raw is not None:
entry["content_base64"]=base64.b64encode(raw).decode("ascii")
entry["size"]=len(raw)
files.append(entry)
continue
path=resolve_attachment_path(path_str)
if not path.is_file():
continue
try:
raw=path.read_bytes()
except OSError:
continue
entry["file_name"]=path.name
entry["content_base64"]=base64.b64encode(raw).decode("ascii")
entry["size"]=len(raw)
files.append(entry)
return files

View File

@ -256,23 +256,7 @@ class Email:
item["files"]=files
from job.candidate.views import CandidateView
cv=CandidateView(session=self.session)
suggested=[]
for job_id in item.get("suggested_job_post_ids") or []:
jp=await cv.get_job_post_by_id(record_id=job_id)
if jp:
if jp.get("is_deleted") or not jp.get("is_active"):
suggested.append({**jp,"unavailable":True})
else:
suggested.append(jp)
else:
suggested.append({"id":str(job_id),"unavailable":True})
item["suggested_job_posts"]=suggested
assigned_id=item.get("assigned_job_post_id")
if assigned_id:
item["assigned_job_post"]=await cv.get_job_post_by_id(record_id=assigned_id)
else:
item["assigned_job_post"]=None
items=await self._paint_inbox_ats([item])
items=await self._attach_job_posts([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,source=None):
@ -289,10 +273,11 @@ class Email:
return await CandidateView(session=self.session).attach_application_history(items)
async def _attach_job_posts(self,items):
"""List payload needs assigned + suggested job objects — export reads titles.
"""List payload needs assigned + suggested titles — export reads names.
Detail hydrates one row; the queue used to ship ids only. Assigned job and
Suggested jobs in the Inbox .xlsx were then blank for email applicants.
Detail hydrates one row. Full JD (location, requirements) loads when
the recruiter expands a card. Assigned job and Suggested jobs in the
Inbox .xlsx stay as titles.
"""
ids=[]
for item in items:
@ -305,9 +290,9 @@ class Email:
by_id={}
if ids:
from job.job_post.models import JobPosts
from job.job_post.serializers import serialize_job_post
for post in await JobPosts.get_by_ids(self.session,ids,active_only=False):
payload=serialize_job_post(post)
from job.job_post.serializers import serialize_job_post_title
for post in await JobPosts.titles_by_ids(self.session,ids,active_only=False):
payload=serialize_job_post_title(post)
if post.is_deleted or not post.is_active:
payload={**payload,"unavailable":True}
by_id[str(post.id)]=payload

View File

@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, Optional
from sqlalchemy import DateTime, JSON, Index, String, and_, case, cast, func, or_, union_all
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from sqlalchemy.orm import aliased, load_only
from sqlmodel import Field, Relationship, SQLModel, select
from job.job_post.enums import RequisitionStatus
@ -127,6 +127,31 @@ class JobPosts(SQLModel, table=True):
# Preserve request order so suggestion ranks stay stable.
return [by_id[str(u)] for u in uids if str(u) in by_id]
@classmethod
async def titles_by_ids(cls, session: AsyncSession, ids: list[str], *, active_only: bool = False):
"""id + title + status only — inbox suggestion rail before a card expands.
Skips description / post_text TOAST columns. Rank order matches `ids`.
"""
uids = []
for raw in ids or []:
uid = cls._as_uuid(raw)
if uid is not None:
uids.append(uid)
if not uids:
return []
statement = (
select(cls)
.options(load_only(cls.id, cls.title, cls.status, cls.is_active, cls.is_deleted))
.where(cls.id.in_(uids))
)
if active_only:
statement = statement.where(cls.is_active == True, cls.is_deleted == False) # noqa: E712
result = await session.execute(statement)
rows = list(result.scalars().all())
by_id = {str(r.id): r for r in rows}
return [by_id[str(u)] for u in uids if str(u) in by_id]
@classmethod
async def get_by_titles(cls, session: AsyncSession, titles: list[str], *, active_only: bool = False):
"""Match job posts whose title equals any of `titles` (trim + case-insensitive).

View File

@ -8,6 +8,17 @@ def _status_label(value):
return parsed.label if parsed else value
def serialize_job_post_title(row) -> dict:
"""Inbox suggestion rail — title only until the recruiter expands the card."""
return {
"id": str(row.id),
"title": row.title,
"status": row.status,
"is_active": row.is_active,
"is_deleted": row.is_deleted,
}
def serialize_job_post(row) -> dict:
return {
"id": str(row.id),

View File

@ -78,6 +78,7 @@ export const qk = {
jobPosts: {
all: () => ['jobPosts'],
list: (p = {}) => ['jobPosts', 'list', p],
detail: (id) => ['jobPosts', 'detail', id],
departments: (p = {}) => ['jobPosts', 'departments', p],
},
jobs: {

View File

@ -71,22 +71,39 @@ const COUNT_CACHE = { staleTime: INBOX_STALE_MS, gcTime: INBOX_GC_MS }
/**
* Last 10 opened applicant details. The queue list is a light row; GET-by-id
* is the body, suggested roles, resume text. Re-opening one of these should
* not pay that trip again. Cap is LRU the 11th open drops the oldest from
* the query cache. Mutations that write THIS row still invalidate its key.
* not pay that trip again for 15 minutes. Cap is LRU the 11th open drops
* the oldest. After the TTL the snapshot is dropped so the next open refetches.
* Mutations that write THIS row still invalidate its key.
*/
const OPENED_DETAIL_CAP = 10
const OPENED_DETAIL_TTL_MS = 15 * 60_000
const openedDetailLru = []
function detailQueryKey(kind, id) {
return kind === 'form' ? qk.mailbox.formRow(id) : qk.mailbox.message(id)
}
function pruneOpenedDetailLru(qc, keepSig) {
const cutoff = Date.now() - OPENED_DETAIL_TTL_MS
const next = []
for (const x of openedDetailLru) {
if (x.at > cutoff || x.sig === keepSig) {
next.push(x)
continue
}
qc.removeQueries({ queryKey: x.key })
}
openedDetailLru.length = 0
openedDetailLru.push(...next)
}
function rememberOpenedDetail(qc, kind, id) {
if (!id) return
const sig = `${kind}:${id}`
const key = detailQueryKey(kind, id)
pruneOpenedDetailLru(qc, sig)
const next = openedDetailLru.filter((x) => x.sig !== sig)
next.push({ sig, kind, id, key })
next.push({ sig, kind, id, key, at: Date.now() })
while (next.length > OPENED_DETAIL_CAP) {
const dropped = next.shift()
if (dropped && dropped.sig !== sig) {
@ -98,9 +115,9 @@ function rememberOpenedDetail(qc, kind, id) {
}
const DETAIL_CACHE = {
staleTime: Infinity,
gcTime: INBOX_GC_MS,
refetchOnMount: false,
staleTime: OPENED_DETAIL_TTL_MS,
gcTime: OPENED_DETAIL_TTL_MS,
refetchOnMount: true,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}
@ -1428,6 +1445,7 @@ export default function Inbox() {
...DETAIL_CACHE,
})
useEffect(() => {
pruneOpenedDetailLru(qc, selectedId ? `${selectedKind}:${selectedId}` : null)
if (!selectedId || !detailQuery.isSuccess || !detailQuery.data) return
rememberOpenedDetail(qc, selectedKind, selectedId)
}, [qc, selectedId, selectedKind, detailQuery.isSuccess, detailQuery.data])
@ -2381,6 +2399,7 @@ function FormApplicantDetail({
badge={`Match #${rank}`}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(String(id))}
lazy
/>
))}
{manualPost && (
@ -2390,6 +2409,7 @@ function FormApplicantDetail({
manual
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(String(id))}
lazy
/>
)}
</>
@ -2788,6 +2808,7 @@ function ApplicationDetail({
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(String(id))}
resumeText={resumeText}
lazy
/>
))
)}
@ -2799,6 +2820,7 @@ function ApplicationDetail({
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(String(id))}
resumeText={resumeText}
lazy
/>
)}
<button

View File

@ -30,25 +30,48 @@ function reqLabel(req) {
return String(req)
}
export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge }) {
export function JobCard({ post, rank, selected, onSelect, resumeText, manual, badge, lazy = false }) {
const unavailable = Boolean(post?.unavailable) || !post?.title
const title = post?.title || 'Unavailable'
const tag = badge ?? (manual ? 'Manual' : `AI #${rank}`)
const [open, setOpen] = useState(false)
const hasBody = Boolean(
post?.location || post?.employment_type || post?.description
|| (Array.isArray(post?.requirements) && post.requirements.length),
)
const detailQuery = useQuery({
queryKey: qk.jobPosts.detail(post?.id),
queryFn: async () => {
const res = await jobPostsApi.list({ ids: [post.id], activeOnly: false })
const rows = Array.isArray(res?.data) ? res.data : []
return rows[0] || null
},
enabled: Boolean(lazy && open && post?.id && !unavailable && !hasBody),
staleTime: 10 * 60_000,
gcTime: 60 * 60_000,
})
const body = (!lazy || hasBody) ? post : (detailQuery.data || post)
const meta = [
post?.employment_type,
post?.location,
post?.experience_min != null || post?.experience_max != null
? `${post?.experience_min ?? '?'}${post?.experience_max ?? '?'} yrs`
body?.employment_type,
body?.location,
body?.experience_min != null || body?.experience_max != null
? `${body?.experience_min ?? '?'}${body?.experience_max ?? '?'} yrs`
: null,
].filter(Boolean).join(' · ')
const tag = badge ?? (manual ? 'Manual' : `AI #${rank}`)
const showBody = !lazy || open
return (
<div
role="radio"
aria-checked={selected}
aria-expanded={lazy ? open : undefined}
tabIndex={0}
className="list-row"
onClick={() => !unavailable && onSelect(post.id)}
onClick={() => {
if (unavailable) return
onSelect(post.id)
if (lazy) setOpen(true)
}}
onKeyDown={(e) => {
if (unavailable) return
if (e.key === 'Enter' || e.key === ' ') {
@ -67,6 +90,20 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
>
<div className="lr-main" style={{ minWidth: 0 }}>
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
{lazy && !unavailable && (
<button
type="button"
className="btn btn-ghost btn-sm"
aria-label={open ? 'Hide role details' : 'Show role details'}
onClick={(e) => {
e.stopPropagation()
setOpen((v) => !v)
}}
style={{ padding: 4, minWidth: 28 }}
>
<Icon name={open ? 'chevron-down' : 'chevron-right'} />
</button>
)}
<span className="tag">{tag}</span>
<div className="lr-title">{title}</div>
{unavailable ? (
@ -77,10 +114,16 @@ export function JobCard({ post, rank, selected, onSelect, resumeText, manual, ba
{post?.overall_score != null && <ScoreChip score={post.overall_score} />}
{selected && <Icon name="check-circle" />}
</div>
{meta && <div className="cell-sub">{meta}</div>}
{!unavailable && Array.isArray(post.requirements) && post.requirements.length > 0 && (
{showBody && detailQuery.isPending && lazy && !hasBody && (
<div className="cell-sub">Loading role</div>
)}
{showBody && detailQuery.isError && lazy && (
<div className="cell-sub">{friendlyAuthError(detailQuery.error, 'Could not load this role.')}</div>
)}
{showBody && meta && <div className="cell-sub">{meta}</div>}
{showBody && !unavailable && Array.isArray(body.requirements) && body.requirements.length > 0 && (
<div className="k-tags" style={{ marginTop: 8 }}>
{post.requirements.slice(0, 8).map((req, i) => {
{body.requirements.slice(0, 8).map((req, i) => {
const label = reqLabel(req)
if (!label) return null
const hit = reqInResume(req, resumeText)